diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 09d7e22c546..0ce2a1be5ab 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/dependabot-to-jira.yml b/.github/workflows/dependabot-to-jira.yml index 8f750cd33d4..80a8ebcb976 100644 --- a/.github/workflows/dependabot-to-jira.yml +++ b/.github/workflows/dependabot-to-jira.yml @@ -15,26 +15,32 @@ jobs: steps: - name: create ticket id: create_ticket + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_HTML_URL: ${{ github.event.pull_request.html_url }} + REPO_NAME: ${{ github.event.repository.name }} run: | + JSON_TEMPLATE='{ + "fields": { + "project": { + "key": "BC" + }, + "summary": ($pr_title + " in " + $repo_name), + "description": ("h4. Task:\n" + $pr_title + "\n" + $pr_html_url + "\nh4.Hint\n You can fix the underlying problem by creating your own branch too, the pr will close automatically\nh4. Acceptance criteria\n1. https://docs.dbildungscloud.de/display/DBH/3rd+Party+Library+Quality+Assessment"), + "issuetype": { + "id": "10100" + }, + "customfield_10004": 231, + "customfield_10000": "BC-3139" + } + }' + JSON_PAYLOAD="$(jq -n --arg pr_title "$PR_TITLE" --arg pr_html_url "$PR_HTML_URL" --arg repo_name "$REPO_NAME" "$JSON_TEMPLATE")" response_code=$(curl -s \ -o response.txt \ -w "%{http_code}" \ -u ${{ secrets.JIRA_USER_NAME }}:${{ secrets.JIRA_USER_PASSWORD }}\ -H "Content-Type: application/json" \ - -X POST --data '{ - "fields": { - "project": { - "key": "BC" - }, - "summary": "${{ github.event.pull_request.title }} in ${{ github.event.repository.name }}", - "description": "h4. Task:\n${{ github.event.pull_request.title }}\n${{ github.event.pull_request.html_url }}\nh4.Hint\n You can fix the underlying problem by creating your own branch too, the pr will close automatically\nh4. Acceptance criteria\n1. https://docs.dbildungscloud.de/display/DBH/3rd+Party+Library+Quality+Assessment", - "issuetype": { - "id": "10100" - }, - "customfield_10004" : 231, - "customfield_10000": "BC-3139" - } - }' \ + -X POST --data "$JSON_PAYLOAD" \ 'https://ticketsystem.dbildungscloud.de/rest/api/2/issue/'); if [[ $response_code == 2* ]]; then echo "all good"; diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 73aaec3be87..0ab0b8d8645 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -3,6 +3,7 @@ on: [pull_request] permissions: contents: read + pull-requests: write jobs: dependency-review: @@ -11,9 +12,9 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@v4 - name: 'Dependency Review' - uses: actions/dependency-review-action@v3 + uses: actions/dependency-review-action@v4 with: - allow-licenses: AGPL-3.0-only, LGPL-3.0, MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, X11, 0BSD, GPL-3.0 AND BSD-3-Clause-Clear, Unlicense - allow-dependencies-licenses: 'pkg:npm/parse-mongo-url' - # temporarily ignore dependency error for upgrade mongodb 4.9 to 4.11, remove when mikroORM is upgraded to 5.9 + allow-licenses: AGPL-3.0-only, LGPL-3.0, MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, X11, 0BSD, GPL-3.0, Unlicense, CC0-1.0 + # temporarily ignore dependency error sprintf-js 1.0.3, remove when it gets upgraded to 1.1.3 + allow-dependencies-licenses: 'pkg:npm/sprintf-js@1.0.3' allow-ghsas: 'GHSA-vxvm-qww3-2fh7' diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 0635a6006d8..4467076fb11 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -1,4 +1,8 @@ -name: Migrations updated +name: Reminder to update seed data after migration +# If this workflow fails, it is a hint that you forgot to update the seed data after a migration. +# It is only a hint, because it only checks if you updated the migration collection in the seed data. +# It is not a check that you updated the whole seed data correctly. +# See the documentation for advice: https://documentation.dbildungscloud.dev/docs/schulcloud-server/Migrations#committing-a-migration on: push: @@ -6,6 +10,9 @@ on: pull_request: branches: [ main ] +env: + MONGODB_VERSION: 4.4 + NODE_VERSION: '18' jobs: migration: runs-on: ubuntu-latest @@ -14,21 +21,13 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@v4 - - name: check all migrations are up in database seed - run: test $(grep "\"down\"" ./backup/setup/migrations.json -c) -eq 0 - name: mongodb setup - uses: supercharge/mongodb-github-action@1.8.0 + uses: supercharge/mongodb-github-action@1.10.0 - name: setup uses: actions/setup-node@v4 with: - node-version: '16' + node-version: ${{ env.NODE_VERSION }} - run: npm ci - - run: npm run setup - - name: check migrations.json formatting - run: | - npm run migration-persist - git diff --exit-code backup/** - - name: check filesystem migrations have been added to database - run: npm run migration-list - - name: check migrations in database exist in filesystem - run: npm run migration-prune + - run: npm run setup:db:seed + - name: check no pending migrations (migration is in db) + run: test $(npm run migration:pending | grep -c "Migration") -eq 0 diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index a7435d1cde9..32a20eac108 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -34,7 +34,7 @@ jobs: fetch-depth: 0 - name: Login to registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -122,13 +122,18 @@ jobs: - name: Extract branch meta shell: bash id: extract_branch_meta + env: + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BRANCH_REF_NAME: ${{ github.ref_name}} + BRANCH_SHA: ${{ github.sha }} run: | if [ "${{ github.event_name }}" == 'pull_request' ]; then - echo "branch=${{ github.event.pull_request.head.ref }}" >> $GITHUB_OUTPUT - echo "sha=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT + echo "branch=$PR_HEAD_REF" >> $GITHUB_OUTPUT + echo "sha=$PR_HEAD_SHA" >> $GITHUB_OUTPUT else - echo "branch=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT - echo "sha=${{ github.sha }}" >> $GITHUB_OUTPUT + echo "branch=$BRANCH_REF_NAME" >> $GITHUB_OUTPUT + echo "sha=$BRANCH_SHA" >> $GITHUB_OUTPUT fi deploy: @@ -167,7 +172,7 @@ jobs: security-events: write steps: - name: run trivy vulnerability scanner - uses: aquasecurity/trivy-action@9ab158e8597f3b310480b9a69402b419bc03dbd5 + uses: aquasecurity/trivy-action@1f6384b6ceecbbc6673526f865b818a2a06b07c9 with: image-ref: 'ghcr.io/${{ github.repository }}:${{ needs.branch_meta.outputs.sha }}' format: 'sarif' @@ -176,7 +181,7 @@ jobs: ignore-unfixed: true - name: upload trivy results if: ${{ always() }} - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@v3 with: sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 6089c0739a1..cf78afaff0e 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -26,13 +26,13 @@ jobs: type=semver,pattern={{major}}.{{minor}} - name: Log into docker registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Log into quay registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} @@ -81,7 +81,7 @@ jobs: contents: write steps: - name: create sbom - uses: aquasecurity/trivy-action@e5f43133f6e8736992c9f3c1b3296e24b37e17f2 + uses: aquasecurity/trivy-action@1f6384b6ceecbbc6673526f865b818a2a06b07c9 with: scan-type: 'image' format: 'cyclonedx' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5aaa8567005..a901c3704a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} - name: Start MongoDB - uses: supercharge/mongodb-github-action@1.8.0 + uses: supercharge/mongodb-github-action@1.10.0 with: mongodb-version: ${{ env.MONGODB_VERSION }} - name: environment setup @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - shard: [1, 2, 3, 4] + shard: [1, 2, 3, 4, 5, 6, 7, 8] services: rabbitmq: image: rabbitmq:3 @@ -53,20 +53,20 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} - name: Start MongoDB - uses: supercharge/mongodb-github-action@1.8.0 + uses: supercharge/mongodb-github-action@1.10.0 with: mongodb-version: ${{ env.MONGODB_VERSION }} - name: npm ci run: npm ci --prefer-offline --no-audit - name: nest:test:cov - test all with coverage - timeout-minutes: 25 + timeout-minutes: 11 run: export RUN_WITHOUT_JEST_COVERAGE='true' && export NODE_OPTIONS='--max_old_space_size=4096' && ./node_modules/.bin/jest --shard=${{ matrix.shard }}/${{ strategy.job-total }} --coverage --force-exit - name: save-coverage run: mv coverage/lcov.info coverage/${{matrix.shard}}.info - name: "upload-artifacts" - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: coverage-artifacts + name: coverage-artifacts-${{ matrix.shard }} path: coverage/ sonarcloud: name: SonarCloud coverage @@ -76,15 +76,16 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: - name: coverage-artifacts + pattern: coverage-artifacts-* path: coverage + merge-multiple: true - name: Merge Code Coverage run: | sudo apt-get install -y lcov find coverage -name *.info -exec echo -a {} \; | xargs lcov -o merged-lcov.info - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/test_unstable_e2e.yml b/.github/workflows/test_unstable_e2e.yml index 89c613dc022..a3f64e4de8e 100644 --- a/.github/workflows/test_unstable_e2e.yml +++ b/.github/workflows/test_unstable_e2e.yml @@ -38,7 +38,7 @@ jobs: SECRET_ES_MERLIN_PW: ${{ secrets.SECRET_ES_MERLIN_PW }} DOCKER_ID: ${{ secrets.DOCKER_ID }} MY_DOCKER_PASSWORD: ${{ secrets.MY_DOCKER_PASSWORD }} - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 name: upload results if: always() with: diff --git a/Dockerfile b/Dockerfile index 77ed4a1088b..036bd9734d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,6 @@ COPY esbuild ./esbuild RUN npm ci && npm cache clean --force COPY config /schulcloud-server/config COPY backup /schulcloud-server/backup -COPY migrations /schulcloud-server/migrations COPY src /schulcloud-server/src COPY apps /schulcloud-server/apps COPY --from=git /app/serverversion /schulcloud-server/apps/server/static-assets diff --git a/ansible/roles/h5p-library-management/tasks/main.yml b/ansible/roles/h5p-library-management/tasks/main.yml index 7d25364c934..695c1448ee9 100644 --- a/ansible/roles/h5p-library-management/tasks/main.yml +++ b/ansible/roles/h5p-library-management/tasks/main.yml @@ -1,3 +1,17 @@ + - name: Secret by 1Password (Library S3 Bucket) + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: api-h5p-library-management-onepassword.yml.j2 + when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool and WITH_H5P_LIBRARY_MANAGEMENT is defined and WITH_H5P_LIBRARY_MANAGEMENT|bool == true + + - name: H5pLibraryManagement ConfigMap + when: WITH_H5P_LIBRARY_MANAGEMENT is defined and WITH_H5P_LIBRARY_MANAGEMENT|bool == true + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: api-h5p-library-management-configmap.yml.j2 + - name: H5pLibraryManagement CronJob when: WITH_H5P_LIBRARY_MANAGEMENT is defined and WITH_H5P_LIBRARY_MANAGEMENT|bool == true kubernetes.core.k8s: diff --git a/ansible/roles/h5p-library-management/templates/api-h5p-library-management-configmap.yml.j2 b/ansible/roles/h5p-library-management/templates/api-h5p-library-management-configmap.yml.j2 new file mode 100644 index 00000000000..88ff05b546b --- /dev/null +++ b/ansible/roles/h5p-library-management/templates/api-h5p-library-management-configmap.yml.j2 @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: api-h5p-library-management-configmap + namespace: {{ NAMESPACE }} + labels: + app: api-h5p-library-management-cronjob +data: + h5p-libraries.yaml: | + h5p_libraries: {{ H5P_LIBRARIES }} \ No newline at end of file diff --git a/ansible/roles/h5p-library-management/templates/api-h5p-library-management-cronjob.yml.j2 b/ansible/roles/h5p-library-management/templates/api-h5p-library-management-cronjob.yml.j2 index a578a23d796..cecf6c2aa78 100644 --- a/ansible/roles/h5p-library-management/templates/api-h5p-library-management-cronjob.yml.j2 +++ b/ansible/roles/h5p-library-management/templates/api-h5p-library-management-cronjob.yml.j2 @@ -3,15 +3,15 @@ kind: CronJob metadata: namespace: {{ NAMESPACE }} labels: - app: api-library-management-cronjob + app: api-h5p-library-management-cronjob app.kubernetes.io/part-of: schulcloud-verbund app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} - app.kubernetes.io/name: api-library-management-cronjob + app.kubernetes.io/name: api-h5p-library-management-cronjob app.kubernetes.io/component: h5p app.kubernetes.io/managed-by: ansible git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} - name: api-library-management-cronjob + name: api-h5p-library-management-cronjob spec: schedule: "{{ SERVER_H5P_LIBRARY_MANAGEMENT_CRONJOB|default("0 3 * * 3,6", true) }}" concurrencyPolicy: Forbid @@ -21,15 +21,19 @@ spec: template: metadata: labels: - app: api-library-management-cronjob + app: api-h5p-library-management-cronjob app.kubernetes.io/part-of: schulcloud-verbund app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} - app.kubernetes.io/name: api-library-management-cronjob + app.kubernetes.io/name: api-h5p-library-management-cronjob app.kubernetes.io/component: h5p app.kubernetes.io/managed-by: ansible git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} spec: + volumes: + - name: libraries-list + configMap: + name: api-h5p-library-management-configmap containers: - name: api-h5p-library-management-cronjob image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} @@ -38,6 +42,13 @@ spec: name: api-configmap - secretRef: name: api-secret + - secretRef: + name: api-h5p-library-management-secret + volumeMounts: + - name: libraries-list + mountPath: /schulcloud-server/config/h5p-libraries.yaml + subPath: h5p-libraries.yaml + readOnly: true command: ['/bin/sh', '-c'] args: ['npm run nest:start:h5p:library-management'] resources: diff --git a/ansible/roles/h5p-library-management/templates/api-h5p-library-management-onepassword.yml.j2 b/ansible/roles/h5p-library-management/templates/api-h5p-library-management-onepassword.yml.j2 new file mode 100644 index 00000000000..303e479004f --- /dev/null +++ b/ansible/roles/h5p-library-management/templates/api-h5p-library-management-onepassword.yml.j2 @@ -0,0 +1,9 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: api-h5p-library-management-secret + namespace: {{ NAMESPACE }} + labels: + app: api-h5p-library-management-cronjob +spec: + itemPath: "vaults/{{ ONEPASSWORD_OPERATOR_VAULT }}/items/h5p-library-management" diff --git a/ansible/roles/moin-schule-sync/meta/main.yml b/ansible/roles/moin-schule-sync/meta/main.yml new file mode 100644 index 00000000000..e1708400321 --- /dev/null +++ b/ansible/roles/moin-schule-sync/meta/main.yml @@ -0,0 +1,9 @@ +galaxy_info: + role_name: moin-schule-sync + author: Schul-Cloud Verbund + description: moin-schule-sync role for the moin.schule synchronization purposes + company: Schul-Cloud Verbund + license: license (AGPLv3) + min_ansible_version: 2.8 + galaxy_tags: [] +dependencies: [] diff --git a/ansible/roles/moin-schule-sync/tasks/main.yml b/ansible/roles/moin-schule-sync/tasks/main.yml new file mode 100644 index 00000000000..4efee08f12d --- /dev/null +++ b/ansible/roles/moin-schule-sync/tasks/main.yml @@ -0,0 +1,20 @@ +- name: moin-schule-sync secret provisioned by 1Password + when: WITH_MOIN_SCHULE is defined and WITH_MOIN_SCHULE|bool == true + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: moin-schule-sync-onepassword.yml.j2 + +- name: moin.schule users sync CronJob + when: WITH_MOIN_SCHULE is defined and WITH_MOIN_SCHULE|bool == true + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: moin-schule-users-sync-cronjob.yml.j2 + +- name: moin.schule users sync CronJob ConfigMap + when: WITH_MOIN_SCHULE is defined and WITH_MOIN_SCHULE|bool == true + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: moin-schule-users-sync-cronjob-configmap.yml.j2 diff --git a/ansible/roles/moin-schule-sync/templates/moin-schule-sync-onepassword.yml.j2 b/ansible/roles/moin-schule-sync/templates/moin-schule-sync-onepassword.yml.j2 new file mode 100644 index 00000000000..caedf057413 --- /dev/null +++ b/ansible/roles/moin-schule-sync/templates/moin-schule-sync-onepassword.yml.j2 @@ -0,0 +1,9 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: moin-schule-sync-secret + namespace: {{ NAMESPACE }} + labels: + app: moin-schule-sync +spec: + itemPath: "vaults/{{ ONEPASSWORD_OPERATOR_VAULT }}/items/moin-schule-sync" diff --git a/ansible/roles/moin-schule-sync/templates/moin-schule-users-sync-cronjob-configmap.yml.j2 b/ansible/roles/moin-schule-sync/templates/moin-schule-users-sync-cronjob-configmap.yml.j2 new file mode 100644 index 00000000000..428b2b65df9 --- /dev/null +++ b/ansible/roles/moin-schule-sync/templates/moin-schule-users-sync-cronjob-configmap.yml.j2 @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + namespace: {{ NAMESPACE }} + name: moin-schule-users-sync-cronjob-configmap + labels: + app: moin-schule-users-sync-cronjob +data: + NODE_OPTIONS: "--max-old-space-size=3072" + NEST_LOG_LEVEL: "info" + SC_DOMAIN: "{{ DOMAIN }}" + FEATURE_PROMETHEUS_METRICS_ENABLED: "true" diff --git a/ansible/roles/moin-schule-sync/templates/moin-schule-users-sync-cronjob.yml.j2 b/ansible/roles/moin-schule-sync/templates/moin-schule-users-sync-cronjob.yml.j2 new file mode 100644 index 00000000000..7182999e1b1 --- /dev/null +++ b/ansible/roles/moin-schule-sync/templates/moin-schule-users-sync-cronjob.yml.j2 @@ -0,0 +1,63 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + namespace: {{ NAMESPACE }} + labels: + app: moin-schule-users-sync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: moin-schule-users-sync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + name: moin-schule-users-sync-cronjob +spec: + schedule: "{{ MOIN_SCHULE_USERS_SYNC_CRONJOB_SCHEDULE|default("@hourly", true) }}" + jobTemplate: + spec: + template: + spec: + containers: + - name: moin-schule-users-sync-cronjob + image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} + envFrom: + - configMapRef: + name: moin-schule-users-sync-cronjob-configmap + - secretRef: + name: moin-schule-sync-secret + command: ['/bin/sh','-c'] + args: ['npm run nest:start:idp-console -- sync users --systemType moin.schule --systemId $SYSTEM_ID'] + resources: + limits: + cpu: {{ API_CPU_LIMITS|default("2000m", true) }} + memory: {{ API_MEMORY_LIMITS|default("2Gi", true) }} + requests: + cpu: {{ API_CPU_REQUESTS|default("100m", true) }} + memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} + restartPolicy: OnFailure +{% if AFFINITY_ENABLE is defined and AFFINITY_ENABLE|bool %} + affinity: + podAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/part-of + operator: In + values: + - schulcloud-verbund + topologyKey: "kubernetes.io/hostname" + namespaceSelector: {} +{% endif %} + metadata: + labels: + app: moin-schule-users-sync-cronjob + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: moin-schule-users-sync-cronjob + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index afa2563936b..c9a23aa152e 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -37,26 +37,39 @@ template: onepassword.yml.j2 when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - name: Admin Api ingress + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: admin-api-ingress.yml.j2 + apply: yes + when: WITH_API_ADMIN is defined and WITH_API_ADMIN|bool + - name: Admin API server ConfigMap kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: admin-api-server-configmap.yml.j2 apply: yes + when: WITH_API_ADMIN is defined and WITH_API_ADMIN|bool - name: Admin API server Secret (from 1Password) kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: admin-api-server-onepassword.yml.j2 - when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + when: + - ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - WITH_API_ADMIN is defined and WITH_API_ADMIN|bool - name: Admin API client secret (from 1Password) kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: onepassword-admin-api-client.yml.j2 - when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + when: + - ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - WITH_API_ADMIN is defined and WITH_API_ADMIN|bool - name: remove old migration Job kubernetes.core.k8s: @@ -129,6 +142,13 @@ namespace: "{{ NAMESPACE }}" template: api-delete-s3-files-cronjob.yml.j2 + - name: Delete Tldraw Files CronJob + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: tldraw-delete-files-cronjob.yml.j2 + when: WITH_TLDRAW is defined and WITH_TLDRAW|bool + - name: Data deletion trigger CronJob kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -136,12 +156,25 @@ template: data-deletion-trigger-cronjob.yml.j2 when: WITH_API_ADMIN is defined and WITH_API_ADMIN|bool - - name: AMQPFileStorageDeployment + - name: amqp files storage Deployment kubernetes.core.k8s: kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: amqp-files-deployment.yml.j2 + - name: amqp files storage configmap + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: amqp-files-configmap.yml.j2 + + - name: amqp files storage Secret by 1Password + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: amqp-files-onepassword.yml.j2 + when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - name: Preview Generator Deployment kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -186,6 +219,15 @@ template: admin-api-server-svc.yml.j2 when: WITH_API_ADMIN is defined and WITH_API_ADMIN|bool + - name: TlDraw server Secret (from 1Password) + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: tldraw-server-onepassword.yml.j2 + when: + - ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool + - WITH_TLDRAW is defined and WITH_TLDRAW|bool + - name: TlDraw server deployment kubernetes.core.k8s: kubeconfig: ~/.kube/config diff --git a/ansible/roles/schulcloud-server-h5p/templates/api-h5p-ingress.yml.j2 b/ansible/roles/schulcloud-server-core/templates/admin-api-ingress.yml.j2 similarity index 88% rename from ansible/roles/schulcloud-server-h5p/templates/api-h5p-ingress.yml.j2 rename to ansible/roles/schulcloud-server-core/templates/admin-api-ingress.yml.j2 index c3359d63b5f..dbe851b4800 100644 --- a/ansible/roles/schulcloud-server-h5p/templates/api-h5p-ingress.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/admin-api-ingress.yml.j2 @@ -1,41 +1,41 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: {{ NAMESPACE }}-api-h5p-ingress - namespace: {{ NAMESPACE }} - annotations: - nginx.ingress.kubernetes.io/ssl-redirect: "{{ TLS_ENABELD|default("false") }}" - nginx.ingress.kubernetes.io/proxy-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" - nginx.org/client-max-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" - # The following properties added with BC-3606. - # The header size of the request is too big. For e.g. state and the permanent growing jwt. - # Nginx throws away the Location header, resulting in the 502 Bad Gateway. - nginx.ingress.kubernetes.io/client-header-buffer-size: 100k - nginx.ingress.kubernetes.io/http2-max-header-size: 96k - nginx.ingress.kubernetes.io/large-client-header-buffers: 4 100k - nginx.ingress.kubernetes.io/proxy-buffer-size: 96k -{% if CLUSTER_ISSUER is defined %} - cert-manager.io/cluster-issuer: {{ CLUSTER_ISSUER }} -{% endif %} - -spec: - ingressClassName: {{ INGRESS_CLASS }} -{% if CLUSTER_ISSUER is defined or (TLS_ENABELD is defined and TLS_ENABELD|bool) %} - tls: - - hosts: - - {{ DOMAIN }} -{% if CLUSTER_ISSUER is defined %} - secretName: {{ DOMAIN }}-tls -{% endif %} -{% endif %} - rules: - - host: {{ DOMAIN }} - http: - paths: - - path: /api/v3/h5p-editor/ - backend: - service: - name: api-h5p-svc - port: - number: 4448 - pathType: Prefix +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ NAMESPACE }}-admin-api-ingress + namespace: {{ NAMESPACE }} + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "{{ TLS_ENABELD|default("false") }}" + nginx.ingress.kubernetes.io/proxy-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" + nginx.org/client-max-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" + # The following properties added with BC-3606. + # The header size of the request is too big. For e.g. state and the permanent growing jwt. + # Nginx throws away the Location header, resulting in the 502 Bad Gateway. + nginx.ingress.kubernetes.io/client-header-buffer-size: 100k + nginx.ingress.kubernetes.io/http2-max-header-size: 96k + nginx.ingress.kubernetes.io/large-client-header-buffers: 4 100k + nginx.ingress.kubernetes.io/proxy-buffer-size: 96k +{% if CLUSTER_ISSUER is defined %} + cert-manager.io/cluster-issuer: {{ CLUSTER_ISSUER }} +{% endif %} + +spec: + ingressClassName: {{ INGRESS_CLASS }} +{% if CLUSTER_ISSUER is defined or (TLS_ENABELD is defined and TLS_ENABELD|bool) %} + tls: + - hosts: + - {{ DOMAIN }} +{% if CLUSTER_ISSUER is defined %} + secretName: {{ DOMAIN }}-tls +{% endif %} +{% endif %} + rules: + - host: {{ DOMAIN }} + http: + paths: + - path: /admin/api/v1 + backend: + service: + name: api-admin-svc + port: + number: 4030 + pathType: Prefix diff --git a/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 b/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 index 5726812014d..2504ffbfc91 100644 --- a/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/admin-api-server-configmap.yml.j2 @@ -6,8 +6,10 @@ metadata: labels: app: api-admin data: - NODE_OPTIONS: "--max-old-space-size=3072" - NEST_LOG_LEVEL: "info" + NEST_LOG_LEVEL: "{{ NEST_LOG_LEVEL }}" + EXIT_ON_ERROR: "{{ EXIT_ON_ERROR }}" ADMIN_API__PORT: "4030" SC_DOMAIN: "{{ DOMAIN }}" FEATURE_PROMETHEUS_METRICS_ENABLED: "true" + CALENDAR_URI: "{{ CALENDAR_URI }}" + ROCKET_CHAT_URI: "{{ ROCKET_CHAT_URI }}" diff --git a/ansible/roles/schulcloud-server-core/templates/amqp-files-configmap.yml.j2 b/ansible/roles/schulcloud-server-core/templates/amqp-files-configmap.yml.j2 new file mode 100644 index 00000000000..070ab9b8a8c --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/amqp-files-configmap.yml.j2 @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: amqp-files-configmap + namespace: {{ NAMESPACE }} + labels: + app: amqp-files +data: + NEST_LOG_LEVEL: "{{ NEST_LOG_LEVEL }}" + EXIT_ON_ERROR: "{{ EXIT_ON_ERROR }}" diff --git a/ansible/roles/schulcloud-server-core/templates/amqp-files-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/amqp-files-deployment.yml.j2 index 66f60f06b39..c8232c7bd04 100644 --- a/ansible/roles/schulcloud-server-core/templates/amqp-files-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/amqp-files-deployment.yml.j2 @@ -48,8 +48,10 @@ spec: envFrom: - configMapRef: name: api-configmap + - configMapRef: + name: amqp-files-configmap - secretRef: - name: api-secret + name: amqp-files-secret command: ['npm', 'run', 'nest:start:files-storage-amqp:prod'] resources: limits: diff --git a/ansible/roles/schulcloud-server-core/templates/amqp-files-onepassword.yml.j2 b/ansible/roles/schulcloud-server-core/templates/amqp-files-onepassword.yml.j2 new file mode 100644 index 00000000000..f8adcbf2a85 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/amqp-files-onepassword.yml.j2 @@ -0,0 +1,9 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: amqp-files-secret + namespace: {{ NAMESPACE }} + labels: + app: amqp-files +spec: + itemPath: "vaults/{{ ONEPASSWORD_OPERATOR_VAULT }}/items/amqp-files" diff --git a/ansible/roles/schulcloud-server-core/templates/api-delete-s3-files-cronjob.yml.j2 b/ansible/roles/schulcloud-server-core/templates/api-delete-s3-files-cronjob.yml.j2 index 075f1dc7227..e376e15ddbd 100644 --- a/ansible/roles/schulcloud-server-core/templates/api-delete-s3-files-cronjob.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/api-delete-s3-files-cronjob.yml.j2 @@ -3,7 +3,7 @@ kind: CronJob metadata: namespace: {{ NAMESPACE }} labels: - app: api + app: api-delete-s3-files-cronjob cronjob: delete-s3-files app.kubernetes.io/part-of: schulcloud-verbund app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} @@ -55,7 +55,7 @@ spec: {% endif %} metadata: labels: - app: api + app: api-delete-s3-files-cronjob cronjob: delete-s3-files app.kubernetes.io/part-of: schulcloud-verbund app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} diff --git a/ansible/roles/schulcloud-server-core/templates/api-files-ingress.yml.j2 b/ansible/roles/schulcloud-server-core/templates/api-files-ingress.yml.j2 index 3eabd5d2659..fd48f0686db 100644 --- a/ansible/roles/schulcloud-server-core/templates/api-files-ingress.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/api-files-ingress.yml.j2 @@ -4,7 +4,7 @@ metadata: name: {{ NAMESPACE }}-api-files-ingress namespace: {{ NAMESPACE }} annotations: - nginx.ingress.kubernetes.io/ssl-redirect: "{{ TLS_ENABELD|default("false") }}" + nginx.ingress.kubernetes.io/ssl-redirect: "{{ TLS_ENABLED|default("false") }}" nginx.ingress.kubernetes.io/proxy-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" nginx.org/client-max-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" # The following properties added with BC-3606. @@ -14,13 +14,14 @@ metadata: nginx.ingress.kubernetes.io/http2-max-header-size: 96k nginx.ingress.kubernetes.io/large-client-header-buffers: 4 100k nginx.ingress.kubernetes.io/proxy-buffer-size: 96k + nginx.ingress.kubernetes.io/proxy-request-buffering: "off" {% if CLUSTER_ISSUER is defined %} cert-manager.io/cluster-issuer: {{ CLUSTER_ISSUER }} {% endif %} spec: ingressClassName: {{ INGRESS_CLASS }} -{% if CLUSTER_ISSUER is defined or (TLS_ENABELD is defined and TLS_ENABELD|bool) %} +{% if CLUSTER_ISSUER is defined or (TLS_ENABLED is defined and TLS_ENABLED|bool) %} tls: - hosts: - {{ DOMAIN }} diff --git a/ansible/roles/schulcloud-server-core/templates/deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/deployment.yml.j2 index 4aa16f23dd5..44cdea89a2c 100644 --- a/ansible/roles/schulcloud-server-core/templates/deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/deployment.yml.j2 @@ -59,7 +59,7 @@ spec: name: api-secret readinessProbe: httpGet: - path: /serverversion + path: /internal/health port: 3030 timeoutSeconds: 4 failureThreshold: 3 @@ -67,14 +67,14 @@ spec: # liveless if unsatisfactory reply livenessProbe: httpGet: - path: /serverversion + path: /internal/health port: 3030 timeoutSeconds: 4 failureThreshold: 3 periodSeconds: 15 startupProbe: httpGet: - path: /serverversion + path: /internal/health port: 3030 timeoutSeconds: 4 failureThreshold: 36 diff --git a/ansible/roles/schulcloud-server-core/templates/ingress.yml.j2 b/ansible/roles/schulcloud-server-core/templates/ingress.yml.j2 index 86588e74b47..67ef7f7a6fb 100644 --- a/ansible/roles/schulcloud-server-core/templates/ingress.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/ingress.yml.j2 @@ -4,7 +4,7 @@ metadata: name: {{ NAMESPACE }}-api-ingress namespace: {{ NAMESPACE }} annotations: - nginx.ingress.kubernetes.io/ssl-redirect: "{{ TLS_ENABELD|default("false") }}" + nginx.ingress.kubernetes.io/ssl-redirect: "{{ TLS_ENABLED|default("false") }}" nginx.ingress.kubernetes.io/proxy-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" nginx.org/client-max-body-size: "{{ INGRESS_MAX_BODY_SIZE|default("2560") }}m" # The following properties added with BC-3606. @@ -20,7 +20,7 @@ metadata: spec: ingressClassName: {{ INGRESS_CLASS }} -{% if CLUSTER_ISSUER is defined or (TLS_ENABELD is defined and TLS_ENABELD|bool) %} +{% if CLUSTER_ISSUER is defined or (TLS_ENABLED is defined and TLS_ENABLED|bool) %} tls: - hosts: - {{ DOMAIN }} diff --git a/ansible/roles/schulcloud-server-core/templates/migration-job.yml.j2 b/ansible/roles/schulcloud-server-core/templates/migration-job.yml.j2 index ab859cd0487..09b59075ec1 100644 --- a/ansible/roles/schulcloud-server-core/templates/migration-job.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/migration-job.yml.j2 @@ -21,7 +21,7 @@ spec: - secretRef: name: api-secret command: ['/bin/sh','-c'] - args: ['npm run ensureIndexes && npm run migration-sync && npm run migration-prune && npm run migration up'] + args: ['npm run ensureIndexes && npm run migration:up'] resources: limits: cpu: {{ API_CPU_LIMITS|default("2000m", true) }} @@ -30,4 +30,4 @@ spec: cpu: {{ API_CPU_REQUESTS|default("100m", true) }} memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} restartPolicy: Never - backoffLimit: 3 + backoffLimit: 5 diff --git a/ansible/roles/schulcloud-server-core/templates/preview-generator-configmap.yml.j2 b/ansible/roles/schulcloud-server-core/templates/preview-generator-configmap.yml.j2 index 8d3a4b1f4c2..7acc666673d 100644 --- a/ansible/roles/schulcloud-server-core/templates/preview-generator-configmap.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/preview-generator-configmap.yml.j2 @@ -6,4 +6,5 @@ metadata: labels: app: preview-generator data: - NEST_LOG_LEVEL: "info" + NEST_LOG_LEVEL: "{{ NEST_LOG_LEVEL }}" + EXIT_ON_ERROR: "{{ EXIT_ON_ERROR }}" diff --git a/ansible/roles/schulcloud-server-core/templates/preview-generator-scaled-object.yml.j2 b/ansible/roles/schulcloud-server-core/templates/preview-generator-scaled-object.yml.j2 index 6058b1b0ccb..0cbddb88d7c 100644 --- a/ansible/roles/schulcloud-server-core/templates/preview-generator-scaled-object.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/preview-generator-scaled-object.yml.j2 @@ -16,7 +16,7 @@ metadata: spec: scaleTargetRef: name: preview-generator-deployment - idleReplicaCount: {{ AMQP_FILE_PREVIEW_IDLE_REPLICA_COUNT|default("1", true) }} + # add idleReplicaCount: 0 if you want to scale to 0 minReplicaCount: {{ AMQP_FILE_PREVIEW_MIN_REPLICA_COUNT|default("1", true) }} maxReplicaCount: {{ AMQP_FILE_PREVIEW_MAX_REPLICA_COUNT|default("5", true) }} triggers: diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-delete-files-cronjob.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-delete-files-cronjob.yml.j2 new file mode 100644 index 00000000000..6975c9bc728 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-delete-files-cronjob.yml.j2 @@ -0,0 +1,66 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + namespace: {{ NAMESPACE }} + labels: + app: tldraw-delete-files-cronjob + cronjob: tldraw-delete-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: tldraw-delete-files + app.kubernetes.io/component: tldraw + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} + name: tldraw-delete-files-cronjob +spec: + concurrencyPolicy: Forbid + schedule: "{{ TLDRAW_FILE_DELETION_CRONJOB_SCHEDULE|default("@midnight", true) }}" + jobTemplate: + spec: + template: + spec: + containers: + - name: tldraw-delete-files-cronjob + image: {{ SCHULCLOUD_SERVER_IMAGE }}:{{ SCHULCLOUD_SERVER_IMAGE_TAG }} + envFrom: + - configMapRef: + name: api-configmap + - secretRef: + name: api-secret + command: ['/bin/sh', '-c'] + args: ['npm run nest:start:tldraw-console -- files deletion-job 24'] + resources: + limits: + cpu: {{ API_CPU_LIMITS|default("2000m", true) }} + memory: {{ API_MEMORY_LIMITS|default("2Gi", true) }} + requests: + cpu: {{ API_CPU_REQUESTS|default("100m", true) }} + memory: {{ API_MEMORY_REQUESTS|default("150Mi", true) }} + restartPolicy: OnFailure +{% if AFFINITY_ENABLE is defined and AFFINITY_ENABLE|bool %} + affinity: + podAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/part-of + operator: In + values: + - schulcloud-verbund + topologyKey: "kubernetes.io/hostname" + namespaceSelector: {} +{% endif %} + metadata: + labels: + app: tldraw-delete-files-cronjob + cronjob: tldraw-delete-files + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: tldraw-delete-files + app.kubernetes.io/component: tldraw + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 index 4530c223673..7b45df2357a 100644 --- a/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-deployment.yml.j2 @@ -60,6 +60,8 @@ spec: name: api-configmap - secretRef: name: api-secret + - secretRef: + name: tldraw-server-secret command: ['npm', 'run', 'nest:start:tldraw:prod'] resources: limits: diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 index 37b476e0834..aa765778276 100644 --- a/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-ingress.yml.j2 @@ -15,13 +15,14 @@ metadata: nginx.ingress.kubernetes.io/http2-max-header-size: 96k nginx.ingress.kubernetes.io/large-client-header-buffers: 4 100k nginx.ingress.kubernetes.io/proxy-buffer-size: 96k + nginx.org/websocket-services: "tldraw-server-svc" {% if CLUSTER_ISSUER is defined %} cert-manager.io/cluster-issuer: {{ CLUSTER_ISSUER }} {% endif %} spec: ingressClassName: {{ INGRESS_CLASS }} -{% if CLUSTER_ISSUER is defined or (TLS_ENABELD is defined and TLS_ENABELD|bool) %} +{% if CLUSTER_ISSUER is defined or (TLS_ENABLED is defined and TLS_ENABLED|bool) %} tls: - hosts: - {{ DOMAIN }} diff --git a/ansible/roles/schulcloud-server-core/templates/tldraw-server-onepassword.yml.j2 b/ansible/roles/schulcloud-server-core/templates/tldraw-server-onepassword.yml.j2 new file mode 100644 index 00000000000..14021d8bd9c --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/tldraw-server-onepassword.yml.j2 @@ -0,0 +1,9 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: tldraw-server-secret + namespace: {{ NAMESPACE }} + labels: + app: tldraw-server +spec: + itemPath: "vaults/{{ ONEPASSWORD_OPERATOR_VAULT }}/items/tldraw-server" diff --git a/ansible/roles/schulcloud-server-h5p/tasks/main.yml b/ansible/roles/schulcloud-server-h5p/tasks/main.yml index 0cb4feff19c..1063f5e098d 100644 --- a/ansible/roles/schulcloud-server-h5p/tasks/main.yml +++ b/ansible/roles/schulcloud-server-h5p/tasks/main.yml @@ -1,3 +1,10 @@ + - name: Secret by 1Password (H5P S3 Buckets) + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: api-h5p-onepassword.yml.j2 + when: ONEPASSWORD_OPERATOR is defined and ONEPASSWORD_OPERATOR|bool and WITH_H5P_EDITOR is defined and WITH_H5P_EDITOR|bool + - name: H5PEditorProvider kubernetes.core.k8s: kubeconfig: ~/.kube/config @@ -11,12 +18,3 @@ namespace: "{{ NAMESPACE }}" template: api-h5p-deployment.yml.j2 when: WITH_H5P_EDITOR is defined and WITH_H5P_EDITOR|bool - - - name: H5p Editor Ingress - kubernetes.core.k8s: - kubeconfig: ~/.kube/config - namespace: "{{ NAMESPACE }}" - template: api-h5p-ingress.yml.j2 - apply: yes - when: WITH_H5P_EDITOR is defined and WITH_H5P_EDITOR|bool - \ No newline at end of file diff --git a/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 b/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 index 782fd01bf1e..f24a4ee0ae7 100644 --- a/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-h5p/templates/api-h5p-deployment.yml.j2 @@ -54,6 +54,8 @@ spec: name: api-configmap - secretRef: name: api-secret + - secretRef: + name: api-h5p-editor-secret command: ['npm', 'run', 'nest:start:h5p:prod'] readinessProbe: httpGet: @@ -80,10 +82,10 @@ spec: resources: limits: cpu: {{ API_H5P_EDITOR_CPU_LIMITS|default("2000m", true) }} - memory: {{ API_H5P_EDITOR_MEMORY_LIMITS|default("500Mi", true) }} + memory: {{ API_H5P_EDITOR_MEMORY_LIMITS|default("8Gi", true) }} requests: cpu: {{ API_H5P_EDITOR_CPU_REQUESTS|default("100m", true) }} - memory: {{ API_H5P_EDITOR_MEMORY_REQUESTS|default("50Mi", true) }} + memory: {{ API_H5P_EDITOR_MEMORY_REQUESTS|default("2Gi", true) }} {% if AFFINITY_ENABLE is defined and AFFINITY_ENABLE|bool %} affinity: podAffinity: diff --git a/ansible/roles/schulcloud-server-h5p/templates/api-h5p-onepassword.yml.j2 b/ansible/roles/schulcloud-server-h5p/templates/api-h5p-onepassword.yml.j2 new file mode 100644 index 00000000000..7e9c1332282 --- /dev/null +++ b/ansible/roles/schulcloud-server-h5p/templates/api-h5p-onepassword.yml.j2 @@ -0,0 +1,9 @@ +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: api-h5p-editor-secret + namespace: {{ NAMESPACE }} + labels: + app: api-h5p +spec: + itemPath: "vaults/{{ ONEPASSWORD_OPERATOR_VAULT }}/items/h5p-editor" diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index cb2ad5bba9e..bb30700349e 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -8,9 +8,6 @@ metadata: data: update.sh: | #! /bin/bash - {% if KEDA_NAMESPACE_ACTIVATOR_ENABLED is defined %} - curl -XPUT -H 'Content-Type: application/json' -L 'http://ns-activator-svc.sc-common.svc.cluster.local:8080/namespace' -d '{"name" : "{{ NAMESPACE }}"}' - {% endif %} # necessary for secret handling and legacy indexes git clone https://github.com/hpi-schul-cloud/schulcloud-server.git cd /schulcloud-server @@ -317,25 +314,19 @@ data: if [ -n "$NS" ]; then # Set the BETTERMARKS_CLIENT_SECRET and BETTERMARKS_URL variables values according to the k8s namespace. - if [ "$SC_THEME" = "n21" ] && [ "$NS" = "bettermarks-test" ]; then - BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_NBC_BETTERMARKS_TEST_CLIENT_SECRET - BETTERMARKS_URL=$BETTERMARKS_NBC_BETTERMARKS_TEST_ENTRYPOINT - elif [ "$SC_THEME" = "n21" ] && [ "$NS" = "main" ]; then - BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_NBC_MAIN_CLIENT_SECRET - BETTERMARKS_URL=$BETTERMARKS_NBC_MAIN_ENTRYPOINT - elif [ "$SC_THEME" = "brb" ] && [ "$NS" = "bettermarks-test" ]; then - BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_BRB_BETTERMARKS_TEST_CLIENT_SECRET - BETTERMARKS_URL=$BETTERMARKS_BRB_BETTERMARKS_TEST_ENTRYPOINT - elif [ "$SC_THEME" = "brb" ] && [ "$NS" = "main" ]; then - BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_BRB_MAIN_CLIENT_SECRET - BETTERMARKS_URL=$BETTERMARKS_BRB_MAIN_ENTRYPOINT + if [ "$NS" = "main" ]; then + BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_MAIN_CLIENT_SECRET + BETTERMARKS_URL=$BETTERMARKS_MAIN_ENTRYPOINT + elif [ "$NS" = "bettermarks-test" ]; then + BETTERMARKS_CLIENT_SECRET=$BETTERMARKS_BETTERMARKS_TEST_CLIENT_SECRET + BETTERMARKS_URL=$BETTERMARKS_BETTERMARKS_TEST_ENTRYPOINT else # Print some friendly message for any other namespace that's not supported. echo "Sorry, Bettermarks cannot be configured on the '$NS' namespace, omitting the config data init." fi # Perform the final Bettermarks config data init if client secret and URL has been properly set. - if [ -n "$BETTERMARKS_CLIENT_SECRET" ] && [ -n "$BETTERMARKS_URL" ]; then + if [ -n "$BETTERMARKS_CLIENT_SECRET" ] && [ -n "$BETTERMARKS_URL" ] && [ -n "$BETTERMARKS_REDIRECT_DOMAIN" ]; then # Add document to the 'ltitools' collection with Bettermarks tool configuration. mongosh $DATABASE__URL --quiet --eval 'db.getCollection("ltitools").replaceOne( { @@ -384,11 +375,9 @@ data: "scope":"openid offline", "token_endpoint_auth_method":"client_secret_post", "redirect_uris": [ - "https://acc.bettermarks.com/v1.0/schulcloud/oauth/callback", - "https://school.bettermarks.loc/v1.0/schulcloud/oauth/callback", - "https://acc.bettermarks.com/auth/callback", - "https://school.bettermarks.loc/auth/callback", - "https://acc.bettermarks.com/auth/oidc/callback" + "https://'$BETTERMARKS_REDIRECT_DOMAIN'/v1.0/schulcloud/oauth/callback", + "https://'$BETTERMARKS_REDIRECT_DOMAIN'/auth/callback", + "https://'$BETTERMARKS_REDIRECT_DOMAIN'/auth/oidc/callback" ], "subject_type":"pairwise" }' @@ -421,6 +410,8 @@ data: );' echo "Bettermarks config data init performed successfully." + else + echo "Bettermarks variables not provided, omitting the config data init." fi fi @@ -484,7 +475,7 @@ data: "'$PUBLIC_BACKEND_URL'/api/v3/sso/hydra" ], "client_uri": "'$NEXTCLOUD_BASE_URL'", - "frontchannel_logout_uri": "'$NEXTCLOUD_BASE_URL'/apps/schulcloud/logout", + "frontchannel_logout_uri": "'$NEXTCLOUD_BASE_URL'apps/schulcloud/logout", "subject_type": "pairwise" }' echo "POSTed nextcloud to hydra." @@ -494,11 +485,11 @@ data: echo "Inserting nextcloud to external-tools..." mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( { - "name": "nextcloud", + "name": "'$NEXTCLOUD_SOCIALLOGIN_OIDC_INTERNAL_NAME'", "config_type": "oauth2" }, { $setOnInsert: { - "name": "nextcloud", + "name": "'$NEXTCLOUD_SOCIALLOGIN_OIDC_INTERNAL_NAME'", "url": "'$NEXTCLOUD_BASE_URL'", "logoUrl": "", "config_type": "oauth2", @@ -523,5 +514,44 @@ data: # ========== End of the Nextcloud configuration section. + # ========== Start of the CTL seed data configuration section. + echo "Inserting ctl seed data secrets to external-tools..." + mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( + { + "name": "Moodle Fortbildung", + }, + { $set: { + "config_secret": "'$CTL_SEED_SECRET_MOODLE_FORTB'", + } }, + { + "upsert": true + } + );' + mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( + { + "name": "Product Test Onlinediagnose Grundschule - Mathematik", + }, + { $set: { + "config_secret": "'$CTL_SEED_SECRET_ONLINE_DIA_MATHE'", + } }, + { + "upsert": true + } + );' + mongosh $DATABASE__URL --quiet --eval 'db.getCollection("external-tools").updateOne( + { + "name": "Product Test Onlinediagnose Grundschule - Deutsch", + }, + { $set: { + "config_secret": "'$CTL_SEED_SECRET_ONLINE_DIA_DEUTSCH'", + } }, + { + "upsert": true + } + );' + echo "Inserted ctl seed data secrets to external-tools." + + # ========== End of the CTL seed data configuration section. + # Database indexes synchronization, it's crucial until we have all the entities in NestJS app. npm run syncIndexes diff --git a/ansible/roles/schulcloud-server-init/templates/job_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/job_init.yml.j2 index 7555ef8c552..25b3b0fc631 100644 --- a/ansible/roles/schulcloud-server-init/templates/job_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/job_init.yml.j2 @@ -9,7 +9,7 @@ spec: spec: containers: - name: api-init - image: schulcloud/infra-tools:latest + image: quay.io/schulcloudverbund/infra-tools:latest envFrom: - configMapRef: name: api-configmap @@ -29,7 +29,7 @@ spec: resources: limits: cpu: "3000m" - memory: "1Gi" + memory: "2Gi" requests: cpu: "100m" memory: "150Mi" diff --git a/ansible/roles/schulcloud-server-ldapsync/tasks/main.yml b/ansible/roles/schulcloud-server-ldapsync/tasks/main.yml index 3e4024136b4..b23c1ad9b74 100644 --- a/ansible/roles/schulcloud-server-ldapsync/tasks/main.yml +++ b/ansible/roles/schulcloud-server-ldapsync/tasks/main.yml @@ -12,3 +12,14 @@ kubeconfig: ~/.kube/config namespace: "{{ NAMESPACE }}" template: api-ldap-worker-deployment.yml.j2 + + - name: api worker scaled object + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: "{{ NAMESPACE }}" + template: api-ldap-worker-scaled-object.yml.j2 + when: + - WITH_LDAP is defined and WITH_LDAP|bool + - KEDA_ENABLED is defined and KEDA_ENABLED|bool + - SCALED_API_WORKER_ENABLED is defined and SCALED_API_WORKER_ENABLED|bool + diff --git a/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-worker-scaled-object.yml.j2 b/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-worker-scaled-object.yml.j2 new file mode 100644 index 00000000000..e92b05b7f0f --- /dev/null +++ b/ansible/roles/schulcloud-server-ldapsync/templates/api-ldap-worker-scaled-object.yml.j2 @@ -0,0 +1,30 @@ +--- +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: api-ldap-worker-scaledobject + namespace: {{ NAMESPACE }} + labels: + app: api-worker + app.kubernetes.io/part-of: schulcloud-verbund + app.kubernetes.io/version: {{ SCHULCLOUD_SERVER_IMAGE_TAG }} + app.kubernetes.io/name: api-worker + app.kubernetes.io/component: sync + app.kubernetes.io/managed-by: ansible + git.branch: {{ SCHULCLOUD_SERVER_BRANCH_NAME }} + git.repo: {{ SCHULCLOUD_SERVER_REPO_NAME }} +spec: + scaleTargetRef: + name: api-worker-deployment + idleReplicaCount: 0 + minReplicaCount: {{ API_WORKER_MIN_REPLICA_COUNT|default("1", true) }} + maxReplicaCount: {{ API_WORKER_MAX_REPLICA_COUNT|default("40", true) }} + triggers: + - type: rabbitmq + metadata: + protocol: amqp + queueName: sync_ldap + mode: QueueLength + value: "1" + authenticationRef: + name: rabbitmq-trigger-auth diff --git a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-base-cronjob.yml.j2 b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-base-cronjob.yml.j2 index 87a8d205398..ae71c9b50e4 100644 --- a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-base-cronjob.yml.j2 +++ b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-base-cronjob.yml.j2 @@ -20,7 +20,7 @@ spec: spec: containers: - name: api-tsp-sync-base-cronjob - image: schulcloud/infra-tools:latest + image: quay.io/schulcloudverbund/infra-tools:latest envFrom: - secretRef: name: api-secret diff --git a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-school-cronjob.yml.j2 b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-school-cronjob.yml.j2 index c09907c1656..75a1e0b35ad 100644 --- a/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-school-cronjob.yml.j2 +++ b/ansible/roles/schulcloud-server-tspsync/templates/api-tsp-sync-school-cronjob.yml.j2 @@ -20,7 +20,7 @@ spec: spec: containers: - name: api-tsp-sync-school-cronjob - image: schulcloud/infra-tools:latest + image: quay.io/schulcloudverbund/infra-tools:latest envFrom: - secretRef: name: api-secret diff --git a/apps/server/README.md b/apps/server/README.md index a8427699c5a..5d99d350456 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -14,7 +14,7 @@ You find the whole [documentation published as GitHub Page](https://hpi-schul-cl ### preconditions 1. Have a MongoDB started, run `mongod` -2. Have some seed data in database, use `npm run setup` to reset the db and apply seed data +2. Have some seed data in database, use `npm run setup:db:seed` to reset the db and apply seed data 3. Have RabbitMQ started, run `docker run -d -p 5672:5672 -p 15672:15672 --name rabbitmq rabbitmq:3.8.9-management`. This starts RabbitMQ on port 5672 and a web admin console at localhost:15672 (use guest:guest to login). 4. Have MinIO (S3 compatible object storage), run [optional if you need files-storage module] diff --git a/apps/server/doc/file-structure.md b/apps/server/doc/file-structure.md index 8b0c2969a8c..9b3007ef6b8 100644 --- a/apps/server/doc/file-structure.md +++ b/apps/server/doc/file-structure.md @@ -198,4 +198,4 @@ The use of a mapper gives us the guarantee, that - no additional data beside the known properties is published. - A plain object might contain more properties than defined in TS-interfaces. Sample: All school properties are published while only name & id are intended to be published. -- the API definition is complete \ No newline at end of file +- the API definition is complete diff --git a/apps/server/doc/keycloak.md b/apps/server/doc/keycloak.md index e84a7d6a51a..48fb3a2f0f5 100644 --- a/apps/server/doc/keycloak.md +++ b/apps/server/doc/keycloak.md @@ -41,7 +41,7 @@ To add ErWIn-IDM identity broker feature via OpenID Connect (OIDC) Identity Prov - Set env vars (or in your .env file) 'OIDCMOCK\_\_BASE_URL' to http://\:4011. - To make it work with the nuxt client set the env var HOST=http://localhost:4000 -- re-trigger `npm run setup:db` and `npm run setup:idm` to reset and apply seed data. +- re-trigger `npm run setup:db:seed` and `npm run setup:idm` to reset and apply seed data. - start the 'oidc-server-mock' as follows: ```bash diff --git a/apps/server/src/apps/files-storage-consumer.app.ts b/apps/server/src/apps/files-storage-consumer.app.ts index cb62c9c76f9..19e2fe7ac05 100644 --- a/apps/server/src/apps/files-storage-consumer.app.ts +++ b/apps/server/src/apps/files-storage-consumer.app.ts @@ -9,7 +9,7 @@ import { install as sourceMapInstall } from 'source-map-support'; async function bootstrap() { sourceMapInstall(); - const nestApp = await NestFactory.createMicroservice(FilesStorageAMQPModule); + const nestApp = await NestFactory.create(FilesStorageAMQPModule); await nestApp.init(); console.log('#########################################'); diff --git a/apps/server/src/apps/idp-console.app.ts b/apps/server/src/apps/idp-console.app.ts new file mode 100644 index 00000000000..da523496526 --- /dev/null +++ b/apps/server/src/apps/idp-console.app.ts @@ -0,0 +1,31 @@ +/* istanbul ignore file */ +import { IdpConsoleModule } from '@modules/idp-console'; +import { BootstrapConsole } from 'nestjs-console'; + +async function run() { + const bootstrap = new BootstrapConsole({ + module: IdpConsoleModule, + useDecorators: true, + }); + + const app = await bootstrap.init(); + + try { + await app.init(); + + // Execute console application with provided arguments. + await bootstrap.boot(); + } catch (err) { + // eslint-disable-next-line no-console, @typescript-eslint/no-unsafe-call + console.error(err); + + // Set the exit code to 1 to indicate a console app failure. + process.exitCode = 1; + } + + // Always close the app, even if some exception + // has been thrown from the console app. + await app.close(); +} + +void run(); diff --git a/apps/server/src/apps/preview-generator-consumer.app.ts b/apps/server/src/apps/preview-generator-consumer.app.ts index 1c2be631233..82a8d8bd9fd 100644 --- a/apps/server/src/apps/preview-generator-consumer.app.ts +++ b/apps/server/src/apps/preview-generator-consumer.app.ts @@ -7,7 +7,7 @@ import { install as sourceMapInstall } from 'source-map-support'; async function bootstrap() { sourceMapInstall(); - const nestApp = await NestFactory.createMicroservice(PreviewGeneratorAMQPModule); + const nestApp = await NestFactory.create(PreviewGeneratorAMQPModule); await nestApp.init(); console.log('#############################################'); diff --git a/apps/server/src/apps/server.app.ts b/apps/server/src/apps/server.app.ts index 83f924ca7f1..98672b1d672 100644 --- a/apps/server/src/apps/server.app.ts +++ b/apps/server/src/apps/server.app.ts @@ -12,6 +12,7 @@ import { GroupService } from '@modules/group'; import { FeathersRosterService } from '@modules/pseudonym'; import { RocketChatService } from '@modules/rocketchat'; import { ServerModule } from '@modules/server'; +import { InternalServerModule } from '@modules/internal-server'; import { TeamService } from '@modules/teams/service/team.service'; import { NestFactory } from '@nestjs/core'; import { ExpressAdapter } from '@nestjs/platform-express'; @@ -23,6 +24,7 @@ import { join } from 'path'; // register source-map-support for debugging import { install as sourceMapInstall } from 'source-map-support'; +import { ColumnBoardService } from '@modules/board'; import { AppStartLoggable } from './helpers/app-start-loggable'; import { addPrometheusMetricsMiddlewaresIfEnabled, @@ -62,6 +64,12 @@ async function bootstrap() { await nestApp.init(); + // create the internal server module on a separate express instance + const internalServerExpress = express(); + const internalServerExpressAdapter = new ExpressAdapter(internalServerExpress); + const internalServerApp = await NestFactory.create(InternalServerModule, internalServerExpressAdapter); + await internalServerApp.init(); + // provide NestJS mail service to feathers app // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access feathersExpress.services['nest-mail'] = { @@ -87,6 +95,8 @@ async function bootstrap() { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access feathersExpress.services['nest-group-service'] = nestApp.get(GroupService); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access + feathersExpress.services['nest-column-board-service'] = nestApp.get(ColumnBoardService); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access feathersExpress.services['nest-system-rule'] = nestApp.get(SystemRule); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment feathersExpress.services['nest-orm'] = orm; @@ -100,6 +110,7 @@ async function bootstrap() { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument rootExpress.use('/api/v1', feathersExpress); rootExpress.use('/api/v3', nestExpress); + rootExpress.use('/internal', internalServerExpress); rootExpress.use(express.static(join(__dirname, '../static-assets'))); // logger middleware for deprecated paths diff --git a/apps/server/src/apps/tldraw-console.app.ts b/apps/server/src/apps/tldraw-console.app.ts new file mode 100644 index 00000000000..30ca108f016 --- /dev/null +++ b/apps/server/src/apps/tldraw-console.app.ts @@ -0,0 +1,31 @@ +/* istanbul ignore file */ +import { BootstrapConsole } from 'nestjs-console'; +import { TldrawConsoleModule } from '@modules/tldraw/tldraw-console.module'; + +async function run() { + const bootstrap = new BootstrapConsole({ + module: TldrawConsoleModule, + useDecorators: true, + }); + + const app = await bootstrap.init(); + + try { + await app.init(); + + // Execute console application with provided arguments. + await bootstrap.boot(); + } catch (err) { + // eslint-disable-next-line no-console, @typescript-eslint/no-unsafe-call + console.error(err); + + // Set the exit code to 1 to indicate a console app failure. + process.exit(1); + } + + // Always close the app, even if some exception + // has been thrown from the console app. + await app.close(); +} + +void run(); diff --git a/apps/server/src/apps/tldraw.app.ts b/apps/server/src/apps/tldraw.app.ts index 704ef3be4f1..134d4e5d94a 100644 --- a/apps/server/src/apps/tldraw.app.ts +++ b/apps/server/src/apps/tldraw.app.ts @@ -2,7 +2,8 @@ /* eslint-disable no-console */ import { NestFactory } from '@nestjs/core'; import { install as sourceMapInstall } from 'source-map-support'; -import { TldrawModule, TldrawWsModule } from '@modules/tldraw'; +import { TldrawApiModule } from '@modules/tldraw/tldraw-api.module'; +import { TldrawWsModule } from '@modules/tldraw/tldraw-ws.module'; import { LegacyLogger, Logger } from '@src/core/logger'; import * as WebSocket from 'ws'; import { WsAdapter } from '@nestjs/platform-ws'; @@ -20,7 +21,7 @@ async function bootstrap() { const nestExpress = express(); const nestExpressAdapter = new ExpressAdapter(nestExpress); - const nestApp = await NestFactory.create(TldrawModule, nestExpressAdapter); + const nestApp = await NestFactory.create(TldrawApiModule, nestExpressAdapter); nestApp.useLogger(await nestApp.resolve(LegacyLogger)); nestApp.enableCors(); diff --git a/apps/server/src/config/database.config.ts b/apps/server/src/config/database.config.ts index 17c45dd1887..ad97e4c3d66 100644 --- a/apps/server/src/config/database.config.ts +++ b/apps/server/src/config/database.config.ts @@ -4,10 +4,9 @@ interface GlobalConstants { DB_URL: string; DB_PASSWORD?: string; DB_USERNAME?: string; - TLDRAW_DB_URL: string; } const usedGlobals: GlobalConstants = globals; /** Database URL */ -export const { DB_URL, DB_PASSWORD, DB_USERNAME, TLDRAW_DB_URL } = usedGlobals; +export const { DB_URL, DB_PASSWORD, DB_USERNAME } = usedGlobals; diff --git a/apps/server/src/config/mikro-orm-cli.config.ts b/apps/server/src/config/mikro-orm-cli.config.ts new file mode 100644 index 00000000000..97c2b0e698d --- /dev/null +++ b/apps/server/src/config/mikro-orm-cli.config.ts @@ -0,0 +1,38 @@ +import type { MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs/typings'; +import { ALL_ENTITIES } from '@shared/domain/entity'; +import { FileEntity } from '@modules/files/entity'; +import { FileRecord } from '@modules/files-storage/entity'; +import path from 'path'; +import { DB_PASSWORD, DB_URL, DB_USERNAME } from './index'; + +const migrationsPath = path.resolve(__dirname, '..', 'migrations', 'mikro-orm'); + +export const mikroOrmCliConfig: MikroOrmModuleSyncOptions = { + // TODO repeats server module definitions + type: 'mongo', + clientUrl: DB_URL, + password: DB_PASSWORD, + user: DB_USERNAME, + entities: [...ALL_ENTITIES, FileEntity, FileRecord], + allowGlobalContext: true, + /* + findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => + new NotFoundException(`The requested ${entityName}: ${JSON.stringify(where)} has not been found.`), + */ + migrations: { + tableName: 'migrations', // name of database table with log of executed transactions + path: migrationsPath, // path to the folder with migrations + pathTs: migrationsPath, // path to the folder with TS migrations (if used, we should put path to compiled files in `path`) + glob: '!(*.d).{js,ts}', // how to match migration files (all .js and .ts files, but not .d.ts) + transactional: false, // wrap each migration in a transaction + disableForeignKeys: true, // wrap statements with `set foreign_key_checks = 0` or equivalent + allOrNothing: false, // wrap all migrations in master transaction + dropTables: false, // allow to disable table dropping + safe: false, // allow to disable table and column dropping + snapshot: true, // save snapshot when creating new migrations + emit: 'ts', // migration generation mode + // generator: TSMigrationGenerator, // migration generator, e.g. to allow custom formatting + }, +}; + +export default mikroOrmCliConfig; diff --git a/apps/server/src/console/api-test/database-management.console.api.spec.ts b/apps/server/src/console/api-test/database-management.console.api.spec.ts index ea3cc340616..1a7da8f2b63 100644 --- a/apps/server/src/console/api-test/database-management.console.api.spec.ts +++ b/apps/server/src/console/api-test/database-management.console.api.spec.ts @@ -37,9 +37,11 @@ describe('DatabaseManagementConsole (API)', () => { const rootCli = consoleService.getRootCli(); rootCli.exitOverride(exitFn); const spyConsoleWriterInfo = jest.spyOn(consoleWriter, 'info'); - return { spyConsoleWriterInfo }; + const spyConsoleWriterError = jest.spyOn(consoleWriter, 'error'); + return { spyConsoleWriterInfo, spyConsoleWriterError }; }; - describe('when command not exists', () => { + + describe('when command does not exist', () => { it('should fail for unknown command', async () => { setup(); await expect(execute(bootstrap, ['database', 'not_existing_command'])).rejects.toThrow( @@ -74,6 +76,22 @@ describe('DatabaseManagementConsole (API)', () => { expect(spyConsoleWriterInfo).toBeCalled(); }); + + it('should output error if command "migration" is called without flags', async () => { + const { spyConsoleWriterError } = setup(); + + await execute(bootstrap, ['database', 'migration']); + + expect(spyConsoleWriterError).toBeCalled(); + }); + + it('should provide command "migration"', async () => { + const { spyConsoleWriterInfo } = setup(); + + await execute(bootstrap, ['database', 'migration', '--up']); + + expect(spyConsoleWriterInfo).toBeCalled(); + }); }); }); }); diff --git a/apps/server/src/console/api-test/server-console.api.spec.ts b/apps/server/src/console/api-test/server-console.api.spec.ts index d9b71bd2fbc..c3d560f0ce8 100644 --- a/apps/server/src/console/api-test/server-console.api.spec.ts +++ b/apps/server/src/console/api-test/server-console.api.spec.ts @@ -29,7 +29,7 @@ describe('ServerConsole (API)', () => { consoleService.resetCli(); }); - it('should poduce default output when executing "console server test"', async () => { + it('should produce default output when executing "console server test"', async () => { await execute(bootstrap, ['server', 'test']); expect(logMock).toHaveBeenCalledWith('Schulcloud Server API'); }); diff --git a/apps/server/src/console/console.module.ts b/apps/server/src/console/console.module.ts index 55520d2fe34..fab847827d7 100644 --- a/apps/server/src/console/console.module.ts +++ b/apps/server/src/console/console.module.ts @@ -1,19 +1,16 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ConsoleWriterModule } from '@infra/console/console-writer/console-writer.module'; import { KeycloakModule } from '@infra/identity-management/keycloak/keycloak.module'; -import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { FilesModule } from '@modules/files'; -import { FileRecord } from '@modules/files-storage/entity'; -import { FileEntity } from '@modules/files/entity'; import { ManagementModule } from '@modules/management/management.module'; import { serverConfig } from '@modules/server'; -import { Module, NotFoundException } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { ALL_ENTITIES } from '@shared/domain/entity'; -import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; +import { createConfigModuleOptions } from '@src/config'; import { ConsoleModule } from 'nestjs-console'; import { ServerConsole } from './server.console'; +import { mikroOrmCliConfig } from '../config/mikro-orm-cli.config'; @Module({ imports: [ @@ -23,17 +20,7 @@ import { ServerConsole } from './server.console'; FilesModule, ConfigModule.forRoot(createConfigModuleOptions(serverConfig)), ...((Configuration.get('FEATURE_IDENTITY_MANAGEMENT_ENABLED') as boolean) ? [KeycloakModule] : []), - MikroOrmModule.forRoot({ - // TODO repeats server module definitions - type: 'mongo', - clientUrl: DB_URL, - password: DB_PASSWORD, - user: DB_USERNAME, - entities: [...ALL_ENTITIES, FileEntity, FileRecord], - allowGlobalContext: true, - findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => - new NotFoundException(`The requested ${entityName}: ${JSON.stringify(where)} has not been found.`), - }), + MikroOrmModule.forRoot(mikroOrmCliConfig), ], providers: [ /** add console services as providers */ diff --git a/apps/server/src/core/interceptor/interceptor.module.ts b/apps/server/src/core/interceptor/interceptor.module.ts index 12f12306335..271ccf7766b 100644 --- a/apps/server/src/core/interceptor/interceptor.module.ts +++ b/apps/server/src/core/interceptor/interceptor.module.ts @@ -1,7 +1,7 @@ import { ClassSerializerInterceptor, Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { APP_INTERCEPTOR } from '@nestjs/core'; -import { InterceptorConfig, TimeoutInterceptor } from '@shared/common'; +import { TimeoutInterceptor } from '@shared/common'; /** ********************************************* * Global Interceptor setup @@ -17,11 +17,8 @@ import { InterceptorConfig, TimeoutInterceptor } from '@shared/common'; useClass: ClassSerializerInterceptor, }, { - provide: APP_INTERCEPTOR, // TODO remove (for testing) - useFactory: (configService: ConfigService) => { - const timeout = configService.get('INCOMING_REQUEST_TIMEOUT'); - return new TimeoutInterceptor(timeout); - }, + provide: APP_INTERCEPTOR, + useFactory: (configService: ConfigService) => new TimeoutInterceptor(configService), inject: [ConfigService], }, ], diff --git a/apps/server/src/core/logger/interfaces/logger-config.ts b/apps/server/src/core/logger/interfaces/logger-config.ts index 0d9c651ec70..851986d07b9 100644 --- a/apps/server/src/core/logger/interfaces/logger-config.ts +++ b/apps/server/src/core/logger/interfaces/logger-config.ts @@ -1,3 +1,4 @@ export interface LoggerConfig { NEST_LOG_LEVEL: string; + EXIT_ON_ERROR?: boolean; } diff --git a/apps/server/src/core/logger/logger.module.ts b/apps/server/src/core/logger/logger.module.ts index 477037b7cfa..89054e13c00 100644 --- a/apps/server/src/core/logger/logger.module.ts +++ b/apps/server/src/core/logger/logger.module.ts @@ -14,7 +14,7 @@ import { Logger } from './logger'; return { levels: winston.config.syslog.levels, level: configService.get('NEST_LOG_LEVEL'), - exitOnError: false, + exitOnError: configService.get('EXIT_ON_ERROR'), transports: [ new winston.transports.Console({ handleExceptions: true, diff --git a/apps/server/src/infra/calendar/calendar.module.ts b/apps/server/src/infra/calendar/calendar.module.ts index b4eddacef92..ac24f584c41 100644 --- a/apps/server/src/infra/calendar/calendar.module.ts +++ b/apps/server/src/infra/calendar/calendar.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; +import { CqrsModule } from '@nestjs/cqrs'; +import { LoggerModule } from '@src/core/logger'; import { CalendarService } from './service/calendar.service'; import { CalendarMapper } from './mapper/calendar.mapper'; @Module({ - imports: [HttpModule], + imports: [HttpModule, CqrsModule, LoggerModule], providers: [CalendarMapper, CalendarService], exports: [CalendarService], }) diff --git a/apps/server/src/infra/calendar/interface/calendar-event-id.interface.ts b/apps/server/src/infra/calendar/interface/calendar-event-id.interface.ts new file mode 100644 index 00000000000..2e0debfe66f --- /dev/null +++ b/apps/server/src/infra/calendar/interface/calendar-event-id.interface.ts @@ -0,0 +1,5 @@ +export interface CalendarEventId { + data: { + id: string; + }[]; +} diff --git a/apps/server/src/infra/calendar/mapper/calendar.mapper.spec.ts b/apps/server/src/infra/calendar/mapper/calendar.mapper.spec.ts index f84427791b9..0492f5ceeb2 100644 --- a/apps/server/src/infra/calendar/mapper/calendar.mapper.spec.ts +++ b/apps/server/src/infra/calendar/mapper/calendar.mapper.spec.ts @@ -1,6 +1,7 @@ import { CalendarEvent } from '@infra/calendar/interface/calendar-event.interface'; import { Test, TestingModule } from '@nestjs/testing'; import { CalendarMapper } from './calendar.mapper'; +import { CalendarEventId } from '../interface/calendar-event-id.interface'; describe('CalendarMapper', () => { let module: TestingModule; @@ -17,6 +18,10 @@ describe('CalendarMapper', () => { ], }; + const events: CalendarEventId = { + data: [{ id: '1' }, { id: '2' }], + }; + beforeAll(async () => { module = await Test.createTestingModule({ providers: [CalendarMapper], @@ -36,4 +41,12 @@ describe('CalendarMapper', () => { expect(result.teamId).toEqual('teamId'); expect(result.title).toEqual('eventTitle'); }); + + it('mapEventsToDto', () => { + const result = mapper.mapEventsToId(events); + + expect(result[0]).toEqual('1'); + expect(result[1]).toEqual('2'); + expect(result.length).toEqual(2); + }); }); diff --git a/apps/server/src/infra/calendar/mapper/calendar.mapper.ts b/apps/server/src/infra/calendar/mapper/calendar.mapper.ts index c229df3c278..5d5e67daef9 100644 --- a/apps/server/src/infra/calendar/mapper/calendar.mapper.ts +++ b/apps/server/src/infra/calendar/mapper/calendar.mapper.ts @@ -1,6 +1,7 @@ import { CalendarEvent } from '@infra/calendar/interface/calendar-event.interface'; import { Injectable } from '@nestjs/common'; import { CalendarEventDto } from '../dto/calendar-event.dto'; +import { CalendarEventId } from '../interface/calendar-event-id.interface'; @Injectable() export class CalendarMapper { @@ -11,4 +12,8 @@ export class CalendarMapper { title: attributes.summary, }); } + + mapEventsToId(events: CalendarEventId): string[] { + return events.data.map((it) => it.id); + } } diff --git a/apps/server/src/infra/calendar/service/calendar.service.spec.ts b/apps/server/src/infra/calendar/service/calendar.service.spec.ts index 95d1fa95e22..bfbf5e0396b 100644 --- a/apps/server/src/infra/calendar/service/calendar.service.spec.ts +++ b/apps/server/src/infra/calendar/service/calendar.service.spec.ts @@ -2,13 +2,22 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { CalendarEventDto, CalendarService } from '@infra/calendar'; import { HttpService } from '@nestjs/axios'; -import { InternalServerErrorException } from '@nestjs/common'; +import { HttpStatus, InternalServerErrorException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { axiosResponseFactory } from '@shared/testing'; import { AxiosResponse } from 'axios'; import { of, throwError } from 'rxjs'; +import { Logger } from '@src/core/logger'; +import { EntityId } from '@shared/domain/types'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, +} from '@modules/deletion'; import { CalendarEvent } from '../interface/calendar-event.interface'; import { CalendarMapper } from '../mapper/calendar.mapper'; +import { CalendarEventId } from '../interface/calendar-event-id.interface'; describe('CalendarServiceSpec', () => { let module: TestingModule; @@ -26,7 +35,6 @@ describe('CalendarServiceSpec', () => { return null; } }); - module = await Test.createTestingModule({ providers: [ CalendarService, @@ -38,6 +46,10 @@ describe('CalendarServiceSpec', () => { provide: CalendarMapper, useValue: createMock(), }, + { + provide: Logger, + useValue: createMock(), + }, ], }).compile(); service = module.get(CalendarService); @@ -50,7 +62,7 @@ describe('CalendarServiceSpec', () => { jest.clearAllMocks(); }); - describe('findEvent', () => { + describe('findEvents', () => { it('should successfully find an event', async () => { // Arrange const title = 'eventTitle'; @@ -82,7 +94,7 @@ describe('CalendarServiceSpec', () => { }); it('should throw if event cannot be found, because of invalid parameters', async () => { - const error = 'error'; + const error = 'error1'; httpService.get.mockReturnValue(throwError(() => error)); // Act & Assert @@ -91,4 +103,135 @@ describe('CalendarServiceSpec', () => { ); }); }); + + describe('getAllEvents', () => { + it('should successfully get all events', async () => { + const event: CalendarEventId = { + data: [{ id: '1' }, { id: '2' }], + }; + const axiosResponse: AxiosResponse = axiosResponseFactory.build({ + data: [event], + }); + httpService.get.mockReturnValue(of(axiosResponse)); + calendarMapper.mapEventsToId.mockReturnValueOnce(['1', '2']); + const result: string[] = await service.getAllEvents('userId'); + + expect(result.length).toEqual(2); + expect(result[0]).toEqual('1'); + expect(result[1]).toEqual('2'); + }); + + it('should throw if event cannot be found, because of invalid parameters', async () => { + const error = 'error1'; + httpService.get.mockReturnValue(throwError(() => error)); + + // Act & Assert + await expect(service.getAllEvents('invalid userId')).rejects.toThrow(InternalServerErrorException); + }); + }); + + describe('deleteEventsByScopeId', () => { + describe('when calling the delete events method', () => { + const setup = () => { + httpService.delete.mockReturnValue( + of( + axiosResponseFactory.build({ + data: '', + status: HttpStatus.NO_CONTENT, + statusText: 'NO_CONTENT', + }) + ) + ); + }; + + it('should call axios delete method', async () => { + setup(); + await service.deleteEventsByScopeId('test'); + expect(httpService.delete).toHaveBeenCalled(); + }); + }); + describe('When calling the delete events method with scopeId which does not exist', () => { + const setup = () => { + const error = 'error'; + httpService.delete.mockReturnValue(throwError(() => error)); + }; + + it('should throw error if cannot delete a events', async () => { + setup(); + await expect(service.deleteEventsByScopeId('invalid eventId')).rejects.toThrowError( + InternalServerErrorException + ); + }); + }); + describe('when received invalid HTTP status code in a response', () => { + const setup = () => { + const response: AxiosResponse = axiosResponseFactory.build({ + status: 200, + }); + + httpService.delete.mockReturnValueOnce(of(response)); + }; + + it('should throw an exception', async () => { + setup(); + + await expect(service.deleteEventsByScopeId('userId')).rejects.toThrow(Error); + }); + }); + + describe('when calling the deleteUserEvent events method', () => { + const setup = () => { + httpService.delete.mockReturnValue( + of( + axiosResponseFactory.build({ + data: '', + status: HttpStatus.NO_CONTENT, + statusText: 'NO_CONTENT', + }) + ) + ); + + const event: CalendarEventId = { + data: [{ id: '1' }, { id: '2' }], + }; + + const axiosResponse: AxiosResponse = axiosResponseFactory.build({ + data: [event], + }); + + httpService.get.mockReturnValue(of(axiosResponse)); + calendarMapper.mapEventsToId.mockReturnValueOnce(['1', '2']); + + const userId: EntityId = '1'; + + const expectedResult = DomainDeletionReportBuilder.build(DomainName.CALENDAR, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 2, ['1', '2']), + ]); + + return { + expectedResult, + userId, + }; + }; + + it('should call service.deleteEventsByScopeId with userId', async () => { + const { userId } = setup(); + const spyEvents = jest.spyOn(service, 'getAllEvents'); + const spyDelete = jest.spyOn(service, 'deleteEventsByScopeId'); + + await service.deleteUserData(userId); + + expect(spyEvents).toHaveBeenCalledWith(userId); + expect(spyDelete).toHaveBeenCalledWith(userId); + }); + + it('should return domainOperation object with information about deleted user', async () => { + const { expectedResult, userId } = setup(); + + const result = await service.deleteUserData(userId); + + expect(result).toEqual(expectedResult); + }); + }); + }); }); diff --git a/apps/server/src/infra/calendar/service/calendar.service.ts b/apps/server/src/infra/calendar/service/calendar.service.ts index 654da665b1b..df6bfdf73dc 100644 --- a/apps/server/src/infra/calendar/service/calendar.service.ts +++ b/apps/server/src/infra/calendar/service/calendar.service.ts @@ -1,32 +1,80 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { HttpService } from '@nestjs/axios'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; import { ErrorUtils } from '@src/core/error/utils'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { firstValueFrom, Observable } from 'rxjs'; import { URL, URLSearchParams } from 'url'; +import { Logger } from '@src/core/logger'; +import { + DataDeletionDomainOperationLoggable, + DeletionService, + DomainDeletionReport, + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + StatusModel, +} from '@modules/deletion'; +import { EntityId } from '@shared/domain/types'; import { CalendarEventDto } from '../dto/calendar-event.dto'; import { CalendarEvent } from '../interface/calendar-event.interface'; import { CalendarMapper } from '../mapper/calendar.mapper'; +import { CalendarEventId } from '../interface/calendar-event-id.interface'; @Injectable() -export class CalendarService { +export class CalendarService implements DeletionService { private readonly baseURL: string; private readonly timeoutMs: number; - constructor(private readonly httpService: HttpService, private readonly calendarMapper: CalendarMapper) { + constructor( + private readonly httpService: HttpService, + private readonly calendarMapper: CalendarMapper, + private readonly logger: Logger + ) { + this.logger.setContext(CalendarService.name); this.baseURL = Configuration.get('CALENDAR_URI') as string; this.timeoutMs = Configuration.get('REQUEST_OPTION__TIMEOUT_MS') as number; } + public async deleteUserData(userId: EntityId): Promise { + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'Deleting data from Calendar Service', + DomainName.CALENDAR, + userId, + StatusModel.PENDING + ) + ); + + const eventIds = await this.getAllEvents(userId); + + await this.deleteEventsByScopeId(userId); + + const result = DomainDeletionReportBuilder.build(DomainName.CALENDAR, [ + DomainOperationReportBuilder.build(OperationType.DELETE, eventIds.length, eventIds), + ]); + + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'Successfully removed user data from Calendar Service', + DomainName.CALENDAR, + userId, + StatusModel.FINISHED, + 0, + eventIds.length + ) + ); + + return result; + } + async findEvent(userId: EntityId, eventId: EntityId): Promise { const params = new URLSearchParams(); params.append('event-id', eventId); - return firstValueFrom( - this.get('/events', params, { + this.get('/events', params, { headers: { Authorization: userId, Accept: 'Application/json', @@ -43,14 +91,60 @@ export class CalendarService { }); } - private get( - path: string, - queryParams: URLSearchParams, - config: AxiosRequestConfig - ): Observable> { + async getAllEvents(userId: EntityId): Promise { + const params = new URLSearchParams(); + try { + const resp = await firstValueFrom( + this.get('/events', params, { + headers: { + Authorization: userId, + Accept: 'Application/json', + }, + timeout: this.timeoutMs, + }) + ); + return this.calendarMapper.mapEventsToId(resp.data); + } catch (error) { + throw new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(error, 'CalendarService:getAllEvents') + ); + } + } + + async deleteEventsByScopeId(scopeId: EntityId): Promise { + try { + const request = this.delete(`/scopes/${scopeId}`, { + headers: { + Authorization: scopeId, + Accept: 'Application/json', + }, + timeout: this.timeoutMs, + }); + + const resp = await firstValueFrom(request); + + if (resp.status !== 204) { + throw new Error(`invalid HTTP status code = ${resp.status} in a response from the server instead of 204`); + } + } catch (err) { + throw new InternalServerErrorException( + 'CalendarService:deleteEventsByScopeId', + ErrorUtils.createHttpExceptionOptions(err) + ); + } + } + + private get(path: string, queryParams: URLSearchParams, config: AxiosRequestConfig): Observable> { const url: URL = new URL(this.baseURL); url.pathname = path; url.search = queryParams.toString(); return this.httpService.get(url.toString(), config); } + + private delete(path: string, config: AxiosRequestConfig): Observable> { + const url: URL = new URL(this.baseURL); + url.pathname = path; + return this.httpService.delete(url.toString(), config); + } } diff --git a/apps/server/src/infra/console/console-writer/console-writer.service.spec.ts b/apps/server/src/infra/console/console-writer/console-writer.service.spec.ts index 09cbb79c9ff..9cc84a27ab5 100644 --- a/apps/server/src/infra/console/console-writer/console-writer.service.spec.ts +++ b/apps/server/src/infra/console/console-writer/console-writer.service.spec.ts @@ -20,4 +20,16 @@ describe('ConsoleWriterService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + it('should log info', () => { + const spy = jest.spyOn(console, 'info').mockImplementation(() => {}); + service.info('test'); + expect(spy).toHaveBeenCalledWith('Info:', 'test'); + }); + + it('should log error', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + service.error('test'); + expect(spy).toHaveBeenCalledWith('Error:', 'test'); + }); }); diff --git a/apps/server/src/infra/console/console-writer/console-writer.service.ts b/apps/server/src/infra/console/console-writer/console-writer.service.ts index f02f7b8785d..d439cc3cd7b 100644 --- a/apps/server/src/infra/console/console-writer/console-writer.service.ts +++ b/apps/server/src/infra/console/console-writer/console-writer.service.ts @@ -6,4 +6,9 @@ export class ConsoleWriterService { // eslint-disable-next-line no-console console.info('Info:', text); } + + error(text: string): void { + // eslint-disable-next-line no-console + console.error('Error:', text); + } } diff --git a/apps/server/src/infra/database/management/database-management.service.spec.ts b/apps/server/src/infra/database/management/database-management.service.spec.ts index 7bbe7bdc5e3..5def01912c4 100644 --- a/apps/server/src/infra/database/management/database-management.service.spec.ts +++ b/apps/server/src/infra/database/management/database-management.service.spec.ts @@ -1,7 +1,7 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; import { MikroORM } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { ObjectId } from 'mongodb'; import { DatabaseManagementService } from './database-management.service'; const randomChars = () => new ObjectId().toHexString(); @@ -86,4 +86,82 @@ describe('DatabaseManagementService', () => { expect(spy).toHaveBeenCalled(); }); }); + + describe('When call migrationUp()', () => { + const setup = () => { + orm.getMigrator().up = jest.fn(); + }; + it('should call migrator.up()', async () => { + setup(); + await service.migrationUp(); + expect(orm.getMigrator().up).toHaveBeenCalled(); + }); + it('should call migrator.up() with from', async () => { + setup(); + const params = { from: 'foo' }; + await service.migrationUp(params.from, undefined, undefined); + expect(orm.getMigrator().up).toHaveBeenCalledWith(params); + }); + it('should call migrator.up() with param "to"', async () => { + setup(); + const params = { to: 'foo' }; + await service.migrationUp(undefined, params.to, undefined); + expect(orm.getMigrator().up).toHaveBeenCalledWith(params); + }); + it('should call migrator.up() with param "only"', async () => { + setup(); + const params = { only: 'foo' }; + await service.migrationUp(undefined, undefined, params.only); + expect(orm.getMigrator().up).toHaveBeenCalledWith({ migrations: [params.only] }); + }); + it('should call migrator.up() with param "only" and ignore from and to', async () => { + setup(); + const params = { only: 'foo' }; + await service.migrationUp('bar', 'baz', params.only); + expect(orm.getMigrator().up).toHaveBeenCalledWith({ migrations: [params.only] }); + }); + }); + + describe('When call migrationDown()', () => { + const setup = () => { + orm.getMigrator().down = jest.fn(); + }; + it('should call migrator.down()', async () => { + setup(); + await service.migrationDown(); + expect(orm.getMigrator().down).toHaveBeenCalled(); + }); + it('should call migrator.down() with from', async () => { + setup(); + const params = { from: 'foo' }; + await service.migrationDown(params.from, undefined, undefined); + expect(orm.getMigrator().down).toHaveBeenCalledWith(params); + }); + it('should call migrator.down() with param "to"', async () => { + setup(); + const params = { to: 'foo' }; + await service.migrationDown(undefined, params.to, undefined); + expect(orm.getMigrator().down).toHaveBeenCalledWith(params); + }); + it('should call migrator.down() with param "only"', async () => { + setup(); + const params = { only: 'foo' }; + await service.migrationDown(undefined, undefined, params.only); + expect(orm.getMigrator().down).toHaveBeenCalledWith({ migrations: [params.only] }); + }); + it('should call migrator.down() with param "only" and ignore from and to', async () => { + setup(); + const params = { only: 'foo' }; + await service.migrationDown('bar', 'baz', params.only); + expect(orm.getMigrator().down).toHaveBeenCalledWith({ migrations: [params.only] }); + }); + }); + + describe('When call migrationPending()', () => { + it('should call migrator.getPendingMigrations()', async () => { + const spy = jest.spyOn(orm.getMigrator(), 'getPendingMigrations'); + await service.migrationPending(); + expect(spy).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/server/src/infra/database/management/database-management.service.ts b/apps/server/src/infra/database/management/database-management.service.ts index d7e0aa22e56..2967a83f3d2 100644 --- a/apps/server/src/infra/database/management/database-management.service.ts +++ b/apps/server/src/infra/database/management/database-management.service.ts @@ -1,15 +1,17 @@ import { MikroORM } from '@mikro-orm/core'; +import { MigrateOptions, UmzugMigration } from '@mikro-orm/migrations-mongodb'; import { EntityManager } from '@mikro-orm/mongodb'; +import { Collection, Db } from '@mikro-orm/mongodb/node_modules/mongodb'; import { Injectable } from '@nestjs/common'; import { BaseEntity } from '@shared/domain/entity'; -import { Collection, Db } from 'mongodb'; @Injectable() export class DatabaseManagementService { constructor(private em: EntityManager, private readonly orm: MikroORM) {} private get db(): Db { - return this.em.getConnection('write').getDb(); + const connection = this.em.getConnection('write').getDb(); + return connection; } getDatabaseCollection(collectionName: string): Collection { @@ -26,6 +28,7 @@ export class DatabaseManagementService { forceServerObjectId: true, bypassDocumentValidation: true, }); + return insertedCount; } @@ -66,4 +69,38 @@ export class DatabaseManagementService { async syncIndexes(): Promise { return this.orm.getSchemaGenerator().ensureIndexes(); } + + async migrationUp(from?: string, to?: string, only?: string): Promise { + const migrator = this.orm.getMigrator(); + const params = this.migrationParams(only, from, to); + await migrator.up(params); + } + + async migrationDown(from?: string, to?: string, only?: string): Promise { + const migrator = this.orm.getMigrator(); + const params = this.migrationParams(only, from, to); + + await migrator.down(params); + } + + async migrationPending(): Promise { + const migrator = this.orm.getMigrator(); + const pendingMigrations = await migrator.getPendingMigrations(); + return pendingMigrations; + } + + private migrationParams(only?: string, from?: string, to?: string) { + const params: MigrateOptions = {}; + if (only) { + params.migrations = [only]; + } else { + if (from) { + params.from = from; + } + if (to) { + params.to = to; + } + } + return params; + } } diff --git a/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.ts b/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.ts index a953d0b558a..a6e1669f869 100644 --- a/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.ts +++ b/apps/server/src/infra/identity-management/keycloak-administration/service/keycloak-administration.service.ts @@ -8,7 +8,7 @@ export class KeycloakAdministrationService { private static AUTHORIZATION_TIMEBOX_MS = 59 * 1000; - public constructor( + constructor( private readonly kcAdminClient: KeycloakAdminClient, @Inject(KeycloakSettings) private readonly kcSettings: IKeycloakSettings ) { diff --git a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts index fc9f592f428..3c8294eb788 100644 --- a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.integration.spec.ts @@ -3,10 +3,10 @@ import KeycloakAdminClient from '@keycloak/keycloak-admin-client'; import { EntityManager } from '@mikro-orm/mongodb'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { Account } from '@shared/domain/entity'; import { accountFactory, cleanupCollections } from '@shared/testing'; import { LoggerModule } from '@src/core/logger'; import { v1 } from 'uuid'; +import { AccountEntity } from '@modules/account/entity/account.entity'; import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; import { KeycloakConfigurationModule } from '../keycloak-configuration.module'; import { KeycloakMigrationService } from './keycloak-migration.service'; @@ -19,13 +19,13 @@ describe('KeycloakConfigurationService Integration', () => { let keycloakAdministrationService: KeycloakAdministrationService; let isKeycloakAvailable = false; - let dbOnlyAccounts: Account[]; - let dbAndIdmAccounts: Account[]; - let allAccounts: Account[]; + let dbOnlyAccounts: AccountEntity[]; + let dbAndIdmAccounts: AccountEntity[]; + let allAccounts: AccountEntity[]; const testRealm = `test-realm-${v1().toString()}`; - const createAccountInIdm = async (account: Account): Promise => { + const createAccountInIdm = async (account: AccountEntity): Promise => { const { id } = await keycloak.users.create({ username: account.username, firstName: undefined, diff --git a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.spec.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.spec.ts index e02ade6a26b..2aaf741feb0 100644 --- a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.spec.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.spec.ts @@ -1,8 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto/account.dto'; +import { AccountService, Account } from '@modules/account'; import KeycloakAdminClient from '@keycloak/keycloak-admin-client-cjs/keycloak-admin-client-cjs-index'; import { Users } from '@keycloak/keycloak-admin-client/lib/resources/users'; import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'; @@ -31,7 +30,7 @@ describe('KeycloakMigrationService', () => { { provide: AccountService, useValue: { - findMany: jest.fn().mockImplementation((skip: number, amount: number): Promise[]> => { + findMany: jest.fn().mockImplementation((skip: number, amount: number): Promise[]> => { if (skip >= maxAccounts) { return Promise.resolve([]); } diff --git a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.ts b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.ts index ce87478f637..ef4f8a44bb3 100644 --- a/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.ts +++ b/apps/server/src/infra/identity-management/keycloak-configuration/service/keycloak-migration.service.ts @@ -1,8 +1,7 @@ import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation'; import { Injectable } from '@nestjs/common'; import { LegacyLogger } from '@src/core/logger'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto'; +import { AccountService, Account } from '@modules/account'; import { KeycloakAdministrationService } from '../../keycloak-administration/service/keycloak-administration.service'; @Injectable() @@ -20,7 +19,7 @@ export class KeycloakMigrationService { let skip = start; let foundAccounts = 1; let migratedAccounts = 0; - let accounts: AccountDto[] = []; + let accounts: Account[] = []; while (foundAccounts > 0) { // eslint-disable-next-line no-await-in-loop accounts = await this.accountService.findMany(skip, amount); @@ -45,7 +44,7 @@ export class KeycloakMigrationService { return migratedAccounts; } - private async createOrUpdateIdmAccount(account: AccountDto): Promise { + private async createOrUpdateIdmAccount(account: Account): Promise { const idmUserRepresentation: UserRepresentation = { username: account.username, enabled: true, diff --git a/apps/server/src/infra/preview-generator/preview-generator-consumer.module.ts b/apps/server/src/infra/preview-generator/preview-generator-consumer.module.ts index ca4df0d074c..abda8e8338c 100644 --- a/apps/server/src/infra/preview-generator/preview-generator-consumer.module.ts +++ b/apps/server/src/infra/preview-generator/preview-generator-consumer.module.ts @@ -1,8 +1,7 @@ -import { DynamicModule, Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; import { RabbitMQWrapperModule } from '@infra/rabbitmq'; import { S3ClientAdapter, S3ClientModule } from '@infra/s3-client'; -import { createConfigModuleOptions } from '@src/config'; +import { DynamicModule, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { Logger, LoggerModule } from '@src/core/logger'; import { PreviewConfig } from './interface/preview-consumer-config'; import { PreviewGeneratorConsumer } from './preview-generator.consumer'; @@ -28,7 +27,7 @@ export class PreviewGeneratorConsumerModule { LoggerModule, S3ClientModule.register([storageConfig]), RabbitMQWrapperModule, - ConfigModule.forRoot(createConfigModuleOptions(() => serverConfig)), + ConfigModule.forFeature(() => serverConfig), ], providers, }; diff --git a/apps/server/src/infra/rabbitmq/exchange/files-storage.ts b/apps/server/src/infra/rabbitmq/exchange/files-storage.ts index 39e763eacff..9677777b084 100644 --- a/apps/server/src/infra/rabbitmq/exchange/files-storage.ts +++ b/apps/server/src/infra/rabbitmq/exchange/files-storage.ts @@ -8,6 +8,7 @@ export enum FilesStorageEvents { 'LIST_FILES_OF_PARENT' = 'list-files-of-parent', 'DELETE_FILES_OF_PARENT' = 'delete-files-of-parent', 'REMOVE_CREATORID_OF_FILES' = 'remove-creatorId-of-files', + 'DELETE_FILES' = 'delete-files', } export enum ScanStatus { @@ -25,6 +26,7 @@ export enum FileRecordParentType { 'Task' = 'tasks', 'Lesson' = 'lessons', 'Submission' = 'submissions', + 'Grading' = 'gradings', 'BoardNode' = 'boardnodes', } @@ -56,4 +58,6 @@ export interface FileDO { mimeType: string; parentType: FileRecordParentType; deletedSince?: Date; + createdAt?: Date; + updatedAt?: Date; } diff --git a/apps/server/src/infra/redis/index.ts b/apps/server/src/infra/redis/index.ts deleted file mode 100644 index 2465e50ce81..00000000000 --- a/apps/server/src/infra/redis/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './redis.module'; -export * from './interface/redis.constants'; diff --git a/apps/server/src/infra/redis/interface/redis.constants.ts b/apps/server/src/infra/redis/interface/redis.constants.ts deleted file mode 100644 index d7acc566ca5..00000000000 --- a/apps/server/src/infra/redis/interface/redis.constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const REDIS_CLIENT = Symbol('INFRA:REDIS'); diff --git a/apps/server/src/infra/redis/redis.module.ts b/apps/server/src/infra/redis/redis.module.ts deleted file mode 100644 index 569fad4e101..00000000000 --- a/apps/server/src/infra/redis/redis.module.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { Module } from '@nestjs/common'; -import { LegacyLogger, LoggerModule } from '@src/core/logger'; -import { createClient, RedisClientType } from 'redis'; -import { REDIS_CLIENT } from './interface/redis.constants'; - -@Module({ - imports: [LoggerModule], - providers: [ - { - provide: REDIS_CLIENT, - useFactory: (logger: LegacyLogger) => { - logger.setContext(RedisModule.name); - - if (Configuration.has('REDIS_URI')) { - const redisUrl: string = Configuration.get('REDIS_URI') as string; - const client: RedisClientType = createClient({ url: redisUrl }); - - client.on('error', (error) => logger.error(error)); - client.on('connect', (msg) => logger.log(msg)); - - return client; - } - - return undefined; - }, - inject: [LegacyLogger], - }, - ], - exports: [REDIS_CLIENT], -}) -export class RedisModule {} diff --git a/apps/server/src/infra/schulconnex-client/index.ts b/apps/server/src/infra/schulconnex-client/index.ts new file mode 100644 index 00000000000..1bc03b95236 --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/index.ts @@ -0,0 +1,21 @@ +export { SchulconnexRestClientOptions } from './schulconnex-rest-client-options'; +export { SchulconnexClientModule } from './schulconnex-client.module'; +export { SchulconnexRestClient } from './schulconnex-rest-client'; +export { + SanisResponse, + SanisRole, + SanisGroupRole, + SanisGroupType, + SanisGruppenResponse, + SanisResponseValidationGroups, + SanisPersonResponse, + SanisAnschriftResponse, + SanisGruppenzugehoerigkeitResponse, + SanisGruppeResponse, + SanisNameResponse, + SanisOrganisationResponse, + SanisPersonenkontextResponse, + SanisSonstigeGruppenzugehoerigeResponse, +} from './response'; +export { schulconnexResponseFactory } from './testing/schulconnex-response-factory'; +export { SchulconnexClientConfig } from './schulconnex-client-config'; diff --git a/apps/server/src/infra/schulconnex-client/loggable/index.ts b/apps/server/src/infra/schulconnex-client/loggable/index.ts new file mode 100644 index 00000000000..1b4cbfe6148 --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/loggable/index.ts @@ -0,0 +1 @@ +export { SchulconnexConfigurationMissingLoggable } from './schulconnex-configuration-missing.loggable'; diff --git a/apps/server/src/infra/schulconnex-client/loggable/schulconnex-configuration-missing.loggable.spec.ts b/apps/server/src/infra/schulconnex-client/loggable/schulconnex-configuration-missing.loggable.spec.ts new file mode 100644 index 00000000000..20adf239652 --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/loggable/schulconnex-configuration-missing.loggable.spec.ts @@ -0,0 +1,16 @@ +import { SchulconnexConfigurationMissingLoggable } from './schulconnex-configuration-missing.loggable'; + +describe(SchulconnexConfigurationMissingLoggable.name, () => { + describe('getLogMessage', () => { + it('should return a log message', () => { + const loggable: SchulconnexConfigurationMissingLoggable = new SchulconnexConfigurationMissingLoggable(); + + const logMessage = loggable.getLogMessage(); + + expect(logMessage).toEqual({ + message: + 'SchulconnexRestClient: Missing configuration. Please check your environment variables in SCHULCONNEX_CLIENT.', + }); + }); + }); +}); diff --git a/apps/server/src/infra/schulconnex-client/loggable/schulconnex-configuration-missing.loggable.ts b/apps/server/src/infra/schulconnex-client/loggable/schulconnex-configuration-missing.loggable.ts new file mode 100644 index 00000000000..ec0e299c21b --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/loggable/schulconnex-configuration-missing.loggable.ts @@ -0,0 +1,9 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class SchulconnexConfigurationMissingLoggable implements Loggable { + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `SchulconnexRestClient: Missing configuration. Please check your environment variables in SCHULCONNEX_CLIENT.`, + }; + } +} diff --git a/apps/server/src/infra/schulconnex-client/request/index.ts b/apps/server/src/infra/schulconnex-client/request/index.ts new file mode 100644 index 00000000000..a0bae6cf2d6 --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/request/index.ts @@ -0,0 +1 @@ +export { SchulconnexPersonenInfoParams } from './schulconnex-personen-info-params'; diff --git a/apps/server/src/infra/schulconnex-client/request/schulconnex-personen-info-params.ts b/apps/server/src/infra/schulconnex-client/request/schulconnex-personen-info-params.ts new file mode 100644 index 00000000000..35aa16da657 --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/request/schulconnex-personen-info-params.ts @@ -0,0 +1,13 @@ +export type SchulconnexPropertyContext = 'personen' | 'personenkontexte' | 'organisationen' | 'gruppen' | 'beziehungen'; + +export interface SchulconnexPersonenInfoParams { + vollstaendig?: SchulconnexPropertyContext[]; + + pid?: string; + + 'personenkontext.id'?: string; + + 'organisation.id'?: string; + + 'gruppe.id'?: string; +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts b/apps/server/src/infra/schulconnex-client/response/index.ts similarity index 78% rename from apps/server/src/modules/provisioning/strategy/sanis/response/index.ts rename to apps/server/src/infra/schulconnex-client/response/index.ts index bf4151eb116..dd4e88ff691 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/index.ts +++ b/apps/server/src/infra/schulconnex-client/response/index.ts @@ -12,3 +12,5 @@ export * from './sanis-person-response'; export * from './sanis-sonstige-gruppenzugehoerige-response'; export * from './sanis-anschrift-response'; export * from './sanis-response-validation-groups'; +export { SanisErreichbarkeitenResponse } from './sanis-erreichbarkeiten-response'; +export { SchulconnexCommunicationType } from './schulconnex-communication-type'; diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts b/apps/server/src/infra/schulconnex-client/response/sanis-anschrift-response.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-anschrift-response.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-anschrift-response.ts diff --git a/apps/server/src/infra/schulconnex-client/response/sanis-erreichbarkeiten-response.ts b/apps/server/src/infra/schulconnex-client/response/sanis-erreichbarkeiten-response.ts new file mode 100644 index 00000000000..0a3c386c4fd --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/response/sanis-erreichbarkeiten-response.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class SanisErreichbarkeitenResponse { + @IsString() + typ!: string; + + @IsString() + kennung!: string; +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-geburt-response.ts b/apps/server/src/infra/schulconnex-client/response/sanis-geburt-response.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-geburt-response.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-geburt-response.ts diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-group-role.ts b/apps/server/src/infra/schulconnex-client/response/sanis-group-role.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-group-role.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-group-role.ts diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-group-type.ts b/apps/server/src/infra/schulconnex-client/response/sanis-group-type.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-group-type.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-group-type.ts diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppe-response.ts b/apps/server/src/infra/schulconnex-client/response/sanis-gruppe-response.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppe-response.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-gruppe-response.ts diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppen-response.ts b/apps/server/src/infra/schulconnex-client/response/sanis-gruppen-response.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppen-response.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-gruppen-response.ts diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppenzugehoerigkeit-response.ts b/apps/server/src/infra/schulconnex-client/response/sanis-gruppenzugehoerigkeit-response.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-gruppenzugehoerigkeit-response.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-gruppenzugehoerigkeit-response.ts diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-name-response.ts b/apps/server/src/infra/schulconnex-client/response/sanis-name-response.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-name-response.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-name-response.ts diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts b/apps/server/src/infra/schulconnex-client/response/sanis-organisation-response.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-organisation-response.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-organisation-response.ts diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-person-response.ts b/apps/server/src/infra/schulconnex-client/response/sanis-person-response.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-person-response.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-person-response.ts diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-personenkontext-response.ts b/apps/server/src/infra/schulconnex-client/response/sanis-personenkontext-response.ts similarity index 82% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-personenkontext-response.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-personenkontext-response.ts index 6cf78aeb109..dc5ba40eea9 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-personenkontext-response.ts +++ b/apps/server/src/infra/schulconnex-client/response/sanis-personenkontext-response.ts @@ -1,5 +1,6 @@ import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { SanisErreichbarkeitenResponse } from './sanis-erreichbarkeiten-response'; import { SanisGruppenResponse } from './sanis-gruppen-response'; import { SanisOrganisationResponse } from './sanis-organisation-response'; import { SanisResponseValidationGroups } from './sanis-response-validation-groups'; @@ -22,4 +23,10 @@ export class SanisPersonenkontextResponse { @ValidateNested({ each: true, groups: [SanisResponseValidationGroups.GROUPS] }) @Type(() => SanisGruppenResponse) gruppen?: SanisGruppenResponse[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SanisErreichbarkeitenResponse) + erreichbarkeiten?: SanisErreichbarkeitenResponse[]; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-response-validation-groups.ts b/apps/server/src/infra/schulconnex-client/response/sanis-response-validation-groups.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-response-validation-groups.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-response-validation-groups.ts diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-role.ts b/apps/server/src/infra/schulconnex-client/response/sanis-role.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-role.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-role.ts diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis-sonstige-gruppenzugehoerige-response.ts b/apps/server/src/infra/schulconnex-client/response/sanis-sonstige-gruppenzugehoerige-response.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis-sonstige-gruppenzugehoerige-response.ts rename to apps/server/src/infra/schulconnex-client/response/sanis-sonstige-gruppenzugehoerige-response.ts diff --git a/apps/server/src/modules/provisioning/strategy/sanis/response/sanis.response.ts b/apps/server/src/infra/schulconnex-client/response/sanis.response.ts similarity index 100% rename from apps/server/src/modules/provisioning/strategy/sanis/response/sanis.response.ts rename to apps/server/src/infra/schulconnex-client/response/sanis.response.ts diff --git a/apps/server/src/infra/schulconnex-client/response/schulconnex-communication-type.ts b/apps/server/src/infra/schulconnex-client/response/schulconnex-communication-type.ts new file mode 100644 index 00000000000..8e9dfe59041 --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/response/schulconnex-communication-type.ts @@ -0,0 +1,3 @@ +export enum SchulconnexCommunicationType { + EMAIL = 'E-Mail', +} diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-api.interface.ts b/apps/server/src/infra/schulconnex-client/schulconnex-api.interface.ts new file mode 100644 index 00000000000..66ea77be98d --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/schulconnex-api.interface.ts @@ -0,0 +1,8 @@ +import { SchulconnexPersonenInfoParams } from './request'; +import { SanisResponse } from './response'; + +export interface SchulconnexApiInterface { + getPersonInfo(accessToken: string, options?: { overrideUrl: string }): Promise; + + getPersonenInfo(params: SchulconnexPersonenInfoParams): Promise; +} diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts b/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts new file mode 100644 index 00000000000..057392d593e --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/schulconnex-client-config.ts @@ -0,0 +1,3 @@ +export interface SchulconnexClientConfig { + SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS: number; +} diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts b/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts new file mode 100644 index 00000000000..ec4b08dd303 --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/schulconnex-client.module.ts @@ -0,0 +1,30 @@ +import { OauthAdapterService, OauthModule } from '@modules/oauth'; +import { HttpModule, HttpService } from '@nestjs/axios'; +import { DynamicModule, Global, Module } from '@nestjs/common'; +import { Logger, LoggerModule } from '@src/core/logger'; +import { SchulconnexRestClient } from './schulconnex-rest-client'; +import { SchulconnexRestClientOptions } from './schulconnex-rest-client-options'; + +@Global() +/** + * @Global is used here to make sure that the module is only instantiated once, with the configuration and can be used in every module. + * Otherwise, you need to import the module with configuration in every module where you want to use it. + */ +@Module({}) +export class SchulconnexClientModule { + static register(options: SchulconnexRestClientOptions): DynamicModule { + return { + imports: [HttpModule, OauthModule, LoggerModule], + module: SchulconnexClientModule, + providers: [ + { + provide: SchulconnexRestClient, + useFactory: (httpService: HttpService, oauthAdapterService: OauthAdapterService, logger: Logger) => + new SchulconnexRestClient(options, httpService, oauthAdapterService, logger), + inject: [HttpService, OauthAdapterService, Logger], + }, + ], + exports: [SchulconnexRestClient], + }; + } +} diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts new file mode 100644 index 00000000000..d2f8ebb3a1a --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client-options.ts @@ -0,0 +1,11 @@ +export interface SchulconnexRestClientOptions { + apiUrl: string; + + tokenEndpoint: string; + + clientId: string; + + clientSecret: string; + + personenInfoTimeoutInMs?: number; +} diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts new file mode 100644 index 00000000000..c25d2962a98 --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.spec.ts @@ -0,0 +1,185 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { OauthAdapterService, OAuthTokenDto } from '@modules/oauth'; +import { HttpService } from '@nestjs/axios'; +import { axiosResponseFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { of } from 'rxjs'; +import { SchulconnexConfigurationMissingLoggable } from './loggable'; +import { SanisResponse } from './response'; +import { SchulconnexRestClient } from './schulconnex-rest-client'; +import { SchulconnexRestClientOptions } from './schulconnex-rest-client-options'; +import { schulconnexResponseFactory } from './testing'; + +describe(SchulconnexRestClient.name, () => { + let client: SchulconnexRestClient; + + let httpService: DeepMocked; + let oauthAdapterService: DeepMocked; + let logger: DeepMocked; + const options: SchulconnexRestClientOptions = { + apiUrl: 'https://schulconnex.url/api', + clientId: 'clientId', + clientSecret: 'clientSecret', + tokenEndpoint: 'https://schulconnex.url/token', + }; + + beforeAll(() => { + httpService = createMock(); + oauthAdapterService = createMock(); + logger = createMock(); + + client = new SchulconnexRestClient(options, httpService, oauthAdapterService, logger); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('constructor', () => { + describe('when configuration is missing', () => { + const setup = () => { + const badOptions: SchulconnexRestClientOptions = { + apiUrl: '', + clientId: '', + clientSecret: '', + tokenEndpoint: '', + }; + return { + badOptions, + }; + }; + + it('should log a message', () => { + const { badOptions } = setup(); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const badOptionsClient = new SchulconnexRestClient(badOptions, httpService, oauthAdapterService, logger); + + expect(logger.debug).toHaveBeenCalledWith(new SchulconnexConfigurationMissingLoggable()); + }); + }); + }); + + describe('getPersonInfo', () => { + describe('when requesting person-info', () => { + const setup = () => { + const accessToken = 'accessToken'; + const response: SanisResponse = schulconnexResponseFactory.build(); + + httpService.get.mockReturnValueOnce(of(axiosResponseFactory.build({ data: response }))); + + return { + accessToken, + response, + }; + }; + + it('should make a request to a SchulConneX-API', async () => { + const { accessToken } = setup(); + + await client.getPersonInfo(accessToken); + + expect(httpService.get).toHaveBeenCalledWith(`${options.apiUrl}/person-info`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'gzip', + }, + }); + }); + + it('should return the response', async () => { + const { accessToken, response } = setup(); + + const result: SanisResponse = await client.getPersonInfo(accessToken); + + expect(result).toEqual(response); + }); + }); + + describe('when overriding the url', () => { + const setup = () => { + const accessToken = 'accessToken'; + const customUrl = 'https://override.url/person-info'; + const response: SanisResponse = schulconnexResponseFactory.build(); + + httpService.get.mockReturnValueOnce(of(axiosResponseFactory.build({ data: response }))); + + return { + accessToken, + customUrl, + }; + }; + + it('should make a request to a SchulConneX-API', async () => { + const { accessToken, customUrl } = setup(); + + await client.getPersonInfo(accessToken, { overrideUrl: customUrl }); + + expect(httpService.get).toHaveBeenCalledWith(customUrl, expect.anything()); + }); + }); + }); + + describe('getPersonenInfo', () => { + describe('when requesting personen-info', () => { + const setup = () => { + const tokens: OAuthTokenDto = new OAuthTokenDto({ + idToken: 'id_token', + accessToken: 'access_token', + refreshToken: 'refresh_token', + }); + const response: SanisResponse[] = schulconnexResponseFactory.buildList(2); + + const optionsWithTimeout: SchulconnexRestClientOptions = { + ...options, + personenInfoTimeoutInMs: 30000, + }; + + const optionsClient: SchulconnexRestClient = new SchulconnexRestClient( + optionsWithTimeout, + httpService, + oauthAdapterService, + logger + ); + + oauthAdapterService.sendTokenRequest.mockResolvedValueOnce(tokens); + httpService.get.mockReturnValueOnce(of(axiosResponseFactory.build({ data: response }))); + + return { + tokens, + response, + optionsClient, + optionsWithTimeout, + }; + }; + + it('should make a request to a SchulConneX-API', async () => { + const { tokens, optionsClient, optionsWithTimeout } = setup(); + + await optionsClient.getPersonenInfo({ + 'organisation.id': '1234', + vollstaendig: ['personen', 'organisationen'], + }); + + expect(httpService.get).toHaveBeenCalledWith( + `${optionsWithTimeout.apiUrl}/personen-info?organisation.id=1234&vollstaendig=personen%2Corganisationen`, + { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + 'Accept-Encoding': 'gzip', + }, + timeout: optionsWithTimeout.personenInfoTimeoutInMs, + } + ); + }); + + it('should return the response', async () => { + const { response } = setup(); + + const result: SanisResponse[] = await client.getPersonenInfo({ 'organisation.id': '1234' }); + + expect(result).toEqual(response); + }); + }); + }); +}); diff --git a/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts new file mode 100644 index 00000000000..77d45b4873e --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/schulconnex-rest-client.ts @@ -0,0 +1,85 @@ +import { OauthAdapterService, OAuthTokenDto } from '@modules/oauth'; +import { OAuthGrantType } from '@modules/oauth/interface/oauth-grant-type.enum'; +import { ClientCredentialsGrantTokenRequest } from '@modules/oauth/service/dto'; +import { HttpService } from '@nestjs/axios'; +import { Logger } from '@src/core/logger'; +import { AxiosResponse } from 'axios'; +import QueryString from 'qs'; +import { lastValueFrom, Observable } from 'rxjs'; +import { SchulconnexConfigurationMissingLoggable } from './loggable'; +import { SchulconnexPersonenInfoParams } from './request'; +import { SanisResponse } from './response'; +import { SchulconnexApiInterface } from './schulconnex-api.interface'; +import { SchulconnexRestClientOptions } from './schulconnex-rest-client-options'; + +export class SchulconnexRestClient implements SchulconnexApiInterface { + private readonly SCHULCONNEX_API_BASE_URL: string; + + constructor( + private readonly options: SchulconnexRestClientOptions, + private readonly httpService: HttpService, + private readonly oauthAdapterService: OauthAdapterService, + private readonly logger: Logger + ) { + this.checkOptions(); + this.SCHULCONNEX_API_BASE_URL = options.apiUrl; + } + + // TODO: N21-1678 use this in provisioning module + public async getPersonInfo(accessToken: string, options?: { overrideUrl: string }): Promise { + const url: URL = new URL(options?.overrideUrl ?? `${this.SCHULCONNEX_API_BASE_URL}/person-info`); + + const response: Promise = this.getRequest(url, accessToken); + + return response; + } + + public async getPersonenInfo(params: SchulconnexPersonenInfoParams): Promise { + const token: OAuthTokenDto = await this.requestClientCredentialToken(); + + const url: URL = new URL(`${this.SCHULCONNEX_API_BASE_URL}/personen-info`); + url.search = QueryString.stringify(params, { arrayFormat: 'comma' }); + + const response: Promise = this.getRequest( + url, + token.accessToken, + this.options.personenInfoTimeoutInMs + ); + + return response; + } + + private checkOptions(): void { + if (!this.options.apiUrl || !this.options.clientId || !this.options.clientSecret || !this.options.tokenEndpoint) { + this.logger.debug(new SchulconnexConfigurationMissingLoggable()); + } + } + + private async getRequest(url: URL, accessToken: string, timeout?: number): Promise { + const observable: Observable> = this.httpService.get(url.toString(), { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Accept-Encoding': 'gzip', + }, + timeout, + }); + + const responseToken: AxiosResponse = await lastValueFrom(observable); + + return responseToken.data; + } + + private async requestClientCredentialToken(): Promise { + const { tokenEndpoint, clientId, clientSecret } = this.options; + + const payload: ClientCredentialsGrantTokenRequest = new ClientCredentialsGrantTokenRequest({ + client_id: clientId, + client_secret: clientSecret, + grant_type: OAuthGrantType.CLIENT_CREDENTIALS_GRANT, + }); + + const tokenDto: OAuthTokenDto = await this.oauthAdapterService.sendTokenRequest(tokenEndpoint, payload); + + return tokenDto; + } +} diff --git a/apps/server/src/infra/schulconnex-client/testing/index.ts b/apps/server/src/infra/schulconnex-client/testing/index.ts new file mode 100644 index 00000000000..673fc651a51 --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/testing/index.ts @@ -0,0 +1 @@ +export { schulconnexResponseFactory } from './schulconnex-response-factory'; diff --git a/apps/server/src/infra/schulconnex-client/testing/schulconnex-response-factory.ts b/apps/server/src/infra/schulconnex-client/testing/schulconnex-response-factory.ts new file mode 100644 index 00000000000..931281287d2 --- /dev/null +++ b/apps/server/src/infra/schulconnex-client/testing/schulconnex-response-factory.ts @@ -0,0 +1,56 @@ +import { UUID } from 'bson'; +import { Factory } from 'fishery'; +import { SanisGroupRole, SanisGroupType, SanisResponse, SanisRole } from '../response'; + +export const schulconnexResponseFactory = Factory.define(() => { + return { + pid: 'aef1f4fd-c323-466e-962b-a84354c0e713', + person: { + name: { + vorname: 'Hans', + familienname: 'Peter', + }, + geburt: { + datum: '2023-11-17', + }, + }, + personenkontexte: [ + { + id: new UUID().toString(), + rolle: SanisRole.LEIT, + organisation: { + id: new UUID('df66c8e6-cfac-40f7-b35b-0da5d8ee680e').toString(), + name: 'schoolName', + kennung: 'Kennung', + anschrift: { + ort: 'Hannover', + }, + }, + erreichbarkeiten: [ + { + typ: 'E-Mail', + kennung: 'hans.peter@muster-schule.de', + }, + ], + gruppen: [ + { + gruppe: { + id: new UUID().toString(), + bezeichnung: 'bezeichnung', + typ: SanisGroupType.CLASS, + }, + gruppenzugehoerigkeit: { + rollen: [SanisGroupRole.TEACHER], + }, + sonstige_gruppenzugehoerige: [ + { + rollen: [SanisGroupRole.STUDENT], + ktid: 'ktid', + }, + ], + }, + ], + }, + ], + }; +}); diff --git a/apps/server/src/migrations/mikro-orm/Migration20240108145519.ts b/apps/server/src/migrations/mikro-orm/Migration20240108145519.ts new file mode 100644 index 00000000000..aeab8dd7650 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240108145519.ts @@ -0,0 +1,17 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +/* + * cleanup old migration records from db + */ +export class Migration20240108111130 extends Migration { + async up(): Promise { + const { deletedCount } = await this.getCollection('migrations').deleteMany({}, { session: this.ctx }); + console.log(`removed ${deletedCount} records`); + } + + // eslint-disable-next-line @typescript-eslint/require-await + async down(): Promise { + // do nothing + console.error(`Migration down not implemented. You might need to restore database from backup!`); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20240115103302.ts b/apps/server/src/migrations/mikro-orm/Migration20240115103302.ts new file mode 100644 index 00000000000..6947e820e04 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240115103302.ts @@ -0,0 +1,29 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +// remove-undefined-parameters-from-external-tool +export class Migration20240115103302 extends Migration { + async up(): Promise { + const contextExternalToolResponse = await this.driver.nativeUpdate( + 'context-external-tools', + { $or: [{ 'parameters.value': undefined }, { 'parameters.value': '' }] }, + { $pull: { parameters: { $or: [{ value: undefined }, { value: '' }] } } } + // { ctx: this.ctx } + ); + + console.info(`Removed ${contextExternalToolResponse.affectedRows} parameter(s) in context-external-tools`); + + const schoolExternalToolResponse = await this.driver.nativeUpdate( + 'school-external-tools', + { $or: [{ 'parameters.value': undefined }, { 'parameters.value': '' }] }, + { $pull: { parameters: { $or: [{ value: undefined }, { value: '' }] } } } + // { ctx: this.ctx } + ); + + console.info(`Removed ${schoolExternalToolResponse.affectedRows} parameter(s) in school-external-tools`); + } + + // eslint-disable-next-line @typescript-eslint/require-await + async down() { + console.error('This migration cannot be undone'); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20240221131029.ts b/apps/server/src/migrations/mikro-orm/Migration20240221131029.ts new file mode 100644 index 00000000000..5406d1c478b --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240221131029.ts @@ -0,0 +1,19 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20240221131029 extends Migration { + async up(): Promise { + const contextExternalToolResponse = await this.driver.nativeUpdate( + 'external-tools', + { config_type: 'lti11' }, + { $unset: { config_resource_link_id: '' } } + ); + + console.info(`Removed ${contextExternalToolResponse.affectedRows} resource_link_id(s) in context-external-tools`); + } + + // eslint-disable-next-line @typescript-eslint/require-await + async down(): Promise { + // do nothing + console.error(`Migration down not implemented. You might need to restore database from backup!`); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20240304123509.ts b/apps/server/src/migrations/mikro-orm/Migration20240304123509.ts new file mode 100644 index 00000000000..64b1f72cc44 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240304123509.ts @@ -0,0 +1,69 @@ +/* istanbul ignore file */ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20240304123509 extends Migration { + async up(): Promise { + const columBoardResponse = await this.driver.nativeUpdate( + 'boardnodes', + { type: 'column-board' }, + { $set: { isVisible: true } } + ); + + console.info(`Updated ${columBoardResponse.affectedRows} records in boardnodes`); + + const boardElemensView = [ + { + $match: { + boardElementType: 'columnboard', + }, + }, + { + $lookup: { + from: 'column-board-target', + localField: 'target', + foreignField: '_id', + as: 'result', + }, + }, + ]; + const columBoardElements = await this.driver.aggregate('board-element', boardElemensView); + let affectedRows = 0; + for (const columnboard of columBoardElements) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (columnboard.result.length) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access + const targetBoard = columnboard.result[0].columnBoard; + if (targetBoard) { + // eslint-disable-next-line no-await-in-loop + const updatedBoard = await this.driver.nativeUpdate( + 'board-element', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access + { _id: columnboard._id }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + { $set: { target: targetBoard } } + ); + affectedRows += updatedBoard.affectedRows; + } + } + } + console.info(`Updated ${affectedRows} records in column-element`); + + // TODO remove this collection at a later time. We keep it for now in case is needed to restore + // await this.getCollection('column-board-target').drop(); + + // console.info(`Collection colum-board-target was NOT removed`); + } + + async down(): Promise { + const columBoardResponse = await this.driver.nativeUpdate( + 'boardnodes', + { type: 'column-board' }, + { $unset: { isVisible: false } } + ); + + console.info(`Updated ${columBoardResponse.affectedRows} records in boardnodes`); + + console.error(`column-element cannot be rolled-back. It must be restored from backup!`); + console.error(`column-board-target cannot be rolled-back. It must be restored from backup!`); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20240315140224.ts b/apps/server/src/migrations/mikro-orm/Migration20240315140224.ts new file mode 100644 index 00000000000..789fa5642bb --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240315140224.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Migration } from '@mikro-orm/migrations-mongodb'; +import { RoleName } from '@shared/domain/interface'; +import { FileRecordParentType } from '@src/modules/files-storage/entity'; + +export class Migration20240315140224 extends Migration { + async up(): Promise { + console.log('Start updating parentType of fileRecords if creator is teacher.'); + + const teacherRole = (await this.driver.findOne('roles', { name: RoleName.TEACHER })) as any; + const teachers = await this.driver.aggregate('users', [ + { $match: { roles: teacherRole._id } }, + { $project: { _id: 1 } }, + ]); + const teacherIds = teachers.map((teacher: any) => teacher._id); + + console.log(`Found ${teacherIds.length} teachers.`); + + // Teachers can make submissions themselves and they sometimes misuse that for planning stuff or so. + // Thus the filter by creator of the filerecord is not enough to identify a grading and we exclude these submissions by teachers below. + const submissionsByTeachers = await this.driver.aggregate('submissions', [ + { $match: { studentId: { $in: teacherIds } } }, + { $project: { _id: 1 } }, + ]); + const submissionsByTeachersIds = submissionsByTeachers.map((submission: any) => submission._id); + + console.log( + `Found ${submissionsByTeachersIds.length} submissions by teachers, which will be excluded from update.` + ); + + const result = await this.driver.nativeUpdate( + 'filerecords', + { + parentType: FileRecordParentType.Submission, + creator: { $in: teacherIds }, + parent: { $nin: submissionsByTeachersIds }, + }, + { $set: { parentType: FileRecordParentType.Grading } } + ); + + console.log(`Updated ${result.affectedRows} filerecords.`); + } + + async down(): Promise { + console.log('Resetting parentType "gradings" of fileRecords to "submissions".'); + + const result = await this.driver.nativeUpdate( + 'filerecords', + { parentType: FileRecordParentType.Grading }, + { $set: { parentType: FileRecordParentType.Submission } } + ); + + console.log(`Updated ${result.affectedRows} filerecords.`); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20240320122229.ts b/apps/server/src/migrations/mikro-orm/Migration20240320122229.ts new file mode 100644 index 00000000000..568a5799d5c --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240320122229.ts @@ -0,0 +1,17 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20240320122229 extends Migration { + async up(): Promise { + const columBoardResponse = await this.driver.nativeUpdate( + 'boardnodes', + { $and: [{ type: 'column-board' }, { title: '' }] }, + { title: 'Kurs-Board' } + ); + console.info(`Updated ${columBoardResponse.affectedRows} records in boardnodes`); + } + + // eslint-disable-next-line @typescript-eslint/require-await + async down(): Promise { + console.error(`boardnodes cannot be rolled-back. It must be restored from backup!`); + } +} diff --git a/apps/server/src/migrations/mikro-orm/Migration20240326072506.ts b/apps/server/src/migrations/mikro-orm/Migration20240326072506.ts new file mode 100644 index 00000000000..bc5c9b7b298 --- /dev/null +++ b/apps/server/src/migrations/mikro-orm/Migration20240326072506.ts @@ -0,0 +1,99 @@ +import { Migration } from '@mikro-orm/migrations-mongodb'; + +export class Migration20240326072506 extends Migration { + async up(): Promise { + const adminRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'administrator' }, + { + $addToSet: { + permissions: { + $each: ['USER_CHANGE_OWN_NAME'], + }, + }, + } + ); + + if (adminRoleUpdate.modifiedCount > 0) { + console.info('Permission USER_CHANGE_OWN_NAME was added to role administrator.'); + } + + const teacherRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'teacher' }, + { + $addToSet: { + permissions: { + $each: ['USER_CHANGE_OWN_NAME'], + }, + }, + } + ); + + if (teacherRoleUpdate.modifiedCount > 0) { + console.info('Permission USER_CHANGE_OWN_NAME was added to role teacher.'); + } + + const superheroRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'superhero' }, + { + $addToSet: { + permissions: { + $each: ['USER_CHANGE_OWN_NAME', 'ACCOUNT_VIEW', 'ACCOUNT_DELETE'], + }, + }, + } + ); + + if (superheroRoleUpdate.modifiedCount > 0) { + console.info('Permissions USER_CHANGE_OWN_NAME, ACCOUNT_VIEW and ACCOUNT_DELETE were added to role superhero.'); + } + } + + async down(): Promise { + const adminRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'administrator' }, + { + $pull: { + permissions: { + $in: ['USER_CHANGE_OWN_NAME'], + }, + }, + } + ); + + if (adminRoleUpdate.modifiedCount > 0) { + console.info('Rollback: Removed permission USER_CHANGE_OWN_NAME from role administrator.'); + } + + const teacherRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'teacher' }, + { + $pull: { + permissions: { + $in: ['USER_CHANGE_OWN_NAME'], + }, + }, + } + ); + + if (teacherRoleUpdate.modifiedCount > 0) { + console.info('Rollback: Removed permission USER_CHANGE_OWN_NAME from role teacher.'); + } + + const superheroRoleUpdate = await this.getCollection('roles').updateOne( + { name: 'superhero' }, + { + $pull: { + permissions: { + $in: ['USER_CHANGE_OWN_NAME', 'ACCOUNT_VIEW', 'ACCOUNT_DELETE'], + }, + }, + } + ); + + if (superheroRoleUpdate.modifiedCount > 0) { + console.info( + 'Rollback: Removed permissions USER_CHANGE_OWN_NAME, ACCOUNT_VIEW and ACCOUNT_DELETE from role superhero.' + ); + } + } +} diff --git a/apps/server/src/modules/account/account-api.module.ts b/apps/server/src/modules/account/account-api.module.ts index d06a9dabebd..c4228baf6a6 100644 --- a/apps/server/src/modules/account/account-api.module.ts +++ b/apps/server/src/modules/account/account-api.module.ts @@ -1,13 +1,14 @@ +import { AuthorizationModule } from '@modules/authorization'; import { Module } from '@nestjs/common'; import { PermissionService } from '@shared/domain/service'; import { UserRepo } from '@shared/repo'; -import { LoggerModule } from '../../core/logger/logger.module'; +import { LoggerModule } from '@src/core/logger'; +import { AccountUc } from './uc/account.uc'; import { AccountModule } from './account.module'; import { AccountController } from './controller/account.controller'; -import { AccountUc } from './uc/account.uc'; @Module({ - imports: [AccountModule, LoggerModule], + imports: [AccountModule, LoggerModule, AuthorizationModule], providers: [UserRepo, PermissionService, AccountUc], controllers: [AccountController], exports: [], diff --git a/apps/server/src/modules/account/account-config.ts b/apps/server/src/modules/account/account-config.ts index 38b6acf7e58..9a0676782f2 100644 --- a/apps/server/src/modules/account/account-config.ts +++ b/apps/server/src/modules/account/account-config.ts @@ -1,4 +1,6 @@ export interface AccountConfig { LOGIN_BLOCK_TIME: number; TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE: boolean; + FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED: boolean; + FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED: boolean; } diff --git a/apps/server/src/modules/account/account.module.spec.ts b/apps/server/src/modules/account/account.module.spec.ts index 8acbff04090..8351c4b38d2 100644 --- a/apps/server/src/modules/account/account.module.spec.ts +++ b/apps/server/src/modules/account/account.module.spec.ts @@ -2,7 +2,7 @@ import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { AccountModule } from './account.module'; -import { AccountIdmToDtoMapper, AccountIdmToDtoMapperDb, AccountIdmToDtoMapperIdm } from './mapper'; +import { AccountIdmToDoMapper, AccountIdmToDoMapperDb, AccountIdmToDoMapperIdm } from './repo/mapper'; import { AccountService } from './services/account.service'; import { AccountValidationService } from './services/account.validation.service'; @@ -64,8 +64,8 @@ describe('AccountModule', () => { }); it('should use AccountIdmToDtoMapperIdm', () => { - const mapper = moduleFeatureEnabled.get(AccountIdmToDtoMapper); - expect(mapper).toBeInstanceOf(AccountIdmToDtoMapperIdm); + const mapper = moduleFeatureEnabled.get(AccountIdmToDoMapper); + expect(mapper).toBeInstanceOf(AccountIdmToDoMapperIdm); }); }); @@ -96,8 +96,8 @@ describe('AccountModule', () => { }); it('should use AccountIdmToDtoMapperDb', () => { - const mapper = moduleFeatureDisabled.get(AccountIdmToDtoMapper); - expect(mapper).toBeInstanceOf(AccountIdmToDtoMapperDb); + const mapper = moduleFeatureDisabled.get(AccountIdmToDoMapper); + expect(mapper).toBeInstanceOf(AccountIdmToDoMapperDb); }); }); }); diff --git a/apps/server/src/modules/account/account.module.ts b/apps/server/src/modules/account/account.module.ts index 7f5cf04cd0e..8e1bc2afc00 100644 --- a/apps/server/src/modules/account/account.module.ts +++ b/apps/server/src/modules/account/account.module.ts @@ -4,25 +4,25 @@ import { ConfigService } from '@nestjs/config'; import { PermissionService } from '@shared/domain/service'; import { LegacySystemRepo, UserRepo } from '@shared/repo'; +import { CqrsModule } from '@nestjs/cqrs'; import { LoggerModule } from '@src/core/logger/logger.module'; -import { ServerConfig } from '../server/server.config'; -import { AccountIdmToDtoMapper, AccountIdmToDtoMapperDb, AccountIdmToDtoMapperIdm } from './mapper'; +import { AccountConfig } from './account-config'; import { AccountRepo } from './repo/account.repo'; +import { AccountIdmToDoMapper, AccountIdmToDoMapperDb, AccountIdmToDoMapperIdm } from './repo/mapper'; import { AccountServiceDb } from './services/account-db.service'; import { AccountServiceIdm } from './services/account-idm.service'; -import { AccountLookupService } from './services/account-lookup.service'; import { AccountService } from './services/account.service'; import { AccountValidationService } from './services/account.validation.service'; -function accountIdmToDtoMapperFactory(configService: ConfigService): AccountIdmToDtoMapper { +function accountIdmToDtoMapperFactory(configService: ConfigService): AccountIdmToDoMapper { if (configService.get('FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED') === true) { - return new AccountIdmToDtoMapperIdm(); + return new AccountIdmToDoMapperIdm(); } - return new AccountIdmToDtoMapperDb(); + return new AccountIdmToDoMapperDb(); } @Module({ - imports: [IdentityManagementModule, LoggerModule], + imports: [CqrsModule, IdentityManagementModule, LoggerModule], providers: [ UserRepo, LegacySystemRepo, @@ -31,10 +31,9 @@ function accountIdmToDtoMapperFactory(configService: ConfigService { - let module: TestingModule; - let controller: AccountController; - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - AccountController, - { - provide: AccountUc, - useValue: {}, - }, - ], - }).compile(); - controller = module.get(AccountController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/apps/server/src/modules/account/controller/account.controller.ts b/apps/server/src/modules/account/controller/account.controller.ts index 3265693915c..f86a1acdebb 100644 --- a/apps/server/src/modules/account/controller/account.controller.ts +++ b/apps/server/src/modules/account/controller/account.controller.ts @@ -1,9 +1,11 @@ import { Body, Controller, Delete, Get, Param, Patch, Query } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; -import { ICurrentUser } from '@src/modules/authentication'; -import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator'; +import { ICurrentUser, Authenticate, CurrentUser } from '@modules/authentication'; import { AccountUc } from '../uc/account.uc'; +import { AccountSearchDto } from '../uc/dto/account-search.dto'; +import { UpdateAccountDto } from '../uc/dto/update-account.dto'; +import { UpdateMyAccountDto } from '../uc/dto/update-my-account.dto'; import { AccountByIdBodyParams, AccountByIdParams, @@ -13,6 +15,7 @@ import { PatchMyAccountParams, PatchMyPasswordParams, } from './dto'; +import { AccountResponseMapper } from './mapper/account-response.mapper'; @ApiTags('Account') @Authenticate('jwt') @@ -32,7 +35,10 @@ export class AccountController { @CurrentUser() currentUser: ICurrentUser, @Query() query: AccountSearchQueryParams ): Promise { - return this.accountUc.searchAccounts(currentUser, query); + const search = new AccountSearchDto(query); + const searchResult = await this.accountUc.searchAccounts(currentUser, search); + + return AccountResponseMapper.mapToAccountSearchListResponse(searchResult); } @Get(':id') @@ -45,7 +51,8 @@ export class AccountController { @CurrentUser() currentUser: ICurrentUser, @Param() params: AccountByIdParams ): Promise { - return this.accountUc.findAccountById(currentUser, params); + const dto = await this.accountUc.findAccountById(currentUser, params.id); + return AccountResponseMapper.mapToAccountResponse(dto); } // IMPORTANT!!! @@ -58,7 +65,8 @@ export class AccountController { @ApiResponse({ status: 403, type: ForbiddenOperationError, description: 'Invalid password.' }) @ApiResponse({ status: 404, type: EntityNotFoundError, description: 'Account not found.' }) async updateMyAccount(@CurrentUser() currentUser: ICurrentUser, @Body() params: PatchMyAccountParams): Promise { - return this.accountUc.updateMyAccount(currentUser.userId, params); + const updateData = new UpdateMyAccountDto(params); + return this.accountUc.updateMyAccount(currentUser.userId, updateData); } @Patch(':id') @@ -72,7 +80,10 @@ export class AccountController { @Param() params: AccountByIdParams, @Body() body: AccountByIdBodyParams ): Promise { - return this.accountUc.updateAccountById(currentUser, params, body); + const updateData = new UpdateAccountDto(body); + const dto = await this.accountUc.updateAccountById(currentUser, params.id, updateData); + + return AccountResponseMapper.mapToAccountResponse(dto); } @Delete(':id') @@ -85,7 +96,8 @@ export class AccountController { @CurrentUser() currentUser: ICurrentUser, @Param() params: AccountByIdParams ): Promise { - return this.accountUc.deleteAccountById(currentUser, params); + const dto = await this.accountUc.deleteAccountById(currentUser, params.id); + return AccountResponseMapper.mapToAccountResponse(dto); } @Patch('me/password') diff --git a/apps/server/src/modules/account/controller/api-test/account.api.spec.ts b/apps/server/src/modules/account/controller/api-test/account.api.spec.ts index 14d68ae5668..6179aa0ee19 100644 --- a/apps/server/src/modules/account/controller/api-test/account.api.spec.ts +++ b/apps/server/src/modules/account/controller/api-test/account.api.spec.ts @@ -1,316 +1,684 @@ -import { EntityManager } from '@mikro-orm/core'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Account, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { Permission, RoleName } from '@shared/domain/interface'; -import { accountFactory, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; +import { + TestApiClient, + accountFactory, + cleanupCollections, + roleFactory, + schoolEntityFactory, + userFactory, +} from '@shared/testing'; +import { ServerTestModule } from '@modules/server/server.module'; import { AccountByIdBodyParams, AccountSearchQueryParams, AccountSearchType, PatchMyAccountParams, PatchMyPasswordParams, -} from '@src/modules/account/controller/dto'; -import { ICurrentUser } from '@src/modules/authentication'; -import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; -import { ServerTestModule } from '@src/modules/server/server.module'; -import { Request } from 'express'; -import request from 'supertest'; +} from '../dto'; +import { AccountEntity } from '../../entity/account.entity'; describe('Account Controller (API)', () => { const basePath = '/account'; let app: INestApplication; let em: EntityManager; - - let adminAccount: Account; - let teacherAccount: Account; - let studentAccount: Account; - let superheroAccount: Account; - - let adminUser: User; - let teacherUser: User; - let studentUser: User; - let superheroUser: User; - - let currentUser: ICurrentUser; + let testApiClient: TestApiClient; const defaultPassword = 'DummyPasswd!1'; const defaultPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; - const setup = async () => { - const school = schoolFactory.buildWithId(); - - const adminRoles = roleFactory.build({ - name: RoleName.ADMINISTRATOR, - permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + const mapUserToAccount = (user: User): AccountEntity => + accountFactory.buildWithId({ + userId: user.id, + username: user.email, + password: defaultPasswordHash, }); - const teacherRoles = roleFactory.build({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] }); - const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); - const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); - - adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); - teacherUser = userFactory.buildWithId({ school, roles: [teacherRoles] }); - studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); - superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); - - const mapUserToAccount = (user: User): Account => - accountFactory.buildWithId({ - userId: user.id, - username: user.email, - password: defaultPasswordHash, - }); - adminAccount = mapUserToAccount(adminUser); - teacherAccount = mapUserToAccount(teacherUser); - studentAccount = mapUserToAccount(studentUser); - superheroAccount = mapUserToAccount(superheroUser); - - em.persist(school); - em.persist([adminRoles, teacherRoles, studentRoles, superheroRoles]); - em.persist([adminUser, teacherUser, studentUser, superheroUser]); - em.persist([adminAccount, teacherAccount, studentAccount, superheroAccount]); - await em.flush(); - }; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - .compile(); + }).compile(); app = moduleFixture.createNestApplication(); await app.init(); em = app.get(EntityManager); + testApiClient = new TestApiClient(app, basePath); }); beforeEach(async () => { - await setup(); + await cleanupCollections(em); }); afterAll(async () => { - // await cleanupCollections(em); + await cleanupCollections(em); await app.close(); }); describe('[PATCH] me/password', () => { - it(`should update the current user's (temporary) password`, async () => { - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const params: PatchMyPasswordParams = { - password: 'Valid12$', - confirmPassword: 'Valid12$', + describe('When patching with a valid password', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const passwordPatchParams: PatchMyPasswordParams = { + password: 'Valid12$', + confirmPassword: 'Valid12$', + }; + + return { passwordPatchParams, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/me/password`) - .send(params) - .expect(200); - const updatedAccount = await em.findOneOrFail(Account, studentAccount.id); - expect(updatedAccount.password).not.toEqual(defaultPasswordHash); + it(`should update the current user's (temporary) password`, async () => { + const { passwordPatchParams, loggedInClient, studentAccount } = await setup(); + + await loggedInClient.patch('/me/password', passwordPatchParams).expect(200); + + const updatedAccount = await em.findOneOrFail(AccountEntity, studentAccount.id); + expect(updatedAccount.password).not.toEqual(defaultPasswordHash); + }); }); - it('should reject if new password is weak', async () => { - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const params: PatchMyPasswordParams = { - password: 'weak', - confirmPassword: 'weak', + + describe('When using a weak password', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const passwordPatchParams: PatchMyPasswordParams = { + password: 'weak', + confirmPassword: 'weak', + }; + + return { passwordPatchParams, loggedInClient }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/me/password`) - .send(params) - .expect(400); + + it('should reject the password change', async () => { + const { passwordPatchParams, loggedInClient } = await setup(); + + await loggedInClient.patch('/me/password', passwordPatchParams).expect(400); + }); }); }); describe('[PATCH] me', () => { - it(`should update a users account`, async () => { - const newEmailValue = 'new@mail.com'; - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const params: PatchMyAccountParams = { - passwordOld: defaultPassword, - email: newEmailValue, + describe('When patching the account with account info', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const newEmailValue = 'new@mail.com'; + + const patchMyAccountParams: PatchMyAccountParams = { + passwordOld: defaultPassword, + email: newEmailValue, + }; + return { newEmailValue, patchMyAccountParams, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/me`) - .send(params) - .expect(200); - const updatedAccount = await em.findOneOrFail(Account, studentAccount.id); - expect(updatedAccount.username).toEqual(newEmailValue); + it(`should update a users account`, async () => { + const { newEmailValue, patchMyAccountParams, loggedInClient, studentAccount } = await setup(); + + await loggedInClient.patch('/me', patchMyAccountParams).expect(200); + + const updatedAccount = await em.findOneOrFail(AccountEntity, studentAccount.id); + expect(updatedAccount.username).toEqual(newEmailValue); + }); }); - it('should reject if new email is not valid', async () => { - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const params: PatchMyAccountParams = { - passwordOld: defaultPassword, - email: 'invalid', + describe('When patching with a not valid email', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const newEmailValue = 'new@mail.com'; + + const patchMyAccountParams: PatchMyAccountParams = { + passwordOld: defaultPassword, + email: 'invalid', + }; + return { newEmailValue, patchMyAccountParams, loggedInClient }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/me`) - .send(params) - .expect(400); + + it('should reject patch request', async () => { + const { patchMyAccountParams, loggedInClient } = await setup(); + + await loggedInClient.patch('/me', patchMyAccountParams).expect(400); + }); + }); + + describe('When patching with html inside name', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + const teacherRoles = roleFactory.build({ + name: RoleName.TEACHER, + permissions: [Permission.USER_CHANGE_OWN_NAME], + }); + const teacherUser = userFactory.buildWithId({ school, roles: [teacherRoles] }); + const teacherAccount = mapUserToAccount(teacherUser); + + em.persist([school, teacherRoles, teacherUser, teacherAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + const patchMyAccountParams: PatchMyAccountParams = { + passwordOld: defaultPassword, + firstName: 'Jane', + lastName: 'Doe', + }; + return { patchMyAccountParams, loggedInClient, teacherUser }; + }; + + it('should strip HTML off of firstName and lastName', async () => { + const { teacherUser, loggedInClient, patchMyAccountParams } = await setup(); + + await loggedInClient.patch('/me', patchMyAccountParams).expect(200); + + const updatedUser = await em.findOneOrFail(User, teacherUser.id); + expect(updatedUser.firstName).toEqual('Jane'); + expect(updatedUser.lastName).toEqual('Doe'); + }); }); }); describe('[GET]', () => { - it('should search for user id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - const query: AccountSearchQueryParams = { - type: AccountSearchType.USER_ID, - value: studentUser.id, - skip: 5, - limit: 5, + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const query: AccountSearchQueryParams = { + type: AccountSearchType.USER_ID, + value: studentUser.id, + skip: 5, + limit: 5, + }; + + return { query, loggedInClient }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(200); + it('should successfully search for user id', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(200); + }); }); + // If skip is too big, just return an empty list. // We testing it here, because we are mocking the database in the use case unit tests // and for realistic behavior we need database. - it('should search for user id with large skip', async () => { - currentUser = mapUserToCurrentUser(superheroUser); - const query: AccountSearchQueryParams = { - type: AccountSearchType.USER_ID, - value: studentUser.id, - skip: 50000, - limit: 5, + describe('When searching with a superhero user with large skip', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const query: AccountSearchQueryParams = { + type: AccountSearchType.USER_ID, + value: studentUser.id, + skip: 50000, + limit: 5, + }; + + return { query, loggedInClient }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(200); + it('should search for user id', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(200); + }); }); - it('should search for user name', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - const query: AccountSearchQueryParams = { - type: AccountSearchType.USERNAME, - value: '', - skip: 5, - limit: 5, + + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [Permission.ACCOUNT_VIEW] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const query: AccountSearchQueryParams = { + type: AccountSearchType.USERNAME, + value: '', + skip: 5, + limit: 5, + }; + + return { query, loggedInClient }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(200); + it('should search for username', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(200); + }); }); - it('should reject if type is unknown', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - const query: AccountSearchQueryParams = { - type: '' as AccountSearchType, - value: '', - skip: 5, - limit: 5, + + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const query: AccountSearchQueryParams = { + type: '' as AccountSearchType, + value: '', + skip: 5, + limit: 5, + }; + + return { query, loggedInClient }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(400); + + it('should reject if type is unknown', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(400); + }); }); - it('should reject if user is not authorized', async () => { - currentUser = mapUserToCurrentUser(adminUser, adminAccount); - const query: AccountSearchQueryParams = { - type: AccountSearchType.USERNAME, - value: '', - skip: 5, - limit: 5, + describe('When searching with an admin user (not authorized)', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + + const adminRoles = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + + const adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + + const adminAccount = mapUserToAccount(adminUser); + const studentAccount = mapUserToAccount(studentUser); + + em.persist(school); + em.persist([adminRoles, studentRoles]); + em.persist([adminUser, studentUser]); + em.persist([adminAccount, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(adminAccount); + + const query: AccountSearchQueryParams = { + type: AccountSearchType.USERNAME, + value: '', + skip: 5, + limit: 5, + }; + + return { query, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .get(`${basePath}`) - .query(query) - .send() - .expect(403); + + it('should reject search for user', async () => { + const { query, loggedInClient } = await setup(); + + await loggedInClient.get().query(query).send().expect(401); + }); }); }); describe('[GET] :id', () => { - it('should return account for account id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - await request(app.getHttpServer()) // - .get(`${basePath}/${studentAccount.id}`) - .expect(200); + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [Permission.ACCOUNT_VIEW] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { loggedInClient, studentAccount }; + }; + it('should return account for account id', async () => { + const { loggedInClient, studentAccount } = await setup(); + await loggedInClient.get(`/${studentAccount.id}`).expect(200); + }); }); - it('should reject if user is not a authorized', async () => { - currentUser = mapUserToCurrentUser(adminUser, adminAccount); - await request(app.getHttpServer()) // - .get(`${basePath}/${studentAccount.id}`) - .expect(403); + + describe('When searching with a not authorized user', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + + const adminRoles = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + + const adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + + const adminAccount = mapUserToAccount(adminUser); + const studentAccount = mapUserToAccount(studentUser); + + em.persist(school); + em.persist([adminRoles, studentRoles]); + em.persist([adminUser, studentUser]); + em.persist([adminAccount, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { loggedInClient, studentAccount }; + }; + it('should reject request', async () => { + const { loggedInClient, studentAccount } = await setup(); + await loggedInClient.get(`/${studentAccount.id}`).expect(401); + }); }); - it('should reject not existing account id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - await request(app.getHttpServer()) // - .get(`${basePath}/000000000000000000000000`) - .expect(404); + + describe('When searching with a superhero user', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [Permission.ACCOUNT_VIEW] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist([school, superheroRoles, superheroUser, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { loggedInClient }; + }; + + it('should reject not existing account id', async () => { + const { loggedInClient } = await setup(); + await loggedInClient.get(`/000000000000000000000000`).expect(404); + }); }); }); describe('[PATCH] :id', () => { - it('should update account', async () => { - currentUser = mapUserToCurrentUser(superheroUser, superheroAccount); - const body: AccountByIdBodyParams = { - password: defaultPassword, - username: studentAccount.username, - activated: true, + describe('When using a superhero user', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const body: AccountByIdBodyParams = { + password: defaultPassword, + username: studentAccount.username, + activated: true, + }; + + return { body, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/${studentAccount.id}`) - .send(body) - .expect(200); + + it('should update account', async () => { + const { body, loggedInClient, studentAccount } = await setup(); + + await loggedInClient.patch(`/${studentAccount.id}`, body).expect(200); + }); }); - it('should reject if user is not authorized', async () => { - currentUser = mapUserToCurrentUser(studentUser, studentAccount); - const body: AccountByIdBodyParams = { - password: defaultPassword, - username: studentAccount.username, - activated: true, + + describe('When the user is not authorized', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const studentAccount = mapUserToAccount(studentUser); + + em.persist([school, studentRoles, studentUser, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(studentAccount); + + const body: AccountByIdBodyParams = { + password: defaultPassword, + username: studentAccount.username, + activated: true, + }; + + return { body, loggedInClient, studentAccount }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/${studentAccount.id}`) - .send(body) - .expect(403); + it('should reject update request', async () => { + const { body, loggedInClient, studentAccount } = await setup(); + + await loggedInClient.patch(`/${studentAccount.id}`, body).expect(401); + }); }); - it('should reject not existing account id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, studentAccount); - const body: AccountByIdBodyParams = { - password: defaultPassword, - username: studentAccount.username, - activated: true, + + describe('When updating with a superhero user', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ name: RoleName.SUPERHERO, permissions: [] }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + const body: AccountByIdBodyParams = { + password: defaultPassword, + username: studentAccount.username, + activated: true, + }; + + return { body, loggedInClient }; }; - await request(app.getHttpServer()) // - .patch(`${basePath}/000000000000000000000000`) - .send(body) - .expect(404); + it('should reject not existing account id', async () => { + const { body, loggedInClient } = await setup(); + await loggedInClient.patch('/000000000000000000000000', body).expect(404); + }); }); }); describe('[DELETE] :id', () => { - it('should delete account', async () => { - currentUser = mapUserToCurrentUser(superheroUser, studentAccount); - await request(app.getHttpServer()) // - .delete(`${basePath}/${studentAccount.id}`) - .expect(200); + describe('When using a superhero user', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + const superheroRoles = roleFactory.build({ + name: RoleName.SUPERHERO, + permissions: [Permission.ACCOUNT_DELETE], + }); + + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + + const studentAccount = mapUserToAccount(studentUser); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist(school); + em.persist([studentRoles, superheroRoles]); + em.persist([studentUser, superheroUser]); + em.persist([studentAccount, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { loggedInClient, studentAccount }; + }; + it('should delete account', async () => { + const { loggedInClient, studentAccount } = await setup(); + await loggedInClient.delete(`/${studentAccount.id}`).expect(200); + }); }); - it('should reject if user is not a authorized', async () => { - currentUser = mapUserToCurrentUser(adminUser, adminAccount); - await request(app.getHttpServer()) // - .delete(`${basePath}/${studentAccount.id}`) - .expect(403); + + describe('When using a not authorized (admin) user', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + + const adminRoles = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + + const adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); + const studentUser = userFactory.buildWithId({ school, roles: [studentRoles] }); + + const adminAccount = mapUserToAccount(adminUser); + const studentAccount = mapUserToAccount(studentUser); + + em.persist(school); + em.persist([adminRoles, studentRoles]); + em.persist([adminUser, studentUser]); + em.persist([adminAccount, studentAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { loggedInClient, studentAccount }; + }; + + it('should reject delete request', async () => { + const { loggedInClient, studentAccount } = await setup(); + await loggedInClient.delete(`/${studentAccount.id}`).expect(401); + }); }); - it('should reject not existing account id', async () => { - currentUser = mapUserToCurrentUser(superheroUser, studentAccount); - await request(app.getHttpServer()) // - .delete(`${basePath}/000000000000000000000000`) - .expect(404); + + describe('When using a superhero user', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + const superheroRoles = roleFactory.build({ + name: RoleName.SUPERHERO, + permissions: [Permission.ACCOUNT_DELETE], + }); + const superheroUser = userFactory.buildWithId({ roles: [superheroRoles] }); + const superheroAccount = mapUserToAccount(superheroUser); + + em.persist([school, superheroRoles, superheroUser, superheroAccount]); + await em.flush(); + + const loggedInClient = await testApiClient.login(superheroAccount); + + return { loggedInClient }; + }; + + it('should reject not existing account id', async () => { + const { loggedInClient } = await setup(); + await loggedInClient.delete('/000000000000000000000000').expect(404); + }); }); }); }); diff --git a/apps/server/src/modules/account/controller/dto/account-by-id.body.params.ts b/apps/server/src/modules/account/controller/dto/account-by-id.body.params.ts index 4ad564f17b2..f2994913b62 100644 --- a/apps/server/src/modules/account/controller/dto/account-by-id.body.params.ts +++ b/apps/server/src/modules/account/controller/dto/account-by-id.body.params.ts @@ -1,11 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; -import { PrivacyProtect } from '@shared/controller'; +import { PrivacyProtect, SanitizeHtml } from '@shared/controller'; import { IsBoolean, IsString, IsOptional, Matches, IsEmail } from 'class-validator'; import { passwordPattern } from './password-pattern'; export class AccountByIdBodyParams { @IsOptional() @IsString() + @SanitizeHtml() @IsEmail() @ApiProperty({ description: 'The new user name for the user.', diff --git a/apps/server/src/modules/account/controller/dto/account-search.query.params.ts b/apps/server/src/modules/account/controller/dto/account-search.query.params.ts index 2ecd75fb16d..cad04ab540f 100644 --- a/apps/server/src/modules/account/controller/dto/account-search.query.params.ts +++ b/apps/server/src/modules/account/controller/dto/account-search.query.params.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsString } from 'class-validator'; -import { PaginationParams } from '@shared/controller'; +import { PaginationParams, SanitizeHtml } from '@shared/controller'; import { AccountSearchType } from './account-search-type'; export class AccountSearchQueryParams extends PaginationParams { @@ -14,6 +14,7 @@ export class AccountSearchQueryParams extends PaginationParams { type!: AccountSearchType; @IsString() + @SanitizeHtml() @ApiProperty({ description: 'The search value.', required: true, diff --git a/apps/server/src/modules/account/controller/dto/password-pattern.ts b/apps/server/src/modules/account/controller/dto/password-pattern.ts index 6ca2fd9fab2..d849b8db235 100644 --- a/apps/server/src/modules/account/controller/dto/password-pattern.ts +++ b/apps/server/src/modules/account/controller/dto/password-pattern.ts @@ -1,2 +1 @@ -// TODO Compare with client export const passwordPattern = /^(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z])(?=.*[-_!<>§$%&/()=?\\;:,.#+*~'])\S.{6,253}\S$/; diff --git a/apps/server/src/modules/account/controller/dto/patch-my-account.params.ts b/apps/server/src/modules/account/controller/dto/patch-my-account.params.ts index 28874bb255a..3193ce2b1d2 100644 --- a/apps/server/src/modules/account/controller/dto/patch-my-account.params.ts +++ b/apps/server/src/modules/account/controller/dto/patch-my-account.params.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { PrivacyProtect } from '@shared/controller'; +import { PrivacyProtect, SanitizeHtml } from '@shared/controller'; import { IsEmail, IsOptional, IsString, Matches } from 'class-validator'; import { passwordPattern } from './password-pattern'; @@ -24,6 +24,7 @@ export class PatchMyAccountParams { passwordNew?: string; @IsEmail() + @SanitizeHtml() @IsOptional() @ApiProperty({ description: 'The new email address for the current user.', @@ -33,7 +34,9 @@ export class PatchMyAccountParams { email?: string; @IsString() + @SanitizeHtml() @IsOptional() + @SanitizeHtml() @ApiProperty({ description: 'The new first name for the current user.', required: false, @@ -42,7 +45,9 @@ export class PatchMyAccountParams { firstName?: string; @IsString() + @SanitizeHtml() @IsOptional() + @SanitizeHtml() @ApiProperty({ description: 'The new last name for the current user.', required: false, diff --git a/apps/server/src/modules/account/controller/mapper/account-response.mapper.spec.ts b/apps/server/src/modules/account/controller/mapper/account-response.mapper.spec.ts new file mode 100644 index 00000000000..292609583e1 --- /dev/null +++ b/apps/server/src/modules/account/controller/mapper/account-response.mapper.spec.ts @@ -0,0 +1,72 @@ +import { accountDoFactory } from '@shared/testing'; +import { Account } from '../../domain'; +import { AccountResponseMapper } from './account-response.mapper'; +import { ResolvedSearchListAccountDto } from '../../uc/dto/resolved-account.dto'; + +describe('AccountResponseMapper', () => { + describe('mapToAccountResponse', () => { + describe('When mapping Account to AccountResponse', () => { + const setup = () => { + const testDto: Account = accountDoFactory.build(); + return testDto; + }; + + it('should map all fields', () => { + const testDto = setup(); + + const ret = AccountResponseMapper.mapToAccountResponse(testDto); + + expect(ret.id).toBe(testDto.id); + expect(ret.userId).toBe(testDto.userId?.toString()); + expect(ret.activated).toBe(testDto.activated); + expect(ret.username).toBe(testDto.username); + }); + }); + }); + + describe('mapToAccountResponses', () => { + describe('When mapping Account[] to AccountResponse[]', () => { + const setup = () => { + const testDto: Account[] = accountDoFactory.buildList(3); + return testDto; + }; + + it('should map all fields', () => { + const testDto = setup(); + + const ret = AccountResponseMapper.mapToAccountResponses(testDto); + + expect(ret.length).toBe(testDto.length); + expect(ret[0].id).toBe(testDto[0].id); + expect(ret[0].userId).toBe(testDto[0].userId?.toString()); + expect(ret[0].activated).toBe(testDto[0].activated); + expect(ret[0].username).toBe(testDto[0].username); + }); + }); + }); + + describe('mapToAccountSearchListResponse', () => { + describe('When mapping ResolvedSearchListAccountDto to AccountSearchListResponse', () => { + const setup = () => { + const testDto = accountDoFactory.build(); + const searchListDto = new ResolvedSearchListAccountDto([testDto], 1, 0, 1); + return searchListDto; + }; + + it('should map all fields', () => { + const searchListDto = setup(); + + const ret = AccountResponseMapper.mapToAccountSearchListResponse(searchListDto); + + expect(ret.data.length).toBe(searchListDto.data.length); + expect(ret.data[0].id).toBe(searchListDto.data[0].id); + expect(ret.data[0].userId).toBe(searchListDto.data[0].userId?.toString()); + expect(ret.data[0].activated).toBe(searchListDto.data[0].activated); + expect(ret.data[0].username).toBe(searchListDto.data[0].username); + expect(ret.total).toBe(searchListDto.total); + expect(ret.skip).toBe(searchListDto.skip); + expect(ret.limit).toBe(searchListDto.limit); + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/controller/mapper/account-response.mapper.ts b/apps/server/src/modules/account/controller/mapper/account-response.mapper.ts new file mode 100644 index 00000000000..90611b07cf2 --- /dev/null +++ b/apps/server/src/modules/account/controller/mapper/account-response.mapper.ts @@ -0,0 +1,29 @@ +import { AccountResponse, AccountSearchListResponse } from '../dto'; +import { ResolvedAccountDto, ResolvedSearchListAccountDto } from '../../uc/dto/resolved-account.dto'; + +export class AccountResponseMapper { + static mapToAccountResponse(resolvedAccount: ResolvedAccountDto): AccountResponse { + return new AccountResponse({ + id: resolvedAccount.id, + userId: resolvedAccount.userId, + activated: resolvedAccount.activated, + username: resolvedAccount.username, + updatedAt: resolvedAccount.updatedAt, + }); + } + + static mapToAccountResponses(resolvedAccounts: ResolvedAccountDto[]): AccountResponse[] { + return resolvedAccounts.map((resolvedAccount) => AccountResponseMapper.mapToAccountResponse(resolvedAccount)); + } + + static mapToAccountSearchListResponse( + resolvedSearchListAccountDto: ResolvedSearchListAccountDto + ): AccountSearchListResponse { + return new AccountSearchListResponse( + AccountResponseMapper.mapToAccountResponses(resolvedSearchListAccountDto.data), + resolvedSearchListAccountDto.total, + resolvedSearchListAccountDto.skip, + resolvedSearchListAccountDto.limit + ); + } +} diff --git a/apps/server/src/modules/account/domain/account.spec.ts b/apps/server/src/modules/account/domain/account.spec.ts new file mode 100644 index 00000000000..18ae6ec7a59 --- /dev/null +++ b/apps/server/src/modules/account/domain/account.spec.ts @@ -0,0 +1,94 @@ +import bcrypt from 'bcryptjs'; +import { Account, AccountSave } from './account'; + +describe('Account', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2020, 1, 1)); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + describe('update', () => { + describe('When updating the account', () => { + const setup = () => { + const account = new Account({ + id: 'id', + username: 'username', + updatedAt: new Date(), + createdAt: new Date(), + userId: 'userId', + systemId: 'systemId', + token: 'token', + credentialHash: 'credentialHash', + }); + const accountSave = { + username: 'newUsername', + systemId: 'newSystemId', + userId: 'newUserId', + activated: true, + expiresAt: new Date(), + lasttriedFailedLogin: new Date(), + credentialHash: 'newCredentialHash', + token: 'newToken', + } as AccountSave; + + return { account, accountSave }; + }; + it('should update the account', async () => { + const { account, accountSave } = setup(); + await account.update(accountSave); + expect(account.getProps()).toStrictEqual({ + username: accountSave.username, + systemId: accountSave.systemId, + userId: accountSave.userId, + activated: accountSave.activated, + expiresAt: accountSave.expiresAt, + lasttriedFailedLogin: accountSave.lasttriedFailedLogin, + credentialHash: accountSave.credentialHash, + token: accountSave.token, + id: 'id', + createdAt: new Date(), + updatedAt: new Date(), + }); + }); + }); + describe('When updating the password', () => { + const setup = () => { + const account = new Account({ + id: 'id', + username: 'username', + updatedAt: new Date(), + createdAt: new Date(), + userId: 'userId', + systemId: 'systemId', + password: 'password', + token: 'token', + credentialHash: 'credentialHash', + }); + const accountSave = { + username: 'newUsername', + systemId: 'newSystemId', + userId: 'newUserId', + activated: true, + expiresAt: new Date(), + lasttriedFailedLogin: new Date(), + password: 'newPassword', + credentialHash: 'newCredentialHash', + token: 'newToken', + } as AccountSave; + + return { account, accountSave }; + }; + it('should encrypt Password', async () => { + const { account, accountSave } = setup(); + await account.update(accountSave); + const isPasswordMatch = bcrypt.compareSync(accountSave.password ?? '', account.getProps().password ?? ''); + expect(isPasswordMatch).toBe(true); + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/domain/account.ts b/apps/server/src/modules/account/domain/account.ts new file mode 100644 index 00000000000..9335373acc6 --- /dev/null +++ b/apps/server/src/modules/account/domain/account.ts @@ -0,0 +1,117 @@ +import bcrypt from 'bcryptjs'; +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { EntityId } from '@shared/domain/types'; + +export interface AccountProps extends AuthorizableObject { + id: EntityId; + updatedAt?: Date; + createdAt?: Date; + userId?: EntityId; + systemId?: EntityId; + username: string; + password?: string; + token?: string; + credentialHash?: string; + lasttriedFailedLogin?: Date; + expiresAt?: Date; + activated?: boolean; + idmReferenceId?: string; +} + +export class Account extends DomainObject { + public get id(): EntityId { + return this.props.id; + } + + public get createdAt(): Date | undefined { + return this.props.createdAt; + } + + public get updatedAt(): Date | undefined { + return this.props.updatedAt; + } + + public get userId(): EntityId | undefined { + return this.props.userId; + } + + public set userId(userId: EntityId | undefined) { + this.props.userId = userId; + } + + public get systemId(): EntityId | undefined { + return this.props.systemId; + } + + public set systemId(systemId: EntityId | undefined) { + this.props.systemId = systemId; + } + + public get username(): string { + return this.props.username; + } + + public set username(username: string) { + this.props.username = username; + } + + public get password(): string | undefined { + return this.props.password; + } + + public set password(password: string | undefined) { + this.props.password = password; + } + + public get token(): string | undefined { + return this.props.token; + } + + public get credentialHash(): string | undefined { + return this.props.credentialHash; + } + + public get lasttriedFailedLogin(): Date | undefined { + return this.props.lasttriedFailedLogin; + } + + public set lasttriedFailedLogin(lasttriedFailedLogin: Date | undefined) { + this.props.lasttriedFailedLogin = lasttriedFailedLogin; + } + + public get expiresAt(): Date | undefined { + return this.props.expiresAt; + } + + public get activated(): boolean | undefined { + return this.props.activated; + } + + public set activated(activated: boolean | undefined) { + this.props.activated = activated; + } + + public get idmReferenceId(): string | undefined { + return this.props.idmReferenceId; + } + + public async update(accountSave: AccountSave): Promise { + this.props.userId = accountSave.userId; + this.props.systemId = accountSave.systemId; + this.props.username = accountSave.username; + this.props.activated = accountSave.activated; + this.props.expiresAt = accountSave.expiresAt; + this.props.lasttriedFailedLogin = accountSave.lasttriedFailedLogin; + if (accountSave.password) { + this.props.password = await this.encryptPassword(accountSave.password); + } + this.props.credentialHash = accountSave.credentialHash; + this.props.token = accountSave.token; + } + + private encryptPassword(password: string): Promise { + return bcrypt.hash(password, 10); + } +} + +export type AccountSave = Omit & Partial>; diff --git a/apps/server/src/modules/account/domain/index.ts b/apps/server/src/modules/account/domain/index.ts new file mode 100644 index 00000000000..bc287bc2f99 --- /dev/null +++ b/apps/server/src/modules/account/domain/index.ts @@ -0,0 +1,3 @@ +export * from './account'; +export * from './update-account'; +export * from './update-my-account'; diff --git a/apps/server/src/modules/account/domain/update-account.ts b/apps/server/src/modules/account/domain/update-account.ts new file mode 100644 index 00000000000..8dde8829eee --- /dev/null +++ b/apps/server/src/modules/account/domain/update-account.ts @@ -0,0 +1,13 @@ +export class UpdateAccount { + username?: string; + + password?: string; + + activated?: boolean; + + constructor(props: UpdateAccount) { + this.username = props.username; + this.password = props.password; + this.activated = props.activated; + } +} diff --git a/apps/server/src/modules/account/domain/update-my-account.ts b/apps/server/src/modules/account/domain/update-my-account.ts new file mode 100644 index 00000000000..433b7338cff --- /dev/null +++ b/apps/server/src/modules/account/domain/update-my-account.ts @@ -0,0 +1,19 @@ +export class UpdateMyAccount { + passwordOld!: string; + + passwordNew?: string; + + email?: string; + + firstName?: string; + + lastName?: string; + + constructor(props: UpdateMyAccount) { + this.passwordOld = props.passwordOld; + this.passwordNew = props.passwordNew; + this.email = props.email; + this.firstName = props.firstName; + this.lastName = props.lastName; + } +} diff --git a/apps/server/src/shared/domain/entity/account.entity.ts b/apps/server/src/modules/account/entity/account.entity.ts similarity index 81% rename from apps/server/src/shared/domain/entity/account.entity.ts rename to apps/server/src/modules/account/entity/account.entity.ts index af4a5b02cf0..801fdf097f8 100644 --- a/apps/server/src/shared/domain/entity/account.entity.ts +++ b/apps/server/src/modules/account/entity/account.entity.ts @@ -1,12 +1,12 @@ import { Entity, Property, Index } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; -import { BaseEntityWithTimestamps } from './base.entity'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; -export type IdmAccountProperties = Readonly>; +export type IdmAccountProperties = Readonly>; @Entity({ tableName: 'accounts' }) @Index({ properties: ['userId', 'systemId'] }) -export class Account extends BaseEntityWithTimestamps { +export class AccountEntity extends BaseEntityWithTimestamps { @Property() @Index() username!: string; diff --git a/apps/server/src/modules/account/index.ts b/apps/server/src/modules/account/index.ts index 1cd85dd7252..1106aa5a180 100644 --- a/apps/server/src/modules/account/index.ts +++ b/apps/server/src/modules/account/index.ts @@ -1,3 +1,4 @@ export * from './account.module'; -export * from './account-config'; -export { AccountService, AccountDto, AccountSaveDto } from './services'; +export { AccountConfig } from './account-config'; +export { AccountService } from './services'; +export * from './domain'; diff --git a/apps/server/src/modules/account/loggable/deleted-account-with-user-id.loggable.spec.ts b/apps/server/src/modules/account/loggable/deleted-account-with-user-id.loggable.spec.ts new file mode 100644 index 00000000000..64faa352732 --- /dev/null +++ b/apps/server/src/modules/account/loggable/deleted-account-with-user-id.loggable.spec.ts @@ -0,0 +1,16 @@ +import { DeletedAccountWithUserIdLoggable } from './deleted-account-with-user-id.loggable'; + +describe('DeletedAccountWithUserIdLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const userId = 'test-user-id'; + const loggable = new DeletedAccountWithUserIdLoggable(userId); + + expect(loggable).toEqual({ userId: 'test-user-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { userId: 'test-user-id' }, + message: 'Account deleted', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/deleted-account-with-user-id.loggable.ts b/apps/server/src/modules/account/loggable/deleted-account-with-user-id.loggable.ts new file mode 100644 index 00000000000..c99ca026491 --- /dev/null +++ b/apps/server/src/modules/account/loggable/deleted-account-with-user-id.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class DeletedAccountWithUserIdLoggable implements Loggable { + constructor(private readonly userId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Account deleted`, + data: { userId: this.userId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/deleted-account.loggable.spec.ts b/apps/server/src/modules/account/loggable/deleted-account.loggable.spec.ts new file mode 100644 index 00000000000..fefa764b65f --- /dev/null +++ b/apps/server/src/modules/account/loggable/deleted-account.loggable.spec.ts @@ -0,0 +1,16 @@ +import { DeletedAccountLoggable } from './deleted-account.loggable'; + +describe('DeletedAccountLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const accountId = 'test-account-id'; + const loggable = new DeletedAccountLoggable(accountId); + + expect(loggable).toEqual({ accountId: 'test-account-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { accountId: 'test-account-id' }, + message: 'Account deleted', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/deleted-account.loggable.ts b/apps/server/src/modules/account/loggable/deleted-account.loggable.ts new file mode 100644 index 00000000000..b848664c4e5 --- /dev/null +++ b/apps/server/src/modules/account/loggable/deleted-account.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class DeletedAccountLoggable implements Loggable { + constructor(private readonly accountId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Account deleted`, + data: { accountId: this.accountId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/deleted-user-data.loggable.spec.ts b/apps/server/src/modules/account/loggable/deleted-user-data.loggable.spec.ts new file mode 100644 index 00000000000..cc3bb8d813b --- /dev/null +++ b/apps/server/src/modules/account/loggable/deleted-user-data.loggable.spec.ts @@ -0,0 +1,16 @@ +import { DeletedUserDataLoggable } from './deleted-user-data.loggable'; + +describe('DeletedUserDataLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const userId = 'test-user-id'; + const loggable = new DeletedUserDataLoggable(userId); + + expect(loggable).toEqual({ userId: 'test-user-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { userId: 'test-user-id' }, + message: 'User data deleted from account collection', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/deleted-user-data.loggable.ts b/apps/server/src/modules/account/loggable/deleted-user-data.loggable.ts new file mode 100644 index 00000000000..6160bc283de --- /dev/null +++ b/apps/server/src/modules/account/loggable/deleted-user-data.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class DeletedUserDataLoggable implements Loggable { + constructor(private readonly userId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `User data deleted from account collection`, + data: { userId: this.userId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/deleting-account-with-user-id.loggable.spec.ts b/apps/server/src/modules/account/loggable/deleting-account-with-user-id.loggable.spec.ts new file mode 100644 index 00000000000..148f50f34af --- /dev/null +++ b/apps/server/src/modules/account/loggable/deleting-account-with-user-id.loggable.spec.ts @@ -0,0 +1,16 @@ +import { DeletingAccountWithUserIdLoggable } from './deleting-account-with-user-id.loggable'; + +describe('DeletingAccountWithUserIdLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const userId = 'test-user-id'; + const loggable = new DeletingAccountWithUserIdLoggable(userId); + + expect(loggable).toEqual({ userId: 'test-user-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { userId: 'test-user-id' }, + message: 'Deleting account ...', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/deleting-account-with-user-id.loggable.ts b/apps/server/src/modules/account/loggable/deleting-account-with-user-id.loggable.ts new file mode 100644 index 00000000000..83d0f519eb0 --- /dev/null +++ b/apps/server/src/modules/account/loggable/deleting-account-with-user-id.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class DeletingAccountWithUserIdLoggable implements Loggable { + constructor(private readonly userId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Deleting account ...`, + data: { userId: this.userId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/deleting-account.loggable.spec.ts b/apps/server/src/modules/account/loggable/deleting-account.loggable.spec.ts new file mode 100644 index 00000000000..418038bf7cd --- /dev/null +++ b/apps/server/src/modules/account/loggable/deleting-account.loggable.spec.ts @@ -0,0 +1,16 @@ +import { DeletingAccountLoggable } from './deleting-account.loggable'; + +describe('DeletingAccountLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const accountId = 'test-account-id'; + const loggable = new DeletingAccountLoggable(accountId); + + expect(loggable).toEqual({ accountId: 'test-account-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { accountId: 'test-account-id' }, + message: 'Deleting account ...', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/deleting-account.loggable.ts b/apps/server/src/modules/account/loggable/deleting-account.loggable.ts new file mode 100644 index 00000000000..93390555545 --- /dev/null +++ b/apps/server/src/modules/account/loggable/deleting-account.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class DeletingAccountLoggable implements Loggable { + constructor(private readonly accountId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Deleting account ...`, + data: { accountId: this.accountId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/deleting-user-data.loggable.spec.ts b/apps/server/src/modules/account/loggable/deleting-user-data.loggable.spec.ts new file mode 100644 index 00000000000..dbf93dd08a4 --- /dev/null +++ b/apps/server/src/modules/account/loggable/deleting-user-data.loggable.spec.ts @@ -0,0 +1,16 @@ +import { DeletingUserDataLoggable } from './deleting-user-data.loggable'; + +describe('DeletingUserDataLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const userId = 'test-user-id'; + const loggable = new DeletingUserDataLoggable(userId); + + expect(loggable).toEqual({ userId: 'test-user-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { userId: 'test-user-id' }, + message: 'Start deleting user data in account collection', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/deleting-user-data.loggable.ts b/apps/server/src/modules/account/loggable/deleting-user-data.loggable.ts new file mode 100644 index 00000000000..f044d9ab0e3 --- /dev/null +++ b/apps/server/src/modules/account/loggable/deleting-user-data.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class DeletingUserDataLoggable implements Loggable { + constructor(private readonly userId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Start deleting user data in account collection`, + data: { userId: this.userId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/find-account-by-user-id.loggable.spec.ts b/apps/server/src/modules/account/loggable/find-account-by-user-id.loggable.spec.ts new file mode 100644 index 00000000000..90252806ba9 --- /dev/null +++ b/apps/server/src/modules/account/loggable/find-account-by-user-id.loggable.spec.ts @@ -0,0 +1,16 @@ +import { FindAccountByDbcUserIdLoggable } from './find-account-by-user-id.loggable'; + +describe('FindAccountByDbcUserIdLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const userId = 'test-user-id'; + const loggable = new FindAccountByDbcUserIdLoggable(userId); + + expect(loggable).toEqual({ userId: 'test-user-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { userId: 'test-user-id' }, + message: 'Error while searching for account', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/find-account-by-user-id.loggable.ts b/apps/server/src/modules/account/loggable/find-account-by-user-id.loggable.ts new file mode 100644 index 00000000000..4873f0cf267 --- /dev/null +++ b/apps/server/src/modules/account/loggable/find-account-by-user-id.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class FindAccountByDbcUserIdLoggable implements Loggable { + constructor(private readonly userId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Error while searching for account`, + data: { userId: this.userId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/get-idm-account-by-id.loggable.spec.ts b/apps/server/src/modules/account/loggable/get-idm-account-by-id.loggable.spec.ts new file mode 100644 index 00000000000..1198c9cc6d5 --- /dev/null +++ b/apps/server/src/modules/account/loggable/get-idm-account-by-id.loggable.spec.ts @@ -0,0 +1,16 @@ +import { GetOptionalIdmAccountLoggable } from './get-idm-account-by-id.loggable'; + +describe('GetOptionalIdmAccountLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const accountId = 'test-account-id'; + const loggable = new GetOptionalIdmAccountLoggable(accountId); + + expect(loggable).toEqual({ accountId: 'test-account-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { accountId: 'test-account-id' }, + message: 'Account ID could not be resolved. Creating new account and ID ...', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/get-idm-account-by-id.loggable.ts b/apps/server/src/modules/account/loggable/get-idm-account-by-id.loggable.ts new file mode 100644 index 00000000000..18e2c4a4b25 --- /dev/null +++ b/apps/server/src/modules/account/loggable/get-idm-account-by-id.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class GetOptionalIdmAccountLoggable implements Loggable { + constructor(private readonly accountId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Account ID could not be resolved. Creating new account and ID ...`, + data: { accountId: this.accountId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/idm-callback-loggable-exception.spec.ts b/apps/server/src/modules/account/loggable/idm-callback-loggable-exception.spec.ts new file mode 100644 index 00000000000..670f4314e3e --- /dev/null +++ b/apps/server/src/modules/account/loggable/idm-callback-loggable-exception.spec.ts @@ -0,0 +1,31 @@ +import { IdmCallbackLoggableException } from './idm-callback-loggable-exception'; + +describe('IdmCallbackLoggableException', () => { + describe('getLogMessage', () => { + describe('when error ist of type Error', () => { + it('should return log message', () => { + const error = new Error('error'); + const exception = new IdmCallbackLoggableException(error); + + const result = exception.getLogMessage(); + + expect(result).toEqual(expect.objectContaining({ message: 'error', stack: error.stack })); + }); + }); + describe('when error is not of type Error', () => { + it('should return log message', () => { + const error = 'error'; + const exception = new IdmCallbackLoggableException(error); + + const result = exception.getLogMessage(); + + expect(result).toEqual( + expect.objectContaining({ + message: 'error accessing IDM callback', + data: { callbackError: JSON.stringify(error) }, + }) + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/idm-callback-loggable-exception.ts b/apps/server/src/modules/account/loggable/idm-callback-loggable-exception.ts new file mode 100644 index 00000000000..2a897cd8090 --- /dev/null +++ b/apps/server/src/modules/account/loggable/idm-callback-loggable-exception.ts @@ -0,0 +1,20 @@ +import { ErrorLogMessage, LogMessage, Loggable, ValidationErrorLogMessage } from '@src/core/logger'; + +export class IdmCallbackLoggableException implements Loggable { + constructor(private readonly callbackError: any) {} + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + if (this.callbackError instanceof Error) { + return { + type: this.callbackError.name, + message: this.callbackError.message, + stack: this.callbackError.stack, + }; + } + return { + type: 'CALLBACK_ERROR', + message: 'error accessing IDM callback', + data: { callbackError: JSON.stringify(this.callbackError) }, + }; + } +} diff --git a/apps/server/src/modules/account/loggable/index.ts b/apps/server/src/modules/account/loggable/index.ts new file mode 100644 index 00000000000..87e1c281448 --- /dev/null +++ b/apps/server/src/modules/account/loggable/index.ts @@ -0,0 +1,17 @@ +export * from './saving-account.loggable'; +export * from './saved-account.loggable'; +export * from './updating-account-username.loggable'; +export * from './updated-account-username.loggable'; +export * from './updating-last-failed-login.loggable'; +export * from './updated-last-failed-login.loggable'; +export * from './updating-account-password.loggable'; +export * from './updated-account-password.loggable'; +export * from './deleting-account.loggable'; +export * from './deleted-account.loggable'; +export * from './deleting-account-with-user-id.loggable'; +export * from './deleted-account-with-user-id.loggable'; +export * from './deleting-user-data.loggable'; +export * from './deleted-user-data.loggable'; +export * from './idm-callback-loggable-exception'; +export * from './find-account-by-user-id.loggable'; +export * from './get-idm-account-by-id.loggable'; diff --git a/apps/server/src/modules/account/loggable/saved-account.loggable.spec.ts b/apps/server/src/modules/account/loggable/saved-account.loggable.spec.ts new file mode 100644 index 00000000000..9686e25e1fa --- /dev/null +++ b/apps/server/src/modules/account/loggable/saved-account.loggable.spec.ts @@ -0,0 +1,16 @@ +import { SavedAccountLoggable } from './saved-account.loggable'; + +describe('SavedAccountLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const accountId = 'test-account-id'; + const loggable = new SavedAccountLoggable(accountId); + + expect(loggable).toEqual({ accountId: 'test-account-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { accountId: 'test-account-id' }, + message: 'Account saved', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/saved-account.loggable.ts b/apps/server/src/modules/account/loggable/saved-account.loggable.ts new file mode 100644 index 00000000000..e10e4fda459 --- /dev/null +++ b/apps/server/src/modules/account/loggable/saved-account.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class SavedAccountLoggable implements Loggable { + constructor(private readonly accountId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Account saved`, + data: { accountId: this.accountId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/saving-account.loggable.spec.ts b/apps/server/src/modules/account/loggable/saving-account.loggable.spec.ts new file mode 100644 index 00000000000..de333648a7e --- /dev/null +++ b/apps/server/src/modules/account/loggable/saving-account.loggable.spec.ts @@ -0,0 +1,16 @@ +import { SavingAccountLoggable } from './saving-account.loggable'; + +describe('SavingAccountLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const accountId = 'test-account-id'; + const loggable = new SavingAccountLoggable(accountId); + + expect(loggable).toEqual({ accountId: 'test-account-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { accountId: 'test-account-id' }, + message: 'Saving account ...', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/saving-account.loggable.ts b/apps/server/src/modules/account/loggable/saving-account.loggable.ts new file mode 100644 index 00000000000..dfe9be05ae1 --- /dev/null +++ b/apps/server/src/modules/account/loggable/saving-account.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class SavingAccountLoggable implements Loggable { + constructor(private readonly accountId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Saving account ...`, + data: { accountId: this.accountId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/updated-account-password.loggable.spec.ts b/apps/server/src/modules/account/loggable/updated-account-password.loggable.spec.ts new file mode 100644 index 00000000000..03637db234a --- /dev/null +++ b/apps/server/src/modules/account/loggable/updated-account-password.loggable.spec.ts @@ -0,0 +1,16 @@ +import { UpdatedAccountPasswordLoggable } from './updated-account-password.loggable'; + +describe('UpdatedAccountPasswordLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const accountId = 'test-account-id'; + const loggable = new UpdatedAccountPasswordLoggable(accountId); + + expect(loggable).toEqual({ accountId: 'test-account-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { accountId: 'test-account-id' }, + message: 'Updated password', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/updated-account-password.loggable.ts b/apps/server/src/modules/account/loggable/updated-account-password.loggable.ts new file mode 100644 index 00000000000..72d2a99ef35 --- /dev/null +++ b/apps/server/src/modules/account/loggable/updated-account-password.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class UpdatedAccountPasswordLoggable implements Loggable { + constructor(private readonly accountId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Updated password`, + data: { accountId: this.accountId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/updated-account-username.loggable.spec.ts b/apps/server/src/modules/account/loggable/updated-account-username.loggable.spec.ts new file mode 100644 index 00000000000..8376f802d2a --- /dev/null +++ b/apps/server/src/modules/account/loggable/updated-account-username.loggable.spec.ts @@ -0,0 +1,16 @@ +import { UpdatedAccountUsernameLoggable } from './updated-account-username.loggable'; + +describe('UpdatedAccountPasswordLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const accountId = 'test-account-id'; + const loggable = new UpdatedAccountUsernameLoggable(accountId); + + expect(loggable).toEqual({ accountId: 'test-account-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { accountId: 'test-account-id' }, + message: 'Updated username', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/updated-account-username.loggable.ts b/apps/server/src/modules/account/loggable/updated-account-username.loggable.ts new file mode 100644 index 00000000000..7e346c0daed --- /dev/null +++ b/apps/server/src/modules/account/loggable/updated-account-username.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class UpdatedAccountUsernameLoggable implements Loggable { + constructor(private readonly accountId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Updated username`, + data: { accountId: this.accountId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/updated-last-failed-login.loggable.spec.ts b/apps/server/src/modules/account/loggable/updated-last-failed-login.loggable.spec.ts new file mode 100644 index 00000000000..b8d490165d0 --- /dev/null +++ b/apps/server/src/modules/account/loggable/updated-last-failed-login.loggable.spec.ts @@ -0,0 +1,16 @@ +import { UpdatedLastFailedLoginLoggable } from './updated-last-failed-login.loggable'; + +describe('UpdatedLastFailedLoginLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const accountId = 'test-account-id'; + const loggable = new UpdatedLastFailedLoginLoggable(accountId); + + expect(loggable).toEqual({ accountId: 'test-account-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { accountId: 'test-account-id' }, + message: 'Updated last tried failed login', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/updated-last-failed-login.loggable.ts b/apps/server/src/modules/account/loggable/updated-last-failed-login.loggable.ts new file mode 100644 index 00000000000..f6d404b10a0 --- /dev/null +++ b/apps/server/src/modules/account/loggable/updated-last-failed-login.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class UpdatedLastFailedLoginLoggable implements Loggable { + constructor(private readonly accountId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Updated last tried failed login`, + data: { accountId: this.accountId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/updating-account-password.loggable.spec.ts b/apps/server/src/modules/account/loggable/updating-account-password.loggable.spec.ts new file mode 100644 index 00000000000..29fb2b9c786 --- /dev/null +++ b/apps/server/src/modules/account/loggable/updating-account-password.loggable.spec.ts @@ -0,0 +1,16 @@ +import { UpdatingAccountPasswordLoggable } from './updating-account-password.loggable'; + +describe('UpdatingAccountPasswordLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const accountId = 'test-account-id'; + const loggable = new UpdatingAccountPasswordLoggable(accountId); + + expect(loggable).toEqual({ accountId: 'test-account-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { accountId: 'test-account-id' }, + message: 'Updating password ...', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/updating-account-password.loggable.ts b/apps/server/src/modules/account/loggable/updating-account-password.loggable.ts new file mode 100644 index 00000000000..8f0bb7cd8e8 --- /dev/null +++ b/apps/server/src/modules/account/loggable/updating-account-password.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class UpdatingAccountPasswordLoggable implements Loggable { + constructor(private readonly accountId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Updating password ...`, + data: { accountId: this.accountId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/updating-account-username.loggable.spec.ts b/apps/server/src/modules/account/loggable/updating-account-username.loggable.spec.ts new file mode 100644 index 00000000000..945535dfec0 --- /dev/null +++ b/apps/server/src/modules/account/loggable/updating-account-username.loggable.spec.ts @@ -0,0 +1,16 @@ +import { UpdatingAccountUsernameLoggable } from './updating-account-username.loggable'; + +describe('UpdatingAccountUsernameLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const accountId = 'test-account-id'; + const loggable = new UpdatingAccountUsernameLoggable(accountId); + + expect(loggable).toEqual({ accountId: 'test-account-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { accountId: 'test-account-id' }, + message: 'Updating username ...', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/updating-account-username.loggable.ts b/apps/server/src/modules/account/loggable/updating-account-username.loggable.ts new file mode 100644 index 00000000000..8f83855ce77 --- /dev/null +++ b/apps/server/src/modules/account/loggable/updating-account-username.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class UpdatingAccountUsernameLoggable implements Loggable { + constructor(private readonly accountId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Updating username ...`, + data: { accountId: this.accountId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/loggable/updating-last-failed-login.loggable.spec.ts b/apps/server/src/modules/account/loggable/updating-last-failed-login.loggable.spec.ts new file mode 100644 index 00000000000..335927c0578 --- /dev/null +++ b/apps/server/src/modules/account/loggable/updating-last-failed-login.loggable.spec.ts @@ -0,0 +1,16 @@ +import { UpdatingLastFailedLoginLoggable } from './updating-last-failed-login.loggable'; + +describe('UpdatingLastFailedLoginLoggable', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const accountId = 'test-account-id'; + const loggable = new UpdatingLastFailedLoginLoggable(accountId); + + expect(loggable).toEqual({ accountId: 'test-account-id' }); + expect(loggable.getLogMessage()).toStrictEqual({ + data: { accountId: 'test-account-id' }, + message: 'Updating last tried failed login ...', + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/loggable/updating-last-failed-login.loggable.ts b/apps/server/src/modules/account/loggable/updating-last-failed-login.loggable.ts new file mode 100644 index 00000000000..a294ce0dadb --- /dev/null +++ b/apps/server/src/modules/account/loggable/updating-last-failed-login.loggable.ts @@ -0,0 +1,14 @@ +import { Loggable, LogMessage } from '@src/core/logger'; + +export class UpdatingLastFailedLoginLoggable implements Loggable { + constructor(private readonly accountId: string) {} + + getLogMessage(): LogMessage { + const message = { + message: `Updating last tried failed login ...`, + data: { accountId: this.accountId }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.spec.ts b/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.spec.ts deleted file mode 100644 index e5e828d3b87..00000000000 --- a/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Account } from '@shared/domain/entity'; -import { ObjectId } from 'bson'; -import { AccountEntityToDtoMapper } from './account-entity-to-dto.mapper'; - -describe('AccountEntityToDtoMapper', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(2020, 1, 1)); - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - - describe('mapToDto', () => { - it('should map all fields', () => { - const testEntity: Account = { - _id: new ObjectId(), - id: 'id', - createdAt: new Date(), - updatedAt: new Date(), - userId: new ObjectId(), - username: 'username', - activated: true, - credentialHash: 'credentialHash', - expiresAt: new Date(), - lasttriedFailedLogin: new Date(), - password: 'password', - systemId: new ObjectId(), - token: 'token', - }; - const ret = AccountEntityToDtoMapper.mapToDto(testEntity); - - expect(ret.id).toBe(testEntity.id); - expect(ret.createdAt).toEqual(testEntity.createdAt); - expect(ret.updatedAt).toEqual(testEntity.createdAt); - expect(ret.userId).toBe(testEntity.userId?.toString()); - expect(ret.username).toBe(testEntity.username); - expect(ret.activated).toBe(testEntity.activated); - expect(ret.credentialHash).toBe(testEntity.credentialHash); - expect(ret.expiresAt).toBe(testEntity.expiresAt); - expect(ret.lasttriedFailedLogin).toBe(testEntity.lasttriedFailedLogin); - expect(ret.password).toBe(testEntity.password); - expect(ret.systemId).toBe(testEntity.systemId?.toString()); - expect(ret.token).toBe(testEntity.token); - }); - - it('should ignore missing ids', () => { - const testEntity: Account = { - _id: new ObjectId(), - id: 'id', - username: 'username', - createdAt: new Date(), - updatedAt: new Date(), - }; - const ret = AccountEntityToDtoMapper.mapToDto(testEntity); - - expect(ret.userId).toBeUndefined(); - expect(ret.systemId).toBeUndefined(); - }); - }); - - describe('mapSearchResult', () => { - it('should use actual date if date is', () => { - const testEntity1: Account = { - _id: new ObjectId(), - id: '1', - username: '1', - createdAt: new Date(), - updatedAt: new Date(), - }; - const testEntity2: Account = { - _id: new ObjectId(), - id: '2', - username: '2', - createdAt: new Date(), - updatedAt: new Date(), - }; - const testAmount = 10; - - const [accounts, total] = AccountEntityToDtoMapper.mapSearchResult([[testEntity1, testEntity2], testAmount]); - - expect(total).toBe(testAmount); - expect(accounts).toHaveLength(2); - expect(accounts).toContainEqual(expect.objectContaining({ id: '1' })); - expect(accounts).toContainEqual(expect.objectContaining({ id: '2' })); - }); - }); - - describe('mapAccountsToDto', () => { - it('should use actual date if date is', () => { - const testEntity1: Account = { - _id: new ObjectId(), - username: '1', - id: '1', - createdAt: new Date(), - updatedAt: new Date(), - }; - const testEntity2: Account = { - _id: new ObjectId(), - username: '2', - id: '2', - createdAt: new Date(), - updatedAt: new Date(), - }; - const ret = AccountEntityToDtoMapper.mapAccountsToDto([testEntity1, testEntity2]); - - expect(ret).toHaveLength(2); - expect(ret).toContainEqual(expect.objectContaining({ id: '1' })); - expect(ret).toContainEqual(expect.objectContaining({ id: '2' })); - }); - }); -}); diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.abstract.ts b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.abstract.ts deleted file mode 100644 index 9e3f0b83501..00000000000 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.abstract.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { IdmAccount } from '@shared/domain/interface'; -import { AccountDto } from '../services/dto/account.dto'; - -@Injectable() -export abstract class AccountIdmToDtoMapper { - abstract mapToDto(account: IdmAccount): AccountDto; -} diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts deleted file mode 100644 index bd6284383db..00000000000 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { IdmAccount } from '@shared/domain/interface'; -import { AccountDto } from '../services/dto'; -import { AccountIdmToDtoMapper } from './account-idm-to-dto.mapper.abstract'; -import { AccountIdmToDtoMapperDb } from './account-idm-to-dto.mapper.db'; - -describe('AccountIdmToDtoMapperDb', () => { - let module: TestingModule; - let mapper: AccountIdmToDtoMapper; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - { - provide: AccountIdmToDtoMapper, - useClass: AccountIdmToDtoMapperDb, - }, - ], - }).compile(); - - mapper = module.get(AccountIdmToDtoMapper); - }); - - afterAll(async () => { - await module.close(); - }); - describe('when mapping from entity to dto', () => { - describe('mapToDto', () => { - it('should map all fields', () => { - const testIdmEntity: IdmAccount = { - id: 'id', - username: 'username', - email: 'email', - firstName: 'firstName', - lastName: 'lastName', - createdDate: new Date(), - attDbcAccountId: 'attDbcAccountId', - attDbcUserId: 'attDbcUserId', - attDbcSystemId: 'attDbcSystemId', - }; - const ret = mapper.mapToDto(testIdmEntity); - - expect(ret).toEqual( - expect.objectContaining>({ - id: testIdmEntity.attDbcAccountId, - idmReferenceId: testIdmEntity.id, - userId: testIdmEntity.attDbcUserId, - systemId: testIdmEntity.attDbcSystemId, - createdAt: testIdmEntity.createdDate, - updatedAt: testIdmEntity.createdDate, - username: testIdmEntity.username, - }) - ); - }); - - describe('when date is undefined', () => { - const setup = () => { - const testIdmEntity: IdmAccount = { - id: 'id', - }; - - const dateMock = new Date(); - jest.useFakeTimers(); - jest.setSystemTime(dateMock); - - return { testIdmEntity, dateMock }; - }; - - it('should use actual date', () => { - const { testIdmEntity, dateMock } = setup(); - - const ret = mapper.mapToDto(testIdmEntity); - - expect(ret.createdAt).toEqual(dateMock); - expect(ret.updatedAt).toEqual(dateMock); - - jest.useRealTimers(); - }); - }); - - describe('when a fields value is missing', () => { - it('should fill with empty string', () => { - const testIdmEntity: IdmAccount = { - id: 'id', - }; - const ret = mapper.mapToDto(testIdmEntity); - - expect(ret.id).toBe(''); - expect(ret.username).toBe(''); - }); - }); - }); - }); -}); diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts b/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts deleted file mode 100644 index 7ef8508c0c2..00000000000 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { IdmAccount } from '@shared/domain/interface'; -import { AccountDto } from '../services/dto'; -import { AccountIdmToDtoMapper } from './account-idm-to-dto.mapper.abstract'; -import { AccountIdmToDtoMapperIdm } from './account-idm-to-dto.mapper.idm'; - -describe('AccountIdmToDtoMapperIdm', () => { - let module: TestingModule; - let mapper: AccountIdmToDtoMapper; - - const now: Date = new Date(2022, 1, 22); - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - { - provide: AccountIdmToDtoMapper, - useClass: AccountIdmToDtoMapperIdm, - }, - ], - }).compile(); - - mapper = module.get(AccountIdmToDtoMapper); - - jest.useFakeTimers(); - jest.setSystemTime(now); - }); - - afterAll(async () => { - await module.close(); - }); - - describe('when mapping from entity to dto', () => { - it('should map all fields', () => { - const testIdmEntity: IdmAccount = { - id: 'id', - username: 'username', - email: 'email', - firstName: 'firstName', - lastName: 'lastName', - createdDate: new Date(), - attDbcAccountId: 'attDbcAccountId', - attDbcUserId: 'attDbcUserId', - attDbcSystemId: 'attDbcSystemId', - }; - const ret = mapper.mapToDto(testIdmEntity); - - expect(ret).toEqual( - expect.objectContaining>({ - id: testIdmEntity.id, - idmReferenceId: undefined, - userId: testIdmEntity.attDbcUserId, - systemId: testIdmEntity.attDbcSystemId, - createdAt: testIdmEntity.createdDate, - updatedAt: testIdmEntity.createdDate, - username: testIdmEntity.username, - }) - ); - }); - - describe('when date is undefined', () => { - it('should use actual date', () => { - const testIdmEntity: IdmAccount = { - id: 'id', - }; - const ret = mapper.mapToDto(testIdmEntity); - - expect(ret.createdAt).toEqual(now); - expect(ret.updatedAt).toEqual(now); - }); - }); - - describe('when a fields value is missing', () => { - it('should fill with empty string', () => { - const testIdmEntity: IdmAccount = { - id: 'id', - }; - const ret = mapper.mapToDto(testIdmEntity); - - expect(ret.username).toBe(''); - }); - }); - }); -}); diff --git a/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts b/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts deleted file mode 100644 index ef9b64e8257..00000000000 --- a/apps/server/src/modules/account/mapper/account-response.mapper.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ObjectId } from '@mikro-orm/mongodb'; -import { AccountDto } from '@modules/account/services/dto/account.dto'; -import { Account } from '@shared/domain/entity'; -import { AccountResponseMapper } from '.'; - -describe('AccountResponseMapper', () => { - describe('mapToResponseFromEntity', () => { - it('should map all fields', () => { - const testEntity: Account = { - _id: new ObjectId(), - id: new ObjectId().toString(), - userId: new ObjectId(), - activated: true, - username: 'username', - createdAt: new Date(), - updatedAt: new Date(), - }; - const ret = AccountResponseMapper.mapToResponseFromEntity(testEntity); - - expect(ret.id).toBe(testEntity.id); - expect(ret.userId).toBe(testEntity.userId?.toString()); - expect(ret.activated).toBe(testEntity.activated); - expect(ret.username).toBe(testEntity.username); - expect(ret.updatedAt).toBe(testEntity.updatedAt); - }); - - it('should ignore missing userId', () => { - const testEntity: Account = { - _id: new ObjectId(), - id: new ObjectId().toString(), - userId: undefined, - activated: true, - username: 'username', - createdAt: new Date(), - updatedAt: new Date(), - }; - const ret = AccountResponseMapper.mapToResponseFromEntity(testEntity); - - expect(ret.userId).toBeUndefined(); - }); - }); - - describe('mapToResponse', () => { - it('should map all fields', () => { - const testDto: AccountDto = { - id: new ObjectId().toString(), - userId: new ObjectId().toString(), - activated: true, - username: 'username', - createdAt: new Date(), - updatedAt: new Date(), - }; - const ret = AccountResponseMapper.mapToResponse(testDto); - - expect(ret.id).toBe(testDto.id); - expect(ret.userId).toBe(testDto.userId?.toString()); - expect(ret.activated).toBe(testDto.activated); - expect(ret.username).toBe(testDto.username); - expect(ret.updatedAt).toBe(testDto.updatedAt); - }); - }); -}); diff --git a/apps/server/src/modules/account/mapper/account-response.mapper.ts b/apps/server/src/modules/account/mapper/account-response.mapper.ts deleted file mode 100644 index 04e2f7c9ac8..00000000000 --- a/apps/server/src/modules/account/mapper/account-response.mapper.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AccountDto } from '@modules/account/services/dto/account.dto'; -import { Account } from '@shared/domain/entity'; -import { AccountResponse } from '../controller/dto'; - -export class AccountResponseMapper { - static mapToResponseFromEntity(account: Account): AccountResponse { - return new AccountResponse({ - id: account.id, - userId: account.userId?.toString(), - activated: account.activated, - username: account.username, - updatedAt: account.updatedAt, - }); - } - - static mapToResponse(account: AccountDto): AccountResponse { - return new AccountResponse({ - id: account.id, - userId: account.userId, - activated: account.activated, - username: account.username, - updatedAt: account.updatedAt, - }); - } -} diff --git a/apps/server/src/modules/account/mapper/index.ts b/apps/server/src/modules/account/mapper/index.ts deleted file mode 100644 index 6aa2106119e..00000000000 --- a/apps/server/src/modules/account/mapper/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './account-idm-to-dto.mapper.abstract'; -export * from './account-idm-to-dto.mapper.idm'; -export * from './account-idm-to-dto.mapper.db'; -export * from './account-entity-to-dto.mapper'; -export * from './account-response.mapper'; diff --git a/apps/server/src/modules/account/repo/account-scope.spec.ts b/apps/server/src/modules/account/repo/account-scope.spec.ts new file mode 100644 index 00000000000..2f8d80eebd9 --- /dev/null +++ b/apps/server/src/modules/account/repo/account-scope.spec.ts @@ -0,0 +1,45 @@ +import { FilterQuery } from '@mikro-orm/core'; +import { ObjectId } from 'bson'; +import { EmptyResultQuery } from '@shared/repo/query'; +import { AccountScope } from './account-scope'; +import { AccountEntity } from '../entity/account.entity'; + +describe(AccountScope.name, () => { + describe('byUserIdAndSystemId', () => { + describe('when build scope query', () => { + const setup = () => { + const scope = new AccountScope(); + const systemId = new ObjectId().toHexString(); + const user1Id = new ObjectId().toHexString(); + const user2Id = new ObjectId().toHexString(); + const userIds = [user1Id, user2Id]; + + const expected = { + $and: [ + { + userId: { $in: [new ObjectId(user1Id), new ObjectId(user2Id)] }, + }, + { + systemId: new ObjectId(systemId), + }, + ], + } as FilterQuery; + + return { scope, userIds, systemId, expected }; + }; + it('should create valid query returning no results for empty scope', () => { + const { scope } = setup(); + const result = scope.query; + + expect(result).toBe(EmptyResultQuery); + }); + it('should create correct query for byUserIdAndSystemId', () => { + const { scope, userIds, systemId, expected } = setup(); + scope.byUserIdsAndSystemId(userIds, systemId); + const result = scope.query; + + expect(JSON.stringify(result)).toBe(JSON.stringify(expected)); + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/repo/account-scope.ts b/apps/server/src/modules/account/repo/account-scope.ts new file mode 100644 index 00000000000..5d8fdd373e1 --- /dev/null +++ b/apps/server/src/modules/account/repo/account-scope.ts @@ -0,0 +1,13 @@ +import { Scope } from '@shared/repo'; +import { ObjectId } from 'bson'; +import { AccountEntity } from '../entity/account.entity'; + +export class AccountScope extends Scope { + byUserIdsAndSystemId(userIds: string[], systemId: string): AccountScope { + const userIdsAsObjectId = userIds.length > 0 ? userIds.map((id) => new ObjectId(id)) : []; + this.addQuery({ + $and: [{ userId: { $in: userIdsAsObjectId } }, { systemId: new ObjectId(systemId) }], + }); + return this; + } +} diff --git a/apps/server/src/modules/account/repo/account.repo.integration.spec.ts b/apps/server/src/modules/account/repo/account.repo.integration.spec.ts index 70778e590a3..8e5049a5c4b 100644 --- a/apps/server/src/modules/account/repo/account.repo.integration.spec.ts +++ b/apps/server/src/modules/account/repo/account.repo.integration.spec.ts @@ -2,15 +2,17 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { Account, User } from '@shared/domain/entity'; -import { accountFactory, cleanupCollections, userFactory } from '@shared/testing'; +import { User } from '@shared/domain/entity'; +import { accountDoFactory, accountFactory, cleanupCollections, userFactory } from '@shared/testing'; import { AccountRepo } from './account.repo'; +import { AccountEntity } from '../entity/account.entity'; +import { AccountDoToEntityMapper } from './mapper/account-do-to-entity.mapper'; +import { AccountEntityToDoMapper } from './mapper'; describe('account repo', () => { let module: TestingModule; let em: EntityManager; let repo: AccountRepo; - let mockAccounts: Account[]; beforeAll(async () => { module = await Test.createTestingModule({ @@ -25,202 +27,504 @@ describe('account repo', () => { await module.close(); }); - beforeEach(async () => { - mockAccounts = [ - accountFactory.build({ username: 'John Doe' }), - accountFactory.build({ username: 'Marry Doe' }), - accountFactory.build({ username: 'Susi Doe' }), - accountFactory.build({ username: 'Tim Doe' }), - ]; - await em.persistAndFlush(mockAccounts); - }); - afterEach(async () => { await cleanupCollections(em); }); it('should implement entityName getter', () => { - expect(repo.entityName).toBe(Account); + expect(repo.entityName).toBe(AccountEntity); + }); + + describe('save', () => { + describe('When an account is given', () => { + it('should save an account', async () => { + const account = accountDoFactory.build(); + + await repo.save(account); + + const foundAccount = await repo.findById(account.id); + expect(foundAccount).toBeDefined(); + }); + }); + + describe('When an existing account is given', () => { + const setup = async () => { + const account = accountFactory.build(); + await em.persistAndFlush(account); + em.clear(); + return account; + }; + + it('should update the account', async () => { + const account = await setup(); + + const updatedAccount = accountDoFactory.build({ id: account.id }); + await repo.save(updatedAccount); + + const foundAccount = await repo.findById(account.id); + expect(foundAccount?.username).toBe(updatedAccount.username); + }); + }); + }); + + describe('findById', () => { + describe('When the account exists', () => { + const setup = async () => { + const account = accountFactory.build(); + await em.persistAndFlush(account); + em.clear(); + return account; + }; + + it('should find it by id', async () => { + const account = await setup(); + const foundAccount = await repo.findById(account.id); + expect(foundAccount.id).toEqual(account.id); + }); + }); + + describe('When the account does not exist', () => { + it('should throw not found error', async () => { + await expect(repo.findById('000')).rejects.toThrow(NotFoundError); + }); + }); }); describe('findByUserId', () => { - it('should findByUserId', async () => { - const accountToFind = accountFactory.build(); - await em.persistAndFlush(accountToFind); - em.clear(); - const account = await repo.findByUserId(accountToFind.userId ?? ''); - expect(account?.id).toEqual(accountToFind.id); + describe('When calling findByUserId with id', () => { + const setup = async () => { + const accountToFind = accountFactory.build(); + await em.persistAndFlush(accountToFind); + em.clear(); + return accountToFind; + }; + + it('should find user with id', async () => { + const accountToFind = await setup(); + const account = await repo.findByUserId(accountToFind.userId ?? ''); + expect(account?.id).toEqual(accountToFind.id); + }); + }); + + describe('When id does not exist', () => { + it('should return null', async () => { + const account = await repo.findByUserId(new ObjectId().toHexString()); + expect(account).toBeNull(); + }); }); }); describe('findByUsernameAndSystemId', () => { - it('should return account', async () => { - const accountToFind = accountFactory.withSystemId(new ObjectId(10)).build(); - await em.persistAndFlush(accountToFind); - em.clear(); - const account = await repo.findByUsernameAndSystemId(accountToFind.username ?? '', accountToFind.systemId ?? ''); - expect(account?.username).toEqual(accountToFind.username); + describe('When username and systemId are given', () => { + const setup = async () => { + const accountToFind = accountFactory.withSystemId(new ObjectId(10)).build(); + await em.persistAndFlush(accountToFind); + em.clear(); + return accountToFind; + }; + + it('should return account', async () => { + const accountToFind = await setup(); + const account = await repo.findByUsernameAndSystemId(accountToFind.username, accountToFind.systemId ?? ''); + expect(account?.username).toEqual(accountToFind.username); + }); }); - it('should return null', async () => { - const account = await repo.findByUsernameAndSystemId('', new ObjectId(undefined)); - expect(account).toBeNull(); + + describe('When username and systemId are not given', () => { + it('should return null', async () => { + const account = await repo.findByUsernameAndSystemId('', new ObjectId(undefined)); + expect(account).toBeNull(); + }); }); }); describe('findMultipleByUserId', () => { - it('should find multiple user by id', async () => { - const anAccountToFind = accountFactory.build(); - const anotherAccountToFind = accountFactory.build(); - await em.persistAndFlush(anAccountToFind); - await em.persistAndFlush(anotherAccountToFind); - em.clear(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const accounts = await repo.findMultipleByUserId([anAccountToFind.userId!, anotherAccountToFind.userId!]); - expect(accounts).toContainEqual(anAccountToFind); - expect(accounts).toContainEqual(anotherAccountToFind); - expect(accounts).toHaveLength(2); + describe('When multiple user ids are given', () => { + const setup = async () => { + const anAccountToFind = accountDoFactory.build({ + userId: new ObjectId().toHexString(), + }); + const anotherAccountToFind = accountDoFactory.build({ + userId: new ObjectId().toHexString(), + }); + + await em.persistAndFlush(AccountDoToEntityMapper.mapToEntity(anAccountToFind)); + await em.persistAndFlush(AccountDoToEntityMapper.mapToEntity(anotherAccountToFind)); + em.clear(); + + return { anAccountToFind, anotherAccountToFind }; + }; + + it('should find multiple users', async () => { + const { anAccountToFind, anotherAccountToFind } = await setup(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const accounts = await repo.findMultipleByUserId([anAccountToFind.userId!, anotherAccountToFind.userId!]); + + expect(accounts).toHaveLength(2); + expect(accounts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ...anAccountToFind.getProps() }), + expect.objectContaining({ ...anotherAccountToFind.getProps() }), + ]) + ); + }); }); - it('should return empty list if no results', async () => { - const accountToFind = accountFactory.build(); - await em.persistAndFlush(accountToFind); - em.clear(); - const accounts = await repo.findMultipleByUserId(['123456789012', '098765432101']); - expect(accounts).toHaveLength(0); + describe('When not existing user ids are given', () => { + it('should return empty list', async () => { + const accounts = await repo.findMultipleByUserId(['123456789012', '098765432101']); + expect(accounts).toHaveLength(0); + }); }); }); describe('findByUserIdOrFail', () => { - it('should find a user by id', async () => { - const accountToFind = accountFactory.build(); - await em.persistAndFlush(accountToFind); - em.clear(); - const account = await repo.findByUserIdOrFail(accountToFind.userId ?? ''); - expect(account.id).toEqual(accountToFind.id); + describe('When existing id is given', () => { + const setup = async () => { + const accountToFind = accountFactory.build(); + await em.persistAndFlush(accountToFind); + em.clear(); + return accountToFind; + }; + + it('should find a user', async () => { + const accountToFind = await setup(); + const account = await repo.findByUserIdOrFail(accountToFind.userId ?? ''); + expect(account.id).toEqual(accountToFind.id); + }); }); - it('should throw if id does not exist', async () => { - const accountToFind = accountFactory.build(); - await em.persistAndFlush(accountToFind); - em.clear(); - await expect(repo.findByUserIdOrFail('123456789012')).rejects.toThrow(NotFoundError); + describe('When id does not exist', () => { + it('should throw not found error', async () => { + await expect(repo.findByUserIdOrFail('123456789012')).rejects.toThrow(NotFoundError); + }); }); }); describe('getObjectReference', () => { - it('should return a valid reference', async () => { - const user = userFactory.buildWithId(); - const account = accountFactory.build({ userId: user.id }); - await em.persistAndFlush([user, account]); + describe('When a user id is given', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const account = accountFactory.build({ userId: user.id }); + await em.persistAndFlush([user, account]); + return { user, account }; + }; + + it('should return a valid reference', async () => { + const { user, account } = await setup(); - const reference = repo.getObjectReference(User, account.userId ?? ''); + const reference = repo.getObjectReference(User, account.userId ?? ''); - expect(reference).toBe(user); + expect(reference).toBe(user); + }); }); }); describe('saveWithoutFlush', () => { - it('should add an account to the persist stack', () => { - const account = accountFactory.build(); - - repo.saveWithoutFlush(account); - expect(em.getUnitOfWork().getPersistStack().size).toBe(1); + describe('When calling saveWithoutFlush', () => { + const setup = () => { + const account = accountDoFactory.build(); + return account; + }; + + it('should add an account to the persist stack', async () => { + const account = setup(); + + await repo.saveWithoutFlush(account); + expect(em.getUnitOfWork().getPersistStack().size).toBe(1); + }); + }); + describe('When an account is updated', () => { + const setup = async () => { + const account = accountFactory.build(); + await em.persistAndFlush(account); + em.clear(); + return account; + }; + + it('should add it to the change set', async () => { + const account = await setup(); + + const updatedAccount = accountDoFactory.build({ id: account.id }); + await repo.saveWithoutFlush(updatedAccount); + + em.getUnitOfWork().computeChangeSets(); + expect(em.getUnitOfWork().getChangeSets().length).toBe(1); + }); }); }); describe('flush', () => { - it('should flush after save', async () => { - const account = accountFactory.build(); - em.persist(account); + describe('When repo is flushed', () => { + const setup = () => { + const account = accountFactory.build(); + em.persist(account); + return account; + }; - expect(account.id).toBeNull(); + it('should save account', async () => { + const account = setup(); - await repo.flush(); + expect(account.id).toBeNull(); - expect(account.id).not.toBeNull(); + await repo.flush(); + + expect(account.id).not.toBeNull(); + }); }); }); - describe('findByUsername', () => { - it('should find account by user name', async () => { - const originalUsername = 'USER@EXAMPLE.COM'; - const account = accountFactory.build({ username: originalUsername }); - await em.persistAndFlush([account]); - em.clear(); - - const [result] = await repo.searchByUsernameExactMatch('USER@EXAMPLE.COM'); - expect(result).toHaveLength(1); - expect(result[0]).toEqual(expect.objectContaining({ username: originalUsername })); - - const [result2] = await repo.searchByUsernamePartialMatch('user'); - expect(result2).toHaveLength(1); - expect(result2[0]).toEqual(expect.objectContaining({ username: originalUsername })); + describe('searchByUsernamePartialMatch', () => { + describe('When searching with a partial user name', () => { + const setup = async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const partialUsername = 'user'; + const account = accountFactory.build({ username: originalUsername }); + await em.persistAndFlush([account]); + em.clear(); + return { originalUsername, partialUsername, account }; + }; + + it('should find exact one user', async () => { + const { originalUsername, partialUsername } = await setup(); + const [result] = await repo.searchByUsernamePartialMatch(partialUsername); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ username: originalUsername })); + }); }); - it('should find account by user name, ignoring case', async () => { - const originalUsername = 'USER@EXAMPLE.COM'; - const account = accountFactory.build({ username: originalUsername }); - await em.persistAndFlush([account]); - em.clear(); - - let [accounts] = await repo.searchByUsernameExactMatch('USER@example.COM'); - expect(accounts).toHaveLength(1); - expect(accounts[0]).toEqual(expect.objectContaining({ username: originalUsername })); + }); - [accounts] = await repo.searchByUsernameExactMatch('user@example.com'); - expect(accounts).toHaveLength(1); - expect(accounts[0]).toEqual(expect.objectContaining({ username: originalUsername })); + describe('searchByUsernameExactMatch', () => { + describe('When searching for an exact match', () => { + const setup = async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const account = accountFactory.build({ username: originalUsername }); + await em.persistAndFlush([account]); + em.clear(); + return { originalUsername, account }; + }; + + it('should find exact one account', async () => { + const { originalUsername } = await setup(); + + const [result] = await repo.searchByUsernameExactMatch(originalUsername); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(expect.objectContaining({ username: originalUsername })); + }); }); - it('should not find by wildcard', async () => { - const originalUsername = 'USER@EXAMPLE.COM'; - const account = accountFactory.build({ username: originalUsername }); - await em.persistAndFlush([account]); - em.clear(); - let [accounts] = await repo.searchByUsernameExactMatch('USER@EXAMPLECCOM'); - expect(accounts).toHaveLength(0); + describe('When searching by username', () => { + const setup = async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const partialLowerCaseUsername = 'USER@example.COM'; + const lowercaseUsername = 'user@example.com'; + const account = accountFactory.build({ username: originalUsername }); + await em.persistAndFlush([account]); + em.clear(); + return { originalUsername, partialLowerCaseUsername, lowercaseUsername, account }; + }; + + it('should find account by user name, ignoring case', async () => { + const { originalUsername, partialLowerCaseUsername, lowercaseUsername } = await setup(); + + let [accounts] = await repo.searchByUsernameExactMatch(partialLowerCaseUsername); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toEqual(expect.objectContaining({ username: originalUsername })); + + [accounts] = await repo.searchByUsernameExactMatch(lowercaseUsername); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toEqual(expect.objectContaining({ username: originalUsername })); + }); + }); - [accounts] = await repo.searchByUsernameExactMatch('.*'); - expect(accounts).toHaveLength(0); + describe('When using wildcard', () => { + const setup = async () => { + const originalUsername = 'USER@EXAMPLE.COM'; + const missingDotUserName = 'USER@EXAMPLECCOM'; + const wildcard = '.*'; + const account = accountFactory.build({ username: originalUsername }); + await em.persistAndFlush([account]); + em.clear(); + return { originalUsername, missingDotUserName, wildcard, account }; + }; + + it('should not find account', async () => { + const { missingDotUserName, wildcard } = await setup(); + + let [accounts] = await repo.searchByUsernameExactMatch(missingDotUserName); + expect(accounts).toHaveLength(0); + + [accounts] = await repo.searchByUsernameExactMatch(wildcard); + expect(accounts).toHaveLength(0); + }); }); }); - describe('deleteId', () => { - it('should delete an account by id', async () => { - const account = accountFactory.buildWithId(); - await em.persistAndFlush([account]); + describe('deleteById', () => { + describe('When an id is given', () => { + const setup = async () => { + const account = accountFactory.buildWithId(); + await em.persistAndFlush([account]); + + return account; + }; - await expect(repo.deleteById(account.id)).resolves.not.toThrow(); + it('should delete an account by id', async () => { + const account = await setup(); - await expect(repo.findById(account.id)).rejects.toThrow(NotFoundError); + await expect(repo.deleteById(account.id)).resolves.not.toThrow(); + + await expect(repo.findById(account.id)).rejects.toThrow(NotFoundError); + }); }); }); describe('deleteByUserId', () => { - it('should delete an account by user id', async () => { - const user = userFactory.buildWithId(); - const account = accountFactory.build({ userId: user.id }); - await em.persistAndFlush([user, account]); + describe('When an user id is given', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const account = accountFactory.build({ userId: user.id }); + await em.persistAndFlush([user, account]); + + return { user, account }; + }; - await expect(repo.deleteByUserId(user.id)).resolves.not.toThrow(); + it('should delete an account by user id', async () => { + const { user, account } = await setup(); + + await expect(repo.deleteByUserId(user.id)).resolves.not.toThrow(); + + await expect(repo.findById(account.id)).rejects.toThrow(NotFoundError); + }); + }); - await expect(repo.findById(account.id)).rejects.toThrow(NotFoundError); + describe('When account is not deleted', () => { + it('should return empty list', async () => { + const accounts = await repo.deleteByUserId(new ObjectId().toHexString()); + expect(accounts).toHaveLength(0); + }); }); }); describe('findMany', () => { - it('should find all accounts', async () => { - const foundAccounts = await repo.findMany(); - expect(foundAccounts).toEqual(mockAccounts); - }); - it('limit the result set ', async () => { - const limit = 1; - const foundAccounts = await repo.findMany(0, limit); - expect(foundAccounts).toHaveLength(limit); - }); - it('skip n entries ', async () => { - const offset = 2; - const foundAccounts = await repo.findMany(offset); - expect(foundAccounts).toHaveLength(mockAccounts.length - offset); + describe('When no limit and offset are given', () => { + const setup = async () => { + const mockAccounts = [ + accountFactory.build({ username: 'John Doe' }), + accountFactory.build({ username: 'Marry Doe' }), + accountFactory.build({ username: 'Susi Doe' }), + accountFactory.build({ username: 'Tim Doe' }), + ]; + await em.persistAndFlush(mockAccounts); + return mockAccounts; + }; + + it('should find all accounts', async () => { + const mockAccounts = await setup(); + const foundAccounts = await repo.findMany(); + expect(foundAccounts).toEqual(AccountEntityToDoMapper.mapEntitiesToDos(mockAccounts)); + }); + }); + + describe('When limit is given', () => { + const setup = async () => { + const limit = 1; + + const mockAccounts = [ + accountFactory.build({ username: 'John Doe' }), + accountFactory.build({ username: 'Marry Doe' }), + accountFactory.build({ username: 'Susi Doe' }), + accountFactory.build({ username: 'Tim Doe' }), + ]; + await em.persistAndFlush(mockAccounts); + return { limit, mockAccounts }; + }; + + it('should limit the result set', async () => { + const { limit } = await setup(); + const foundAccounts = await repo.findMany(0, limit); + expect(foundAccounts).toHaveLength(limit); + }); + }); + + describe('When offset is given', () => { + const setup = async () => { + const offset = 2; + + const mockAccounts = [ + accountFactory.build({ username: 'John Doe' }), + accountFactory.build({ username: 'Marry Doe' }), + accountFactory.build({ username: 'Susi Doe' }), + accountFactory.build({ username: 'Tim Doe' }), + ]; + await em.persistAndFlush(mockAccounts); + return { offset, mockAccounts }; + }; + + it('should skip n entries', async () => { + const { offset, mockAccounts } = await setup(); + + const foundAccounts = await repo.findMany(offset); + expect(foundAccounts).toHaveLength(mockAccounts.length - offset); + }); + }); + }); + + describe('findByUserIdsAndSystemId', () => { + describe('when accounts exist', () => { + const setup = async () => { + const systemId = new ObjectId().toHexString(); + const userAId = new ObjectId().toHexString(); + const userBId = new ObjectId().toHexString(); + const userCId = new ObjectId().toHexString(); + + const accountA = accountFactory.withSystemId(systemId).build({ userId: userAId }); + const accountB = accountFactory.withSystemId(systemId).build({ userId: userBId }); + const accountC = accountFactory.withSystemId(new ObjectId().toHexString()).build({ userId: userCId }); + + await em.persistAndFlush([accountA, accountB, accountC]); + em.clear(); + + const userIds = [userAId, userBId, userCId]; + const expectedUserIds = [userAId, userBId]; + + return { + expectedUserIds, + systemId, + userIds, + }; + }; + + it('should return array of verified userIds', async () => { + const { expectedUserIds, systemId, userIds } = await setup(); + + const verifiedUserIds = await repo.findByUserIdsAndSystemId(userIds, systemId); + + expect(verifiedUserIds).toEqual(expectedUserIds); + }); + }); + + describe('when accounts do not exist', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const userAId = new ObjectId().toHexString(); + const userBId = new ObjectId().toHexString(); + + const userIds = [userAId, userBId]; + + return { + systemId, + userIds, + }; + }; + + it('should return empty array', async () => { + const { systemId, userIds } = setup(); + + const result = await repo.findByUserIdsAndSystemId(userIds, systemId); + + expect(result).toHaveLength(0); + }); }); }); }); diff --git a/apps/server/src/modules/account/repo/account.repo.ts b/apps/server/src/modules/account/repo/account.repo.ts index d9c07297e8d..dac5d578f86 100644 --- a/apps/server/src/modules/account/repo/account.repo.ts +++ b/apps/server/src/modules/account/repo/account.repo.ts @@ -1,79 +1,147 @@ -import { AnyEntity, EntityName, Primary } from '@mikro-orm/core'; -import { ObjectId } from '@mikro-orm/mongodb'; +import { AnyEntity, EntityName, FilterQuery, Primary } from '@mikro-orm/core'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { Account } from '@shared/domain/entity/account.entity'; import { SortOrder } from '@shared/domain/interface'; -import { EntityId } from '@shared/domain/types'; -import { BaseRepo } from '@shared/repo/base.repo'; +import { Counted, EntityId } from '@shared/domain/types'; +import { AccountEntity } from '../entity/account.entity'; +import { AccountDoToEntityMapper } from './mapper/account-do-to-entity.mapper'; +import { Account } from '../domain/account'; +import { AccountEntityToDoMapper } from './mapper'; +import { AccountScope } from './account-scope'; @Injectable() -export class AccountRepo extends BaseRepo { +export class AccountRepo { + constructor(private readonly em: EntityManager) {} + get entityName() { - return Account; + return AccountEntity; + } + + public async save(account: Account): Promise { + const saveEntity = AccountDoToEntityMapper.mapToEntity(account); + const existing = await this.em.findOne(AccountEntity, { id: account.id }); + + let saved: AccountEntity; + if (existing) { + saved = this.em.assign(existing, saveEntity); + } else { + this.em.persist(saveEntity); + saved = saveEntity; + } + await this.flush(); + + return AccountEntityToDoMapper.mapToDo(saved); + } + + public async findById(id: EntityId | ObjectId): Promise { + const entity = await this.em.findOneOrFail(this.entityName, id as FilterQuery); + + return AccountEntityToDoMapper.mapToDo(entity); } /** * Finds an account by user id. * @param userId the user id */ - async findByUserId(userId: EntityId | ObjectId): Promise { - return this._em.findOne(Account, { userId: new ObjectId(userId) }); + public async findByUserId(userId: EntityId | ObjectId): Promise { + const entity = await this.em.findOne(AccountEntity, { userId: new ObjectId(userId) }); + + if (!entity) { + return null; + } + + return AccountEntityToDoMapper.mapToDo(entity); } - async findMultipleByUserId(userIds: EntityId[] | ObjectId[]): Promise { + public async findMultipleByUserId(userIds: EntityId[] | ObjectId[]): Promise { const objectIds = userIds.map((id: EntityId | ObjectId) => new ObjectId(id)); - return this._em.find(Account, { userId: objectIds }); + const entities = await this.em.find(AccountEntity, { userId: objectIds }); + + return AccountEntityToDoMapper.mapEntitiesToDos(entities); } - async findByUserIdOrFail(userId: EntityId | ObjectId): Promise { - return this._em.findOneOrFail(Account, { userId: new ObjectId(userId) }); + public async findByUserIdOrFail(userId: EntityId | ObjectId): Promise { + const entity = await this.em.findOneOrFail(AccountEntity, { userId: new ObjectId(userId) }); + + return AccountEntityToDoMapper.mapToDo(entity); } - async findByUsernameAndSystemId(username: string, systemId: EntityId | ObjectId): Promise { - return this._em.findOne(Account, { username, systemId: new ObjectId(systemId) }); + public async findByUsernameAndSystemId(username: string, systemId: EntityId | ObjectId): Promise { + const entity = await this.em.findOne(AccountEntity, { username, systemId: new ObjectId(systemId) }); + + if (!entity) { + return null; + } + + return AccountEntityToDoMapper.mapToDo(entity); } getObjectReference>( entityName: EntityName, id: Primary | Primary[] ): Entity { - return this._em.getReference(entityName, id); + return this.em.getReference(entityName, id); } - saveWithoutFlush(account: Account): void { - this._em.persist(account); + public async saveWithoutFlush(account: Account): Promise { + const saveEntity = AccountDoToEntityMapper.mapToEntity(account); + const existing = await this.em.findOne(AccountEntity, { id: account.id }); + + if (existing) { + this.em.assign(existing, saveEntity); + } else { + this.em.persist(saveEntity); + } } - async flush(): Promise { - await this._em.flush(); + public async flush(): Promise { + await this.em.flush(); } - async searchByUsernameExactMatch(username: string, skip = 0, limit = 1): Promise<[Account[], number]> { + public async searchByUsernameExactMatch(username: string, skip = 0, limit = 1): Promise> { return this.searchByUsername(username, skip, limit, true); } - async searchByUsernamePartialMatch(username: string, skip = 0, limit = 10): Promise<[Account[], number]> { + public async searchByUsernamePartialMatch(username: string, skip = 0, limit = 10): Promise> { return this.searchByUsername(username, skip, limit, false); } - async deleteById(accountId: EntityId | ObjectId): Promise { - const account = await this.findById(accountId); - return this.delete(account); + public async deleteById(accountId: EntityId | ObjectId): Promise { + const entity = await this.em.findOneOrFail(AccountEntity, { id: accountId.toString() }); + await this.em.removeAndFlush(entity); } - async deleteByUserId(userId: EntityId): Promise { - const account = await this.findByUserId(userId); - if (account) { - await this._em.removeAndFlush(account); + public async deleteByUserId(userId: EntityId): Promise { + const entities = await this.em.find(this.entityName, { userId: new ObjectId(userId) }); + if (entities.length === 0) { + return []; } + await this.em.removeAndFlush(entities); + + return [entities[0].id]; } /** * @deprecated For migration purpose only */ - async findMany(offset = 0, limit = 100): Promise { - const result = await this._em.find(this.entityName, {}, { offset, limit, orderBy: { _id: SortOrder.asc } }); - this._em.clear(); + public async findMany(offset = 0, limit = 100): Promise { + const result = await this.em.find(this.entityName, {}, { offset, limit, orderBy: { _id: SortOrder.asc } }); + this.em.clear(); + return AccountEntityToDoMapper.mapEntitiesToDos(result); + } + + async findByUserIdsAndSystemId(userIds: string[], systemId: string): Promise { + const scope = new AccountScope(); + const userIdScope = new AccountScope(); + + userIdScope.byUserIdsAndSystemId(userIds, systemId); + + scope.addQuery(userIdScope.query); + + const foundUsers = await this.em.find(AccountEntity, scope.query); + + const result = foundUsers.filter((user) => user.userId !== undefined).map(({ userId }) => userId!.toHexString()); + return result; } @@ -82,11 +150,10 @@ export class AccountRepo extends BaseRepo { offset: number, limit: number, exactMatch: boolean - ): Promise<[Account[], number]> { - // escapes every character, that's not a unicode letter or number + ): Promise> { const escapedUsername = username.replace(/[^(\p{L}\p{N})]/gu, '\\$&'); const searchUsername = exactMatch ? `^${escapedUsername}$` : escapedUsername; - return this._em.findAndCount( + const [entities, count] = await this.em.findAndCount( this.entityName, { // NOTE: The default behavior of the MongoDB driver allows @@ -101,5 +168,7 @@ export class AccountRepo extends BaseRepo { orderBy: { username: 1 }, } ); + const accounts = AccountEntityToDoMapper.mapEntitiesToDos(entities); + return [accounts, count]; } } diff --git a/apps/server/src/modules/account/repo/mapper/account-do-to-entity.mapper.spec.ts b/apps/server/src/modules/account/repo/mapper/account-do-to-entity.mapper.spec.ts new file mode 100644 index 00000000000..ce50d169c50 --- /dev/null +++ b/apps/server/src/modules/account/repo/mapper/account-do-to-entity.mapper.spec.ts @@ -0,0 +1,58 @@ +import { accountDoFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AccountDoToEntityMapper } from './account-do-to-entity.mapper'; + +describe('AccountEntityToDoMapper', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2020, 1, 1)); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + describe('mapToEntity', () => { + describe('When mapping Account DO to AccountEntity', () => { + const setup = () => { + const account = accountDoFactory.build({ + password: 'password', + credentialHash: 'credentialHash', + userId: new ObjectId().toHexString(), + systemId: new ObjectId().toHexString(), + expiresAt: new Date(), + lasttriedFailedLogin: new Date(), + token: 'token', + activated: true, + idmReferenceId: 'idmReferenceId', + }); + + return { account }; + }; + + it('should map all fields', () => { + const { account } = setup(); + + const ret = AccountDoToEntityMapper.mapToEntity(account); + + const expected = { + password: account.password, + credentialHash: account.credentialHash, + userId: new ObjectId(account.userId), + systemId: new ObjectId(account.systemId), + expiresAt: account.expiresAt, + lasttriedFailedLogin: account.lasttriedFailedLogin, + token: account.token, + activated: account.activated, + id: account.id, + _id: new ObjectId(account.id), + createdAt: account.createdAt, + updatedAt: account.updatedAt, + }; + + expect(ret).toEqual(expect.objectContaining(expected)); + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/repo/mapper/account-do-to-entity.mapper.ts b/apps/server/src/modules/account/repo/mapper/account-do-to-entity.mapper.ts new file mode 100644 index 00000000000..3463df5ae80 --- /dev/null +++ b/apps/server/src/modules/account/repo/mapper/account-do-to-entity.mapper.ts @@ -0,0 +1,31 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Account } from '../../domain'; +import { AccountEntity } from '../../entity/account.entity'; + +export class AccountDoToEntityMapper { + public static mapToEntity(account: Account): AccountEntity { + const accountEntity = new AccountEntity({ + userId: account.userId ? new ObjectId(account.userId) : undefined, + username: account.username, + activated: account.activated, + credentialHash: account.credentialHash, + expiresAt: account.expiresAt, + lasttriedFailedLogin: account.lasttriedFailedLogin, + password: account.password, + systemId: account.systemId ? new ObjectId(account.systemId) : undefined, + token: account.token, + }); + + if (account.id) { + accountEntity._id = new ObjectId(account.id); + accountEntity.id = accountEntity._id.toHexString(); + } + if (account.createdAt) { + accountEntity.createdAt = account.createdAt; + } + if (account.updatedAt) { + accountEntity.updatedAt = account.updatedAt; + } + return accountEntity; + } +} diff --git a/apps/server/src/modules/account/repo/mapper/account-entity-to-do.mapper.spec.ts b/apps/server/src/modules/account/repo/mapper/account-entity-to-do.mapper.spec.ts new file mode 100644 index 00000000000..de38ad636cf --- /dev/null +++ b/apps/server/src/modules/account/repo/mapper/account-entity-to-do.mapper.spec.ts @@ -0,0 +1,93 @@ +import { accountFactory } from '@shared/testing'; +import { AccountEntityToDoMapper } from './account-entity-to-do.mapper'; +import { AccountEntity } from '../../entity/account.entity'; + +describe('AccountEntityToDoMapper', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2020, 1, 1)); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + describe('mapToDo', () => { + describe('When mapping AccountEntity to Account', () => { + const setup = () => { + const accountEntity = accountFactory.withAllProperties().buildWithId({}, '000000000000000000000001'); + + const missingSystemUserIdEntity: AccountEntity = accountFactory.withoutSystemAndUserId().build(); + + return { accountEntity, missingSystemUserIdEntity }; + }; + + it('should map all fields', () => { + const { accountEntity } = setup(); + + const ret = AccountEntityToDoMapper.mapToDo(accountEntity); + + expect({ ...ret.getProps(), _id: accountEntity._id }).toMatchObject(accountEntity); + }); + + it('should ignore missing ids', () => { + const { missingSystemUserIdEntity } = setup(); + + const ret = AccountEntityToDoMapper.mapToDo(missingSystemUserIdEntity); + + expect(ret.userId).toBeUndefined(); + expect(ret.systemId).toBeUndefined(); + }); + }); + }); + + describe('mapCountedEntities', () => { + describe('When mapping multiple Account entities', () => { + const setup = () => { + const testEntity1: AccountEntity = accountFactory.buildWithId({}, '000000000000000000000001'); + const testEntity2: AccountEntity = accountFactory.buildWithId({}, '000000000000000000000002'); + + const testAmount = 10; + + const testEntities = [testEntity1, testEntity2]; + + return { testEntities, testAmount }; + }; + + it('should map exact same amount of entities', () => { + const { testEntities, testAmount } = setup(); + + const [accounts, total] = AccountEntityToDoMapper.mapCountedEntities([testEntities, testAmount]); + + expect(total).toBe(testAmount); + expect(accounts).toHaveLength(2); + expect(accounts).toContainEqual(expect.objectContaining({ id: '000000000000000000000001' })); + expect(accounts).toContainEqual(expect.objectContaining({ id: '000000000000000000000002' })); + }); + }); + }); + + describe('mapEntitiesToDos', () => { + describe('When mapping multiple Account entities', () => { + const setup = () => { + const testEntity1: AccountEntity = accountFactory.buildWithId({}, '000000000000000000000001'); + const testEntity2: AccountEntity = accountFactory.buildWithId({}, '000000000000000000000002'); + + const testEntities = [testEntity1, testEntity2]; + + return testEntities; + }; + + it('should map all entities', () => { + const testEntities = setup(); + + const ret = AccountEntityToDoMapper.mapEntitiesToDos(testEntities); + + expect(ret).toHaveLength(2); + expect(ret).toContainEqual(expect.objectContaining({ id: '000000000000000000000001' })); + expect(ret).toContainEqual(expect.objectContaining({ id: '000000000000000000000002' })); + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.ts b/apps/server/src/modules/account/repo/mapper/account-entity-to-do.mapper.ts similarity index 50% rename from apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.ts rename to apps/server/src/modules/account/repo/mapper/account-entity-to-do.mapper.ts index 4ce38116477..03324055659 100644 --- a/apps/server/src/modules/account/mapper/account-entity-to-dto.mapper.ts +++ b/apps/server/src/modules/account/repo/mapper/account-entity-to-do.mapper.ts @@ -1,10 +1,10 @@ -import { Account } from '@shared/domain/entity'; import { Counted } from '@shared/domain/types'; -import { AccountDto } from '../services/dto/account.dto'; +import { Account } from '../../domain/account'; +import { AccountEntity } from '../../entity/account.entity'; -export class AccountEntityToDtoMapper { - static mapToDto(account: Account): AccountDto { - return new AccountDto({ +export class AccountEntityToDoMapper { + static mapToDo(account: AccountEntity): Account { + return new Account({ id: account.id, createdAt: account.createdAt, updatedAt: account.updatedAt, @@ -20,13 +20,13 @@ export class AccountEntityToDtoMapper { }); } - static mapSearchResult(accountEntities: [Account[], number]): Counted { + static mapCountedEntities(accountEntities: Counted): Counted { const foundAccounts = accountEntities[0]; - const accountDtos: AccountDto[] = AccountEntityToDtoMapper.mapAccountsToDto(foundAccounts); + const accountDtos: Account[] = AccountEntityToDoMapper.mapEntitiesToDos(foundAccounts); return [accountDtos, accountEntities[1]]; } - static mapAccountsToDto(accounts: Account[]): AccountDto[] { - return accounts.map((accountEntity) => AccountEntityToDtoMapper.mapToDto(accountEntity)); + static mapEntitiesToDos(accounts: AccountEntity[]): Account[] { + return accounts.map((accountEntity) => AccountEntityToDoMapper.mapToDo(accountEntity)); } } diff --git a/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.abstract.ts b/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.abstract.ts new file mode 100644 index 00000000000..bfc347a9940 --- /dev/null +++ b/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.abstract.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; +import { IdmAccount } from '@shared/domain/interface'; +import { Account } from '../../domain/account'; + +@Injectable() +export abstract class AccountIdmToDoMapper { + abstract mapToDo(account: IdmAccount): Account; +} diff --git a/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.db.spec.ts b/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.db.spec.ts new file mode 100644 index 00000000000..ff7c65effda --- /dev/null +++ b/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.db.spec.ts @@ -0,0 +1,106 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { IdmAccount } from '@shared/domain/interface'; +import { Account } from '../../domain'; +import { AccountIdmToDoMapper } from './account-idm-to-do.mapper.abstract'; +import { AccountIdmToDoMapperDb } from './account-idm-to-do.mapper.db'; + +describe('AccountIdmToDoMapperDb', () => { + let module: TestingModule; + let mapper: AccountIdmToDoMapper; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + { + provide: AccountIdmToDoMapper, + useClass: AccountIdmToDoMapperDb, + }, + ], + }).compile(); + + mapper = module.get(AccountIdmToDoMapper); + }); + + afterAll(async () => { + await module.close(); + }); + describe('mapToDo', () => { + describe('when mapping from entity to dto', () => { + const setup = () => { + const testIdmEntity: IdmAccount = { + id: 'id', + username: 'username', + email: 'email', + firstName: 'firstName', + lastName: 'lastName', + createdDate: new Date(), + attDbcAccountId: 'attDbcAccountId', + attDbcUserId: 'attDbcUserId', + attDbcSystemId: 'attDbcSystemId', + }; + return testIdmEntity; + }; + + it('should map all fields', () => { + const testIdmEntity = setup(); + + const ret = mapper.mapToDo(testIdmEntity); + + expect(ret).toEqual( + expect.objectContaining>({ + id: testIdmEntity.attDbcAccountId, + idmReferenceId: testIdmEntity.id, + userId: testIdmEntity.attDbcUserId, + systemId: testIdmEntity.attDbcSystemId, + createdAt: testIdmEntity.createdDate, + updatedAt: testIdmEntity.createdDate, + username: testIdmEntity.username, + }) + ); + }); + }); + + describe('when date is undefined', () => { + const setup = () => { + const testIdmEntity: IdmAccount = { + id: 'id', + }; + + const dateMock = new Date(); + jest.useFakeTimers(); + jest.setSystemTime(dateMock); + + return { testIdmEntity, dateMock }; + }; + + it('should use actual date', () => { + const { testIdmEntity, dateMock } = setup(); + + const ret = mapper.mapToDo(testIdmEntity); + + expect(ret.createdAt).toEqual(dateMock); + expect(ret.updatedAt).toEqual(dateMock); + + jest.useRealTimers(); + }); + }); + }); + + describe('when a fields value is missing', () => { + const setup = () => { + const testIdmEntity: IdmAccount = { + id: 'id', + }; + return testIdmEntity; + }; + + it('should fill with empty string', () => { + const testIdmEntity = setup(); + + const ret = mapper.mapToDo(testIdmEntity); + + expect(ret.id).toBe(''); + expect(ret.username).toBe(''); + }); + }); +}); diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.ts b/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.db.ts similarity index 57% rename from apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.ts rename to apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.db.ts index 16cc41f5170..b0d70383de7 100644 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.db.ts +++ b/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.db.ts @@ -1,11 +1,11 @@ import { IdmAccount } from '@shared/domain/interface'; -import { AccountDto } from '../services/dto/account.dto'; -import { AccountIdmToDtoMapper } from './account-idm-to-dto.mapper.abstract'; +import { Account } from '../../domain/account'; +import { AccountIdmToDoMapper } from './account-idm-to-do.mapper.abstract'; -export class AccountIdmToDtoMapperDb extends AccountIdmToDtoMapper { - mapToDto(account: IdmAccount): AccountDto { +export class AccountIdmToDoMapperDb extends AccountIdmToDoMapper { + mapToDo(account: IdmAccount): Account { const createdDate = account.createdDate ? account.createdDate : new Date(); - return new AccountDto({ + return new Account({ id: account.attDbcAccountId ?? '', idmReferenceId: account.id, userId: account.attDbcUserId, diff --git a/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.idm.spec.ts b/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.idm.spec.ts new file mode 100644 index 00000000000..9803daaafa0 --- /dev/null +++ b/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.idm.spec.ts @@ -0,0 +1,103 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { IdmAccount } from '@shared/domain/interface'; +import { Account } from '../../domain'; +import { AccountIdmToDoMapper } from './account-idm-to-do.mapper.abstract'; +import { AccountIdmToDoMapperIdm } from './account-idm-to-do.mapper.idm'; + +describe('AccountIdmToDoMapperIdm', () => { + let module: TestingModule; + let mapper: AccountIdmToDoMapper; + + const now: Date = new Date(2022, 1, 22); + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + { + provide: AccountIdmToDoMapper, + useClass: AccountIdmToDoMapperIdm, + }, + ], + }).compile(); + + mapper = module.get(AccountIdmToDoMapper); + + jest.useFakeTimers(); + jest.setSystemTime(now); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('mapToDo', () => { + describe('when mapping from entity to do', () => { + const setup = () => { + const testIdmEntity: IdmAccount = { + id: 'id', + username: 'username', + email: 'email', + firstName: 'firstName', + lastName: 'lastName', + createdDate: new Date(), + attDbcAccountId: 'attDbcAccountId', + attDbcUserId: 'attDbcUserId', + attDbcSystemId: 'attDbcSystemId', + }; + return testIdmEntity; + }; + + it('should map all fields', () => { + const testIdmEntity = setup(); + + const ret = mapper.mapToDo(testIdmEntity); + + expect(ret).toEqual( + expect.objectContaining>({ + id: testIdmEntity.id, + idmReferenceId: undefined, + userId: testIdmEntity.attDbcUserId, + systemId: testIdmEntity.attDbcSystemId, + createdAt: testIdmEntity.createdDate, + updatedAt: testIdmEntity.createdDate, + username: testIdmEntity.username, + }) + ); + }); + }); + describe('when date is undefined', () => { + const setup = () => { + const testIdmEntity: IdmAccount = { + id: 'id', + }; + return testIdmEntity; + }; + + it('should use actual date', () => { + const testIdmEntity = setup(); + + const ret = mapper.mapToDo(testIdmEntity); + + expect(ret.createdAt).toEqual(now); + expect(ret.updatedAt).toEqual(now); + }); + }); + + describe('when a fields value is missing', () => { + const setup = () => { + const testIdmEntity: IdmAccount = { + id: 'id', + }; + return testIdmEntity; + }; + + it('should fill with empty string', () => { + const testIdmEntity = setup(); + + const ret = mapper.mapToDo(testIdmEntity); + + expect(ret.username).toBe(''); + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.ts b/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.idm.ts similarity index 56% rename from apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.ts rename to apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.idm.ts index 0f57a63dee4..ac5cd66c1d9 100644 --- a/apps/server/src/modules/account/mapper/account-idm-to-dto.mapper.idm.ts +++ b/apps/server/src/modules/account/repo/mapper/account-idm-to-do.mapper.idm.ts @@ -1,11 +1,11 @@ import { IdmAccount } from '@shared/domain/interface'; -import { AccountDto } from '../services/dto/account.dto'; -import { AccountIdmToDtoMapper } from './account-idm-to-dto.mapper.abstract'; +import { Account } from '../../domain/account'; +import { AccountIdmToDoMapper } from './account-idm-to-do.mapper.abstract'; -export class AccountIdmToDtoMapperIdm extends AccountIdmToDtoMapper { - mapToDto(account: IdmAccount): AccountDto { +export class AccountIdmToDoMapperIdm extends AccountIdmToDoMapper { + mapToDo(account: IdmAccount): Account { const createdDate = account.createdDate ? account.createdDate : new Date(); - return new AccountDto({ + return new Account({ id: account.id, idmReferenceId: undefined, userId: account.attDbcUserId, diff --git a/apps/server/src/modules/account/repo/mapper/index.ts b/apps/server/src/modules/account/repo/mapper/index.ts new file mode 100644 index 00000000000..ac9e28937c3 --- /dev/null +++ b/apps/server/src/modules/account/repo/mapper/index.ts @@ -0,0 +1,4 @@ +export * from './account-idm-to-do.mapper.abstract'; +export * from './account-idm-to-do.mapper.idm'; +export * from './account-idm-to-do.mapper.db'; +export * from './account-entity-to-do.mapper'; diff --git a/apps/server/src/modules/account/services/account-db.service.spec.ts b/apps/server/src/modules/account/services/account-db.service.spec.ts index 21b8c890245..f6be767a3f8 100644 --- a/apps/server/src/modules/account/services/account-db.service.spec.ts +++ b/apps/server/src/modules/account/services/account-db.service.spec.ts @@ -1,43 +1,36 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { IdentityManagementService } from '@infra/identity-management/identity-management.service'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AccountEntityToDtoMapper } from '@modules/account/mapper'; -import { AccountDto } from '@modules/account/services/dto'; -import { ServerConfig } from '@modules/server'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; -import { Account, Role, SchoolEntity, User } from '@shared/domain/entity'; - -import { Permission, RoleName } from '@shared/domain/interface'; +import { IdmAccount } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { accountFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { accountDoFactory, setupEntities, userFactory } from '@shared/testing'; +import { IdentityManagementService } from '@infra/identity-management'; import bcrypt from 'bcryptjs'; -import { LegacyLogger } from '../../../core/logger'; +import { v1 } from 'uuid'; +import { Logger } from '@src/core/logger'; +import { AccountConfig } from '../account-config'; +import { Account } from '../domain'; +import { AccountEntity } from '../entity/account.entity'; import { AccountRepo } from '../repo/account.repo'; import { AccountServiceDb } from './account-db.service'; -import { AccountLookupService } from './account-lookup.service'; -import { AbstractAccountService } from './account.service.abstract'; describe('AccountDbService', () => { let module: TestingModule; - let accountService: AbstractAccountService; - let mockAccounts: Account[]; - let accountRepo: AccountRepo; - let accountLookupServiceMock: DeepMocked; + let accountService: AccountServiceDb; + let accountRepo: DeepMocked; + let configServiceMock: DeepMocked; + let idmServiceMock: DeepMocked; const defaultPassword = 'DummyPasswd!1'; - let mockSchool: SchoolEntity; - - let mockTeacherUser: User; - let mockStudentUser: User; - let mockUserWithoutAccount: User; - - let mockTeacherAccount: Account; - let mockStudentAccount: Account; - - let mockAccountWithSystemId: Account; + const internalId = new ObjectId().toHexString(); + const externalId = v1(); + const accountMock: IdmAccount = { + id: externalId, + attDbcAccountId: internalId, + }; afterAll(async () => { await module.close(); @@ -47,127 +40,36 @@ describe('AccountDbService', () => { module = await Test.createTestingModule({ providers: [ AccountServiceDb, - AccountLookupService, { provide: AccountRepo, - useValue: { - save: jest.fn().mockImplementation((account: Account): Promise => { - if (account.username === 'fail@to.update') { - return Promise.reject(); - } - const accountEntity = mockAccounts.find((tempAccount) => tempAccount.userId === account.userId); - if (accountEntity) { - Object.assign(accountEntity, account); - } - - return Promise.resolve(); - }), - deleteById: jest.fn().mockImplementation((): Promise => Promise.resolve()), - findMultipleByUserId: (userIds: EntityId[]): Promise => { - const accounts = mockAccounts.filter((tempAccount) => - userIds.find((userId) => tempAccount.userId?.toString() === userId) - ); - return Promise.resolve(accounts); - }, - findByUserId: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - if (account) { - return Promise.resolve(account); - } - return Promise.resolve(null); - }, - findByUserIdOrFail: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - - if (account) { - return Promise.resolve(account); - } - throw new EntityNotFoundError(Account.name); - }, - findByUsernameAndSystemId: (username: string, systemId: EntityId | ObjectId): Promise => { - const account = mockAccounts.find( - (tempAccount) => tempAccount.username === username && tempAccount.systemId === systemId - ); - if (account) { - return Promise.resolve(account); - } - return Promise.resolve(null); - }, - - findById: jest.fn().mockImplementation((accountId: EntityId | ObjectId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.id === accountId.toString()); - - if (account) { - return Promise.resolve(account); - } - throw new EntityNotFoundError(Account.name); - }), - searchByUsernameExactMatch: jest - .fn() - .mockImplementation((): Promise<[Account[], number]> => Promise.resolve([[mockTeacherAccount], 1])), - searchByUsernamePartialMatch: jest - .fn() - .mockImplementation( - (): Promise<[Account[], number]> => Promise.resolve([mockAccounts, mockAccounts.length]) - ), - deleteByUserId: jest.fn().mockImplementation((): Promise => Promise.resolve()), - findMany: jest.fn().mockImplementation((): Promise => Promise.resolve(mockAccounts)), - }, + useValue: createMock(), }, { - provide: LegacyLogger, - useValue: createMock(), + provide: Logger, + useValue: createMock(), }, { provide: ConfigService, - useValue: createMock>(), + useValue: createMock>(), }, { provide: IdentityManagementService, useValue: createMock(), }, - { - provide: AccountLookupService, - useValue: createMock({ - getInternalId: (id: EntityId | ObjectId): Promise => { - if (ObjectId.isValid(id)) { - return Promise.resolve(new ObjectId(id)); - } - return Promise.resolve(null); - }, - }), - }, ], }).compile(); accountRepo = module.get(AccountRepo); accountService = module.get(AccountServiceDb); - accountLookupServiceMock = module.get(AccountLookupService); + configServiceMock = module.get(ConfigService); + idmServiceMock = module.get(IdentityManagementService); + await setupEntities(); }); beforeEach(() => { + jest.resetAllMocks(); jest.useFakeTimers(); jest.setSystemTime(new Date(2020, 1, 1)); - - mockSchool = schoolFactory.buildWithId(); - - mockTeacherUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - mockStudentUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockUserWithoutAccount = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - mockTeacherAccount = accountFactory.buildWithId({ userId: mockTeacherUser.id, password: defaultPassword }); - mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id, password: defaultPassword }); - - mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId()).build(); - mockAccounts = [mockTeacherAccount, mockStudentAccount, mockAccountWithSystemId]; }); afterEach(() => { @@ -176,294 +78,737 @@ describe('AccountDbService', () => { }); describe('findById', () => { - it( - 'should return accountDto', - async () => { - const resultAccount = await accountService.findById(mockTeacherAccount.id); - expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); - }, - 10 * 60 * 1000 - ); + describe('when searching by Id', () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + mockTeacherAccount.username = 'changedUsername@example.org'; + mockTeacherAccount.activated = false; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherAccount }; + }; + + it( + 'should return accountDto', + async () => { + const { mockTeacherAccount } = setup(); + + const resultAccount = await accountService.findById(mockTeacherAccount.id); + expect(resultAccount).toEqual(mockTeacherAccount); + }, + 10 * 60 * 1000 + ); + }); + + describe('when id is external calls idm service', () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + mockTeacherAccount.username = 'changedUsername@example.org'; + mockTeacherAccount.activated = false; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + configServiceMock.get.mockReturnValue(true); + idmServiceMock.findAccountById.mockResolvedValue(accountMock); + + return { mockTeacherAccount }; + }; + + it('should return accountDto', async () => { + const { mockTeacherAccount } = setup(); + + const resultAccount = await accountService.findById(externalId); + expect(resultAccount).toEqual(mockTeacherAccount); + }); + }); }); describe('findByUserId', () => { - it('should return accountDto', async () => { - const resultAccount = await accountService.findByUserId(mockTeacherUser.id); - expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + describe('when user id exists', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + + const mockTeacherAccount = accountDoFactory.build(); + + accountRepo.findByUserId.mockImplementation((userId: EntityId | ObjectId): Promise => { + if (userId === mockTeacherUser.id) { + return Promise.resolve(mockTeacherAccount); + } + return Promise.resolve(null); + }); + + return { mockTeacherUser, mockTeacherAccount }; + }; + + it('should return accountDto', async () => { + const { mockTeacherUser, mockTeacherAccount } = setup(); + const resultAccount = await accountService.findByUserId(mockTeacherUser.id); + expect(resultAccount).toEqual(mockTeacherAccount); + }); }); - it('should return null', async () => { - const resultAccount = await accountService.findByUserId('nonExistentId'); - expect(resultAccount).toBeNull(); + + describe('when user id not exists', () => { + const setup = () => { + accountRepo.findByUserId.mockResolvedValue(null); + }; + + it('should return null', async () => { + setup(); + const resultAccount = await accountService.findByUserId('nonExistentId'); + expect(resultAccount).toBeNull(); + }); }); }); describe('findByUsernameAndSystemId', () => { - it('should return accountDto', async () => { - const resultAccount = await accountService.findByUsernameAndSystemId( - mockAccountWithSystemId.username, - mockAccountWithSystemId.systemId ?? '' - ); - expect(resultAccount).not.toBe(undefined); + describe('when user name and system id exists', () => { + const setup = () => { + const mockAccountWithSystemId = accountDoFactory.build({ + systemId: new ObjectId().toHexString(), + }); + accountRepo.findByUsernameAndSystemId.mockResolvedValue(mockAccountWithSystemId); + return { mockAccountWithSystemId }; + }; + + it('should return accountDto', async () => { + const { mockAccountWithSystemId } = setup(); + const resultAccount = await accountService.findByUsernameAndSystemId( + mockAccountWithSystemId.username, + mockAccountWithSystemId.systemId ?? '' + ); + expect(resultAccount).not.toBe(undefined); + }); }); - it('should return null if username does not exist', async () => { - const resultAccount = await accountService.findByUsernameAndSystemId( - 'nonExistentUsername', - mockAccountWithSystemId.systemId ?? '' - ); - expect(resultAccount).toBeNull(); + + describe('when only system id exists', () => { + const setup = () => { + const mockAccountWithSystemId = accountDoFactory.build({ + systemId: new ObjectId().toHexString(), + }); + accountRepo.findByUsernameAndSystemId.mockImplementation( + (username: string, systemId: EntityId | ObjectId): Promise => { + if (mockAccountWithSystemId.username === username && mockAccountWithSystemId.systemId === systemId) { + return Promise.resolve(mockAccountWithSystemId); + } + return Promise.resolve(null); + } + ); + return { mockAccountWithSystemId }; + }; + + it('should return null if username does not exist', async () => { + const { mockAccountWithSystemId } = setup(); + const resultAccount = await accountService.findByUsernameAndSystemId( + 'nonExistentUsername', + mockAccountWithSystemId.systemId ?? '' + ); + expect(resultAccount).toBeNull(); + }); }); - it('should return null if system id does not exist', async () => { - const resultAccount = await accountService.findByUsernameAndSystemId( - mockAccountWithSystemId.username, - 'nonExistentSystemId' ?? '' - ); - expect(resultAccount).toBeNull(); + + describe('when only user name exists', () => { + const setup = () => { + const mockAccountWithSystemId = accountDoFactory.build({ + systemId: new ObjectId().toHexString(), + }); + + accountRepo.findByUsernameAndSystemId.mockImplementation( + (username: string, systemId: EntityId | ObjectId): Promise => { + if (mockAccountWithSystemId.username === username && mockAccountWithSystemId.systemId === systemId) { + return Promise.resolve(mockAccountWithSystemId); + } + return Promise.resolve(null); + } + ); + return { mockAccountWithSystemId }; + }; + + it('should return null if system id does not exist', async () => { + const { mockAccountWithSystemId } = setup(); + const resultAccount = await accountService.findByUsernameAndSystemId( + mockAccountWithSystemId.username, + 'nonExistentSystemId' + ); + expect(resultAccount).toBeNull(); + }); }); }); describe('findMultipleByUserId', () => { - it('should return multiple accountDtos', async () => { - const resultAccounts = await accountService.findMultipleByUserId([mockTeacherUser.id, mockStudentUser.id]); - expect(resultAccounts).toContainEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); - expect(resultAccounts).toContainEqual(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); - expect(resultAccounts).toHaveLength(2); + describe('when searching for multiple existing ids', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + const mockStudentUser = userFactory.buildWithId(); + const mockTeacherAccount = accountDoFactory.build({ + userId: mockTeacherUser.id, + password: defaultPassword, + }); + const mockStudentAccount = accountDoFactory.build({ + userId: mockStudentUser.id, + password: defaultPassword, + }); + + accountRepo.findMultipleByUserId.mockImplementation((userIds: (EntityId | ObjectId)[]): Promise => { + const accounts = [mockStudentAccount, mockTeacherAccount].filter((tempAccount) => + userIds.find((userId) => tempAccount.userId?.toString() === userId) + ); + return Promise.resolve(accounts); + }); + return { mockStudentUser, mockStudentAccount, mockTeacherUser, mockTeacherAccount }; + }; + + it('should return multiple accountDtos', async () => { + const { mockStudentUser, mockStudentAccount, mockTeacherUser, mockTeacherAccount } = setup(); + const resultAccounts = await accountService.findMultipleByUserId([mockTeacherUser.id, mockStudentUser.id]); + expect(resultAccounts).toContainEqual(mockTeacherAccount); + expect(resultAccounts).toContainEqual(mockStudentAccount); + expect(resultAccounts).toHaveLength(2); + }); }); - it('should return empty array on mismatch', async () => { - const resultAccount = await accountService.findMultipleByUserId(['nonExistentId1']); - expect(resultAccount).toHaveLength(0); + + describe('when only user name exists', () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + const mockStudentAccount = accountDoFactory.build(); + + accountRepo.findMultipleByUserId.mockImplementation((userIds: (EntityId | ObjectId)[]): Promise => { + const accounts = [mockStudentAccount, mockTeacherAccount].filter((tempAccount) => + userIds.find((userId) => tempAccount.userId?.toString() === userId) + ); + return Promise.resolve(accounts); + }); + return {}; + }; + + it('should return empty array on mismatch', async () => { + setup(); + const resultAccount = await accountService.findMultipleByUserId(['nonExistentId1']); + expect(resultAccount).toHaveLength(0); + }); }); }); describe('findByUserIdOrFail', () => { - it('should return accountDto', async () => { - const resultAccount = await accountService.findByUserIdOrFail(mockTeacherUser.id); - expect(resultAccount).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + describe('when user exists', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + const mockTeacherAccount = accountDoFactory.build({ + userId: mockTeacherUser.id, + password: defaultPassword, + }); + + accountRepo.findByUserIdOrFail.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherUser, mockTeacherAccount }; + }; + + it('should return accountDto', async () => { + const { mockTeacherUser, mockTeacherAccount } = setup(); + const resultAccount = await accountService.findByUserIdOrFail(mockTeacherUser.id); + expect(resultAccount).toEqual(mockTeacherAccount); + }); }); - it('should throw EntityNotFoundError', async () => { - await expect(accountService.findByUserIdOrFail('nonExistentId')).rejects.toThrow(EntityNotFoundError); + + describe('when user does not exist', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + const mockTeacherAccount = accountDoFactory.build({ + userId: mockTeacherUser.id, + password: defaultPassword, + }); + + accountRepo.findByUserIdOrFail.mockImplementation((userId: EntityId | ObjectId): Promise => { + if (mockTeacherUser.id === userId) { + return Promise.resolve(mockTeacherAccount); + } + throw new EntityNotFoundError(AccountEntity.name); + }); + return {}; + }; + + it('should throw EntityNotFoundError', async () => { + setup(); + await expect(accountService.findByUserIdOrFail('nonExistentId')).rejects.toThrow(EntityNotFoundError); + }); }); }); describe('save', () => { - it('should update an existing account', async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - mockTeacherAccountDto.username = 'changedUsername@example.org'; - mockTeacherAccountDto.activated = false; - const ret = await accountService.save(mockTeacherAccountDto); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - id: mockTeacherAccount.id, - username: mockTeacherAccountDto.username, - activated: mockTeacherAccountDto.activated, - systemId: mockTeacherAccount.systemId, - userId: mockTeacherAccount.userId, - }); - }); - - it("should update an existing account's system", async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - mockTeacherAccountDto.username = 'changedUsername@example.org'; - mockTeacherAccountDto.systemId = '123456789012'; - const ret = await accountService.save(mockTeacherAccountDto); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - id: mockTeacherAccount.id, - username: mockTeacherAccountDto.username, - activated: mockTeacherAccount.activated, - systemId: new ObjectId(mockTeacherAccountDto.systemId), - userId: mockTeacherAccount.userId, - }); - }); - it("should update an existing account's user", async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - mockTeacherAccountDto.username = 'changedUsername@example.org'; - mockTeacherAccountDto.userId = mockStudentUser.id; - const ret = await accountService.save(mockTeacherAccountDto); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - id: mockTeacherAccount.id, - username: mockTeacherAccountDto.username, - activated: mockTeacherAccount.activated, - systemId: mockTeacherAccount.systemId, - userId: new ObjectId(mockStudentUser.id), - }); - }); - - it("should keep existing account's system undefined on update", async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - mockTeacherAccountDto.username = 'changedUsername@example.org'; - mockTeacherAccountDto.systemId = undefined; - const ret = await accountService.save(mockTeacherAccountDto); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - id: mockTeacherAccount.id, - username: mockTeacherAccountDto.username, - activated: mockTeacherAccount.activated, - systemId: mockTeacherAccountDto.systemId, - userId: mockTeacherAccount.userId, - }); - }); - it('should save a new account', async () => { - const accountToSave: AccountDto = { - createdAt: new Date(), - updatedAt: new Date(), - username: 'asdf@asdf.de', - userId: mockUserWithoutAccount.id, - systemId: '012345678912', - password: defaultPassword, - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - const ret = await accountService.save(accountToSave); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - username: accountToSave.username, - userId: new ObjectId(accountToSave.userId), - systemId: new ObjectId(accountToSave.systemId), - createdAt: accountToSave.createdAt, - updatedAt: accountToSave.updatedAt, - }); - }); - - it("should keep account's system undefined on save", async () => { - const accountToSave: AccountDto = { - createdAt: new Date(), - updatedAt: new Date(), - username: 'asdf@asdf.de', - userId: mockUserWithoutAccount.id, - password: defaultPassword, - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - const ret = await accountService.save(accountToSave); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - systemId: undefined, - }); - }); - - it('should encrypt password', async () => { - const accountToSave = { - createdAt: new Date(), - updatedAt: new Date(), - username: 'asdf@asdf.de', - userId: mockUserWithoutAccount.id, - systemId: '012345678912', - password: defaultPassword, - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - await accountService.save(accountToSave); - const ret = await accountService.save(accountToSave); - expect(ret).toBeDefined(); - expect(ret).not.toMatchObject({ - password: defaultPassword, - }); - }); - - it('should set password to undefined if password is empty while creating a new account', async () => { - const spy = jest.spyOn(accountRepo, 'save'); - const dto = { - username: 'john.doe@domain.tld', - password: '', - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - await expect(accountService.save(dto)).resolves.not.toThrow(); - expect(accountRepo.findById).not.toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - password: undefined, - }) - ); + describe('when update an existing account', () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + + mockTeacherAccount.username = 'changedUsername@example.org'; + mockTeacherAccount.activated = false; + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountRepo.save.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherAccount }; + }; + + it('should update account', async () => { + const { mockTeacherAccount } = setup(); + const ret = await accountService.save(mockTeacherAccount); + + expect(accountRepo.save).toBeCalledTimes(1); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + id: mockTeacherAccount.id, + username: mockTeacherAccount.username, + activated: mockTeacherAccount.activated, + systemId: mockTeacherAccount.systemId, + userId: mockTeacherAccount.userId, + }); + }); + }); + + describe("when update an existing account's system", () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + + mockTeacherAccount.username = 'changedUsername@example.org'; + mockTeacherAccount.systemId = '123456789012'; + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountRepo.save.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherAccount }; + }; + + it("should update an existing account's system", async () => { + const { mockTeacherAccount } = setup(); + + const ret = await accountService.save(mockTeacherAccount); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + id: mockTeacherAccount.id, + username: mockTeacherAccount.username, + activated: mockTeacherAccount.activated, + systemId: mockTeacherAccount.systemId, + userId: mockTeacherAccount.userId, + }); + }); + }); + + describe("when update an existing account's user", () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + const mockStudentUser = accountDoFactory.build(); + + mockTeacherAccount.username = 'changedUsername@example.org'; + mockTeacherAccount.userId = mockStudentUser.id; + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountRepo.save.mockResolvedValue(mockTeacherAccount); + + return { mockStudentUser, mockTeacherAccount }; + }; + + it('should update account', async () => { + const { mockStudentUser, mockTeacherAccount } = setup(); + + const ret = await accountService.save(mockTeacherAccount); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + id: mockTeacherAccount.id, + username: mockTeacherAccount.username, + activated: mockTeacherAccount.activated, + systemId: mockTeacherAccount.systemId, + userId: new ObjectId(mockStudentUser.id), + }); + }); }); - it('should not change password if password is empty while editing an existing account', async () => { - const spy = jest.spyOn(accountRepo, 'save'); - const dto = { - id: mockTeacherAccount.id, - // username: 'john.doe@domain.tld', - password: undefined, - } as AccountDto; - (accountRepo.findById as jest.Mock).mockClear(); - (accountRepo.save as jest.Mock).mockClear(); - await expect(accountService.save(dto)).resolves.not.toThrow(); - expect(accountRepo.findById).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ + describe("when existing account's system is undefined", () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + + mockTeacherAccount.username = 'changedUsername@example.org'; + mockTeacherAccount.systemId = undefined; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountRepo.save.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherAccount }; + }; + + it('should keep undefined on update', async () => { + const { mockTeacherAccount } = setup(); + + const ret = await accountService.save(mockTeacherAccount); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + id: mockTeacherAccount.id, + username: mockTeacherAccount.username, + activated: mockTeacherAccount.activated, + systemId: mockTeacherAccount.systemId, + userId: mockTeacherAccount.userId, + }); + }); + }); + + describe('when account does not exists', () => { + const setup = () => { + const mockUserWithoutAccount = userFactory.buildWithId(); + + const accountToSave: Account = { + createdAt: new Date(), + updatedAt: new Date(), + username: 'asdf@asdf.de', + userId: mockUserWithoutAccount.id, + systemId: '012345678912', password: defaultPassword, - }) - ); + } as Account; + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + accountRepo.save.mockResolvedValue( + new Account({ + id: new ObjectId().toHexString(), + username: accountToSave.username, + userId: accountToSave.userId, + systemId: accountToSave.systemId, + createdAt: accountToSave.createdAt, + updatedAt: accountToSave.updatedAt, + }) + ); + + return { accountToSave }; + }; + + it('should save a new account', async () => { + const { accountToSave } = setup(); + + const ret = await accountService.save(accountToSave); + + expect(accountRepo.save).toBeCalledTimes(1); + expect(ret).toBeDefined(); + expect(ret).toBeInstanceOf(Account); + expect(ret).toMatchObject({ + username: accountToSave.username, + userId: accountToSave.userId, + systemId: accountToSave.systemId, + createdAt: accountToSave.createdAt, + updatedAt: accountToSave.updatedAt, + }); + }); + }); + + describe("when account's system undefined", () => { + const setup = () => { + const mockUserWithoutAccount = userFactory.buildWithId(); + + const accountToSave: Account = { + createdAt: new Date(), + updatedAt: new Date(), + username: 'asdf@asdf.de', + userId: mockUserWithoutAccount.id, + password: defaultPassword, + } as Account; + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + accountRepo.save.mockResolvedValue( + new Account({ + id: new ObjectId().toHexString(), + username: accountToSave.username, + userId: accountToSave.userId, + createdAt: accountToSave.createdAt, + updatedAt: accountToSave.updatedAt, + }) + ); + + return { accountToSave }; + }; + + it('should keep undefined on save', async () => { + const { accountToSave } = setup(); + + const ret = await accountService.save(accountToSave); + expect(ret).toBeDefined(); + expect(accountRepo.save).toBeCalledWith(expect.objectContaining({ systemId: undefined })); + }); + }); + + describe('when save account', () => { + const setup = () => { + const mockUserWithoutAccount = userFactory.buildWithId(); + + const accountToSave = { + createdAt: new Date(), + updatedAt: new Date(), + username: 'asdf@asdf.de', + userId: mockUserWithoutAccount.id, + systemId: '012345678912', + password: defaultPassword, + } as Account; + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + accountRepo.save.mockResolvedValue( + new Account({ + id: new ObjectId().toHexString(), + username: accountToSave.username, + userId: accountToSave.userId, + createdAt: accountToSave.createdAt, + updatedAt: accountToSave.updatedAt, + }) + ); + + return { accountToSave }; + }; + + it('should encrypt password', async () => { + const { accountToSave } = setup(); + + const ret = await accountService.save(accountToSave); + expect(ret).toBeDefined(); + + expect(accountRepo.save).toBeCalledWith( + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ password: expect.not.stringMatching(defaultPassword) }) + ); + }); + }); + + describe('when save account with id', () => { + const setup = () => { + const mockUserWithoutAccount = userFactory.buildWithId(); + + const accountToSave = { + id: new ObjectId().toHexString(), + createdAt: new Date(), + updatedAt: new Date(), + username: 'asdf@asdf.de', + userId: mockUserWithoutAccount.id, + systemId: '012345678912', + password: defaultPassword, + } as Account; + const accountInRepo = { + id: new ObjectId().toHexString(), + createdAt: new Date(), + updatedAt: new Date(), + username: 'asdf@asdf.de', + userId: mockUserWithoutAccount.id, + systemId: '012345678912', + password: defaultPassword, + } as Account; + accountInRepo.update = jest.fn(); + + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + accountRepo.findById.mockResolvedValue(accountInRepo); + accountRepo.save.mockResolvedValue( + new Account({ + id: new ObjectId().toHexString(), + username: accountToSave.username, + userId: accountToSave.userId, + createdAt: accountToSave.createdAt, + updatedAt: accountToSave.updatedAt, + }) + ); + + return { accountToSave, accountInRepo }; + }; + + it('should encrypt password', async () => { + const { accountToSave, accountInRepo } = setup(); + + const ret = await accountService.save(accountToSave); + expect(ret).toBeDefined(); + expect(accountInRepo.update).toHaveBeenCalledWith(accountToSave); + }); + }); + + describe('when creating a new account', () => { + const setup = () => { + const spy = jest.spyOn(accountRepo, 'save'); + const account = { + username: 'john.doe@domain.tld', + password: '', + } as Account; + (accountRepo.findById as jest.Mock).mockClear(); + (accountRepo.save as jest.Mock).mockClear(); + + return { spy, account }; + }; + + it('should set password to undefined if password is empty', async () => { + const { spy, account } = setup(); + + await expect(accountService.save(account)).resolves.not.toThrow(); + expect(accountRepo.findById).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + password: undefined, + }) + ); + }); + }); + + describe('when password is empty while editing an existing account', () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + + const spy = jest.spyOn(accountRepo, 'save'); + const account = { + id: mockTeacherAccount.id, + password: undefined, + } as Account; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountRepo.save.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherAccount, spy, account }; + }; + + it('should not change password', async () => { + const { mockTeacherAccount, spy, account } = setup(); + await expect(accountService.save(account)).resolves.not.toThrow(); + expect(accountRepo.findById).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + password: mockTeacherAccount.password, + }) + ); + }); }); }); describe('updateUsername', () => { - it('should update an existing account but no other information', async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - const newUsername = 'newUsername'; - const ret = await accountService.updateUsername(mockTeacherAccount.id, newUsername); + describe('when updating username', () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + const newUsername = 'newUsername'; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherAccount, newUsername }; + }; - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - ...mockTeacherAccountDto, - username: newUsername, + it('should update only user name', async () => { + const { mockTeacherAccount, newUsername } = setup(); + const ret = await accountService.updateUsername(mockTeacherAccount.id, newUsername); + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + ...mockTeacherAccount.getProps(), + username: newUsername, + }); }); }); }); describe('updateLastTriedFailedLogin', () => { - it('should update last tried failed login', async () => { - const mockTeacherAccountDto = AccountEntityToDtoMapper.mapToDto(mockTeacherAccount); - const theNewDate = new Date(); - const ret = await accountService.updateLastTriedFailedLogin(mockTeacherAccount.id, theNewDate); + describe('when update last failed Login', () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + const theNewDate = new Date(); - expect(ret).toBeDefined(); - expect(ret).toMatchObject({ - ...mockTeacherAccountDto, - lasttriedFailedLogin: theNewDate, + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherAccount, theNewDate }; + }; + + it('should update last tried failed login', async () => { + const { mockTeacherAccount, theNewDate } = setup(); + const ret = await accountService.updateLastTriedFailedLogin(mockTeacherAccount.id, theNewDate); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject({ + ...mockTeacherAccount.getProps(), + lasttriedFailedLogin: theNewDate, + }); }); }); }); describe('validatePassword', () => { - it('should validate password', async () => { - const ret = await accountService.validatePassword( - { password: await bcrypt.hash(defaultPassword, 10) } as unknown as AccountDto, - defaultPassword - ); - expect(ret).toBe(true); + describe('when accepted Password', () => { + const setup = async () => { + const ret = await accountService.validatePassword( + { password: await bcrypt.hash(defaultPassword, 10) } as unknown as Account, + defaultPassword + ); + + return { ret }; + }; + + it('should validate password', async () => { + const { ret } = await setup(); + + expect(ret).toBe(true); + }); }); - it('should report wrong password', async () => { - const ret = await accountService.validatePassword( - { password: await bcrypt.hash(defaultPassword, 10) } as unknown as AccountDto, - 'incorrectPwd' - ); - expect(ret).toBe(false); + + describe('when wrong Password', () => { + const setup = async () => { + const ret = await accountService.validatePassword( + { password: await bcrypt.hash(defaultPassword, 10) } as unknown as Account, + 'incorrectPwd' + ); + + return { ret }; + }; + + it('should report', async () => { + const { ret } = await setup(); + + expect(ret).toBe(false); + }); }); - it('should report missing account password', async () => { - const ret = await accountService.validatePassword({ password: undefined } as AccountDto, 'incorrectPwd'); - expect(ret).toBe(false); + + describe('when missing account password', () => { + const setup = async () => { + const ret = await accountService.validatePassword({ password: undefined } as Account, 'incorrectPwd'); + + return { ret }; + }; + + it('should report', async () => { + const { ret } = await setup(); + + expect(ret).toBe(false); + }); }); }); describe('updatePassword', () => { - it('should update password', async () => { - const newPassword = 'newPassword'; - const ret = await accountService.updatePassword(mockTeacherAccount.id, newPassword); + describe('when update Password', () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + const newPassword = 'newPassword'; - expect(ret).toBeDefined(); - if (ret.password) { - await expect(bcrypt.compare(newPassword, ret.password)).resolves.toBe(true); - } else { - fail('return password is undefined'); - } + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherAccount, newPassword }; + }; + + it('should update password', async () => { + const { mockTeacherAccount, newPassword } = setup(); + + const ret = await accountService.updatePassword(mockTeacherAccount.id, newPassword); + + expect(ret).toBeDefined(); + if (ret.password) { + await expect(bcrypt.compare(newPassword, ret.password)).resolves.toBe(true); + } else { + fail('return password is undefined'); + } + }); }); }); describe('delete', () => { - describe('when deleting existing account', () => { + describe('when delete an existing account', () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherAccount }; + }; + it('should delete account via repo', async () => { + const { mockTeacherAccount } = setup(); await accountService.delete(mockTeacherAccount.id); expect(accountRepo.deleteById).toHaveBeenCalledWith(new ObjectId(mockTeacherAccount.id)); }); @@ -471,55 +816,127 @@ describe('AccountDbService', () => { describe('when deleting non existing account', () => { const setup = () => { - accountLookupServiceMock.getInternalId.mockResolvedValueOnce(null); + accountRepo.deleteById.mockImplementationOnce(() => { + throw new EntityNotFoundError(AccountEntity.name); + }); }; - it('should throw', async () => { + it('should throw account not found', async () => { setup(); - await expect(accountService.delete(mockTeacherAccount.id)).rejects.toThrow(); + await expect(accountService.delete('nonExisting')).rejects.toThrow(); }); }); }); describe('deleteByUserId', () => { - it('should delete the account with given user id via repo', async () => { - await accountService.deleteByUserId(mockTeacherAccount.userId?.toString() ?? ''); - expect(accountRepo.deleteByUserId).toHaveBeenCalledWith(mockTeacherAccount.userId); + describe('when delete account with given user id', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId(); + + const mockTeacherAccount = accountDoFactory.build({ + userId: mockTeacherUser.id, + password: defaultPassword, + }); + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + + return { mockTeacherUser, mockTeacherAccount }; + }; + + it('should delete via repo', async () => { + const { mockTeacherUser, mockTeacherAccount } = setup(); + + await accountService.deleteByUserId(mockTeacherAccount.userId?.toString() ?? ''); + expect(accountRepo.deleteByUserId).toHaveBeenCalledWith(mockTeacherUser.id); + }); }); }); describe('searchByUsernamePartialMatch', () => { - it('should call repo', async () => { - const partialUserName = 'admin'; - const skip = 2; - const limit = 10; - const [accounts, total] = await accountService.searchByUsernamePartialMatch(partialUserName, skip, limit); - expect(accountRepo.searchByUsernamePartialMatch).toHaveBeenCalledWith(partialUserName, skip, limit); - expect(total).toBe(mockAccounts.length); + describe('when searching by part of username', () => { + const setup = () => { + const partialUserName = 'admin'; + const skip = 2; + const limit = 10; + const mockTeacherAccount = accountDoFactory.build(); + const mockStudentAccount = accountDoFactory.build(); + const mockAccountWithSystemId = accountDoFactory.build({ + systemId: new ObjectId().toHexString(), + }); + const mockAccounts = [mockTeacherAccount, mockStudentAccount, mockAccountWithSystemId]; + + accountRepo.findById.mockResolvedValue(mockTeacherAccount); + accountRepo.searchByUsernamePartialMatch.mockResolvedValue([ + [mockTeacherAccount, mockStudentAccount, mockAccountWithSystemId], + 3, + ]); + + return { partialUserName, skip, limit, mockTeacherAccount, mockAccounts }; + }; + + it('should call repo', async () => { + const { partialUserName, skip, limit, mockTeacherAccount, mockAccounts } = setup(); + const [accounts, total] = await accountService.searchByUsernamePartialMatch(partialUserName, skip, limit); + expect(accountRepo.searchByUsernamePartialMatch).toHaveBeenCalledWith(partialUserName, skip, limit); + expect(total).toBe(mockAccounts.length); - expect(accounts[0]).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + expect(accounts[0]).toEqual(mockTeacherAccount); + }); }); }); + describe('searchByUsernameExactMatch', () => { - it('should call repo', async () => { - const partialUserName = 'admin'; - const [accounts, total] = await accountService.searchByUsernameExactMatch(partialUserName); - expect(accountRepo.searchByUsernameExactMatch).toHaveBeenCalledWith(partialUserName); - expect(total).toBe(1); - expect(accounts[0]).toEqual(AccountEntityToDtoMapper.mapToDto(mockTeacherAccount)); + describe('when searching by username', () => { + const setup = () => { + const partialUserName = 'admin'; + const mockTeacherAccount = accountDoFactory.build(); + + accountRepo.searchByUsernameExactMatch.mockResolvedValue([[mockTeacherAccount], 1]); + + return { partialUserName, mockTeacherAccount }; + }; + + it('should call repo', async () => { + const { partialUserName, mockTeacherAccount } = setup(); + const [accounts, total] = await accountService.searchByUsernameExactMatch(partialUserName); + expect(accountRepo.searchByUsernameExactMatch).toHaveBeenCalledWith(partialUserName); + expect(total).toBe(1); + expect(accounts[0]).toEqual(mockTeacherAccount); + }); }); }); - describe('findMany', () => { - it('should call repo', async () => { - const foundAccounts = await accountService.findMany(1, 1); - expect(accountRepo.findMany).toHaveBeenCalledWith(1, 1); - expect(foundAccounts).toBeDefined(); - }); - it('should call repo', async () => { - const foundAccounts = await accountService.findMany(); - expect(accountRepo.findMany).toHaveBeenCalledWith(0, 100); - expect(foundAccounts).toBeDefined(); + describe('when find many one time', () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + + accountRepo.findMany.mockResolvedValue([mockTeacherAccount]); + + return {}; + }; + + it('should call repo', async () => { + setup(); + const foundAccounts = await accountService.findMany(1, 1); + expect(accountRepo.findMany).toHaveBeenCalledWith(1, 1); + expect(foundAccounts).toBeDefined(); + }); + }); + describe('when call find many more than one time', () => { + const setup = () => { + const mockTeacherAccount = accountDoFactory.build(); + + accountRepo.findMany.mockResolvedValue([mockTeacherAccount]); + + return {}; + }; + + it('should call repo each time', async () => { + setup(); + const foundAccounts = await accountService.findMany(); + expect(accountRepo.findMany).toHaveBeenCalledWith(0, 100); + expect(foundAccounts).toBeDefined(); + }); }); }); }); diff --git a/apps/server/src/modules/account/services/account-db.service.ts b/apps/server/src/modules/account/services/account-db.service.ts index 7ace1cd0655..52bb38201d6 100644 --- a/apps/server/src/modules/account/services/account-db.service.ts +++ b/apps/server/src/modules/account/services/account-db.service.ts @@ -1,109 +1,82 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config/dist/config.service'; import { EntityNotFoundError } from '@shared/common'; -import { Account } from '@shared/domain/entity'; import { Counted, EntityId } from '@shared/domain/types'; +import { IdentityManagementService } from '@infra/identity-management/identity-management.service'; import bcrypt from 'bcryptjs'; -import { AccountEntityToDtoMapper } from '../mapper'; +import { AccountConfig } from '../account-config'; +import { Account, AccountSave } from '../domain/account'; import { AccountRepo } from '../repo/account.repo'; -import { AccountLookupService } from './account-lookup.service'; -import { AbstractAccountService } from './account.service.abstract'; -import { AccountDto, AccountSaveDto } from './dto'; @Injectable() -export class AccountServiceDb extends AbstractAccountService { - constructor(private readonly accountRepo: AccountRepo, private readonly accountLookupService: AccountLookupService) { - super(); - } - - async findById(id: EntityId): Promise { +export class AccountServiceDb { + constructor( + private readonly accountRepo: AccountRepo, + private readonly idmService: IdentityManagementService, + private readonly configService: ConfigService + ) {} + + async findById(id: EntityId): Promise { const internalId = await this.getInternalId(id); - const accountEntity = await this.accountRepo.findById(internalId); - return AccountEntityToDtoMapper.mapToDto(accountEntity); + + return this.accountRepo.findById(internalId); } - async findMultipleByUserId(userIds: EntityId[]): Promise { - const accountEntities = await this.accountRepo.findMultipleByUserId(userIds); - return AccountEntityToDtoMapper.mapAccountsToDto(accountEntities); + async findMultipleByUserId(userIds: EntityId[]): Promise { + return this.accountRepo.findMultipleByUserId(userIds); } - async findByUserId(userId: EntityId): Promise { - const accountEntity = await this.accountRepo.findByUserId(userId); - return accountEntity ? AccountEntityToDtoMapper.mapToDto(accountEntity) : null; + async findByUserId(userId: EntityId): Promise { + return this.accountRepo.findByUserId(userId); } - async findByUserIdOrFail(userId: EntityId): Promise { - const accountEntity = await this.accountRepo.findByUserId(userId); - if (!accountEntity) { - throw new EntityNotFoundError('Account'); - } - return AccountEntityToDtoMapper.mapToDto(accountEntity); + async findByUserIdOrFail(userId: EntityId): Promise { + return this.accountRepo.findByUserIdOrFail(userId); } - async findByUsernameAndSystemId(username: string, systemId: EntityId | ObjectId): Promise { - const accountEntity = await this.accountRepo.findByUsernameAndSystemId(username, systemId); - return accountEntity ? AccountEntityToDtoMapper.mapToDto(accountEntity) : null; + async findByUsernameAndSystemId(username: string, systemId: EntityId | ObjectId): Promise { + return this.accountRepo.findByUsernameAndSystemId(username, systemId); } - async save(accountDto: AccountSaveDto): Promise { + async save(accountSave: AccountSave): Promise { let account: Account; - if (accountDto.id) { - const internalId = await this.getInternalId(accountDto.id); + if (accountSave.id) { + const internalId = await this.getInternalId(accountSave.id); account = await this.accountRepo.findById(internalId); - account.userId = new ObjectId(accountDto.userId); - account.systemId = accountDto.systemId ? new ObjectId(accountDto.systemId) : undefined; - account.username = accountDto.username; - account.activated = accountDto.activated; - account.expiresAt = accountDto.expiresAt; - account.lasttriedFailedLogin = accountDto.lasttriedFailedLogin; - if (accountDto.password) { - account.password = await this.encryptPassword(accountDto.password); - } - account.credentialHash = accountDto.credentialHash; - account.token = accountDto.token; - - await this.accountRepo.save(account); } else { account = new Account({ - userId: new ObjectId(accountDto.userId), - systemId: accountDto.systemId ? new ObjectId(accountDto.systemId) : undefined, - username: accountDto.username, - activated: accountDto.activated, - expiresAt: accountDto.expiresAt, - lasttriedFailedLogin: accountDto.lasttriedFailedLogin, - password: accountDto.password ? await this.encryptPassword(accountDto.password) : undefined, - token: accountDto.token, - credentialHash: accountDto.credentialHash, + id: new ObjectId().toHexString(), + username: accountSave.username, }); - - await this.accountRepo.save(account); } - return AccountEntityToDtoMapper.mapToDto(account); + await account.update(accountSave); + return this.accountRepo.save(account); } - async updateUsername(accountId: EntityId, username: string): Promise { + async updateUsername(accountId: EntityId, username: string): Promise { const internalId = await this.getInternalId(accountId); const account = await this.accountRepo.findById(internalId); account.username = username; await this.accountRepo.save(account); - return AccountEntityToDtoMapper.mapToDto(account); + return account; } - async updateLastTriedFailedLogin(accountId: EntityId, lastTriedFailedLogin: Date): Promise { + async updateLastTriedFailedLogin(accountId: EntityId, lastTriedFailedLogin: Date): Promise { const internalId = await this.getInternalId(accountId); const account = await this.accountRepo.findById(internalId); account.lasttriedFailedLogin = lastTriedFailedLogin; await this.accountRepo.save(account); - return AccountEntityToDtoMapper.mapToDto(account); + return account; } - async updatePassword(accountId: EntityId, password: string): Promise { + async updatePassword(accountId: EntityId, password: string): Promise { const internalId = await this.getInternalId(accountId); const account = await this.accountRepo.findById(internalId); account.password = await this.encryptPassword(password); await this.accountRepo.save(account); - return AccountEntityToDtoMapper.mapToDto(account); + return account; } async delete(id: EntityId): Promise { @@ -111,40 +84,50 @@ export class AccountServiceDb extends AbstractAccountService { return this.accountRepo.deleteById(internalId); } - async deleteByUserId(userId: EntityId): Promise { + async deleteByUserId(userId: EntityId): Promise { return this.accountRepo.deleteByUserId(userId); } - async searchByUsernamePartialMatch(userName: string, skip: number, limit: number): Promise> { - const accountEntities = await this.accountRepo.searchByUsernamePartialMatch(userName, skip, limit); - return AccountEntityToDtoMapper.mapSearchResult(accountEntities); + async searchByUsernamePartialMatch(userName: string, skip: number, limit: number): Promise> { + return this.accountRepo.searchByUsernamePartialMatch(userName, skip, limit); } - async searchByUsernameExactMatch(userName: string): Promise> { - const accountEntities = await this.accountRepo.searchByUsernameExactMatch(userName); - return AccountEntityToDtoMapper.mapSearchResult(accountEntities); + async searchByUsernameExactMatch(userName: string): Promise> { + return this.accountRepo.searchByUsernameExactMatch(userName); } - validatePassword(account: AccountDto, comparePassword: string): Promise { + validatePassword(account: Account, comparePassword: string): Promise { if (!account.password) { return Promise.resolve(false); } - return bcrypt.compare(comparePassword, account.password); + + const passwordCompare = bcrypt.compare(comparePassword, account.password); + + return passwordCompare; } private async getInternalId(id: EntityId | ObjectId): Promise { - const internalId = await this.accountLookupService.getInternalId(id); - if (!internalId) { - throw new EntityNotFoundError(`Account with id ${id.toString()} not found`); - } + const internalId = await this.convertExternalToInternalId(id); + return internalId; } + private async convertExternalToInternalId(id: EntityId | ObjectId): Promise { + if (id instanceof ObjectId || ObjectId.isValid(id)) { + return new ObjectId(id); + } + if (this.configService.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') === true) { + const account = await this.idmService.findAccountById(id); + return new ObjectId(account.attDbcAccountId); + } + throw new EntityNotFoundError(`Account with id ${id.toString()} not found`); + } + private encryptPassword(password: string): Promise { return bcrypt.hash(password, 10); } - async findMany(offset = 0, limit = 100): Promise { - return AccountEntityToDtoMapper.mapAccountsToDto(await this.accountRepo.findMany(offset, limit)); + async findMany(offset = 0, limit = 100): Promise { + return this.accountRepo.findMany(offset, limit); } } diff --git a/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts b/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts index 59be9d3265c..76cb1e9f589 100644 --- a/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts +++ b/apps/server/src/modules/account/services/account-idm.service.integration.spec.ts @@ -2,16 +2,15 @@ import { IdentityManagementModule, IdentityManagementService } from '@infra/iden import { KeycloakAdministrationService } from '@infra/identity-management/keycloak-administration/service/keycloak-administration.service'; import KeycloakAdminClient from '@keycloak/keycloak-admin-client-cjs/keycloak-admin-client-cjs-index'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AccountSaveDto } from '@modules/account/services/dto'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { IdmAccount } from '@shared/domain/interface'; import { LoggerModule } from '@src/core/logger'; import { v1 } from 'uuid'; -import { AccountIdmToDtoMapper, AccountIdmToDtoMapperDb } from '../mapper'; +import { AccountIdmToDoMapper, AccountIdmToDoMapperDb } from '../repo/mapper'; import { AccountServiceIdm } from './account-idm.service'; -import { AccountLookupService } from './account-lookup.service'; import { AbstractAccountService } from './account.service.abstract'; +import { AccountSave } from '../domain'; describe('AccountIdmService Integration', () => { let module: TestingModule; @@ -23,13 +22,13 @@ describe('AccountIdmService Integration', () => { const testRealm = `test-realm-${v1()}`; const testDbcAccountId = new ObjectId().toString(); - const testAccount = new AccountSaveDto({ + const testAccount = { username: 'john.doe@mail.tld', password: 'super-secret-password', userId: new ObjectId().toString(), systemId: new ObjectId().toString(), idmReferenceId: testDbcAccountId, - }); + } as AccountSave; const createAccount = async (): Promise => identityManagementService.createAccount( { @@ -60,10 +59,9 @@ describe('AccountIdmService Integration', () => { ], providers: [ AccountServiceIdm, - AccountLookupService, { - provide: AccountIdmToDtoMapper, - useClass: AccountIdmToDtoMapperDb, + provide: AccountIdmToDoMapper, + useClass: AccountIdmToDoMapperDb, }, ], }).compile(); @@ -93,79 +91,125 @@ describe('AccountIdmService Integration', () => { } }); - it('save should create a new account', async () => { - if (!isIdmReachable) return; - const createdAccount = await accountIdmService.save(testAccount); - const foundAccount = await identityManagementService.findAccountById(createdAccount.idmReferenceId ?? ''); - - expect(foundAccount).toEqual( - expect.objectContaining({ - id: createdAccount.idmReferenceId ?? '', - username: createdAccount.username, - attDbcAccountId: testDbcAccountId, - attDbcUserId: createdAccount.userId, - attDbcSystemId: createdAccount.systemId, - }) - ); + describe('save', () => { + describe('when account does not exists', () => { + it('should create a new account', async () => { + if (!isIdmReachable) return; + const createdAccount = await accountIdmService.save(testAccount); + const foundAccount = await identityManagementService.findAccountById(createdAccount.idmReferenceId ?? ''); + + expect(foundAccount).toEqual( + expect.objectContaining({ + id: createdAccount.idmReferenceId ?? '', + username: createdAccount.username, + attDbcAccountId: createdAccount.id, + attDbcUserId: createdAccount.userId, + attDbcSystemId: createdAccount.systemId, + }) + ); + }); + }); }); - it('save should update existing account', async () => { - if (!isIdmReachable) return; - const newUsername = 'jane.doe@mail.tld'; - const idmId = await createAccount(); - - await accountIdmService.save({ - id: testDbcAccountId, - username: newUsername, + describe('save', () => { + describe('when account exists', () => { + const setup = async () => { + const newUserName = 'jane.doe@mail.tld'; + const idmId = await createAccount(); + + return { idmId, newUserName }; + }; + it('should update account', async () => { + if (!isIdmReachable) return; + const { idmId, newUserName } = await setup(); + + await accountIdmService.save({ + id: testDbcAccountId, + username: newUserName, + } as AccountSave); + + const foundAccount = await identityManagementService.findAccountById(idmId); + + expect(foundAccount).toEqual( + expect.objectContaining({ + id: idmId, + username: newUserName, + }) + ); + }); }); - const foundAccount = await identityManagementService.findAccountById(idmId); - - expect(foundAccount).toEqual( - expect.objectContaining({ - id: idmId, - username: newUsername, - }) - ); }); - it('updateUsername should update username', async () => { - if (!isIdmReachable) return; - const newUserName = 'jane.doe@mail.tld'; - const idmId = await createAccount(); - await accountIdmService.updateUsername(testDbcAccountId, newUserName); - - const foundAccount = await identityManagementService.findAccountById(idmId); - - expect(foundAccount).toEqual( - expect.objectContaining>({ - username: newUserName, - }) - ); + describe('updateUsername', () => { + describe('when updating username', () => { + const setup = async () => { + const newUserName = 'jane.doe@mail.tld'; + const idmId = await createAccount(); + + return { newUserName, idmId }; + }; + it('should update only username', async () => { + if (!isIdmReachable) return; + const { newUserName, idmId } = await setup(); + + await accountIdmService.updateUsername(testDbcAccountId, newUserName); + const foundAccount = await identityManagementService.findAccountById(idmId); + + expect(foundAccount).toEqual( + expect.objectContaining>({ + username: newUserName, + }) + ); + }); + }); }); - it('updatePassword should update password', async () => { - if (!isIdmReachable) return; - await createAccount(); - await expect(accountIdmService.updatePassword(testDbcAccountId, 'newPassword')).resolves.not.toThrow(); + describe('updatePassword', () => { + describe('when updating with permitted password', () => { + const setup = async () => { + await createAccount(); + }; + it('should update password', async () => { + if (!isIdmReachable) return; + await setup(); + await expect(accountIdmService.updatePassword(testDbcAccountId, 'newPassword')).resolves.not.toThrow(); + }); + }); }); - it('delete should remove account', async () => { - if (!isIdmReachable) return; - const idmId = await createAccount(); - const foundAccount = await identityManagementService.findAccountById(idmId); - expect(foundAccount).toBeDefined(); - - await accountIdmService.delete(testDbcAccountId); - await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + describe('delete', () => { + describe('when delete account', () => { + const setup = async () => { + const idmId = await createAccount(); + const foundAccount = await identityManagementService.findAccountById(idmId); + return { idmId, foundAccount }; + }; + it('should remove account', async () => { + if (!isIdmReachable) return; + const { idmId, foundAccount } = await setup(); + expect(foundAccount).toBeDefined(); + + await accountIdmService.delete(testDbcAccountId); + await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + }); + }); }); - it('deleteByUserId should remove account', async () => { - if (!isIdmReachable) return; - const idmId = await createAccount(); - const foundAccount = await identityManagementService.findAccountById(idmId); - expect(foundAccount).toBeDefined(); - - await accountIdmService.deleteByUserId(testAccount.userId ?? ''); - await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + describe('deleteByUserId', () => { + describe('when deleting by UserId', () => { + const setup = async () => { + const idmId = await createAccount(); + const foundAccount = await identityManagementService.findAccountById(idmId); + return { idmId, foundAccount }; + }; + it('should remove account', async () => { + if (!isIdmReachable) return; + const { idmId, foundAccount } = await setup(); + expect(foundAccount).toBeDefined(); + + await accountIdmService.deleteByUserId(testAccount.userId ?? ''); + await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + }); + }); }); }); diff --git a/apps/server/src/modules/account/services/account-idm.service.spec.ts b/apps/server/src/modules/account/services/account-idm.service.spec.ts index 6a32f588f83..2799462ef09 100644 --- a/apps/server/src/modules/account/services/account-idm.service.spec.ts +++ b/apps/server/src/modules/account/services/account-idm.service.spec.ts @@ -2,23 +2,23 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { IdentityManagementOauthService, IdentityManagementService } from '@infra/identity-management'; import { NotImplementedException } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; import { IdmAccount } from '@shared/domain/interface'; -import { LoggerModule } from '@src/core/logger'; -import { AccountIdmToDtoMapper, AccountIdmToDtoMapperDb } from '../mapper'; +import { Logger } from '@src/core/logger'; +import { AccountConfig } from '../account-config'; +import { Account, AccountSave } from '../domain'; +import { AccountIdmToDoMapper, AccountIdmToDoMapperDb } from '../repo/mapper'; import { AccountServiceIdm } from './account-idm.service'; -import { AccountLookupService } from './account-lookup.service'; -import { AccountDto, AccountSaveDto } from './dto'; describe('AccountIdmService', () => { let module: TestingModule; let accountIdmService: AccountServiceIdm; - let mapper: AccountIdmToDtoMapper; + let mapper: AccountIdmToDoMapper; let idmServiceMock: DeepMocked; - let accountLookupServiceMock: DeepMocked; let idmOauthServiceMock: DeepMocked; + let configServiceMock: DeepMocked; const mockIdmAccountRefId = 'dbcAccountId'; const mockIdmAccount: IdmAccount = { @@ -35,36 +35,36 @@ describe('AccountIdmService', () => { beforeAll(async () => { module = await Test.createTestingModule({ - imports: [ - MongoMemoryDatabaseModule.forRoot(), - LoggerModule, - ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true, ignoreEnvVars: true }), - ], + imports: [MongoMemoryDatabaseModule.forRoot()], providers: [ AccountServiceIdm, { - provide: AccountIdmToDtoMapper, - useValue: new AccountIdmToDtoMapperDb(), + provide: AccountIdmToDoMapper, + useValue: new AccountIdmToDoMapperDb(), }, { provide: IdentityManagementService, useValue: createMock(), }, { - provide: AccountLookupService, - useValue: createMock(), + provide: ConfigService, + useValue: createMock>(), }, { provide: IdentityManagementOauthService, useValue: createMock(), }, + { + provide: Logger, + useValue: createMock(), + }, ], }).compile(); accountIdmService = module.get(AccountServiceIdm); - mapper = module.get(AccountIdmToDtoMapper); + mapper = module.get(AccountIdmToDoMapper); idmServiceMock = module.get(IdentityManagementService); - accountLookupServiceMock = module.get(AccountLookupService); idmOauthServiceMock = module.get(IdentityManagementOauthService); + configServiceMock = module.get(ConfigService); }); afterAll(async () => { @@ -76,155 +76,214 @@ describe('AccountIdmService', () => { }); describe('save', () => { - const setup = () => { - idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); - idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); - idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); - idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); - }; - - it('should update an existing account', async () => { - setup(); - const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); - const createSpy = jest.spyOn(idmServiceMock, 'createAccount'); - - const mockAccountDto = { - id: mockIdmAccountRefId, - username: 'testUserName', - userId: 'userId', - systemId: 'systemId', + describe('when save an existing account', () => { + const setup = () => { + idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); + idmServiceMock.findAccountByDbcAccountId.mockResolvedValue(mockIdmAccount); + configServiceMock.get.mockReturnValue(true); + + const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); + const createSpy = jest.spyOn(idmServiceMock, 'createAccount'); + const mockAccountSave = { + id: mockIdmAccountRefId, + username: 'testUserName', + userId: 'userId', + systemId: 'systemId', + } as AccountSave; + + return { updateSpy, createSpy, mockAccountDto: mockAccountSave }; }; - const ret = await accountIdmService.save(mockAccountDto); - - expect(updateSpy).toHaveBeenCalled(); - expect(createSpy).not.toHaveBeenCalled(); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + + it('should update account information', async () => { + const { updateSpy, createSpy, mockAccountDto } = setup(); + + const ret = await accountIdmService.save(mockAccountDto); + + expect(updateSpy).toHaveBeenCalled(); + expect(createSpy).not.toHaveBeenCalled(); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); - it('should update an existing accounts password', async () => { - setup(); - const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); - const updatePasswordSpy = jest.spyOn(idmServiceMock, 'updateAccountPassword'); - - const mockAccountDto: AccountSaveDto = { - id: mockIdmAccountRefId, - username: 'testUserName', - userId: 'userId', - systemId: 'systemId', - password: 'password', + describe('when save an existing account', () => { + const setup = () => { + idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); + const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); + const updatePasswordSpy = jest.spyOn(idmServiceMock, 'updateAccountPassword'); + + const mockAccountSave = { + id: mockIdmAccountRefId, + username: 'testUserName', + userId: 'userId', + systemId: 'systemId', + password: 'password', + } as AccountSave; + return { updateSpy, updatePasswordSpy, mockAccountDto: mockAccountSave }; }; - const ret = await accountIdmService.save(mockAccountDto); + it('should update account password', async () => { + const { updateSpy, updatePasswordSpy, mockAccountDto } = setup(); + + const ret = await accountIdmService.save(mockAccountDto); - expect(updateSpy).toHaveBeenCalled(); - expect(updatePasswordSpy).toHaveBeenCalled(); - expect(ret).toBeDefined(); + expect(updateSpy).toHaveBeenCalled(); + expect(updatePasswordSpy).toHaveBeenCalled(); + expect(ret).toBeDefined(); + }); }); - it('should create a new account', async () => { - setup(); - const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); - const createSpy = jest.spyOn(idmServiceMock, 'createAccount'); + describe('when save not existing account', () => { + const setup = () => { + idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); + const updateSpy = jest.spyOn(idmServiceMock, 'updateAccount'); + const createSpy = jest.spyOn(idmServiceMock, 'createAccount'); - const mockAccountDto = { username: 'testUserName', id: undefined, userId: 'userId', systemId: 'systemId' }; - const ret = await accountIdmService.save(mockAccountDto); + const mockAccountSave = { + username: 'testUserName', + id: undefined, + userId: 'userId', + systemId: 'systemId', + } as AccountSave; - expect(updateSpy).not.toHaveBeenCalled(); - expect(createSpy).toHaveBeenCalled(); + return { updateSpy, createSpy, mockAccountDto: mockAccountSave }; + }; + it('should create a new account', async () => { + const { updateSpy, createSpy, mockAccountDto } = setup(); - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + const ret = await accountIdmService.save(mockAccountDto); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(createSpy).toHaveBeenCalled(); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); - it('should create a new account on update error', async () => { - setup(); - accountLookupServiceMock.getExternalId.mockResolvedValue(null); - const mockAccountDto = { - id: mockIdmAccountRefId, - username: 'testUserName', - userId: 'userId', - systemId: 'systemId', - }; - const ret = await accountIdmService.save(mockAccountDto); + describe('when save not existing account', () => { + const setup = () => { + idmServiceMock.createAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccount.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.updateAccountPassword.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); + configServiceMock.get.mockReturnValue(false); + + const mockAccountSave = { + id: mockIdmAccountRefId, + username: 'testUserName', + userId: 'userId', + systemId: 'systemId', + } as AccountSave; - expect(idmServiceMock.createAccount).toHaveBeenCalled(); - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + return { mockAccountDto: mockAccountSave }; + }; + it('should create a new account on update error', async () => { + const { mockAccountDto } = setup(); + + const ret = await accountIdmService.save(mockAccountDto); + + expect(idmServiceMock.createAccount).toHaveBeenCalled(); + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); }); describe('updateUsername', () => { - it('should map result correctly', async () => { - accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); - const ret = await accountIdmService.updateUsername(mockIdmAccountRefId, 'any'); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + describe('when update Username', () => { + const setup = () => { + configServiceMock.get.mockReturnValue(true); + idmServiceMock.findAccountByDbcAccountId.mockResolvedValue(mockIdmAccount); + }; + it('should map result correctly', async () => { + setup(); + const ret = await accountIdmService.updateUsername(mockIdmAccountRefId, 'any'); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); }); describe('updatePassword', () => { - it('should map result correctly', async () => { - accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); - const ret = await accountIdmService.updatePassword(mockIdmAccountRefId, 'any'); - - expect(ret).toBeDefined(); - expect(ret).toMatchObject>({ - id: mockIdmAccount.attDbcAccountId, - idmReferenceId: mockIdmAccount.id, - createdAt: mockIdmAccount.createdDate, - updatedAt: mockIdmAccount.createdDate, - username: mockIdmAccount.username, + describe('when update password', () => { + const setup = () => { + configServiceMock.get.mockReturnValue(true); + idmServiceMock.findAccountByDbcAccountId.mockResolvedValue(mockIdmAccount); + }; + it('should map result correctly', async () => { + setup(); + const ret = await accountIdmService.updatePassword(mockIdmAccountRefId, 'any'); + + expect(ret).toBeDefined(); + expect(ret).toMatchObject>({ + id: mockIdmAccount.attDbcAccountId, + idmReferenceId: mockIdmAccount.id, + createdAt: mockIdmAccount.createdDate, + updatedAt: mockIdmAccount.createdDate, + username: mockIdmAccount.username, + }); }); }); }); describe('validatePassword', () => { - const setup = (acceptPassword: boolean) => { - idmOauthServiceMock.resourceOwnerPasswordGrant.mockResolvedValue( - acceptPassword ? '{ "alg": "HS256", "typ": "JWT" }' : undefined - ); - }; - it('should validate password by checking JWT', async () => { - setup(true); - const ret = await accountIdmService.validatePassword( - { username: 'username' } as unknown as AccountDto, - 'password' - ); - expect(ret).toBe(true); - }); - it('should report wrong password, i. e. non successful JWT creation', async () => { - setup(false); - const ret = await accountIdmService.validatePassword( - { username: 'username' } as unknown as AccountDto, - 'password' - ); - expect(ret).toBe(false); + describe('when validate password', () => { + const setup = (acceptPassword: boolean) => { + idmOauthServiceMock.resourceOwnerPasswordGrant.mockResolvedValue( + acceptPassword ? '{ "alg": "HS256", "typ": "JWT" }' : undefined + ); + }; + it('should validate password by checking JWT', async () => { + setup(true); + const ret = await accountIdmService.validatePassword( + { username: 'username' } as unknown as Account, + 'password' + ); + expect(ret).toBe(true); + }); + it('should report wrong password, i. e. non successful JWT creation', async () => { + setup(false); + const ret = await accountIdmService.validatePassword( + { username: 'username' } as unknown as Account, + 'password' + ); + expect(ret).toBe(false); + }); }); }); @@ -232,7 +291,8 @@ describe('AccountIdmService', () => { describe('when deleting existing account', () => { const setup = () => { idmServiceMock.deleteAccountById.mockResolvedValue(mockIdmAccount.id); - accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); + idmServiceMock.findAccountByDbcAccountId.mockResolvedValue(mockIdmAccount); + configServiceMock.get.mockReturnValueOnce(true); }; it('should delete account via idm', async () => { @@ -245,10 +305,10 @@ describe('AccountIdmService', () => { describe('when deleting non existing account', () => { const setup = () => { idmServiceMock.deleteAccountById.mockResolvedValue(mockIdmAccount.id); - accountLookupServiceMock.getExternalId.mockResolvedValue(null); + configServiceMock.get.mockReturnValueOnce(false); }; - it('should throw error', async () => { + it('should throw account not found error', async () => { setup(); await expect(accountIdmService.delete(mockIdmAccountRefId)).rejects.toThrow(); }); @@ -256,16 +316,19 @@ describe('AccountIdmService', () => { }); describe('deleteByUserId', () => { - const setup = () => { - idmServiceMock.findAccountByDbcUserId.mockResolvedValue(mockIdmAccount); - }; + describe('when deleting an account by user id', () => { + const setup = () => { + idmServiceMock.findAccountByDbcUserId.mockResolvedValue(mockIdmAccount); + const deleteSpy = jest.spyOn(idmServiceMock, 'deleteAccountById'); + return { deleteSpy }; + }; - it('should delete the account with given user id via repo', async () => { - setup(); - const deleteSpy = jest.spyOn(idmServiceMock, 'deleteAccountById'); + it('should delete the account with given user id via repo', async () => { + const { deleteSpy } = setup(); - await accountIdmService.deleteByUserId(mockIdmAccount.attDbcUserId ?? ''); - expect(deleteSpy).toHaveBeenCalledWith(mockIdmAccount.id); + await accountIdmService.deleteByUserId(mockIdmAccount.attDbcUserId ?? ''); + expect(deleteSpy).toHaveBeenCalledWith(mockIdmAccount.id); + }); }); }); @@ -278,7 +341,7 @@ describe('AccountIdmService', () => { it('should return the account', async () => { setup(); const result = await accountIdmService.findById(mockIdmAccountRefId); - expect(result).toStrictEqual(mapper.mapToDto(mockIdmAccount)); + expect(result).toStrictEqual(mapper.mapToDo(mockIdmAccount)); }); }); @@ -287,7 +350,7 @@ describe('AccountIdmService', () => { idmServiceMock.findAccountById.mockRejectedValue(new Error()); }; - it('should throw', async () => { + it('should throw account not found', async () => { setup(); await expect(accountIdmService.findById('notExistingId')).rejects.toThrow(); }); @@ -303,14 +366,14 @@ describe('AccountIdmService', () => { if (element) { return Promise.resolve(element); } - throw new Error(); + return Promise.reject(new Error()); }); }; it('should return the accounts', async () => { setup(); const result = await accountIdmService.findMultipleByUserId(['id', 'id1', 'id2']); - expect(result).toStrictEqual([mapper.mapToDto(mockIdmAccount)]); + expect(result).toStrictEqual([mapper.mapToDo(mockIdmAccount)]); }); }); }); @@ -324,7 +387,7 @@ describe('AccountIdmService', () => { it('should return the account', async () => { setup(); const result = await accountIdmService.findByUserId(mockIdmAccount.attDbcUserId ?? ''); - expect(result).toStrictEqual(mapper.mapToDto(mockIdmAccount)); + expect(result).toStrictEqual(mapper.mapToDo(mockIdmAccount)); }); }); @@ -350,7 +413,7 @@ describe('AccountIdmService', () => { it('should return the account', async () => { setup(); const result = await accountIdmService.findByUserIdOrFail(mockIdmAccountRefId); - expect(result).toStrictEqual(mapper.mapToDto(mockIdmAccount)); + expect(result).toStrictEqual(mapper.mapToDo(mockIdmAccount)); }); }); @@ -359,7 +422,7 @@ describe('AccountIdmService', () => { idmServiceMock.findAccountByDbcUserId.mockResolvedValue(undefined as unknown as IdmAccount); }; - it('should throw', async () => { + it('should throw account not found', async () => { setup(); await expect(accountIdmService.findByUserIdOrFail('notExistingId')).rejects.toThrow(EntityNotFoundError); }); @@ -375,7 +438,7 @@ describe('AccountIdmService', () => { it('should return the account', async () => { setup(); const result = await accountIdmService.findByUsernameAndSystemId('username', 'attDbcSystemId'); - expect(result).toStrictEqual(mapper.mapToDto(mockIdmAccount)); + expect(result).toStrictEqual(mapper.mapToDo(mockIdmAccount)); }); }); @@ -401,7 +464,7 @@ describe('AccountIdmService', () => { it('should return the account', async () => { setup(); const [result] = await accountIdmService.searchByUsernamePartialMatch('username', 0, 10); - expect(result).toStrictEqual([mapper.mapToDto(mockIdmAccount)]); + expect(result).toStrictEqual([mapper.mapToDo(mockIdmAccount)]); }); }); @@ -413,7 +476,7 @@ describe('AccountIdmService', () => { it('should return an empty list', async () => { setup(); const [result] = await accountIdmService.searchByUsernamePartialMatch('username', 0, 10); - expect(result).toStrictEqual([]); + expect(result).toStrictEqual([]); }); }); }); @@ -427,7 +490,7 @@ describe('AccountIdmService', () => { it('should return the account', async () => { setup(); const [result] = await accountIdmService.searchByUsernameExactMatch('username'); - expect(result).toStrictEqual([mapper.mapToDto(mockIdmAccount)]); + expect(result).toStrictEqual([mapper.mapToDo(mockIdmAccount)]); }); }); @@ -439,7 +502,7 @@ describe('AccountIdmService', () => { it('should return an empty list', async () => { setup(); const [result] = await accountIdmService.searchByUsernameExactMatch('username'); - expect(result).toStrictEqual([]); + expect(result).toStrictEqual([]); }); }); }); @@ -449,13 +512,13 @@ describe('AccountIdmService', () => { const setup = () => { idmServiceMock.findAccountByDbcAccountId.mockResolvedValue(mockIdmAccount); idmServiceMock.findAccountById.mockResolvedValue(mockIdmAccount); - accountLookupServiceMock.getExternalId.mockResolvedValue(mockIdmAccount.id); + configServiceMock.get.mockReturnValue(true); }; it('should return the account', async () => { setup(); const result = await accountIdmService.updateLastTriedFailedLogin('id', new Date()); - expect(result).toStrictEqual(mapper.mapToDto(mockIdmAccount)); + expect(result).toStrictEqual(mapper.mapToDo(mockIdmAccount)); }); it('should set an user attribute', async () => { @@ -465,7 +528,7 @@ describe('AccountIdmService', () => { }); }); - it('findMany should throw', async () => { + it('findMany should throw not implemented Exception', async () => { await expect(accountIdmService.findMany(0, 0)).rejects.toThrow(NotImplementedException); }); }); diff --git a/apps/server/src/modules/account/services/account-idm.service.ts b/apps/server/src/modules/account/services/account-idm.service.ts index 65368599019..f870a7105cc 100644 --- a/apps/server/src/modules/account/services/account-idm.service.ts +++ b/apps/server/src/modules/account/services/account-idm.service.ts @@ -1,118 +1,120 @@ import { IdentityManagementOauthService, IdentityManagementService } from '@infra/identity-management'; import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable, NotImplementedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config/dist/config.service'; import { EntityNotFoundError } from '@shared/common'; -import { IdmAccount, IdmAccountUpdate } from '@shared/domain/interface'; +import { IdmAccountUpdate } from '@shared/domain/interface'; import { Counted, EntityId } from '@shared/domain/types'; -import { LegacyLogger } from '@src/core/logger'; -import { AccountIdmToDtoMapper } from '../mapper'; -import { AccountLookupService } from './account-lookup.service'; +import { Logger } from '@src/core/logger'; +import { AccountConfig } from '../account-config'; +import { Account, AccountSave } from '../domain'; +import { AccountIdmToDoMapper } from '../repo/mapper'; import { AbstractAccountService } from './account.service.abstract'; -import { AccountDto, AccountSaveDto } from './dto'; +import { FindAccountByDbcUserIdLoggable, GetOptionalIdmAccountLoggable } from '../loggable'; @Injectable() export class AccountServiceIdm extends AbstractAccountService { constructor( private readonly identityManager: IdentityManagementService, - private readonly accountIdmToDtoMapper: AccountIdmToDtoMapper, - private readonly accountLookupService: AccountLookupService, + private readonly accountIdmToDoMapper: AccountIdmToDoMapper, private readonly idmOauthService: IdentityManagementOauthService, - private readonly logger: LegacyLogger + private readonly logger: Logger, + private readonly configService: ConfigService ) { super(); } - async findById(id: EntityId): Promise { + async findById(id: EntityId): Promise { const result = await this.identityManager.findAccountById(id); - const account = this.accountIdmToDtoMapper.mapToDto(result); + const account = this.accountIdmToDoMapper.mapToDo(result); return account; } - async findMultipleByUserId(userIds: EntityId[]): Promise { - const results = new Array(); - for (const userId of userIds) { - try { - // eslint-disable-next-line no-await-in-loop - results.push(await this.identityManager.findAccountByDbcUserId(userId)); - } catch { - // ignore entry + async findMultipleByUserId(userIds: EntityId[]): Promise { + const resultAccounts = new Array(); + + const promises = userIds.map((userId) => this.identityManager.findAccountByDbcUserId(userId).catch(() => null)); + const idmAccounts = await Promise.allSettled(promises); + + idmAccounts.forEach((idmAccount, index) => { + if (idmAccount.status === 'fulfilled' && idmAccount.value) { + const accountDo = this.accountIdmToDoMapper.mapToDo(idmAccount.value); + resultAccounts.push(accountDo); + } else { + this.logger.warning(new FindAccountByDbcUserIdLoggable(userIds[index])); } - } - const accounts = results.map((result) => this.accountIdmToDtoMapper.mapToDto(result)); - return accounts; + }); + + return resultAccounts; } - async findByUserId(userId: EntityId): Promise { + async findByUserId(userId: EntityId): Promise { try { const result = await this.identityManager.findAccountByDbcUserId(userId); - return this.accountIdmToDtoMapper.mapToDto(result); + return this.accountIdmToDoMapper.mapToDo(result); } catch { + this.logger.warning(new FindAccountByDbcUserIdLoggable(userId)); return null; } } - async findByUserIdOrFail(userId: EntityId): Promise { + async findByUserIdOrFail(userId: EntityId): Promise { try { const result = await this.identityManager.findAccountByDbcUserId(userId); - return this.accountIdmToDtoMapper.mapToDto(result); + return this.accountIdmToDoMapper.mapToDo(result); } catch { throw new EntityNotFoundError(`Account with userId ${userId} not found`); } } - async findByUsernameAndSystemId(username: string, systemId: EntityId | ObjectId): Promise { + async findByUsernameAndSystemId(username: string, systemId: EntityId | ObjectId): Promise { const [accounts] = await this.searchByUsernameExactMatch(username); const account = accounts.find((tempAccount) => tempAccount.systemId === systemId) || null; return account; } - async searchByUsernamePartialMatch(userName: string, skip: number, limit: number): Promise> { + async searchByUsernamePartialMatch(userName: string, skip: number, limit: number): Promise> { const [results, total] = await this.identityManager.findAccountsByUsername(userName, { skip, limit, exact: false }); - const accounts = results.map((result) => this.accountIdmToDtoMapper.mapToDto(result)); + const accounts = results.map((result) => this.accountIdmToDoMapper.mapToDo(result)); return [accounts, total]; } - async searchByUsernameExactMatch(userName: string): Promise> { + async searchByUsernameExactMatch(userName: string): Promise> { const [results, total] = await this.identityManager.findAccountsByUsername(userName, { exact: true }); - const accounts = results.map((result) => this.accountIdmToDtoMapper.mapToDto(result)); + const accounts = results.map((result) => this.accountIdmToDoMapper.mapToDo(result)); return [accounts, total]; } - async updateLastTriedFailedLogin(accountId: EntityId, lastTriedFailedLogin: Date): Promise { + async updateLastTriedFailedLogin(accountId: EntityId, lastTriedFailedLogin: Date): Promise { const attributeName = 'lastTriedFailedLogin'; const id = await this.getIdmAccountId(accountId); await this.identityManager.setUserAttribute(id, attributeName, lastTriedFailedLogin.toISOString()); const updatedAccount = await this.identityManager.findAccountById(id); - return this.accountIdmToDtoMapper.mapToDto(updatedAccount); + return this.accountIdmToDoMapper.mapToDo(updatedAccount); } - async save(accountDto: AccountSaveDto): Promise { + async save(accountSave: AccountSave): Promise { let accountId: string; const idmAccount: IdmAccountUpdate = { - username: accountDto.username, - attDbcAccountId: accountDto.idmReferenceId, - attDbcUserId: accountDto.userId, - attDbcSystemId: accountDto.systemId, + username: accountSave.username, + attDbcAccountId: accountSave.idmReferenceId, + attDbcUserId: accountSave.userId, + attDbcSystemId: accountSave.systemId, }; - if (accountDto.id) { - let idmId: string | undefined; - try { - idmId = await this.getIdmAccountId(accountDto.id); - } catch { - this.logger.log(`Account ID ${accountDto.id} could not be resolved. Creating new account and ID ...`); - idmId = undefined; - } + + if (accountSave.id) { + const idmId = await this.getOptionalIdmAccount(accountSave.id); if (idmId) { - accountId = await this.updateAccount(idmId, idmAccount, accountDto.password); + accountId = await this.updateAccount(idmId, idmAccount, accountSave.password); } else { - accountId = await this.createAccount(idmAccount, accountDto.password); + accountId = await this.createAccount(idmAccount, accountSave.password); } } else { - accountId = await this.createAccount(idmAccount, accountDto.password); + accountId = await this.createAccount(idmAccount, accountSave.password); } const updatedAccount = await this.identityManager.findAccountById(accountId); - return this.accountIdmToDtoMapper.mapToDto(updatedAccount); + return this.accountIdmToDoMapper.mapToDo(updatedAccount); } private async updateAccount(idmAccountId: string, idmAccount: IdmAccountUpdate, password?: string): Promise { @@ -128,21 +130,21 @@ export class AccountServiceIdm extends AbstractAccountService { return accountId; } - async updateUsername(accountRefId: EntityId, username: string): Promise { + async updateUsername(accountRefId: EntityId, username: string): Promise { const id = await this.getIdmAccountId(accountRefId); await this.identityManager.updateAccount(id, { username }); const updatedAccount = await this.identityManager.findAccountById(id); - return this.accountIdmToDtoMapper.mapToDto(updatedAccount); + return this.accountIdmToDoMapper.mapToDo(updatedAccount); } - async updatePassword(accountRefId: EntityId, password: string): Promise { + async updatePassword(accountRefId: EntityId, password: string): Promise { const id = await this.getIdmAccountId(accountRefId); await this.identityManager.updateAccountPassword(id, password); const updatedAccount = await this.identityManager.findAccountById(id); - return this.accountIdmToDtoMapper.mapToDto(updatedAccount); + return this.accountIdmToDoMapper.mapToDo(updatedAccount); } - async validatePassword(account: AccountDto, comparePassword: string): Promise { + async validatePassword(account: Account, comparePassword: string): Promise { const jwt = await this.idmOauthService.resourceOwnerPasswordGrant(account.username, comparePassword); return jwt !== undefined; } @@ -152,21 +154,41 @@ export class AccountServiceIdm extends AbstractAccountService { await this.identityManager.deleteAccountById(id); } - async deleteByUserId(userId: EntityId): Promise { + async deleteByUserId(userId: EntityId): Promise { const idmAccount = await this.identityManager.findAccountByDbcUserId(userId); - await this.identityManager.deleteAccountById(idmAccount.id); + const deletedAccountId = await this.identityManager.deleteAccountById(idmAccount.id); + + return [deletedAccountId]; } // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars - async findMany(_offset: number, _limit: number): Promise { + async findMany(_offset: number, _limit: number): Promise { throw new NotImplementedException(); } - private async getIdmAccountId(accountId: string): Promise { - const externalId = await this.accountLookupService.getExternalId(accountId); - if (!externalId) { - throw new EntityNotFoundError(`Account with id ${accountId} not found`); + private async getOptionalIdmAccount(accountId: string): Promise { + try { + return await this.getIdmAccountId(accountId); + } catch { + this.logger.debug(new GetOptionalIdmAccountLoggable(accountId)); + return undefined; } + } + + private async getIdmAccountId(accountId: string): Promise { + const externalId = await this.convertInternalToExternalId(accountId); + return externalId; } + + private async convertInternalToExternalId(id: EntityId | ObjectId): Promise { + if (!(id instanceof ObjectId) && !ObjectId.isValid(id)) { + return id; + } + if (this.configService.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') === true) { + const account = await this.identityManager.findAccountByDbcAccountId(id.toString()); + return account.id; + } + throw new EntityNotFoundError(`Account with id ${id.toString()} not found`); + } } diff --git a/apps/server/src/modules/account/services/account-lookup.service.spec.ts b/apps/server/src/modules/account/services/account-lookup.service.spec.ts deleted file mode 100644 index 351a076396f..00000000000 --- a/apps/server/src/modules/account/services/account-lookup.service.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { IdentityManagementService } from '@infra/identity-management'; -import { ConfigService } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; -import { IdmAccount } from '@shared/domain/interface'; -import { ObjectId } from 'bson'; -import { v1 } from 'uuid'; -import { AccountLookupService } from './account-lookup.service'; - -describe('AccountLookupService', () => { - let module: TestingModule; - let accountLookupService: AccountLookupService; - let idmServiceMock: DeepMocked; - let configServiceMock: DeepMocked; - - const internalId = new ObjectId().toHexString(); - const internalIdAsObjectId = new ObjectId(internalId); - const externalId = v1(); - const accountMock: IdmAccount = { - id: externalId, - attDbcAccountId: internalId, - }; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - AccountLookupService, - { - provide: IdentityManagementService, - useValue: createMock(), - }, - { - provide: ConfigService, - useValue: createMock(), - }, - ], - }).compile(); - accountLookupService = module.get(AccountLookupService); - idmServiceMock = module.get(IdentityManagementService); - configServiceMock = module.get(ConfigService); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('getInternalId', () => { - describe('when id is already an internal id as ObjectId', () => { - it('should return the id as is', async () => { - const result = await accountLookupService.getInternalId(internalIdAsObjectId); - expect(result).toStrictEqual(internalIdAsObjectId); - }); - }); - - describe('when id is already an internal id as string', () => { - it('should return the id as ObjectId', async () => { - const result = await accountLookupService.getInternalId(internalId); - expect(result).toBeInstanceOf(ObjectId); - expect(result?.toHexString()).toBe(internalId); - }); - }); - - describe('when id is an external id and FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED is enabled', () => { - const setup = () => { - configServiceMock.get.mockReturnValue(true); - idmServiceMock.findAccountById.mockResolvedValue(accountMock); - }; - - it('should return the internal id', async () => { - setup(); - const result = await accountLookupService.getInternalId(accountMock.id); - expect(result).toBeInstanceOf(ObjectId); - expect(result?.toHexString()).toBe(accountMock.attDbcAccountId); - }); - }); - - describe('when id is an external id and FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED is disabled', () => { - const setup = () => { - configServiceMock.get.mockReturnValue(false); - }; - - it('should return null', async () => { - setup(); - const result = await accountLookupService.getInternalId(accountMock.id); - expect(result).toBeNull(); - }); - }); - }); - - describe('getExternalId', () => { - describe('when id is already an external id', () => { - it('should return the id as is', async () => { - const result = await accountLookupService.getExternalId(externalId); - expect(result).toBe(externalId); - }); - }); - - describe('when id is an internal id and FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED is enabled', () => { - const setup = () => { - configServiceMock.get.mockReturnValue(true); - idmServiceMock.findAccountByDbcAccountId.mockResolvedValue(accountMock); - }; - - it('should return the external id', async () => { - setup(); - const result = await accountLookupService.getExternalId(internalIdAsObjectId); - expect(result).toBe(externalId); - }); - }); - - describe('when id is an internal id and FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED is disabled', () => { - const setup = () => { - configServiceMock.get.mockReturnValue(false); - }; - - it('should return null', async () => { - setup(); - const result = await accountLookupService.getExternalId(internalIdAsObjectId); - expect(result).toBeNull(); - }); - }); - }); -}); diff --git a/apps/server/src/modules/account/services/account-lookup.service.ts b/apps/server/src/modules/account/services/account-lookup.service.ts deleted file mode 100644 index 8a752d2472d..00000000000 --- a/apps/server/src/modules/account/services/account-lookup.service.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { IdentityManagementService } from '@infra/identity-management'; -import { ServerConfig } from '@modules/server/server.config'; -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; - -/** - * Service to convert between internal and external ids. - * The external ids are the primary keys from the IDM (Keycloak), currently they are UUID formatted strings. - * The internal ids are the primary keys from the mongo db, currently they are BSON object ids or their hex string representation. - * IMPORTANT: This service will not guarantee that the id is valid, it will only try to convert it. - */ -@Injectable() -export class AccountLookupService { - constructor( - private readonly idmService: IdentityManagementService, - private readonly configService: ConfigService - ) {} - - /** - * Converts an external id to the internal id, if the id is already an internal id, it will be returned as is. - * IMPORTANT: This method will not guarantee that the id is valid, it will only try to convert it. - * @param id the id the should be converted to the internal id. - * @returns the converted id or null if conversion failed. - */ - async getInternalId(id: EntityId | ObjectId): Promise { - if (id instanceof ObjectId || ObjectId.isValid(id)) { - return new ObjectId(id); - } - if (this.configService.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') === true) { - const account = await this.idmService.findAccountById(id); - return new ObjectId(account.attDbcAccountId); - } - return null; - } - - /** - * Converts an internal id to the external id, if the id is already an external id, it will be returned as is. - * IMPORTANT: This method will not guarantee that the id is valid, it will only try to convert it. - * @param id the id the should be converted to the external id. - * @returns the converted id or null if conversion failed. - */ - async getExternalId(id: EntityId | ObjectId): Promise { - if (!(id instanceof ObjectId) && !ObjectId.isValid(id)) { - return id; - } - if (this.configService.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') === true) { - const account = await this.idmService.findAccountByDbcAccountId(id.toString()); - return account.id; - } - return null; - } -} diff --git a/apps/server/src/modules/account/services/account.service.abstract.ts b/apps/server/src/modules/account/services/account.service.abstract.ts index d027c30e3da..44229b4ec57 100644 --- a/apps/server/src/modules/account/services/account.service.abstract.ts +++ b/apps/server/src/modules/account/services/account.service.abstract.ts @@ -1,40 +1,49 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Counted, EntityId } from '@shared/domain/types'; -import { AccountDto, AccountSaveDto } from './dto'; +import { Account, AccountSave } from '../domain'; export abstract class AbstractAccountService { - abstract findById(id: EntityId): Promise; + /* + * The following methods are only needed by nest + */ + + abstract searchByUsernamePartialMatch(userName: string, skip: number, limit: number): Promise>; + + abstract validatePassword(account: Account, comparePassword: string): Promise; + /** + * @deprecated For migration purpose only + */ + abstract findMany(offset?: number, limit?: number): Promise; + + /* + * The following methods are also needed by feathers + */ + + abstract findById(id: EntityId): Promise; - abstract findMultipleByUserId(userIds: EntityId[]): Promise; + abstract findMultipleByUserId(userIds: EntityId[]): Promise; - abstract findByUserId(userId: EntityId): Promise; + abstract findByUserId(userId: EntityId): Promise; - abstract findByUserIdOrFail(userId: EntityId): Promise; + abstract findByUserIdOrFail(userId: EntityId): Promise; - abstract findByUsernameAndSystemId(username: string, systemId: EntityId | ObjectId): Promise; + // HINT: it would be preferable to use entityId here. Needs to be checked if this is blocked by lecacy code + abstract findByUsernameAndSystemId(username: string, systemId: EntityId | ObjectId): Promise; - abstract save(accountDto: AccountSaveDto): Promise; + abstract save(accountSave: AccountSave): Promise; - abstract updateUsername(accountId: EntityId, username: string): Promise; + abstract updateUsername(accountId: EntityId, username: string): Promise; /** * @deprecated Used for brute force detection, but will become subject to IDM thus be removed. */ - abstract updateLastTriedFailedLogin(accountId: EntityId, lastTriedFailedLogin: Date): Promise; + abstract updateLastTriedFailedLogin(accountId: EntityId, lastTriedFailedLogin: Date): Promise; - abstract updatePassword(accountId: EntityId, password: string): Promise; + abstract updatePassword(accountId: EntityId, password: string): Promise; abstract delete(id: EntityId): Promise; - abstract deleteByUserId(userId: EntityId): Promise; - - abstract searchByUsernamePartialMatch(userName: string, skip: number, limit: number): Promise>; - - abstract searchByUsernameExactMatch(userName: string): Promise>; + abstract deleteByUserId(userId: EntityId): Promise; - abstract validatePassword(account: AccountDto, comparePassword: string): Promise; - /** - * @deprecated For migration purpose only - */ - abstract findMany(offset?: number, limit?: number): Promise; + abstract searchByUsernameExactMatch(userName: string): Promise>; } diff --git a/apps/server/src/modules/account/services/account.service.integration.spec.ts b/apps/server/src/modules/account/services/account.service.integration.spec.ts index 6c7e0b810d8..af2899cb9d5 100644 --- a/apps/server/src/modules/account/services/account.service.integration.spec.ts +++ b/apps/server/src/modules/account/services/account.service.integration.spec.ts @@ -1,28 +1,26 @@ import { createMock } from '@golevelup/ts-jest'; import { MongoMemoryDatabaseModule } from '@infra/database'; -import { IdentityManagementModule } from '@infra/identity-management'; -import { IdentityManagementService } from '@infra/identity-management/identity-management.service'; +import { IdentityManagementModule, IdentityManagementService } from '@infra/identity-management'; import { KeycloakAdministrationService } from '@infra/identity-management/keycloak-administration/service/keycloak-administration.service'; import KeycloakAdminClient from '@keycloak/keycloak-admin-client-cjs/keycloak-admin-client-cjs-index'; -import { EntityManager } from '@mikro-orm/mongodb'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ConfigModule } from '@nestjs/config'; +import { EventBus } from '@nestjs/cqrs'; import { Test, TestingModule } from '@nestjs/testing'; -import { Account } from '@shared/domain/entity'; import { IdmAccount } from '@shared/domain/interface'; import { UserRepo } from '@shared/repo'; import { accountFactory, cleanupCollections } from '@shared/testing'; -import { ObjectId } from 'bson'; import { v1 } from 'uuid'; -import { LegacyLogger } from '../../../core/logger'; -import { AccountIdmToDtoMapper, AccountIdmToDtoMapperDb } from '../mapper'; +import { Logger } from '@src/core/logger'; +import { Account, AccountSave } from '../domain'; +import { AccountEntity } from '../entity/account.entity'; import { AccountRepo } from '../repo/account.repo'; +import { AccountIdmToDoMapper, AccountIdmToDoMapperDb } from '../repo/mapper'; import { AccountServiceDb } from './account-db.service'; import { AccountServiceIdm } from './account-idm.service'; -import { AccountLookupService } from './account-lookup.service'; import { AccountService } from './account.service'; import { AbstractAccountService } from './account.service.abstract'; import { AccountValidationService } from './account.validation.service'; -import { AccountDto, AccountSaveDto } from './dto'; describe('AccountService Integration', () => { let module: TestingModule; @@ -35,12 +33,12 @@ describe('AccountService Integration', () => { let isIdmReachable = true; const testRealm = `test-realm-${v1()}`; - const testAccount = new AccountSaveDto({ + const testAccount = { username: 'john.doe@mail.tld', password: 'super-secret-password', userId: new ObjectId().toString(), systemId: new ObjectId().toString(), - }); + } as AccountSave; const createDbAccount = async (): Promise => { const accountEntity = accountFactory.build({ @@ -95,14 +93,19 @@ describe('AccountService Integration', () => { AccountRepo, UserRepo, AccountValidationService, - AccountLookupService, { - provide: AccountIdmToDtoMapper, - useValue: new AccountIdmToDtoMapperDb(), + provide: AccountIdmToDoMapper, + useValue: new AccountIdmToDoMapperDb(), + }, + { + provide: Logger, + useValue: createMock(), }, { - provide: LegacyLogger, - useValue: createMock(), + provide: EventBus, + useValue: { + publish: jest.fn(), + }, }, ], }).compile(); @@ -135,7 +138,7 @@ describe('AccountService Integration', () => { await cleanupCollections(em); }); - const compareIdmAccount = async (idmId: string, createdAccount: AccountDto): Promise => { + const compareIdmAccount = async (idmId: string, createdAccount: Account): Promise => { const foundAccount = await identityManagementService.findAccountById(idmId); expect(foundAccount).toEqual( expect.objectContaining({ @@ -148,10 +151,10 @@ describe('AccountService Integration', () => { ); }; - const compareDbAccount = async (dbId: string, createdAccount: AccountDto): Promise => { + const compareDbAccount = async (dbId: string, createdAccount: Account): Promise => { const foundDbAccount = await accountRepo.findById(dbId); expect(foundDbAccount).toEqual( - expect.objectContaining>({ + expect.objectContaining>({ username: createdAccount.username, userId: new ObjectId(createdAccount.userId), systemId: new ObjectId(createdAccount.systemId), @@ -159,95 +162,151 @@ describe('AccountService Integration', () => { ); }; - it('save should create a new account', async () => { - if (!isIdmReachable) return; - const account = await accountService.save(testAccount); - await compareDbAccount(account.id, account); - await compareIdmAccount(account.idmReferenceId ?? '', account); - }); + describe('save', () => { + describe('when account not exists', () => { + it('should create a new account', async () => { + if (!isIdmReachable) return; + const account = await accountService.save(testAccount); + await compareDbAccount(account.id, account); + await compareIdmAccount(account.idmReferenceId ?? '', account); + }); + }); - it('save should update existing account', async () => { - if (!isIdmReachable) return; - const newUsername = 'jane.doe@mail.tld'; - const [dbId, idmId] = await createAccount(); - const originalAccount = await accountService.findById(dbId); - const updatedAccount = await accountService.save({ - ...originalAccount, - username: newUsername, + describe('when account exists', () => { + const setup = async () => { + const newUsername = 'jane.doe@mail.tld'; + const [dbId, idmId] = await createAccount(); + const originalAccount = await accountService.findById(dbId); + return { newUsername, dbId, idmId, originalAccount }; + }; + it('save should update existing account', async () => { + if (!isIdmReachable) return; + const { newUsername, dbId, idmId, originalAccount } = await setup(); + const updatedAccount = await accountService.save({ + ...originalAccount.getProps(), + username: newUsername, + } as AccountSave); + await compareDbAccount(dbId, updatedAccount); + await compareIdmAccount(idmId, updatedAccount); + }); }); - await compareDbAccount(dbId, updatedAccount); - await compareIdmAccount(idmId, updatedAccount); - }); - it('save should create idm account for existing db account', async () => { - if (!isIdmReachable) return; - const newUsername = 'jane.doe@mail.tld'; - const dbId = await createDbAccount(); - const originalAccount = await accountService.findById(dbId); - const updatedAccount = await accountService.save({ - ...originalAccount, - username: newUsername, + describe('when only db account exists', () => { + const setup = async () => { + const newUsername = 'jane.doe@mail.tld'; + const dbId = await createDbAccount(); + const originalAccount = await accountService.findById(dbId); + return { newUsername, dbId, originalAccount }; + }; + it('should create idm account for existing db account', async () => { + if (!isIdmReachable) return; + const { newUsername, dbId, originalAccount } = await setup(); + + const updatedAccount = await accountService.save({ + ...originalAccount.getProps(), + username: newUsername, + } as AccountSave); + await compareDbAccount(dbId, updatedAccount); + await compareIdmAccount(updatedAccount.idmReferenceId ?? '', updatedAccount); + }); }); - await compareDbAccount(dbId, updatedAccount); - await compareIdmAccount(updatedAccount.idmReferenceId ?? '', updatedAccount); }); - it('updateUsername should update username', async () => { - if (!isIdmReachable) return; - const newUserName = 'jane.doe@mail.tld'; - const [dbId, idmId] = await createAccount(); - await accountService.updateUsername(dbId, newUserName); + describe('updateUsername', () => { + describe('when updating Username', () => { + const setup = async () => { + const newUsername = 'jane.doe@mail.tld'; + const [dbId, idmId] = await createAccount(); - const foundAccount = await identityManagementService.findAccountById(idmId); - expect(foundAccount).toEqual( - expect.objectContaining>({ - username: newUserName, - }) - ); - const foundDbAccount = await accountRepo.findById(dbId); - expect(foundDbAccount).toEqual( - expect.objectContaining>({ - username: newUserName, - }) - ); + return { newUsername, dbId, idmId }; + }; + it('should update username', async () => { + if (!isIdmReachable) return; + const { newUsername, dbId, idmId } = await setup(); + + await accountService.updateUsername(dbId, newUsername); + const foundAccount = await identityManagementService.findAccountById(idmId); + const foundDbAccount = await accountRepo.findById(dbId); + + expect(foundAccount).toEqual( + expect.objectContaining>({ + username: newUsername, + }) + ); + expect(foundDbAccount).toEqual( + expect.objectContaining>({ + username: newUsername, + }) + ); + }); + }); }); - it('updatePassword should update password', async () => { - if (!isIdmReachable) return; - const [dbId] = await createAccount(); + describe('updatePassword', () => { + describe('when updating password', () => { + const setup = async () => { + const [dbId] = await createAccount(); - const foundDbAccountBefore = await accountRepo.findById(dbId); - const previousPasswordHash = foundDbAccountBefore.password; + const foundDbAccountBefore = await accountRepo.findById(dbId); + const previousPasswordHash = foundDbAccountBefore.password; + const foundDbAccountAfter = await accountRepo.findById(dbId); - await expect(accountService.updatePassword(dbId, 'newPassword')).resolves.not.toThrow(); + return { dbId, previousPasswordHash, foundDbAccountAfter }; + }; + it('should update password', async () => { + if (!isIdmReachable) return; + const { dbId, previousPasswordHash, foundDbAccountAfter } = await setup(); - const foundDbAccountAfter = await accountRepo.findById(dbId); - expect(foundDbAccountAfter.password).not.toEqual(previousPasswordHash); + await expect(accountService.updatePassword(dbId, 'newPassword')).resolves.not.toThrow(); + + expect(foundDbAccountAfter.password).not.toEqual(previousPasswordHash); + }); + }); }); - it('delete should remove account', async () => { - if (!isIdmReachable) return; - const [dbId, idmId] = await createAccount(); - const foundIdmAccount = await identityManagementService.findAccountById(idmId); - expect(foundIdmAccount).toBeDefined(); - const foundDbAccount = await accountRepo.findById(dbId); - expect(foundDbAccount).toBeDefined(); + describe('delete', () => { + describe('when delete an account', () => { + const setup = async () => { + const [dbId, idmId] = await createAccount(); + const foundIdmAccount = await identityManagementService.findAccountById(idmId); + const foundDbAccount = await accountRepo.findById(dbId); + + return { dbId, idmId, foundIdmAccount, foundDbAccount }; + }; + it('should remove account', async () => { + if (!isIdmReachable) return; + const { dbId, idmId, foundIdmAccount, foundDbAccount } = await setup(); - await accountService.delete(dbId); - await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); - await expect(accountRepo.findById(dbId)).rejects.toThrow(); + expect(foundIdmAccount).toBeDefined(); + expect(foundDbAccount).toBeDefined(); + + await accountService.delete(dbId); + await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + await expect(accountRepo.findById(dbId)).rejects.toThrow(); + }); + }); }); - it('deleteByUserId should remove account', async () => { - if (!isIdmReachable) return; - const [dbId, idmId] = await createAccount(); - const foundAccount = await identityManagementService.findAccountById(idmId); - expect(foundAccount).toBeDefined(); - const foundDbAccount = await accountRepo.findById(dbId); - expect(foundDbAccount).toBeDefined(); + describe('deleteByUserId', () => { + describe('when delete an account by User Id', () => { + const setup = async () => { + const [dbId, idmId] = await createAccount(); + const foundIdmAccount = await identityManagementService.findAccountById(idmId); + const foundDbAccount = await accountRepo.findById(dbId); - await accountService.deleteByUserId(testAccount.userId ?? ''); - await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); - await expect(accountRepo.findById(dbId)).rejects.toThrow(); + return { dbId, idmId, foundIdmAccount, foundDbAccount }; + }; + it('should remove account', async () => { + if (!isIdmReachable) return; + const { dbId, idmId, foundIdmAccount, foundDbAccount } = await setup(); + + expect(foundIdmAccount).toBeDefined(); + expect(foundDbAccount).toBeDefined(); + + await accountService.deleteByUserId(testAccount.userId ?? ''); + await expect(identityManagementService.findAccountById(idmId)).rejects.toThrow(); + await expect(accountRepo.findById(dbId)).rejects.toThrow(); + }); + }); }); }); diff --git a/apps/server/src/modules/account/services/account.service.spec.ts b/apps/server/src/modules/account/services/account.service.spec.ts index 1d7c7d43398..6aa94aa8a6f 100644 --- a/apps/server/src/modules/account/services/account.service.spec.ts +++ b/apps/server/src/modules/account/services/account.service.spec.ts @@ -1,13 +1,32 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ServerConfig } from '@modules/server'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { LegacyLogger } from '../../../core/logger'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationError, EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; +import { User } from '@shared/domain/entity'; +import { UserRepo } from '@shared/repo'; +import { accountFactory, schoolEntityFactory, setupEntities, systemFactory, userFactory } from '@shared/testing'; +import 'reflect-metadata'; +import { EventBus } from '@nestjs/cqrs'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DataDeletedEvent, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; +import { Logger } from '@src/core/logger'; +import { AccountConfig } from '../account-config'; +import { Account, AccountSave, UpdateAccount } from '../domain'; +import { AccountEntity } from '../entity/account.entity'; +import { AccountEntityToDoMapper } from '../repo/mapper'; import { AccountServiceDb } from './account-db.service'; import { AccountServiceIdm } from './account-idm.service'; import { AccountService } from './account.service'; import { AccountValidationService } from './account.validation.service'; -import { AccountDto, AccountSaveDto } from './dto'; +import { IdmCallbackLoggableException } from '../loggable'; +import { AccountRepo } from '../repo/account.repo'; describe('AccountService', () => { let module: TestingModule; @@ -16,7 +35,26 @@ describe('AccountService', () => { let accountServiceDb: DeepMocked; let accountValidationService: DeepMocked; let configService: DeepMocked; - let logger: DeepMocked; + let logger: DeepMocked; + let userRepo: DeepMocked; + let accountRepo: DeepMocked; + let eventBus: DeepMocked; + + const newAccountService = () => + new AccountService( + accountServiceDb, + accountServiceIdm, + configService, + accountValidationService, + logger, + userRepo, + accountRepo, + eventBus + ); + + const defaultPassword = 'DummyPasswd!1'; + const otherPassword = 'DummyPasswd!2'; + const defaultPasswordHash = '$2a$1$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; afterAll(async () => { await module.close(); @@ -35,12 +73,16 @@ describe('AccountService', () => { useValue: createMock(), }, { - provide: LegacyLogger, - useValue: createMock(), + provide: Logger, + useValue: createMock(), }, { provide: ConfigService, - useValue: createMock>(), + useValue: createMock>(), + }, + { + provide: AccountRepo, + useValue: createMock(), }, { provide: AccountValidationService, @@ -48,6 +90,16 @@ describe('AccountService', () => { isUniqueEmail: jest.fn().mockResolvedValue(true), }, }, + { + provide: UserRepo, + useValue: createMock(), + }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); accountServiceDb = module.get(AccountServiceDb); @@ -55,411 +107,1889 @@ describe('AccountService', () => { accountService = module.get(AccountService); accountValidationService = module.get(AccountValidationService); configService = module.get(ConfigService); - logger = module.get(LegacyLogger); + logger = module.get(Logger); + userRepo = module.get(UserRepo); + accountRepo = module.get(AccountRepo); + eventBus = module.get(EventBus); + + await setupEntities(); }); beforeEach(() => { jest.clearAllMocks(); + jest.restoreAllMocks(); + jest.resetAllMocks(); + jest.resetModules(); }); describe('findById', () => { - it('should call findById in accountServiceDb', async () => { - await expect(accountService.findById('id')).resolves.not.toThrow(); - expect(accountServiceDb.findById).toHaveBeenCalledTimes(1); + describe('When calling findById in accountService', () => { + it('should call findById in accountServiceDb', async () => { + await expect(accountService.findById('id')).resolves.not.toThrow(); + expect(accountServiceDb.findById).toHaveBeenCalledTimes(1); + }); }); - }); - describe('findByUserId', () => { - it('should call findByUserId in accountServiceDb', async () => { - await expect(accountService.findByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceDb.findByUserId).toHaveBeenCalledTimes(1); - }); - }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return newAccountService(); + }; - describe('findByUsernameAndSystemId', () => { - it('should call findByUsernameAndSystemId in accountServiceDb', async () => { - await expect(accountService.findByUsernameAndSystemId('username', 'systemId')).resolves.not.toThrow(); - expect(accountServiceDb.findByUsernameAndSystemId).toHaveBeenCalledTimes(1); + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findById('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.findById).toHaveBeenCalledTimes(1); + }); }); }); - describe('findMultipleByUserId', () => { - it('should call findMultipleByUserId in accountServiceDb', async () => { - await expect(accountService.findMultipleByUserId(['userId1, userId2'])).resolves.not.toThrow(); - expect(accountServiceDb.findMultipleByUserId).toHaveBeenCalledTimes(1); + describe('findByUserId', () => { + describe('When calling findByUserId in accountService', () => { + it('should call findByUserId in accountServiceDb', async () => { + await expect(accountService.findByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceDb.findByUserId).toHaveBeenCalledTimes(1); + }); }); - }); - describe('findByUserIdOrFail', () => { - it('should call findByUserIdOrFail in accountServiceDb', async () => { - await expect(accountService.findByUserIdOrFail('userId')).resolves.not.toThrow(); - expect(accountServiceDb.findByUserIdOrFail).toHaveBeenCalledTimes(1); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return newAccountService(); + }; + + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.findByUserId).toHaveBeenCalledTimes(1); + }); }); }); - describe('save', () => { - it('should call save in accountServiceDb', async () => { - await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); - expect(accountServiceDb.save).toHaveBeenCalledTimes(1); + describe('findByUsernameAndSystemId', () => { + describe('When calling findByUsernameAndSystemId in accountService', () => { + it('should call findByUsernameAndSystemId in accountServiceDb', async () => { + await expect(accountService.findByUsernameAndSystemId('username', 'systemId')).resolves.not.toThrow(); + expect(accountServiceDb.findByUsernameAndSystemId).toHaveBeenCalledTimes(1); + }); }); - it('should call save in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); - expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); - }); - it('should not call save in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return newAccountService(); + }; - await expect(accountService.save({} as AccountSaveDto)).resolves.not.toThrow(); - expect(accountServiceIdm.save).not.toHaveBeenCalled(); + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findByUsernameAndSystemId('username', 'systemId')).resolves.not.toThrow(); + expect(accountServiceIdm.findByUsernameAndSystemId).toHaveBeenCalledTimes(1); + }); }); }); - describe('saveWithValidation', () => { - it('should not sanitize username for external user', async () => { - const spy = jest.spyOn(accountService, 'save'); - const params: AccountSaveDto = { - username: ' John.Doe@domain.tld ', - systemId: 'ABC123', - }; - await accountService.saveWithValidation(params); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - username: ' John.Doe@domain.tld ', - }) - ); - spy.mockRestore(); - }); - it('should throw if username for a local user is not an email', async () => { - const params: AccountSaveDto = { - username: 'John Doe', - password: 'JohnsPassword', - }; - await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username is not an email'); - }); - it('should not throw if username for an external user is not an email', async () => { - const params: AccountSaveDto = { - username: 'John Doe', - systemId: 'ABC123', - }; - await expect(accountService.saveWithValidation(params)).resolves.not.toThrow(); - }); - it('should not throw if username for an external user is a ldap search string', async () => { - const params: AccountSaveDto = { - username: 'dc=schul-cloud,dc=org/fake.ldap', - systemId: 'ABC123', - }; - await expect(accountService.saveWithValidation(params)).resolves.not.toThrow(); - }); - it('should throw if no password is provided for an internal user', async () => { - const params: AccountSaveDto = { - username: 'john.doe@mail.tld', - }; - await expect(accountService.saveWithValidation(params)).rejects.toThrow('No password provided'); - }); - it('should throw if account already exists', async () => { - const params: AccountSaveDto = { - username: 'john.doe@mail.tld', - password: 'JohnsPassword', - userId: 'userId123', - }; - accountServiceDb.findByUserId.mockResolvedValueOnce({ id: 'foundAccount123' } as AccountDto); - await expect(accountService.saveWithValidation(params)).rejects.toThrow('Account already exists'); + describe('findMultipleByUserId', () => { + describe('When calling findMultipleByUserId in accountService', () => { + it('should call findMultipleByUserId in accountServiceDb', async () => { + await expect(accountService.findMultipleByUserId(['userId1, userId2'])).resolves.not.toThrow(); + expect(accountServiceDb.findMultipleByUserId).toHaveBeenCalledTimes(1); + }); }); - it('should throw if username already exists', async () => { - const accountIsUniqueEmailSpy = jest.spyOn(accountValidationService, 'isUniqueEmail'); - accountIsUniqueEmailSpy.mockResolvedValueOnce(false); - const params: AccountSaveDto = { - username: 'john.doe@mail.tld', - password: 'JohnsPassword', + + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return newAccountService(); }; - await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username already exists'); + + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findMultipleByUserId(['userId'])).resolves.not.toThrow(); + expect(accountServiceIdm.findMultipleByUserId).toHaveBeenCalledTimes(1); + }); }); }); - describe('updateUsername', () => { - it('should call updateUsername in accountServiceDb', async () => { - await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); - expect(accountServiceDb.updateUsername).toHaveBeenCalledTimes(1); + describe('findByUserIdOrFail', () => { + describe('When calling findByUserIdOrFail in accountService', () => { + it('should call findByUserIdOrFail in accountServiceDb', async () => { + await expect(accountService.findByUserIdOrFail('userId')).resolves.not.toThrow(); + expect(accountServiceDb.findByUserIdOrFail).toHaveBeenCalledTimes(1); + }); }); - it('should call updateUsername in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); - expect(accountServiceIdm.updateUsername).toHaveBeenCalledTimes(1); - }); - it('should not call updateUsername in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return newAccountService(); + }; - await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); - expect(accountServiceIdm.updateUsername).not.toHaveBeenCalled(); + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findByUserIdOrFail('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.findByUserIdOrFail).toHaveBeenCalledTimes(1); + }); }); }); - describe('updateLastTriedFailedLogin', () => { - it('should call updateLastTriedFailedLogin in accountServiceDb', async () => { - await expect(accountService.updateLastTriedFailedLogin('accountId', {} as Date)).resolves.not.toThrow(); - expect(accountServiceDb.updateLastTriedFailedLogin).toHaveBeenCalledTimes(1); + describe('save', () => { + describe('When calling save in accountService', () => { + it('should call save in accountServiceDb', async () => { + await expect(accountService.save({} as Account)).resolves.not.toThrow(); + expect(accountServiceDb.save).toHaveBeenCalledTimes(1); + }); }); - }); - describe('updatePassword', () => { - it('should call updatePassword in accountServiceDb', async () => { - await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); - expect(accountServiceDb.updatePassword).toHaveBeenCalledTimes(1); - }); - it('should call updatePassword in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); + describe('When calling save in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValue(true); - await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); - expect(accountServiceIdm.updatePassword).toHaveBeenCalledTimes(1); - }); - it('should not call updatePassword in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); + accountServiceDb.save.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); - await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); - expect(accountServiceIdm.updatePassword).not.toHaveBeenCalled(); - }); - }); + return newAccountService(); + }; - describe('validatePassword', () => { - const setup = () => { - configService.get.mockReturnValue(true); - return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); - }; - it('should call validatePassword in accountServiceDb', async () => { - await expect(accountService.validatePassword({} as AccountDto, 'password')).resolves.not.toThrow(); - expect(accountServiceIdm.validatePassword).toHaveBeenCalledTimes(0); - expect(accountServiceDb.validatePassword).toHaveBeenCalledTimes(1); - }); - it('should call validatePassword in accountServiceIdm if feature is enabled', async () => { - const service = setup(); - await expect(service.validatePassword({} as AccountDto, 'password')).resolves.not.toThrow(); - expect(accountServiceDb.validatePassword).toHaveBeenCalledTimes(0); - expect(accountServiceIdm.validatePassword).toHaveBeenCalledTimes(1); - }); - }); + it('should call save in accountServiceIdm', async () => { + const service = setup(); - describe('delete', () => { - it('should call delete in accountServiceDb', async () => { - await expect(accountService.delete('accountId')).resolves.not.toThrow(); - expect(accountServiceDb.delete).toHaveBeenCalledTimes(1); + await expect(service.save({} as Account)).resolves.not.toThrow(); + expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + }); }); - it('should call delete in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - await expect(accountService.delete('accountId')).resolves.not.toThrow(); - expect(accountServiceIdm.delete).toHaveBeenCalledTimes(1); - }); - it('should not call delete in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); + describe('When calling save in accountService if feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); - await expect(accountService.delete('accountId')).resolves.not.toThrow(); - expect(accountServiceIdm.delete).not.toHaveBeenCalled(); - }); - }); + accountServiceDb.save.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); - describe('deleteByUserId', () => { - it('should call deleteByUserId in accountServiceDb', async () => { - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceDb.deleteByUserId).toHaveBeenCalledTimes(1); - }); - it('should call deleteByUserId in accountServiceIdm if feature is enabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); + return newAccountService(); + }; - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.deleteByUserId).toHaveBeenCalledTimes(1); - }); - it('should not call deleteByUserId in accountServiceIdm if feature is disabled', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(false); + it('should not call save in accountServiceIdm', async () => { + const service = setup(); - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.deleteByUserId).not.toHaveBeenCalled(); + await expect(service.save({} as Account)).resolves.not.toThrow(); + expect(accountServiceIdm.save).not.toHaveBeenCalled(); + }); }); - }); - describe('findMany', () => { - it('should call findMany in accountServiceDb', async () => { - await expect(accountService.findMany()).resolves.not.toThrow(); - expect(accountServiceDb.findMany).toHaveBeenCalledTimes(1); - }); - }); + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + accountServiceDb.save.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); - describe('searchByUsernamePartialMatch', () => { - it('should call searchByUsernamePartialMatch in accountServiceDb', async () => { - await expect(accountService.searchByUsernamePartialMatch('username', 1, 1)).resolves.not.toThrow(); - expect(accountServiceDb.searchByUsernamePartialMatch).toHaveBeenCalledTimes(1); + return newAccountService(); + }; + + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.save({ username: 'username' } as AccountSave)).resolves.not.toThrow(); + expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + }); }); }); - describe('searchByUsernameExactMatch', () => { - it('should call searchByUsernameExactMatch in accountServiceDb', async () => { - await expect(accountService.searchByUsernameExactMatch('username')).resolves.not.toThrow(); - expect(accountServiceDb.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + describe('saveWithValidation', () => { + describe('When calling with an empty username', () => { + it('should throw an ValidationError', async () => { + const params: AccountSave = { + username: '', + } as AccountSave; + await expect(accountService.saveWithValidation(params)).rejects.toThrow(ValidationError); + }); }); - }); - describe('executeIdmMethod', () => { - it('should throw an error object', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - const spyLogger = jest.spyOn(logger, 'error'); - const testError = new Error('error'); + describe('When calling saveWithValidation on accountService', () => { + const setup = () => { + const spy = jest.spyOn(accountService, 'save'); + + accountServiceDb.save.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + + return spy; + }; + + it('should not sanitize username for external user', async () => { + const spy = setup(); - const deleteByUserIdMock = jest.spyOn(accountServiceIdm, 'deleteByUserId'); - deleteByUserIdMock.mockImplementationOnce(() => { - throw testError; + const params: AccountSave = { + username: ' John.Doe@domain.tld ', + systemId: new ObjectId().toHexString(), + } as AccountSave; + await accountService.saveWithValidation(params); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + username: ' John.Doe@domain.tld ', + }) + ); + spy.mockRestore(); }); + }); - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(spyLogger).toHaveBeenCalledWith(testError, expect.anything()); + describe('When username for a local user is not an email', () => { + it('should throw username is not an email error', async () => { + const params: AccountSave = { + username: 'John Doe', + password: 'JohnsPassword_123', + } as AccountSave; + await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username is not an email'); + }); }); - it('should throw an non error object', async () => { - const spy = jest.spyOn(configService, 'get'); - spy.mockReturnValueOnce(true); - const spyLogger = jest.spyOn(logger, 'error'); + describe('When username for an external user is not an email', () => { + const setup = () => { + accountServiceDb.save.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + }; - const deleteByUserIdMock = jest.spyOn(accountServiceIdm, 'deleteByUserId'); - deleteByUserIdMock.mockImplementationOnce(() => { - // eslint-disable-next-line @typescript-eslint/no-throw-literal - throw 'a non error object'; + it('should not throw an error', async () => { + setup(); + const params: AccountSave = { + username: 'John Doe', + systemId: new ObjectId().toHexString(), + } as AccountSave; + await expect(accountService.saveWithValidation(params)).resolves.not.toThrow(); }); - - await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); - expect(spyLogger).toHaveBeenCalledWith('a non error object'); }); - }); - describe('when identity management is primary', () => { - const setup = () => { - configService.get.mockReturnValue(true); - return new AccountService(accountServiceDb, accountServiceIdm, configService, accountValidationService, logger); - }; + describe('When username for an external user is a ldap search string', () => { + const setup = () => { + accountServiceDb.save.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + }; - describe('findById', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.findById('accountId')).resolves.not.toThrow(); - expect(accountServiceIdm.findById).toHaveBeenCalledTimes(1); + it('should not throw an error', async () => { + setup(); + const params: AccountSave = { + username: 'dc=schul-cloud,dc=org/fake.ldap', + systemId: new ObjectId().toHexString(), + } as AccountSave; + await expect(accountService.saveWithValidation(params)).resolves.not.toThrow(); }); }); - describe('findMultipleByUserId', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.findMultipleByUserId(['userId'])).resolves.not.toThrow(); - expect(accountServiceIdm.findMultipleByUserId).toHaveBeenCalledTimes(1); + describe('When no password is provided for an internal user', () => { + it('should throw no password provided error', async () => { + const params: AccountSave = { + username: 'john.doe@mail.tld', + } as AccountSave; + await expect(accountService.saveWithValidation(params)).rejects.toThrow('No password provided'); }); }); - describe('findByUserId', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.findByUserId('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.findByUserId).toHaveBeenCalledTimes(1); + describe('When account already exists', () => { + it('should throw account already exists', async () => { + const params: AccountSave = { + username: 'john.doe@mail.tld', + password: 'JohnsPassword_123', + userId: new ObjectId().toHexString(), + } as AccountSave; + accountServiceDb.findByUserId.mockResolvedValueOnce({ id: 'foundAccount123' } as Account); + await expect(accountService.saveWithValidation(params)).rejects.toThrow('Account already exists'); }); }); - describe('findByUserIdOrFail', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.findByUserIdOrFail('userId')).resolves.not.toThrow(); - expect(accountServiceIdm.findByUserIdOrFail).toHaveBeenCalledTimes(1); + describe('When username already exists', () => { + const setup = () => { + accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + }; + + it('should throw username already exists', async () => { + setup(); + const params: AccountSave = { + username: 'john.doe@mail.tld', + password: 'JohnsPassword_123', + } as AccountSave; + await expect(accountService.saveWithValidation(params)).rejects.toThrow('Username already exists'); }); }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + accountServiceDb.save.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + + return newAccountService(); + }; - describe('findByUsernameAndSystemId', () => { it('should call idm implementation', async () => { const service = setup(); - await expect(service.findByUsernameAndSystemId('username', 'systemId')).resolves.not.toThrow(); - expect(accountServiceIdm.findByUsernameAndSystemId).toHaveBeenCalledTimes(1); + await expect( + service.saveWithValidation({ username: 'username@mail.tld', password: 'Password_123' } as AccountSave) + ).resolves.not.toThrow(); + expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); }); }); + }); - describe('searchByUsernamePartialMatch', () => { - it('should call idm implementation', async () => { - const service = setup(); - await expect(service.searchByUsernamePartialMatch('username', 0, 1)).resolves.not.toThrow(); - expect(accountServiceIdm.searchByUsernamePartialMatch).toHaveBeenCalledTimes(1); + describe('updateUsername', () => { + describe('When calling updateUsername in accountService', () => { + it('should call updateUsername in accountServiceDb', async () => { + await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); + expect(accountServiceDb.updateUsername).toHaveBeenCalledTimes(1); }); }); - describe('searchByUsernameExactMatch', () => { - it('should call idm implementation', async () => { + describe('When calling updateUsername in accountService if idm feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValue(true); + + accountServiceDb.updateUsername.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); + + return newAccountService(); + }; + + it('should call updateUsername in accountServiceIdm', async () => { const service = setup(); - await expect(service.searchByUsernameExactMatch('username')).resolves.not.toThrow(); - expect(accountServiceIdm.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + + await expect(service.updateUsername('accountId', 'username')).resolves.not.toThrow(); + expect(accountServiceIdm.updateUsername).toHaveBeenCalledTimes(1); }); }); - describe('save', () => { - it('should call idm implementation', async () => { - setup(); - await expect(accountService.save({ username: 'username' })).resolves.not.toThrow(); - expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + describe('When calling updateUsername in accountService if idm feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValue(false); + + accountServiceDb.updateUsername.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); + + return newAccountService(); + }; + + it('should not call updateUsername in accountServiceIdm', async () => { + const service = setup(); + + await expect(service.updateUsername('accountId', 'username')).resolves.not.toThrow(); + expect(accountServiceIdm.updateUsername).not.toHaveBeenCalled(); }); }); - describe('saveWithValidation', () => { + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + + accountServiceDb.updateUsername.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); + + return newAccountService(); + }; + it('should call idm implementation', async () => { - setup(); - await expect( - accountService.saveWithValidation({ username: 'username@mail.tld', password: 'password' }) - ).resolves.not.toThrow(); - expect(accountServiceIdm.save).toHaveBeenCalledTimes(1); + const service = setup(); + await expect(service.updateUsername('accountId', 'username')).resolves.not.toThrow(); + expect(accountServiceIdm.updateUsername).toHaveBeenCalledTimes(1); }); }); + }); - describe('updateUsername', () => { - it('should call idm implementation', async () => { - setup(); - await expect(accountService.updateUsername('accountId', 'username')).resolves.not.toThrow(); - expect(accountServiceIdm.updateUsername).toHaveBeenCalledTimes(1); + describe('updateLastTriedFailedLogin', () => { + describe('When calling updateLastTriedFailedLogin in accountService', () => { + it('should call updateLastTriedFailedLogin in accountServiceDb', async () => { + await expect(accountService.updateLastTriedFailedLogin('accountId', {} as Date)).resolves.not.toThrow(); + expect(accountServiceDb.updateLastTriedFailedLogin).toHaveBeenCalledTimes(1); }); }); - describe('updateLastTriedFailedLogin', () => { + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + + accountServiceDb.updateLastTriedFailedLogin.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); + + return newAccountService(); + }; + it('should call idm implementation', async () => { - setup(); - await expect(accountService.updateLastTriedFailedLogin('accountId', new Date())).resolves.not.toThrow(); + const service = setup(); + await expect(service.updateLastTriedFailedLogin('accountId', new Date())).resolves.not.toThrow(); expect(accountServiceIdm.updateLastTriedFailedLogin).toHaveBeenCalledTimes(1); }); }); + }); - describe('updatePassword', () => { - it('should call idm implementation', async () => { - setup(); + describe('updatePassword', () => { + describe('When calling updatePassword in accountService', () => { + it('should call updatePassword in accountServiceDb', async () => { await expect(accountService.updatePassword('accountId', 'password')).resolves.not.toThrow(); + expect(accountServiceDb.updatePassword).toHaveBeenCalledTimes(1); + }); + }); + + describe('When calling updatePassword in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValue(true); + + accountServiceDb.updatePassword.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); + + return newAccountService(); + }; + + it('should call updatePassword in accountServiceIdm', async () => { + const service = setup(); + + await expect(service.updatePassword('accountId', 'password')).resolves.not.toThrow(); expect(accountServiceIdm.updatePassword).toHaveBeenCalledTimes(1); }); }); - describe('delete', () => { - it('should call idm implementation', async () => { - setup(); - await expect(accountService.delete('accountId')).resolves.not.toThrow(); - expect(accountServiceIdm.delete).toHaveBeenCalledTimes(1); + describe('When calling updatePassword in accountService if feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValue(false); + + accountServiceDb.updatePassword.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); + + return newAccountService(); + }; + + it('should not call updatePassword in accountServiceIdm', async () => { + const service = setup(); + + await expect(service.updatePassword('accountId', 'password')).resolves.not.toThrow(); + expect(accountServiceIdm.updatePassword).not.toHaveBeenCalled(); }); }); + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); - describe('deleteByUserId', () => { - it('should call idm implementation', async () => { + accountServiceDb.updatePassword.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); + + return newAccountService(); + }; + + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.updatePassword('accountId', 'password')).resolves.not.toThrow(); + expect(accountServiceIdm.updatePassword).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('validatePassword', () => { + it('should call validatePassword in accountServiceDb', async () => { + await expect(accountService.validatePassword({} as Account, 'password')).resolves.not.toThrow(); + expect(accountServiceIdm.validatePassword).toHaveBeenCalledTimes(0); + expect(accountServiceDb.validatePassword).toHaveBeenCalledTimes(1); + }); + + describe('When calling validatePassword in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return newAccountService(); + }; + + it('should call validatePassword in accountServiceIdm', async () => { + const service = setup(); + await expect(service.validatePassword({} as Account, 'password')).resolves.not.toThrow(); + expect(accountServiceDb.validatePassword).toHaveBeenCalledTimes(0); + expect(accountServiceIdm.validatePassword).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('delete', () => { + describe('When calling delete in accountService', () => { + it('should call delete in accountServiceDb', async () => { + await expect(accountService.delete('accountId')).resolves.not.toThrow(); + expect(accountServiceDb.delete).toHaveBeenCalledTimes(1); + }); + }); + + describe('When calling delete in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + }; + + it('should call delete in accountServiceIdm', async () => { + setup(); + + await expect(accountService.delete('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.delete).toHaveBeenCalledTimes(1); + }); + }); + + describe('When calling delete in accountService if feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); + }; + + it('should not call delete in accountServiceIdm', async () => { + setup(); + + await expect(accountService.delete('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.delete).not.toHaveBeenCalled(); + }); + }); + + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return newAccountService(); + }; + + it('should call idm implementation', async () => { + setup(); + await expect(accountService.delete('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.delete).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('deleteByUserId', () => { + describe('When calling deleteByUserId in accountService', () => { + it('should call deleteByUserId in accountServiceDb', async () => { + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceDb.deleteByUserId).toHaveBeenCalledTimes(1); + }); + }); + + describe('When calling deleteByUserId in accountService if feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + accountServiceDb.deleteByUserId.mockResolvedValueOnce(['accountId']); + accountServiceIdm.deleteByUserId.mockResolvedValueOnce(['accountId']); + }; + + it('should call deleteByUserId in accountServiceIdm', async () => { + setup(); + + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.deleteByUserId).toHaveBeenCalledTimes(1); + }); + }); + + describe('When calling deleteByUserId in accountService if feature is disabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(false); + }; + + it('should not call deleteByUserId in accountServiceIdm', async () => { + setup(); + + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(accountServiceIdm.deleteByUserId).not.toHaveBeenCalled(); + }); + }); + + describe('When identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return newAccountService(); + }; + + it('should call idm implementation', async () => { setup(); await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); expect(accountServiceIdm.deleteByUserId).toHaveBeenCalledTimes(1); }); }); }); + + describe('deleteAccountByUserId', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const accountId = new ObjectId().toHexString(); + const spy = jest.spyOn(accountService, 'deleteByUserId'); + + const expectedResult = DomainDeletionReportBuilder.build(DomainName.ACCOUNT, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [accountId]), + ]); + + return { accountId, expectedResult, spy, userId }; + }; + + it('should call deleteByUserId in accountService', async () => { + const { spy, userId } = setup(); + + spy.mockResolvedValueOnce([]); + + await accountService.deleteUserData(userId); + expect(spy).toHaveBeenCalledWith(userId); + spy.mockRestore(); + }); + + it('should call deleteByUserId in accountService', async () => { + const { accountId, expectedResult, spy, userId } = setup(); + + spy.mockResolvedValueOnce([accountId]); + + const result = await accountService.deleteUserData(userId); + expect(spy).toHaveBeenCalledWith(userId); + expect(result).toEqual(expectedResult); + spy.mockRestore(); + }); + }); + + describe('findMany', () => { + describe('When calling findMany in accountService', () => { + it('should call findMany in accountServiceDb', async () => { + await expect(accountService.findMany()).resolves.not.toThrow(); + expect(accountServiceDb.findMany).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('searchByUsernamePartialMatch', () => { + describe('When calling searchByUsernamePartialMatch in accountService', () => { + it('should call searchByUsernamePartialMatch in accountServiceDb', async () => { + await expect(accountService.searchByUsernamePartialMatch('username', 1, 1)).resolves.not.toThrow(); + expect(accountServiceDb.searchByUsernamePartialMatch).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('searchByUsernameExactMatch', () => { + it('should call searchByUsernameExactMatch in accountServiceDb', async () => { + await expect(accountService.searchByUsernameExactMatch('username')).resolves.not.toThrow(); + expect(accountServiceDb.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + }); + }); + + describe('when identity management is primary', () => { + describe('findById', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return newAccountService(); + }; + + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.findById('accountId')).resolves.not.toThrow(); + expect(accountServiceIdm.findById).toHaveBeenCalledTimes(1); + }); + }); + + describe('searchByUsernamePartialMatch', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return newAccountService(); + }; + + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.searchByUsernamePartialMatch('username', 0, 1)).resolves.not.toThrow(); + expect(accountServiceIdm.searchByUsernamePartialMatch).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('searchByUsernameExactMatch', () => { + describe('When calling searchByUsernameExactMatch in accountService', () => { + it('should call searchByUsernameExactMatch in accountServiceDb', async () => { + await expect(accountService.searchByUsernameExactMatch('username')).resolves.not.toThrow(); + expect(accountServiceDb.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + }); + }); + + describe('when identity management is primary', () => { + const setup = () => { + configService.get.mockReturnValue(true); + return newAccountService(); + }; + + it('should call idm implementation', async () => { + const service = setup(); + await expect(service.searchByUsernameExactMatch('username')).resolves.not.toThrow(); + expect(accountServiceIdm.searchByUsernameExactMatch).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('executeIdmMethod', () => { + describe('When idm feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + const testError = new Error('error'); + accountServiceIdm.deleteByUserId.mockImplementationOnce(() => { + throw testError; + }); + + const spyLogger = jest.spyOn(logger, 'debug'); + + return { testError, spyLogger }; + }; + + it('should call executeIdmMethod and throw an error object', async () => { + const { testError, spyLogger } = setup(); + + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(spyLogger).toHaveBeenCalledWith(new IdmCallbackLoggableException(testError)); + }); + }); + + describe('When idm feature is enabled', () => { + const setup = () => { + configService.get.mockReturnValueOnce(true); + const spyLogger = jest.spyOn(logger, 'debug'); + const deleteByUserIdMock = jest.spyOn(accountServiceIdm, 'deleteByUserId'); + deleteByUserIdMock.mockImplementationOnce(() => { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw 'a non error object'; + }); + return { spyLogger }; + }; + + it('should call executeIdmMethod and throw a non error object', async () => { + const { spyLogger } = setup(); + + await expect(accountService.deleteByUserId('userId')).resolves.not.toThrow(); + expect(spyLogger).toHaveBeenCalledWith(new IdmCallbackLoggableException('a non error object')); + }); + }); + }); + + describe('updateMyAccount', () => { + describe('When account is external', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockExternalUser = userFactory.buildWithId({ + school: mockSchool, + }); + const externalSystem = systemFactory.build(); + const mockExternalUserAccount = accountFactory.build({ + userId: mockExternalUser.id, + password: defaultPasswordHash, + systemId: externalSystem.id, + }); + + const mockExternalAccount: Account = AccountEntityToDoMapper.mapToDo(mockExternalUserAccount); + + accountServiceDb.findByUserIdOrFail.mockResolvedValueOnce(mockExternalAccount); + + return { mockExternalUser, mockExternalAccount }; + }; + + it('should throw ForbiddenOperationError', async () => { + const { mockExternalUser, mockExternalAccount } = setup(); + + await expect( + accountService.updateMyAccount(mockExternalUser, mockExternalAccount, { + passwordOld: defaultPassword, + }) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When password does not match', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccountEntity = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccount: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccountEntity); + + accountServiceDb.findByUserIdOrFail.mockResolvedValueOnce(mockStudentAccount); + accountServiceDb.validatePassword.mockResolvedValueOnce(false); + + return { mockStudentUser, mockStudentAccount }; + }; + + it('should throw AuthorizationError', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + await expect( + accountService.updateMyAccount(mockStudentUser, mockStudentAccount, { + passwordOld: 'DoesNotMatch', + }) + ).rejects.toThrow(AuthorizationError); + }); + }); + + describe('When new password is given', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + accountServiceDb.validatePassword.mockResolvedValue(true); + accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); + + return { mockStudentUser, mockStudentAccountDo }; + }; + + it('should allow to update with strong password', async () => { + const { mockStudentUser, mockStudentAccountDo } = setup(); + await expect( + accountService.updateMyAccount(mockStudentUser, mockStudentAccountDo, { + passwordOld: defaultPassword, + passwordNew: otherPassword, + }) + ).resolves.not.toThrow(); + }); + }); + + describe('When no new password is given', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + accountServiceDb.validatePassword.mockResolvedValue(true); + accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); + const spyAccountServiceSave = jest.spyOn(accountServiceDb, 'save'); + + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentUser, mockStudentAccountDo, spyAccountServiceSave }; + }; + + it('should not update password', async () => { + const { mockStudentUser, mockStudentAccountDo, spyAccountServiceSave } = setup(); + await accountService.updateMyAccount(mockStudentUser, mockStudentAccountDo, { + passwordOld: defaultPassword, + passwordNew: undefined, + email: 'newemail@to.update', + }); + expect(spyAccountServiceSave).toHaveBeenCalledWith( + expect.objectContaining({ + password: undefined, + }) + ); + }); + }); + + describe('When a new email is given', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + accountServiceDb.validatePassword.mockResolvedValue(true); + accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentUser, mockStudentAccountDo }; + }; + + it('should allow to update email', async () => { + const { mockStudentUser, mockStudentAccountDo } = setup(); + await expect( + accountService.updateMyAccount(mockStudentUser, mockStudentAccountDo, { + passwordOld: defaultPassword, + email: 'an@available.mail', + }) + ).resolves.not.toThrow(); + }); + }); + + describe('When email is not lowercase', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + accountServiceDb.validatePassword.mockResolvedValue(true); + + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); + const accountSaveSpy = jest.spyOn(accountServiceDb, 'save'); + + return { mockStudentUser, mockStudentAccountDo, accountSaveSpy }; + }; + + it('should use email as account user name in lower case', async () => { + const { mockStudentUser, mockStudentAccountDo, accountSaveSpy } = setup(); + + const testMail = 'AN@AVAILABLE.MAIL'; + await expect( + accountService.updateMyAccount(mockStudentUser, mockStudentAccountDo, { + passwordOld: defaultPassword, + email: testMail, + }) + ).resolves.not.toThrow(); + expect(accountSaveSpy).toBeCalledWith(expect.objectContaining({ username: testMail.toLowerCase() })); + }); + }); + + describe('When email is not lowercase', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + accountServiceDb.validatePassword.mockResolvedValue(true); + accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + + const userUpdateSpy = jest.spyOn(userRepo, 'save'); + + return { mockStudentUser, mockStudentAccountDo, userUpdateSpy }; + }; + + it('should use email as user email in lower case', async () => { + const { mockStudentUser, mockStudentAccountDo, userUpdateSpy } = setup(); + const testMail = 'AN@AVAILABLE.MAIL'; + await expect( + accountService.updateMyAccount(mockStudentUser, mockStudentAccountDo, { + passwordOld: defaultPassword, + email: testMail, + }) + ).resolves.not.toThrow(); + expect(userUpdateSpy).toBeCalledWith(expect.objectContaining({ email: testMail.toLowerCase() })); + }); + }); + + describe('When email is given', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + accountServiceDb.validatePassword.mockResolvedValue(true); + accountServiceDb.save.mockResolvedValue(mockStudentAccountDo); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(true); + + const accountSaveSpy = jest.spyOn(accountServiceDb, 'save'); + const userUpdateSpy = jest.spyOn(userRepo, 'save'); + + return { mockStudentUser, mockStudentAccountDo, accountSaveSpy, userUpdateSpy }; + }; + + it('should always update account user name AND user email together.', async () => { + const { mockStudentUser, mockStudentAccountDo, accountSaveSpy, userUpdateSpy } = setup(); + const testMail = 'an@available.mail'; + await expect( + accountService.updateMyAccount(mockStudentUser, mockStudentAccountDo, { + passwordOld: defaultPassword, + email: testMail, + }) + ).resolves.not.toThrow(); + expect(userUpdateSpy).toBeCalledWith(expect.objectContaining({ email: testMail.toLowerCase() })); + expect(accountSaveSpy).toBeCalledWith(expect.objectContaining({ username: testMail.toLowerCase() })); + }); + }); + + describe('When email is already in use', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + accountServiceDb.validatePassword.mockResolvedValue(true); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + + return { mockStudentUser, mockStudentAccountDo }; + }; + + it('should throw ValidationError', async () => { + const { mockStudentUser, mockStudentAccountDo } = setup(); + await expect( + accountService.updateMyAccount(mockStudentUser, mockStudentAccountDo, { + passwordOld: defaultPassword, + email: 'already@in.use', + }) + ).rejects.toThrow(ValidationError); + }); + }); + + describe('When using teacher user', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + }); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); + const mockTeacherAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockTeacherAccount); + + accountServiceDb.validatePassword.mockResolvedValue(true); + + return { mockTeacherUser, mockTeacherAccountDo }; + }; + + it('should allow to update first and last name', async () => { + const { mockTeacherUser, mockTeacherAccountDo } = setup(); + await expect( + accountService.updateMyAccount(mockTeacherUser, mockTeacherAccountDo, { + passwordOld: defaultPassword, + firstName: 'newFirstName', + }) + ).resolves.not.toThrow(); + await expect( + accountService.updateMyAccount(mockTeacherUser, mockTeacherAccountDo, { + passwordOld: defaultPassword, + lastName: 'newLastName', + }) + ).resolves.not.toThrow(); + }); + }); + + describe('When user can not be updated', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); + + const mockTeacherAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockTeacherAccount); + + userRepo.save.mockRejectedValueOnce(undefined); + accountServiceDb.validatePassword.mockResolvedValue(true); + + return { mockTeacherUser, mockTeacherAccountDo }; + }; + + it('should throw EntityNotFoundError', async () => { + const { mockTeacherUser, mockTeacherAccountDo } = setup(); + await expect( + accountService.updateMyAccount(mockTeacherUser, mockTeacherAccountDo, { + passwordOld: defaultPassword, + firstName: 'failToUpdate', + }) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + + describe('When account can not be updated', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + userRepo.save.mockResolvedValueOnce(undefined); + accountServiceDb.validatePassword.mockResolvedValue(true); + accountServiceDb.save.mockRejectedValueOnce(undefined); + + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentUser, mockStudentAccountDo }; + }; + + it('should throw EntityNotFoundError', async () => { + const { mockStudentUser, mockStudentAccountDo } = setup(); + await expect( + accountService.updateMyAccount(mockStudentUser, mockStudentAccountDo, { + passwordOld: defaultPassword, + email: 'fail@to.update', + }) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + }); + + describe('updateAccount', () => { + describe('When new password is given', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + userRepo.save.mockResolvedValue(); + accountServiceDb.save.mockImplementation((account: AccountSave): Promise => { + Object.assign(mockStudentAccount, account.getProps()); + return Promise.resolve(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + }); + + return { mockStudentAccount, mockStudentAccountDo, mockStudentUser }; + }; + + it('should update target account password', async () => { + const { mockStudentAccount, mockStudentAccountDo, mockStudentUser } = setup(); + const previousPasswordHash = mockStudentAccount.password; + const body = { password: defaultPassword } as UpdateAccount; + + expect(mockStudentUser.forcePasswordChange).toBeFalsy(); + await accountService.updateAccount(mockStudentUser, mockStudentAccountDo, body); + expect(mockStudentAccount.password).not.toBe(previousPasswordHash); + expect(mockStudentUser.forcePasswordChange).toBeTruthy(); + }); + }); + + describe('When username is given', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + userRepo.save.mockResolvedValue(); + accountServiceDb.save.mockImplementation((account: AccountSave): Promise => { + Object.assign(mockStudentAccount, account.getProps()); + return Promise.resolve(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + }); + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentUser, mockStudentAccountDo }; + }; + + it('should update target account username', async () => { + const { mockStudentUser, mockStudentAccountDo } = setup(); + const newUsername = 'newUsername'; + const body = { username: newUsername } as UpdateAccount; + + expect(mockStudentAccountDo.username).not.toBe(newUsername); + await accountService.updateAccount(mockStudentUser, mockStudentAccountDo, body); + expect(mockStudentAccountDo.username).toBe(newUsername.toLowerCase()); + }); + }); + + describe('When activated flag is given', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + userRepo.save.mockResolvedValue(); + accountServiceDb.save.mockImplementation((account: AccountSave): Promise => { + Object.assign(mockStudentAccount, account); + return Promise.resolve(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + }); + + return { mockStudentUser, mockStudentAccountDo }; + }; + + it('should update target account activation state', async () => { + const { mockStudentUser, mockStudentAccountDo } = setup(); + const body = { activated: false } as UpdateAccount; + + await accountService.updateAccount(mockStudentUser, mockStudentAccountDo, body); + expect(mockStudentAccountDo.activated).toBeFalsy(); + }); + }); + + describe('When account can not be updated', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + userRepo.save.mockResolvedValue(); + accountServiceDb.save.mockRejectedValueOnce(undefined); + + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentUser, mockStudentAccountDo }; + }; + + it('should throw EntityNotFoundError', async () => { + const { mockStudentUser, mockStudentAccountDo } = setup(); + const body = { username: 'fail@to.update' } as UpdateAccount; + + await expect(accountService.updateAccount(mockStudentUser, mockStudentAccountDo, body)).rejects.toThrow( + EntityNotFoundError + ); + }); + }); + + describe('When user can not be updated', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + userRepo.save.mockRejectedValueOnce(undefined); + + accountValidationService.isUniqueEmail.mockResolvedValue(true); + + return { mockStudentUser, mockStudentAccountDo }; + }; + + it('should throw EntityNotFoundError', async () => { + const { mockStudentUser, mockStudentAccountDo } = setup(); + const body = { username: 'user-fail@to.update' } as UpdateAccount; + + await expect(accountService.updateAccount(mockStudentUser, mockStudentAccountDo, body)).rejects.toThrow( + EntityNotFoundError + ); + }); + }); + + describe('When Account is not updated', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + return { mockStudentUser, mockStudentAccountDo }; + }; + + it('should return target account', async () => { + const { mockStudentUser, mockStudentAccountDo } = setup(); + const body = {} as UpdateAccount; + const result = await accountService.updateAccount(mockStudentUser, mockStudentAccountDo, body); + + expect(result).toBe(mockStudentAccountDo); + }); + }); + + describe('When new username already in use', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockOtherTeacherUser = userFactory.buildWithId({ + school: mockSchool, + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + }); + + const mockOtherTeacherAccount = accountFactory.buildWithId({ + userId: mockOtherTeacherUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockStudentAccountDo: Account = AccountEntityToDoMapper.mapToDo(mockStudentAccount); + + userRepo.save.mockRejectedValueOnce(undefined); + accountValidationService.isUniqueEmail.mockResolvedValueOnce(false); + + return { mockStudentUser, mockStudentAccountDo, mockOtherTeacherAccount }; + }; + + it('should throw ValidationError', async () => { + const { mockStudentUser, mockStudentAccountDo, mockOtherTeacherAccount } = setup(); + const body = { username: mockOtherTeacherAccount.username } as UpdateAccount; + + await expect(accountService.updateAccount(mockStudentUser, mockStudentAccountDo, body)).rejects.toThrow( + ValidationError + ); + }); + }); + }); + + describe('replaceMyTemporaryPassword', () => { + describe('When passwords do not match', () => { + it('should throw ForbiddenOperationError', async () => { + await expect( + accountService.replaceMyTemporaryPassword('userId', defaultPassword, otherPassword) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When account does not exists', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + const mockUserWithoutAccount = userFactory.buildWithId({ + school: mockSchool, + }); + + userRepo.findById.mockResolvedValueOnce(mockUserWithoutAccount); + accountServiceDb.findByUserIdOrFail.mockImplementation(() => { + throw new EntityNotFoundError(AccountEntity.name); + }); + + return { mockUserWithoutAccount }; + }; + + it('should throw EntityNotFoundError', async () => { + const { mockUserWithoutAccount } = setup(); + await expect( + accountService.replaceMyTemporaryPassword(mockUserWithoutAccount.id, defaultPassword, defaultPassword) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + + describe('When user does not exist', () => { + const setup = () => { + userRepo.findById.mockRejectedValueOnce(undefined); + }; + + it('should throw EntityNotFoundError', async () => { + setup(); + await expect( + accountService.replaceMyTemporaryPassword('accountWithoutUser', defaultPassword, defaultPassword) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + + describe('When account is external', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockExternalUser = userFactory.buildWithId({ + school: mockSchool, + }); + const externalSystem = systemFactory.build(); + const mockExternalUserAccount = accountFactory.build({ + userId: mockExternalUser.id, + password: defaultPasswordHash, + systemId: externalSystem.id, + }); + + userRepo.findById.mockResolvedValueOnce(mockExternalUser); + accountServiceDb.findByUserIdOrFail.mockResolvedValueOnce( + AccountEntityToDoMapper.mapToDo(mockExternalUserAccount) + ); + + return { mockExternalUserAccount }; + }; + + it('should throw ForbiddenOperationError', async () => { + const { mockExternalUserAccount } = setup(); + await expect( + accountService.replaceMyTemporaryPassword( + mockExternalUserAccount.userId?.toString() ?? '', + defaultPassword, + defaultPassword + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When not the users password is temporary', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + forcePasswordChange: false, + preferences: { firstLogin: true }, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountServiceDb.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + + return { mockStudentAccount }; + }; + + it('should throw ForbiddenOperationError', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountService.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + defaultPassword, + defaultPassword + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When old password is the same as new password', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + forcePasswordChange: false, + preferences: { firstLogin: false }, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountServiceDb.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + accountServiceDb.validatePassword.mockResolvedValueOnce(true); + + return { mockStudentAccount }; + }; + + it('should throw ForbiddenOperationError', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountService.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + defaultPassword, + defaultPassword + ) + ).rejects.toThrow(ForbiddenOperationError); + }); + }); + + describe('When old password is undefined', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + forcePasswordChange: false, + preferences: { firstLogin: false }, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: undefined, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountServiceDb.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + accountServiceDb.validatePassword.mockResolvedValueOnce(true); + + return { mockStudentAccount }; + }; + + it('should throw Error', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountService.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + defaultPassword, + defaultPassword + ) + ).rejects.toThrow(Error); + }); + }); + + describe('When the admin manipulate the users password', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + forcePasswordChange: true, + preferences: { firstLogin: true }, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountServiceDb.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + accountServiceDb.validatePassword.mockResolvedValueOnce(false); + accountServiceDb.save.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + + return { mockStudentAccount }; + }; + + it('should allow to set strong password', async () => { + const { mockStudentAccount } = setup(); + + await expect( + accountService.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).resolves.not.toThrow(); + }); + }); + + describe('when a user logs in for the first time', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + forcePasswordChange: false, + preferences: { firstLogin: false }, + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountServiceDb.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + accountServiceDb.validatePassword.mockResolvedValueOnce(false); + accountServiceDb.save.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + + return { mockStudentAccount }; + }; + + it('should allow to set strong password', async () => { + const { mockStudentAccount } = setup(); + + await expect( + accountService.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).resolves.not.toThrow(); + }); + }); + + describe('when a user logs in for the first time (if undefined)', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + forcePasswordChange: false, + }); + mockStudentUser.preferences = undefined; + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + accountServiceDb.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + accountServiceDb.validatePassword.mockResolvedValueOnce(false); + accountServiceDb.save.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + + return { mockStudentAccount }; + }; + + it('should allow to set strong password', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountService.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).resolves.not.toThrow(); + }); + }); + + describe('When user can not be updated', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + firstName: 'failToUpdate', + preferences: { firstLogin: false }, + forcePasswordChange: false, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + userRepo.save.mockRejectedValueOnce(undefined); + accountServiceDb.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + accountServiceDb.validatePassword.mockResolvedValueOnce(false); + accountServiceDb.save.mockResolvedValueOnce({ + getProps: () => { + return { id: '' }; + }, + } as Account); + + return { mockStudentAccount }; + }; + + it('should throw EntityNotFoundError', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountService.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).rejects.toThrow(new EntityNotFoundError(User.name)); + }); + }); + + describe('When account can not be updated', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + forcePasswordChange: false, + preferences: { firstLogin: false }, + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + username: 'fail@to.update', + }); + + userRepo.findById.mockResolvedValueOnce(mockStudentUser); + userRepo.save.mockResolvedValueOnce(); + accountServiceDb.findByUserIdOrFail.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + accountServiceDb.save.mockRejectedValueOnce(undefined); + accountServiceDb.validatePassword.mockResolvedValueOnce(false); + + return { mockStudentAccount }; + }; + + it('should throw EntityNotFoundError', async () => { + const { mockStudentAccount } = setup(); + await expect( + accountService.replaceMyTemporaryPassword( + mockStudentAccount.userId?.toString() ?? '', + otherPassword, + otherPassword + ) + ).rejects.toThrow(EntityNotFoundError); + }); + }); + }); + + describe('findByUserIdsAndSystemId', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const userAId = new ObjectId().toHexString(); + const userBId = new ObjectId().toHexString(); + const userCId = new ObjectId().toHexString(); + + const userIds = [userAId, userBId, userCId]; + const expectedResult = [userAId, userBId]; + + accountRepo.findByUserIdsAndSystemId.mockResolvedValue(expectedResult); + + return { expectedResult, systemId, userIds }; + }; + + it('should call accountRepo.findByUserIdsAndSystemId with userIds and systemId', async () => { + const { systemId, userIds } = setup(); + + await accountService.findByUserIdsAndSystemId(userIds, systemId); + + expect(accountRepo.findByUserIdsAndSystemId).toHaveBeenCalledWith(userIds, systemId); + }); + + it('should call deleteByUserId in accountService', async () => { + const { expectedResult, systemId, userIds } = setup(); + + const result = await accountService.findByUserIdsAndSystemId(userIds, systemId); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('deleteUserData', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const accountId = new ObjectId().toHexString(); + + const expectedData = DomainDeletionReportBuilder.build(DomainName.ACCOUNT, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [accountId]), + ]); + + return { + accountId, + expectedData, + userId, + }; + }; + + describe('when deleteUserData', () => { + it('should call deleteByUserId in accountService', async () => { + const { accountId, userId } = setup(); + jest.spyOn(accountService, 'deleteByUserId').mockResolvedValueOnce([accountId]); + + await accountService.deleteUserData(userId); + + expect(accountService.deleteByUserId).toHaveBeenCalledWith(userId); + }); + + it('should call deleteByUserId in accountService', async () => { + const { accountId, expectedData, userId } = setup(); + jest.spyOn(accountService, 'deleteByUserId').mockResolvedValueOnce([accountId]); + + const result = await accountService.deleteUserData(userId); + + expect(result).toEqual(expectedData); + }); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.ACCOUNT; + const accountId = new ObjectId().toHexString(); + const deletionRequest = deletionRequestFactory.buildWithId({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.ACCOUNT, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [accountId]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in accountService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(accountService, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await accountService.handle({ deletionRequestId, targetRefId }); + + expect(accountService.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(accountService, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await accountService.handle({ deletionRequestId, targetRefId }); + + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); + }); + }); + }); }); diff --git a/apps/server/src/modules/account/services/account.service.ts b/apps/server/src/modules/account/services/account.service.ts index 2a461c32540..9888769b4f9 100644 --- a/apps/server/src/modules/account/services/account.service.ts +++ b/apps/server/src/modules/account/services/account.service.ts @@ -1,27 +1,67 @@ import { ObjectId } from '@mikro-orm/mongodb'; +import { + DataDeletedEvent, + DeletionService, + DomainDeletionReport, + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + UserDeletedEvent, +} from '@modules/deletion'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { ValidationError } from '@shared/common'; -import { Counted } from '@shared/domain/types'; -import { isEmail, validateOrReject } from 'class-validator'; -import { LegacyLogger } from '../../../core/logger'; -import { ServerConfig } from '../../server/server.config'; +import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { AuthorizationError, EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; +import { User } from '@shared/domain/entity'; +import { Counted, EntityId } from '@shared/domain/types'; +import { UserRepo } from '@shared/repo/user/user.repo'; +import { Logger } from '@src/core/logger'; +import { isEmail, isNotEmpty } from 'class-validator'; +import { AccountConfig } from '../account-config'; +import { Account, AccountSave, UpdateAccount, UpdateMyAccount } from '../domain'; +import { AccountEntity } from '../entity/account.entity'; import { AccountServiceDb } from './account-db.service'; import { AccountServiceIdm } from './account-idm.service'; import { AbstractAccountService } from './account.service.abstract'; import { AccountValidationService } from './account.validation.service'; -import { AccountDto, AccountSaveDto } from './dto'; +import { + IdmCallbackLoggableException, + DeletedAccountLoggable, + DeletedAccountWithUserIdLoggable, + DeletedUserDataLoggable, + DeletingAccountLoggable, + DeletingAccountWithUserIdLoggable, + DeletingUserDataLoggable, + SavedAccountLoggable, + SavingAccountLoggable, + UpdatedAccountPasswordLoggable, + UpdatedAccountUsernameLoggable, + UpdatedLastFailedLoginLoggable, + UpdatingAccountPasswordLoggable, + UpdatingAccountUsernameLoggable, + UpdatingLastFailedLoginLoggable, +} from '../loggable'; +import { AccountRepo } from '../repo/account.repo'; + +type UserPreferences = { + firstLogin: boolean; +}; @Injectable() -export class AccountService extends AbstractAccountService { +@EventsHandler(UserDeletedEvent) +export class AccountService extends AbstractAccountService implements DeletionService, IEventHandler { private readonly accountImpl: AbstractAccountService; constructor( private readonly accountDb: AccountServiceDb, private readonly accountIdm: AccountServiceIdm, - private readonly configService: ConfigService, + private readonly configService: ConfigService, private readonly accountValidationService: AccountValidationService, - private readonly logger: LegacyLogger + private readonly logger: Logger, + private readonly userRepo: UserRepo, + private readonly accountRepo: AccountRepo, + private readonly eventBus: EventBus ) { super(); this.logger.setContext(AccountService.name); @@ -32,74 +72,230 @@ export class AccountService extends AbstractAccountService { } } - async findById(id: string): Promise { + public async updateMyAccount(user: User, account: Account, updateData: UpdateMyAccount) { + await this.checkUpdateMyAccountPrerequisites(updateData, account); + + let updateUser = false; + let updateAccount = false; + if (updateData.passwordNew) { + account.password = updateData.passwordNew; + updateAccount = true; + } else { + account.password = undefined; + } + + if (updateData.email && user.email !== updateData.email) { + const newMail = updateData.email.toLowerCase(); + await this.checkUniqueEmail(account, user, newMail); + user.email = newMail; + account.username = newMail; + updateUser = true; + updateAccount = true; + } + + if (updateData.firstName && user.firstName !== updateData.firstName) { + user.firstName = updateData.firstName; + updateUser = true; + } + + if (updateData.lastName && user.lastName !== updateData.lastName) { + user.lastName = updateData.lastName; + updateUser = true; + } + + if (updateUser) { + try { + await this.userRepo.save(user); + } catch (err) { + throw new EntityNotFoundError(User.name); + } + } + if (updateAccount) { + try { + await this.save(account); + } catch (err) { + throw new EntityNotFoundError(AccountEntity.name); + } + } + } + + private async checkUpdateMyAccountPrerequisites(updateData: UpdateMyAccount, account: Account) { + if (account.systemId) { + throw new ForbiddenOperationError('External account details can not be changed.'); + } + + if (!updateData.passwordOld || !(await this.validatePassword(account, updateData.passwordOld))) { + throw new AuthorizationError('Your old password is not correct.'); + } + } + + public async updateAccount(targetUser: User, targetAccount: Account, updateData: UpdateAccount): Promise { + let updateUser = false; + let updateAccount = false; + + if (updateData.password !== undefined) { + targetAccount.password = updateData.password; + targetUser.forcePasswordChange = true; + updateUser = true; + updateAccount = true; + } + if (updateData.username !== undefined) { + const newMail = updateData.username.toLowerCase(); + await this.checkUniqueEmail(targetAccount, targetUser, newMail); + targetUser.email = newMail; + targetAccount.username = newMail; + updateUser = true; + updateAccount = true; + } + if (updateData.activated !== undefined) { + targetAccount.activated = updateData.activated; + updateAccount = true; + } + + if (updateUser) { + try { + await this.userRepo.save(targetUser); + } catch (err) { + throw new EntityNotFoundError(User.name); + } + } + if (updateAccount) { + try { + return await this.save(targetAccount); + } catch (err) { + throw new EntityNotFoundError(AccountEntity.name); + } + } + + return targetAccount; + } + + public async replaceMyTemporaryPassword(userId: EntityId, password: string, confirmPassword: string): Promise { + if (password !== confirmPassword) { + throw new ForbiddenOperationError('Password and confirm password do not match.'); + } + + let user: User; + try { + user = await this.userRepo.findById(userId); + } catch (err) { + throw new EntityNotFoundError(User.name); + } + + const userPreferences = user.preferences; + const firstLoginPassed = userPreferences ? userPreferences.firstLogin : false; + + if (!user.forcePasswordChange && firstLoginPassed) { + throw new ForbiddenOperationError('The password is not temporary, hence can not be changed.'); + } // Password change was forces or this is a first logon for the user + + const account: Account = await this.findByUserIdOrFail(userId); + + if (account.systemId) { + throw new ForbiddenOperationError('External account details can not be changed.'); + } + + if (await this.validatePassword(account, password)) { + throw new ForbiddenOperationError('New password can not be same as old password.'); + } + + try { + account.password = password; + await this.save(account); + } catch (err) { + throw new EntityNotFoundError(AccountEntity.name); + } + try { + user.forcePasswordChange = false; + await this.userRepo.save(user); + } catch (err) { + throw new EntityNotFoundError(User.name); + } + } + + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + + async findById(id: string): Promise { return this.accountImpl.findById(id); } - async findMultipleByUserId(userIds: string[]): Promise { + async findMultipleByUserId(userIds: string[]): Promise { return this.accountImpl.findMultipleByUserId(userIds); } - async findByUserId(userId: string): Promise { + async findByUserId(userId: string): Promise { return this.accountImpl.findByUserId(userId); } - async findByUserIdOrFail(userId: string): Promise { + async findByUserIdOrFail(userId: string): Promise { return this.accountImpl.findByUserIdOrFail(userId); } - async findByUsernameAndSystemId(username: string, systemId: string | ObjectId): Promise { + async findByUsernameAndSystemId(username: string, systemId: string | ObjectId): Promise { return this.accountImpl.findByUsernameAndSystemId(username, systemId); } - async searchByUsernamePartialMatch(userName: string, skip: number, limit: number): Promise> { + async searchByUsernamePartialMatch(userName: string, skip: number, limit: number): Promise> { return this.accountImpl.searchByUsernamePartialMatch(userName, skip, limit); } - async searchByUsernameExactMatch(userName: string): Promise> { + async searchByUsernameExactMatch(userName: string): Promise> { return this.accountImpl.searchByUsernameExactMatch(userName); } - async save(accountDto: AccountSaveDto): Promise { - const ret = await this.accountDb.save(accountDto); - const newAccount: AccountSaveDto = { - ...accountDto, - id: accountDto.id, + async save(accountSave: AccountSave): Promise { + const ret = await this.accountDb.save(accountSave); + const newAccount: AccountSave = { + ...accountSave, + id: accountSave.id, idmReferenceId: ret.id, - password: accountDto.password, + password: accountSave.password, }; const idmAccount = await this.executeIdmMethod(async () => { - this.logger.debug(`Saving account with accountID ${ret.id} ...`); + this.logger.debug(new SavingAccountLoggable(ret.id)); const account = await this.accountIdm.save(newAccount); - this.logger.debug(`Saved account with accountID ${ret.id}`); + this.logger.debug(new SavedAccountLoggable(ret.id)); return account; }); - return { ...ret, idmReferenceId: idmAccount?.idmReferenceId }; + return new Account({ ...ret.getProps(), idmReferenceId: idmAccount?.idmReferenceId }); } - async saveWithValidation(dto: AccountSaveDto): Promise { - await validateOrReject(dto); + async validateAccountBeforeSaveOrReject(accountSave: AccountSave) { + if (!isNotEmpty(accountSave.username)) { + throw new ValidationError('username can not be empty'); + } + // sanatizeUsername ✔ - if (!dto.systemId) { - dto.username = dto.username.trim().toLowerCase(); + if (!accountSave.systemId) { + accountSave.username = accountSave.username.trim().toLowerCase(); } - if (!dto.systemId && !dto.password) { + if (!accountSave.systemId && !accountSave.password) { throw new ValidationError('No password provided'); } // validateUserName ✔ // usernames must be an email address, if they are not from an external system - if (!dto.systemId && !isEmail(dto.username)) { + if (!accountSave.systemId && !isEmail(accountSave.username)) { throw new ValidationError('Username is not an email'); } // checkExistence ✔ - if (dto.userId && (await this.findByUserId(dto.userId))) { + if (accountSave.userId && (await this.findByUserId(accountSave.userId))) { throw new ValidationError('Account already exists'); } // validateCredentials hook will not be ported ✔ // trimPassword hook will be done by class-validator ✔ // local.hooks.hashPassword('password'), will be done by account service ✔ // checkUnique ✔ - if (!(await this.accountValidationService.isUniqueEmail(dto.username, dto.userId, dto.id, dto.systemId))) { + if ( + !(await this.accountValidationService.isUniqueEmail( + accountSave.username, + accountSave.userId, + accountSave.id, + accountSave.systemId + )) + ) { throw new ValidationError('Username already exists'); } // removePassword hook is not implemented @@ -107,69 +303,88 @@ export class AccountService extends AbstractAccountService { // if (dto.passwordStrategy && noPasswordStrategies.includes(dto.passwordStrategy)) { // dto.password = undefined; // } + } - await this.save(dto); + async saveWithValidation(accountSave: AccountSave): Promise { + await this.validateAccountBeforeSaveOrReject(accountSave); + await this.save(accountSave); } - async updateUsername(accountId: string, username: string): Promise { + async updateUsername(accountId: string, username: string): Promise { const ret = await this.accountDb.updateUsername(accountId, username); const idmAccount = await this.executeIdmMethod(async () => { - this.logger.debug(`Updating username for account with accountID ${accountId} ...`); + this.logger.debug(new UpdatingAccountUsernameLoggable(accountId)); const account = await this.accountIdm.updateUsername(accountId, username); - this.logger.debug(`Updated username for account with accountID ${accountId}`); + this.logger.debug(new UpdatedAccountUsernameLoggable(accountId)); return account; }); - return { ...ret, idmReferenceId: idmAccount?.idmReferenceId }; + return new Account({ ...ret.getProps(), idmReferenceId: idmAccount?.idmReferenceId }); } - async updateLastTriedFailedLogin(accountId: string, lastTriedFailedLogin: Date): Promise { + async updateLastTriedFailedLogin(accountId: string, lastTriedFailedLogin: Date): Promise { const ret = await this.accountDb.updateLastTriedFailedLogin(accountId, lastTriedFailedLogin); const idmAccount = await this.executeIdmMethod(async () => { - this.logger.debug(`Updating last tried failed login for account with accountID ${accountId} ...`); + this.logger.debug(new UpdatingLastFailedLoginLoggable(accountId)); const account = await this.accountIdm.updateLastTriedFailedLogin(accountId, lastTriedFailedLogin); - this.logger.debug(`Updated last tried failed login for account with accountID ${accountId}`); + this.logger.debug(new UpdatedLastFailedLoginLoggable(accountId)); return account; }); - return { ...ret, idmReferenceId: idmAccount?.idmReferenceId }; + return new Account({ ...ret.getProps(), idmReferenceId: idmAccount?.idmReferenceId }); } - async updatePassword(accountId: string, password: string): Promise { + async updatePassword(accountId: string, password: string): Promise { const ret = await this.accountDb.updatePassword(accountId, password); const idmAccount = await this.executeIdmMethod(async () => { - this.logger.debug(`Updating password for account with accountID ${accountId} ...`); + this.logger.debug(new UpdatingAccountPasswordLoggable(accountId)); const account = await this.accountIdm.updatePassword(accountId, password); - this.logger.debug(`Updated password for account with accountID ${accountId}`); + this.logger.debug(new UpdatedAccountPasswordLoggable(accountId)); return account; }); - return { ...ret, idmReferenceId: idmAccount?.idmReferenceId }; + return new Account({ ...ret.getProps(), idmReferenceId: idmAccount?.idmReferenceId }); } - async validatePassword(account: AccountDto, comparePassword: string): Promise { + async validatePassword(account: Account, comparePassword: string): Promise { return this.accountImpl.validatePassword(account, comparePassword); } async delete(accountId: string): Promise { await this.accountDb.delete(accountId); await this.executeIdmMethod(async () => { - this.logger.debug(`Deleting account with accountId ${accountId} ...`); + this.logger.debug(new DeletingAccountLoggable(accountId)); await this.accountIdm.delete(accountId); - this.logger.debug(`Deleted account with accountId ${accountId}`); + this.logger.debug(new DeletedAccountLoggable(accountId)); }); } - async deleteByUserId(userId: string): Promise { - await this.accountDb.deleteByUserId(userId); + public async deleteByUserId(userId: string): Promise { + const deletedAccounts = await this.accountDb.deleteByUserId(userId); await this.executeIdmMethod(async () => { - this.logger.debug(`Deleting account with userId ${userId} ...`); - await this.accountIdm.deleteByUserId(userId); - this.logger.debug(`Deleted account with userId ${userId}`); + this.logger.debug(new DeletingAccountWithUserIdLoggable(userId)); + const deletedAccountIdm = await this.accountIdm.deleteByUserId(userId); + deletedAccounts.push(...deletedAccountIdm); + this.logger.debug(new DeletedAccountWithUserIdLoggable(userId)); }); + + return deletedAccounts; + } + + public async deleteUserData(userId: EntityId): Promise { + this.logger.debug(new DeletingUserDataLoggable(userId)); + const deletedAccounts = await this.deleteByUserId(userId); + + const result = DomainDeletionReportBuilder.build(DomainName.ACCOUNT, [ + DomainOperationReportBuilder.build(OperationType.DELETE, deletedAccounts.length, deletedAccounts), + ]); + + this.logger.debug(new DeletedUserDataLoggable(userId)); + + return result; } /** * @deprecated For migration purpose only */ - async findMany(offset = 0, limit = 100): Promise { + async findMany(offset = 0, limit = 100): Promise { return this.accountDb.findMany(offset, limit); } @@ -178,13 +393,21 @@ export class AccountService extends AbstractAccountService { try { return await idmCallback(); } catch (error) { - if (error instanceof Error) { - this.logger.error(error, error.stack); - } else { - this.logger.error(error); - } + this.logger.debug(new IdmCallbackLoggableException(error)); } } return null; } + + private async checkUniqueEmail(account: Account, user: User, email: string): Promise { + if (!(await this.accountValidationService.isUniqueEmail(email, user.id, account.id, account.systemId))) { + throw new ValidationError(`The email address is already in use!`); + } + } + + async findByUserIdsAndSystemId(usersIds: string[], systemId: string): Promise { + const foundAccounts = await this.accountRepo.findByUserIdsAndSystemId(usersIds, systemId); + + return foundAccounts; + } } diff --git a/apps/server/src/modules/account/services/account.validation.service.spec.ts b/apps/server/src/modules/account/services/account.validation.service.spec.ts index 835a2055bcc..477b32f8954 100644 --- a/apps/server/src/modules/account/services/account.validation.service.spec.ts +++ b/apps/server/src/modules/account/services/account.validation.service.spec.ts @@ -1,11 +1,10 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { EntityNotFoundError } from '@shared/common'; -import { Account, Role, User } from '@shared/domain/entity'; +import { Role } from '@shared/domain/entity'; import { Permission, RoleName } from '@shared/domain/interface'; -import { EntityId } from '@shared/domain/types'; import { UserRepo } from '@shared/repo'; -import { accountFactory, setupEntities, systemEntityFactory, userFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { accountDoFactory, setupEntities, systemFactory, userFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; import { AccountRepo } from '../repo/account.repo'; import { AccountValidationService } from './account.validation.service'; @@ -13,26 +12,8 @@ describe('AccountValidationService', () => { let module: TestingModule; let accountValidationService: AccountValidationService; - let mockTeacherUser: User; - let mockTeacherAccount: Account; - - let mockStudentUser: User; - let mockStudentAccount: Account; - - let mockOtherTeacherUser: User; - let mockOtherTeacherAccount: Account; - - let mockAdminUser: User; - - let mockExternalUser: User; - let mockExternalUserAccount: Account; - let mockOtherExternalUser: User; - let mockOtherExternalUserAccount: Account; - - let oprhanAccount: Account; - - let mockUsers: User[]; - let mockAccounts: Account[]; + let userRepo: DeepMocked; + let accountRepo: DeepMocked; afterAll(async () => { await module.close(); @@ -44,237 +25,405 @@ describe('AccountValidationService', () => { AccountValidationService, { provide: AccountRepo, - useValue: { - findById: jest.fn().mockImplementation((accountId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.id === accountId); - - if (account) { - return Promise.resolve(account); - } - throw new EntityNotFoundError(Account.name); - }), - searchByUsernameExactMatch: jest - .fn() - .mockImplementation((username: string): Promise<[Account[], number]> => { - const account = mockAccounts.find((tempAccount) => tempAccount.username === username); - - if (account) { - return Promise.resolve([[account], 1]); - } - if (username === 'not@available.username') { - return Promise.resolve([[mockOtherTeacherAccount], 1]); - } - if (username === 'multiple@account.username') { - return Promise.resolve([mockAccounts, mockAccounts.length]); - } - return Promise.resolve([[], 0]); - }), - findByUserId: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - if (account) { - return Promise.resolve(account); - } - return Promise.resolve(null); - }, - }, + useValue: createMock(), }, { provide: UserRepo, - useValue: { - findById: jest.fn().mockImplementation((userId: EntityId): Promise => { - const user = mockUsers.find((tempUser) => tempUser.id === userId); - if (user) { - return Promise.resolve(user); - } - throw new EntityNotFoundError(User.name); - }), - findByEmail: jest.fn().mockImplementation((email: string): Promise => { - const user = mockUsers.find((tempUser) => tempUser.email === email); - - if (user) { - return Promise.resolve([user]); - } - if (email === 'multiple@user.email') { - return Promise.resolve(mockUsers); - } - return Promise.resolve([]); - }), - }, + useValue: createMock(), }, ], }).compile(); accountValidationService = module.get(AccountValidationService); + + userRepo = module.get(UserRepo); + accountRepo = module.get(AccountRepo); + await setupEntities(); }); beforeEach(() => { - mockTeacherUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], - }); - mockStudentUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockOtherTeacherUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - mockExternalUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockOtherExternalUser = userFactory.buildWithId({ - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); + jest.resetAllMocks(); + }); - mockTeacherAccount = accountFactory.buildWithId({ userId: mockTeacherUser.id }); - mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id }); - mockOtherTeacherAccount = accountFactory.buildWithId({ - userId: mockOtherTeacherUser.id, - }); - const externalSystemA = systemEntityFactory.buildWithId(); - const externalSystemB = systemEntityFactory.buildWithId(); - mockExternalUserAccount = accountFactory.buildWithId({ - userId: mockExternalUser.id, - username: 'unique.within@system', - systemId: externalSystemA.id, + describe('isUniqueEmail', () => { + describe('When new email is available', () => { + const setup = () => { + userRepo.findByEmail.mockResolvedValueOnce([]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); + }; + it('should return true', async () => { + setup(); + + const res = await accountValidationService.isUniqueEmail('an@available.email'); + expect(res).toBe(true); + }); }); - mockOtherExternalUserAccount = accountFactory.buildWithId({ - userId: mockOtherExternalUser.id, - username: 'unique.within@system', - systemId: externalSystemB.id, + + describe('When new email is available', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); + + return { mockStudentUser }; + }; + it('should return true and ignore current user', async () => { + const { mockStudentUser } = setup(); + const res = await accountValidationService.isUniqueEmail(mockStudentUser.email, mockStudentUser.id); + expect(res).toBe(true); + }); }); - oprhanAccount = accountFactory.buildWithId({ - username: 'orphan@account', - userId: undefined, - systemId: new ObjectId(), + describe('When new email is available', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + + return { mockStudentUser, mockStudentAccount }; + }; + it('should return true and ignore current users account', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + mockStudentAccount.username, + mockStudentUser.id, + mockStudentAccount.id + ); + expect(res).toBe(true); + }); }); - mockAccounts = [ - mockTeacherAccount, - mockStudentAccount, - mockOtherTeacherAccount, - mockExternalUserAccount, - mockOtherExternalUserAccount, - oprhanAccount, - ]; - mockAdminUser = userFactory.buildWithId({ - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [ - Permission.TEACHER_EDIT, - Permission.STUDENT_EDIT, - Permission.STUDENT_LIST, - Permission.TEACHER_LIST, - Permission.TEACHER_CREATE, - Permission.STUDENT_CREATE, - Permission.TEACHER_DELETE, - Permission.STUDENT_DELETE, + describe('When new email already in use by another user', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockAdminUser = userFactory.buildWithId({ + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), ], - }), - ], - }); - mockUsers = [ - mockTeacherUser, - mockStudentUser, - mockOtherTeacherUser, - mockAdminUser, - mockExternalUser, - mockOtherExternalUser, - ]; - }); + }); + const mockAdminAccount = accountDoFactory.build({ userId: mockAdminUser.id }); + const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); - describe('isUniqueEmail', () => { - it('should return true if new email is available', async () => { - const res = await accountValidationService.isUniqueEmail('an@available.email'); - expect(res).toBe(true); - }); - it('should return true if new email is available and ignore current user', async () => { - const res = await accountValidationService.isUniqueEmail(mockStudentUser.email, mockStudentUser.id); - expect(res).toBe(true); - }); - it('should return true if new email is available and ignore current users account', async () => { - const res = await accountValidationService.isUniqueEmail( - mockStudentAccount.username, - mockStudentUser.id, - mockStudentAccount.id - ); - expect(res).toBe(true); - }); - it('should return false if new email already in use by another user', async () => { - const res = await accountValidationService.isUniqueEmail( - mockAdminUser.email, - mockStudentUser.id, - mockStudentAccount.id - ); - expect(res).toBe(false); + userRepo.findByEmail.mockResolvedValueOnce([mockAdminUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockAdminAccount], 1]); + + return { mockAdminUser, mockStudentUser, mockStudentAccount }; + }; + it('should return false', async () => { + const { mockAdminUser, mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + mockAdminUser.email, + mockStudentUser.id, + mockStudentAccount.id + ); + expect(res).toBe(false); + }); }); - it('should return false if new email is already in use by any user, system id is given', async () => { - const res = await accountValidationService.isUniqueEmail( - mockTeacherAccount.username, - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); + + describe('When new email already in use by any user and system id is given', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], + }); + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockTeacherAccount = accountDoFactory.build({ userId: mockTeacherUser.id }); + const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockTeacherUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockTeacherAccount], 1]); + + return { mockTeacherAccount, mockStudentUser, mockStudentAccount }; + }; + it('should return false', async () => { + const { mockTeacherAccount, mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + mockTeacherAccount.username, + mockStudentUser.id, + mockStudentAccount.id, + mockStudentAccount.systemId?.toString() + ); + expect(res).toBe(false); + }); }); - it('should return false if new email already in use by multiple users', async () => { - const res = await accountValidationService.isUniqueEmail( - 'multiple@user.email', - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); + + describe('When new email already in use by multiple users', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], + }); + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); + + const mockUsers = [mockTeacherUser, mockStudentUser, mockOtherTeacherUser]; + + userRepo.findByEmail.mockResolvedValueOnce(mockUsers); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); + + return { mockStudentUser, mockStudentAccount }; + }; + it('should return false', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + 'multiple@user.email', + mockStudentUser.id, + mockStudentAccount.id, + mockStudentAccount.systemId?.toString() + ); + expect(res).toBe(false); + }); }); - it('should return false if new email already in use by multiple accounts', async () => { - const res = await accountValidationService.isUniqueEmail( - 'multiple@account.username', - mockStudentUser.id, - mockStudentAccount.id, - mockStudentAccount.systemId?.toString() - ); - expect(res).toBe(false); + + describe('When new email already in use by multiple accounts', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], + }); + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + const mockTeacherAccount = accountDoFactory.build({ userId: mockTeacherUser.id }); + const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); + const mockOtherTeacherAccount = accountDoFactory.build({ + userId: mockOtherTeacherUser.id, + }); + + const mockAccounts = [mockTeacherAccount, mockStudentAccount, mockOtherTeacherAccount]; + userRepo.findByEmail.mockResolvedValueOnce([]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([mockAccounts, mockAccounts.length]); + + return { mockStudentUser, mockStudentAccount }; + }; + it('should return false', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + 'multiple@account.username', + mockStudentUser.id, + mockStudentAccount.id, + mockStudentAccount.systemId?.toString() + ); + expect(res).toBe(false); + }); }); - it('should ignore existing username if other system', async () => { - const res = await accountValidationService.isUniqueEmail( - mockExternalUser.email, - mockExternalUser.id, - mockExternalUserAccount.id, - mockOtherExternalUserAccount.systemId?.toString() - ); - expect(res).toBe(true); + + describe('When its another system', () => { + const setup = () => { + const mockExternalUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherExternalUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const externalSystemA = systemFactory.build(); + const externalSystemB = systemFactory.build(); + const mockExternalUserAccount = accountDoFactory.build({ + userId: mockExternalUser.id, + username: 'unique.within@system', + systemId: externalSystemA.id, + }); + const mockOtherExternalUserAccount = accountDoFactory.build({ + userId: mockOtherExternalUser.id, + username: 'unique.within@system', + systemId: externalSystemB.id, + }); + + userRepo.findByEmail.mockResolvedValueOnce([mockExternalUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockExternalUserAccount], 1]); + + return { mockExternalUser, mockExternalUserAccount, mockOtherExternalUserAccount }; + }; + it('should ignore existing username', async () => { + const { mockExternalUser, mockExternalUserAccount, mockOtherExternalUserAccount } = setup(); + const res = await accountValidationService.isUniqueEmail( + mockExternalUser.email, + mockExternalUser.id, + mockExternalUserAccount.id, + mockOtherExternalUserAccount.systemId?.toString() + ); + expect(res).toBe(true); + }); }); }); describe('isUniqueEmailForUser', () => { - it('should return true, if its the email of the given user', async () => { - const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockStudentUser.id); - expect(res).toBe(true); + describe('When its the email of the given user', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + accountRepo.findByUserId.mockResolvedValueOnce(mockStudentAccount); + + return { mockStudentUser }; + }; + it('should return true', async () => { + const { mockStudentUser } = setup(); + const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockStudentUser.id); + expect(res).toBe(true); + }); }); - it('should return false, if not the given users email', async () => { - const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockAdminUser.id); - expect(res).toBe(false); + + describe('When its not the given users email', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); + + const mockAdminUser = userFactory.buildWithId({ + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockAdminAccount = accountDoFactory.build({ userId: mockAdminUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + accountRepo.findByUserIdOrFail.mockResolvedValueOnce(mockAdminAccount); + + return { mockStudentUser, mockAdminUser }; + }; + it('should return false', async () => { + const { mockStudentUser, mockAdminUser } = setup(); + const res = await accountValidationService.isUniqueEmailForUser(mockStudentUser.email, mockAdminUser.id); + expect(res).toBe(false); + }); }); }); describe('isUniqueEmailForAccount', () => { - it('should return true, if its the email of the given user', async () => { - const res = await accountValidationService.isUniqueEmailForAccount(mockStudentUser.email, mockStudentAccount.id); - expect(res).toBe(true); + describe('When its the email of the given user', () => { + const setup = () => { + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + accountRepo.findById.mockResolvedValueOnce(mockStudentAccount); + + return { mockStudentUser, mockStudentAccount }; + }; + it('should return true', async () => { + const { mockStudentUser, mockStudentAccount } = setup(); + const res = await accountValidationService.isUniqueEmailForAccount( + mockStudentUser.email, + mockStudentAccount.id + ); + expect(res).toBe(true); + }); }); - it('should return false, if not the given users email', async () => { - const res = await accountValidationService.isUniqueEmailForAccount(mockStudentUser.email, mockTeacherAccount.id); - expect(res).toBe(false); + describe('When its not the given users email', () => { + const setup = () => { + const mockTeacherUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.TEACHER, permissions: [Permission.STUDENT_EDIT] })], + }); + const mockStudentUser = userFactory.buildWithId({ + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockTeacherAccount = accountDoFactory.build({ userId: mockTeacherUser.id }); + const mockStudentAccount = accountDoFactory.build({ userId: mockStudentUser.id }); + + userRepo.findByEmail.mockResolvedValueOnce([mockStudentUser]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[mockStudentAccount], 1]); + accountRepo.findById.mockResolvedValueOnce(mockTeacherAccount); + + return { mockStudentUser, mockTeacherAccount }; + }; + it('should return false', async () => { + const { mockStudentUser, mockTeacherAccount } = setup(); + const res = await accountValidationService.isUniqueEmailForAccount( + mockStudentUser.email, + mockTeacherAccount.id + ); + expect(res).toBe(false); + }); }); - it('should ignore missing user for a given account', async () => { - const res = await accountValidationService.isUniqueEmailForAccount(oprhanAccount.username, oprhanAccount.id); - expect(res).toBe(true); + + describe('When user is missing in account', () => { + const setup = () => { + const oprhanAccount = accountDoFactory.build({ + username: 'orphan@account', + userId: undefined, + systemId: new ObjectId().toHexString(), + }); + + userRepo.findByEmail.mockResolvedValueOnce([]); + accountRepo.searchByUsernameExactMatch.mockResolvedValueOnce([[], 0]); + accountRepo.findById.mockResolvedValueOnce(oprhanAccount); + + return { oprhanAccount }; + }; + it('should ignore missing user for given account', async () => { + const { oprhanAccount } = setup(); + const res = await accountValidationService.isUniqueEmailForAccount(oprhanAccount.username, oprhanAccount.id); + expect(res).toBe(true); + }); }); }); }); diff --git a/apps/server/src/modules/account/services/account.validation.service.ts b/apps/server/src/modules/account/services/account.validation.service.ts index 2dff4f8b10e..985d0fe6d02 100644 --- a/apps/server/src/modules/account/services/account.validation.service.ts +++ b/apps/server/src/modules/account/services/account.validation.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { UserRepo } from '@shared/repo'; -import { AccountEntityToDtoMapper } from '../mapper/account-entity-to-dto.mapper'; import { AccountRepo } from '../repo/account.repo'; @Injectable() @@ -9,21 +8,20 @@ export class AccountValidationService { constructor(private accountRepo: AccountRepo, private userRepo: UserRepo) {} async isUniqueEmail(email: string, userId?: EntityId, accountId?: EntityId, systemId?: EntityId): Promise { - const [foundUsers, [accounts]] = await Promise.all([ - // Test coverage: Missing branch null check; unreachable - this.userRepo.findByEmail(email), - AccountEntityToDtoMapper.mapSearchResult(await this.accountRepo.searchByUsernameExactMatch(email)), - ]); - + const foundUsers = await this.userRepo.findByEmail(email); + const [accounts] = await this.accountRepo.searchByUsernameExactMatch(email); const filteredAccounts = accounts.filter((foundAccount) => foundAccount.systemId === systemId); - return !( - foundUsers.length > 1 || - filteredAccounts.length > 1 || - // paranoid 'toString': legacy code may call userId or accountId as ObjectID - (foundUsers.length === 1 && foundUsers[0].id.toString() !== userId?.toString()) || - (filteredAccounts.length === 1 && filteredAccounts[0].id.toString() !== accountId?.toString()) - ); + const multipleUsers = foundUsers.length > 1; + const multipleAccounts = filteredAccounts.length > 1; + // paranoid 'toString': legacy code may call userId or accountId as ObjectID + const oneUserWithoutGivenId = foundUsers.length === 1 && foundUsers[0].id.toString() !== userId?.toString(); + const oneAccountWithoutGivenId = + filteredAccounts.length === 1 && filteredAccounts[0].id.toString() !== accountId?.toString(); + + const isUnique = !(multipleUsers || multipleAccounts || oneUserWithoutGivenId || oneAccountWithoutGivenId); + + return isUnique; } async isUniqueEmailForUser(email: string, userId: EntityId): Promise { @@ -33,6 +31,6 @@ export class AccountValidationService { async isUniqueEmailForAccount(email: string, accountId: EntityId): Promise { const account = await this.accountRepo.findById(accountId); - return this.isUniqueEmail(email, account.userId?.toString(), account.id, account?.systemId?.toString()); + return this.isUniqueEmail(email, account.userId?.toString(), account.id, account.systemId?.toString()); } } diff --git a/apps/server/src/modules/account/services/dto/account.dto.ts b/apps/server/src/modules/account/services/dto/account.dto.ts deleted file mode 100644 index 70bcbeca203..00000000000 --- a/apps/server/src/modules/account/services/dto/account.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { EntityId } from '@shared/domain/types'; -import { AccountSaveDto } from './account-save.dto'; - -export class AccountDto extends AccountSaveDto { - readonly id: EntityId; - - readonly createdAt: Date; - - readonly updatedAt: Date; - - constructor(props: AccountDto) { - super(props); - this.id = props.id; - this.createdAt = props.createdAt; - this.updatedAt = props.updatedAt; - } -} diff --git a/apps/server/src/modules/account/services/dto/index.ts b/apps/server/src/modules/account/services/dto/index.ts deleted file mode 100644 index c0975e1db2a..00000000000 --- a/apps/server/src/modules/account/services/dto/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './account.dto'; -export * from './account-save.dto'; diff --git a/apps/server/src/modules/account/services/index.ts b/apps/server/src/modules/account/services/index.ts index 8a864a874e8..72778be1f1e 100644 --- a/apps/server/src/modules/account/services/index.ts +++ b/apps/server/src/modules/account/services/index.ts @@ -1,2 +1 @@ export * from './account.service'; -export { AccountDto, AccountSaveDto } from './dto'; diff --git a/apps/server/src/modules/account/uc/account.uc.spec.ts b/apps/server/src/modules/account/uc/account.uc.spec.ts index b2f6e3356d1..6ef50c0eff0 100644 --- a/apps/server/src/modules/account/uc/account.uc.spec.ts +++ b/apps/server/src/modules/account/uc/account.uc.spec.ts @@ -1,89 +1,40 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountSaveDto } from '@modules/account/services/dto'; -import { AccountDto } from '@modules/account/services/dto/account.dto'; import { ICurrentUser } from '@modules/authentication'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AuthorizationError, EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; -import { Account, Role, SchoolEntity, SchoolRolePermission, SchoolRoles, User } from '@shared/domain/entity'; +import { EntityNotFoundError } from '@shared/common'; + +import { faker } from '@faker-js/faker'; +import { UnauthorizedException } from '@nestjs/common/exceptions/unauthorized.exception'; +import { Role, User } from '@shared/domain/entity'; import { Permission, RoleName } from '@shared/domain/interface'; -import { PermissionService } from '@shared/domain/service'; -import { Counted, EntityId } from '@shared/domain/types'; -import { UserRepo } from '@shared/repo'; -import { accountFactory, schoolFactory, setupEntities, systemEntityFactory, userFactory } from '@shared/testing'; -import { BruteForcePrevention } from '@src/imports-from-feathers'; -import { ObjectId } from 'bson'; -import { - AccountByIdBodyParams, - AccountByIdParams, - AccountSearchListResponse, - AccountSearchQueryParams, - AccountSearchType, -} from '../controller/dto'; -import { AccountEntityToDtoMapper, AccountResponseMapper } from '../mapper'; +import { EntityId } from '@shared/domain/types'; +import { accountFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; +import { AuthorizationService } from '@modules/authorization'; +import { AccountSearchType } from '../controller/dto'; +import { AccountService } from '../services'; import { AccountValidationService } from '../services/account.validation.service'; import { AccountUc } from './account.uc'; +import { AccountSearchDto, UpdateAccountDto } from './dto'; +import { ResolvedAccountDto, ResolvedSearchListAccountDto } from './dto/resolved-account.dto'; +import { AccountEntity } from '../entity/account.entity'; +import { Account, AccountSave } from '../domain'; +import { AccountEntityToDoMapper } from '../repo/mapper'; describe('AccountUc', () => { let module: TestingModule; let accountUc: AccountUc; - let userRepo: UserRepo; - let accountService: AccountService; - let accountValidationService: AccountValidationService; - let configService: DeepMocked; - let mockSchool: SchoolEntity; - let mockOtherSchool: SchoolEntity; - let mockSchoolWithStudentVisibility: SchoolEntity; - - let mockSuperheroUser: User; - let mockAdminUser: User; - let mockTeacherUser: User; - let mockOtherTeacherUser: User; - let mockTeacherNoUserNoSchoolPermissionUser: User; - let mockTeacherNoUserPermissionUser: User; - let mockStudentSchoolPermissionUser: User; - let mockStudentUser: User; - let mockOtherStudentUser: User; - let mockDifferentSchoolAdminUser: User; - let mockDifferentSchoolTeacherUser: User; - let mockDifferentSchoolStudentUser: User; - let mockUnknownRoleUser: User; - let mockExternalUser: User; - let mockUserWithoutAccount: User; - let mockUserWithoutRole: User; - let mockStudentUserWithoutAccount: User; - let mockOtherStudentSchoolPermissionUser: User; - - let mockSuperheroAccount: Account; - let mockTeacherAccount: Account; - let mockOtherTeacherAccount: Account; - let mockTeacherNoUserPermissionAccount: Account; - let mockTeacherNoUserNoSchoolPermissionAccount: Account; - let mockAdminAccount: Account; - let mockStudentAccount: Account; - let mockStudentSchoolPermissionAccount: Account; - let mockDifferentSchoolAdminAccount: Account; - let mockDifferentSchoolTeacherAccount: Account; - let mockDifferentSchoolStudentAccount: Account; - let mockUnknownRoleUserAccount: Account; - let mockExternalUserAccount: Account; - let mockAccountWithoutRole: Account; - let mockAccountWithoutUser: Account; - let mockAccountWithSystemId: Account; - let mockAccountWithLastFailedLogin: Account; - let mockAccountWithOldLastFailedLogin: Account; - let mockAccountWithNoLastFailedLogin: Account; - let mockAccounts: Account[]; - let mockUsers: User[]; + let accountService: DeepMocked; + let authorizationService: DeepMocked; + let configService: DeepMocked; const defaultPassword = 'DummyPasswd!1'; - const otherPassword = 'DummyPasswd!2'; const defaultPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; - const LOGIN_BLOCK_TIME = 15; afterAll(async () => { + jest.restoreAllMocks(); + jest.resetAllMocks(); await module.close(); }); @@ -93,1130 +44,1979 @@ describe('AccountUc', () => { AccountUc, { provide: AccountService, - useValue: { - saveWithValidation: jest.fn().mockImplementation((account: AccountDto): Promise => { - if (account.username === 'fail@to.update') { - return Promise.reject(); - } - const accountEntity = mockAccounts.find( - (tempAccount) => tempAccount.userId?.toString() === account.userId - ); - if (accountEntity) { - Object.assign(accountEntity, account); - return Promise.resolve(); - } - return Promise.reject(); - }), - save: jest.fn().mockImplementation((account: AccountDto): Promise => { - if (account.username === 'fail@to.update') { - return Promise.reject(); - } - const accountEntity = mockAccounts.find( - (tempAccount) => tempAccount.userId?.toString() === account.userId - ); - if (accountEntity) { - Object.assign(accountEntity, account); - return Promise.resolve(); - } - return Promise.reject(); - }), - delete: (id: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.id?.toString() === id); - - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - throw new EntityNotFoundError(Account.name); - }, - create: (): Promise => Promise.resolve(), - findByUserId: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - return Promise.resolve(null); - }, - findByUserIdOrFail: (userId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.userId?.toString() === userId); - - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - if (userId === 'accountWithoutUser') { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(mockStudentAccount)); - } - throw new EntityNotFoundError(Account.name); - }, - findById: (accountId: EntityId): Promise => { - const account = mockAccounts.find((tempAccount) => tempAccount.id === accountId); - - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - throw new EntityNotFoundError(Account.name); - }, - findByUsernameAndSystemId: (username: string, systemId: EntityId | ObjectId): Promise => { - const account = mockAccounts.find( - (tempAccount) => tempAccount.username === username && tempAccount.systemId === systemId - ); - if (account) { - return Promise.resolve(AccountEntityToDtoMapper.mapToDto(account)); - } - throw new EntityNotFoundError(Account.name); - }, - searchByUsernameExactMatch: (username: string): Promise> => { - const account = mockAccounts.find((tempAccount) => tempAccount.username === username); - - if (account) { - return Promise.resolve([[AccountEntityToDtoMapper.mapToDto(account)], 1]); - } - if (username === 'not@available.username') { - return Promise.resolve([[AccountEntityToDtoMapper.mapToDto(mockOtherTeacherAccount)], 1]); - } - if (username === 'multiple@account.username') { - return Promise.resolve([ - mockAccounts.map((mockAccount) => AccountEntityToDtoMapper.mapToDto(mockAccount)), - mockAccounts.length, - ]); - } - return Promise.resolve([[], 0]); - }, - searchByUsernamePartialMatch: (): Promise> => - Promise.resolve([ - mockAccounts.map((mockAccount) => AccountEntityToDtoMapper.mapToDto(mockAccount)), - mockAccounts.length, - ]), - updateLastTriedFailedLogin: jest.fn(), - validatePassword: jest.fn().mockResolvedValue(true), - }, + useValue: createMock(), }, { provide: ConfigService, useValue: createMock(), }, { - provide: UserRepo, - useValue: { - findById: (userId: EntityId): Promise => { - const user = mockUsers.find((tempUser) => tempUser.id === userId); - if (user) { - return Promise.resolve(user); - } - throw new EntityNotFoundError(User.name); - }, - findByEmail: (email: string): Promise => { - const user = mockUsers.find((tempUser) => tempUser.email === email); - - if (user) { - return Promise.resolve([user]); - } - if (email === 'not@available.email') { - return Promise.resolve([mockExternalUser]); - } - if (email === 'multiple@user.email') { - return Promise.resolve(mockUsers); - } - return Promise.resolve([]); - }, - save: jest.fn().mockImplementation((user: User): Promise => { - if (user.firstName === 'failToUpdate' || user.email === 'user-fail@to.update') { - return Promise.reject(); - } - return Promise.resolve(); - }), - }, + provide: AccountValidationService, + useValue: createMock(), }, - PermissionService, { - provide: AccountValidationService, - useValue: { - isUniqueEmail: jest.fn().mockResolvedValue(true), - }, + provide: AuthorizationService, + useValue: createMock(), }, ], }).compile(); accountUc = module.get(AccountUc); - userRepo = module.get(UserRepo); accountService = module.get(AccountService); - await setupEntities(); - accountValidationService = module.get(AccountValidationService); + authorizationService = module.get(AuthorizationService); configService = module.get(ConfigService); + await setupEntities(); }); beforeEach(() => { - mockSchool = schoolFactory.buildWithId(); - mockOtherSchool = schoolFactory.buildWithId(); - mockSchoolWithStudentVisibility = schoolFactory.buildWithId(); - mockSchoolWithStudentVisibility.permissions = new SchoolRoles(); - mockSchoolWithStudentVisibility.permissions.teacher = new SchoolRolePermission(); - mockSchoolWithStudentVisibility.permissions.teacher.STUDENT_LIST = true; - - mockSuperheroUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.SUPERHERO, - permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], - }), - ], + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + describe('updateMyAccount', () => { + describe('When user does not exist', () => { + const setup = () => { + authorizationService.getUserWithPermissions.mockImplementation(() => { + throw new EntityNotFoundError(User.name); + }); + }; + + it('should throw EntityNotFoundError', async () => { + setup(); + await expect(accountUc.updateMyAccount('accountWithoutUser', { passwordOld: defaultPassword })).rejects.toThrow( + EntityNotFoundError + ); + }); }); - mockAdminUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [ - Permission.TEACHER_EDIT, - Permission.STUDENT_EDIT, - Permission.STUDENT_LIST, - Permission.TEACHER_LIST, - Permission.TEACHER_CREATE, - Permission.STUDENT_CREATE, - Permission.TEACHER_DELETE, - Permission.STUDENT_DELETE, + + describe('When account does not exists', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockUserWithoutAccount = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), ], - }), - ], - }); - mockTeacherUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - mockOtherTeacherUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], - }), - ], - }); - mockTeacherNoUserPermissionUser = userFactory.buildWithId({ - school: mockSchoolWithStudentVisibility, - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [], - }), - ], - }); - mockTeacherNoUserNoSchoolPermissionUser = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.TEACHER, - permissions: [], - }), - ], - }); - mockStudentSchoolPermissionUser = userFactory.buildWithId({ - school: mockSchoolWithStudentVisibility, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockOtherStudentSchoolPermissionUser = userFactory.buildWithId({ - school: mockSchoolWithStudentVisibility, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockStudentUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockOtherStudentUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockDifferentSchoolAdminUser = userFactory.buildWithId({ - school: mockOtherSchool, - roles: [...mockAdminUser.roles], - }); - mockDifferentSchoolTeacherUser = userFactory.buildWithId({ - school: mockOtherSchool, - roles: [...mockTeacherUser.roles], - }); - mockDifferentSchoolStudentUser = userFactory.buildWithId({ - school: mockOtherSchool, - roles: [...mockStudentUser.roles], - }); - mockUserWithoutAccount = userFactory.buildWithId({ - school: mockSchool, - roles: [ - new Role({ - name: RoleName.ADMINISTRATOR, - permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], - }), - ], - }); - mockUserWithoutRole = userFactory.buildWithId({ - school: mockSchool, - roles: [], - }); - mockUnknownRoleUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: 'undefinedRole' as RoleName, permissions: ['' as Permission] })], - }); - mockExternalUser = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); - mockStudentUserWithoutAccount = userFactory.buildWithId({ - school: mockSchool, - roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], - }); + }); - mockSuperheroAccount = accountFactory.buildWithId({ - userId: mockSuperheroUser.id, - password: defaultPasswordHash, - }); - mockTeacherAccount = accountFactory.buildWithId({ - userId: mockTeacherUser.id, - password: defaultPasswordHash, - }); - mockOtherTeacherAccount = accountFactory.buildWithId({ - userId: mockOtherTeacherUser.id, - password: defaultPasswordHash, - }); - mockTeacherNoUserPermissionAccount = accountFactory.buildWithId({ - userId: mockTeacherNoUserPermissionUser.id, - password: defaultPasswordHash, - }); - mockTeacherNoUserNoSchoolPermissionAccount = accountFactory.buildWithId({ - userId: mockTeacherNoUserNoSchoolPermissionUser.id, - password: defaultPasswordHash, - }); - mockAdminAccount = accountFactory.buildWithId({ - userId: mockAdminUser.id, - password: defaultPasswordHash, - }); - mockStudentAccount = accountFactory.buildWithId({ - userId: mockStudentUser.id, - password: defaultPasswordHash, - }); - mockStudentSchoolPermissionAccount = accountFactory.buildWithId({ - userId: mockStudentSchoolPermissionUser.id, - password: defaultPasswordHash, - }); - mockAccountWithoutRole = accountFactory.buildWithId({ - userId: mockUserWithoutRole.id, - password: defaultPasswordHash, - }); - mockDifferentSchoolAdminAccount = accountFactory.buildWithId({ - userId: mockDifferentSchoolAdminUser.id, - password: defaultPasswordHash, - }); - mockDifferentSchoolTeacherAccount = accountFactory.buildWithId({ - userId: mockDifferentSchoolTeacherUser.id, - password: defaultPasswordHash, - }); - mockDifferentSchoolStudentAccount = accountFactory.buildWithId({ - userId: mockDifferentSchoolStudentUser.id, - password: defaultPasswordHash, - }); - mockUnknownRoleUserAccount = accountFactory.buildWithId({ - userId: mockUnknownRoleUser.id, - password: defaultPasswordHash, - }); - const externalSystem = systemEntityFactory.buildWithId(); - mockExternalUserAccount = accountFactory.buildWithId({ - userId: mockExternalUser.id, - password: defaultPasswordHash, - systemId: externalSystem.id, - }); - mockAccountWithoutUser = accountFactory.buildWithId({ - userId: undefined, - password: defaultPasswordHash, - systemId: systemEntityFactory.buildWithId().id, - }); - mockAccountWithSystemId = accountFactory.withSystemId(new ObjectId(10)).build(); - mockAccountWithLastFailedLogin = accountFactory.buildWithId({ - userId: undefined, - password: defaultPasswordHash, - systemId: systemEntityFactory.buildWithId().id, - lasttriedFailedLogin: new Date(), - }); - mockAccountWithOldLastFailedLogin = accountFactory.buildWithId({ - userId: undefined, - password: defaultPasswordHash, - systemId: systemEntityFactory.buildWithId().id, - lasttriedFailedLogin: new Date(new Date().getTime() - LOGIN_BLOCK_TIME - 1), - }); - mockAccountWithNoLastFailedLogin = accountFactory.buildWithId({ - userId: undefined, - password: defaultPasswordHash, - systemId: systemEntityFactory.buildWithId().id, - lasttriedFailedLogin: undefined, - }); + accountService.findByUserIdOrFail.mockImplementation((): Promise => { + throw new EntityNotFoundError(AccountEntity.name); + }); - mockUsers = [ - mockSuperheroUser, - mockAdminUser, - mockTeacherUser, - mockOtherTeacherUser, - mockTeacherNoUserPermissionUser, - mockTeacherNoUserNoSchoolPermissionUser, - mockStudentUser, - mockStudentSchoolPermissionUser, - mockDifferentSchoolAdminUser, - mockDifferentSchoolTeacherUser, - mockDifferentSchoolStudentUser, - mockUnknownRoleUser, - mockExternalUser, - mockUserWithoutRole, - mockUserWithoutAccount, - mockStudentUserWithoutAccount, - mockOtherStudentUser, - mockOtherStudentSchoolPermissionUser, - ]; - - mockAccounts = [ - mockSuperheroAccount, - mockAdminAccount, - mockTeacherAccount, - mockOtherTeacherAccount, - mockTeacherNoUserPermissionAccount, - mockTeacherNoUserNoSchoolPermissionAccount, - mockStudentAccount, - mockStudentSchoolPermissionAccount, - mockDifferentSchoolAdminAccount, - mockDifferentSchoolTeacherAccount, - mockDifferentSchoolStudentAccount, - mockUnknownRoleUserAccount, - mockExternalUserAccount, - mockAccountWithoutRole, - mockAccountWithoutUser, - mockAccountWithSystemId, - mockAccountWithLastFailedLogin, - mockAccountWithOldLastFailedLogin, - mockAccountWithNoLastFailedLogin, - ]; - }); + return { mockUserWithoutAccount }; + }; - describe('updateMyAccount', () => { - it('should throw if user does not exist', async () => { - mockStudentUser.forcePasswordChange = true; - mockStudentUser.preferences = { firstLogin: true }; - await expect(accountUc.updateMyAccount('accountWithoutUser', { passwordOld: defaultPassword })).rejects.toThrow( - EntityNotFoundError - ); - }); - it('should throw if account does not exist', async () => { - await expect( - accountUc.updateMyAccount(mockUserWithoutAccount.id, { - passwordOld: defaultPassword, - }) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if account is external', async () => { - await expect( - accountUc.updateMyAccount(mockExternalUserAccount.userId?.toString() ?? '', { - passwordOld: defaultPassword, - }) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw if password does not match', async () => { - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: 'DoesNotMatch', - }) - ).rejects.toThrow(AuthorizationError); - }); - it('should throw if changing own name is not allowed', async () => { - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - firstName: 'newFirstName', - }) - ).rejects.toThrow(ForbiddenOperationError); - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - lastName: 'newLastName', - }) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should allow to update email', async () => { - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: 'an@available.mail', - }) - ).resolves.not.toThrow(); - }); - it('should use email as account user name in lower case', async () => { - const accountSaveSpy = jest.spyOn(accountService, 'save'); - const testMail = 'AN@AVAILABLE.MAIL'; - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: testMail, - }) - ).resolves.not.toThrow(); - expect(accountSaveSpy).toBeCalledWith(expect.objectContaining({ username: testMail.toLowerCase() })); - }); - it('should use email as user email in lower case', async () => { - const userUpdateSpy = jest.spyOn(userRepo, 'save'); - const testMail = 'AN@AVAILABLE.MAIL'; - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: testMail, - }) - ).resolves.not.toThrow(); - expect(userUpdateSpy).toBeCalledWith(expect.objectContaining({ email: testMail.toLowerCase() })); - }); - it('should always update account user name AND user email together.', async () => { - const accountSaveSpy = jest.spyOn(accountService, 'save'); - const userUpdateSpy = jest.spyOn(userRepo, 'save'); - const testMail = 'an@available.mail'; - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: testMail, - }) - ).resolves.not.toThrow(); - expect(userUpdateSpy).toBeCalledWith(expect.objectContaining({ email: testMail.toLowerCase() })); - expect(accountSaveSpy).toBeCalledWith(expect.objectContaining({ username: testMail.toLowerCase() })); - }); - it('should throw if new email already in use', async () => { - const accountIsUniqueEmailSpy = jest.spyOn(accountValidationService, 'isUniqueEmail'); - accountIsUniqueEmailSpy.mockResolvedValueOnce(false); - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: mockAdminUser.email, - }) - ).rejects.toThrow(ValidationError); - }); - it('should allow to update with strong password', async () => { - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - passwordNew: otherPassword, - }) - ).resolves.not.toThrow(); - }); - it('should allow to update first and last name if teacher', async () => { - await expect( - accountUc.updateMyAccount(mockTeacherUser.id, { - passwordOld: defaultPassword, - firstName: 'newFirstName', - }) - ).resolves.not.toThrow(); - await expect( - accountUc.updateMyAccount(mockTeacherUser.id, { - passwordOld: defaultPassword, - lastName: 'newLastName', - }) - ).resolves.not.toThrow(); - }); - it('should allow to update first and last name if admin', async () => { - await expect( - accountUc.updateMyAccount(mockAdminUser.id, { - passwordOld: defaultPassword, - firstName: 'newFirstName', - }) - ).resolves.not.toThrow(); - await expect( - accountUc.updateMyAccount(mockAdminUser.id, { - passwordOld: defaultPassword, - lastName: 'newLastName', - }) - ).resolves.not.toThrow(); - }); - it('should allow to update first and last name if superhero', async () => { - await expect( - accountUc.updateMyAccount(mockSuperheroUser.id, { - passwordOld: defaultPassword, - firstName: 'newFirstName', - }) - ).resolves.not.toThrow(); - await expect( - accountUc.updateMyAccount(mockSuperheroUser.id, { - passwordOld: defaultPassword, - lastName: 'newLastName', - }) - ).resolves.not.toThrow(); + it('should throw entity not found error', async () => { + const { mockUserWithoutAccount } = setup(); + await expect( + accountUc.updateMyAccount(mockUserWithoutAccount.id, { + passwordOld: defaultPassword, + }) + ).rejects.toThrow(EntityNotFoundError); + }); }); - it('should throw if user can not be updated', async () => { - await expect( - accountUc.updateMyAccount(mockTeacherUser.id, { - passwordOld: defaultPassword, - firstName: 'failToUpdate', - }) - ).rejects.toThrow(EntityNotFoundError); + + describe('When changing own name is not allowed', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions.mockResolvedValue(mockStudentUser); + authorizationService.checkAllPermissions.mockImplementation(() => { + throw new UnauthorizedException(); + }); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + accountService.validatePassword.mockResolvedValue(true); + + return { mockStudentUser }; + }; + it('should throw UnauthorizedException', async () => { + const { mockStudentUser } = setup(); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + firstName: 'newFirstName', + }) + ).rejects.toThrow(UnauthorizedException); + await expect( + accountUc.updateMyAccount(mockStudentUser.id, { + passwordOld: defaultPassword, + lastName: 'newLastName', + }) + ).rejects.toThrow(UnauthorizedException); + }); }); - it('should throw if account can not be updated', async () => { - await expect( - accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - email: 'fail@to.update', - }) - ).rejects.toThrow(EntityNotFoundError); + + describe('When using admin user', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + + const mockAdminAccount = accountFactory.buildWithId({ + userId: mockAdminUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions.mockResolvedValue(mockAdminUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDoMapper.mapToDo(mockAdminAccount)); + accountService.validatePassword.mockResolvedValue(true); + + return { mockAdminUser }; + }; + it('should allow to update first and last name', async () => { + const { mockAdminUser } = setup(); + await expect( + accountUc.updateMyAccount(mockAdminUser.id, { + passwordOld: defaultPassword, + firstName: 'newFirstName', + }) + ).resolves.not.toThrow(); + await expect( + accountUc.updateMyAccount(mockAdminUser.id, { + passwordOld: defaultPassword, + lastName: 'newLastName', + }) + ).resolves.not.toThrow(); + }); }); - it('should not update password if no new password', async () => { - const spy = jest.spyOn(accountService, 'save'); - await accountUc.updateMyAccount(mockStudentUser.id, { - passwordOld: defaultPassword, - passwordNew: undefined, - email: 'newemail@to.update', + + describe('When using superhero user', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockSuperheroAccount = accountFactory.buildWithId({ + userId: mockSuperheroUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions.mockResolvedValue(mockSuperheroUser); + accountService.findByUserIdOrFail.mockResolvedValue(AccountEntityToDoMapper.mapToDo(mockSuperheroAccount)); + accountService.validatePassword.mockResolvedValue(true); + + return { mockSuperheroUser }; + }; + it('should allow to update first and last name ', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.updateMyAccount(mockSuperheroUser.id, { + passwordOld: defaultPassword, + firstName: 'newFirstName', + }) + ).resolves.not.toThrow(); + await expect( + accountUc.updateMyAccount(mockSuperheroUser.id, { + passwordOld: defaultPassword, + lastName: 'newLastName', + }) + ).resolves.not.toThrow(); }); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - password: undefined, - }) - ); }); }); describe('replaceMyTemporaryPassword', () => { - it('should throw if passwords do not match', async () => { - await expect( - accountUc.replaceMyTemporaryPassword( - mockStudentAccount.userId?.toString() ?? '', - defaultPassword, - 'FooPasswd!1' - ) - ).rejects.toThrow(ForbiddenOperationError); - }); + describe('When replaceMyTemporaryPassword is called', () => { + it('should call accountService.replaceMyTemporaryPassword', async () => { + await accountUc.replaceMyTemporaryPassword('id', '', ''); - it('should throw if account does not exist', async () => { - await expect( - accountUc.replaceMyTemporaryPassword(mockUserWithoutAccount.id, defaultPassword, defaultPassword) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if user does not exist', async () => { - await expect( - accountUc.replaceMyTemporaryPassword('accountWithoutUser', defaultPassword, defaultPassword) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if account is external', async () => { - await expect( - accountUc.replaceMyTemporaryPassword( - mockExternalUserAccount.userId?.toString() ?? '', - defaultPassword, - defaultPassword - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw if not the users password is temporary', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: true }; - await expect( - accountUc.replaceMyTemporaryPassword( - mockStudentAccount.userId?.toString() ?? '', - defaultPassword, - defaultPassword - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw, if old password is the same as new password', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - await expect( - accountUc.replaceMyTemporaryPassword( - mockStudentAccount.userId?.toString() ?? '', - defaultPassword, - defaultPassword - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw, if old password is undefined', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - mockStudentAccount.password = undefined; - await expect( - accountUc.replaceMyTemporaryPassword( - mockStudentAccount.userId?.toString() ?? '', - defaultPassword, - defaultPassword - ) - ).rejects.toThrow(Error); - }); - it('should allow to set strong password, if the admin manipulated the users password', async () => { - mockStudentUser.forcePasswordChange = true; - mockStudentUser.preferences = { firstLogin: true }; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).resolves.not.toThrow(); - }); - it('should allow to set strong password, if this is the users first login', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).resolves.not.toThrow(); - }); - it('should allow to set strong password, if this is the users first login (if undefined)', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = undefined; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).resolves.not.toThrow(); - }); - it('should throw if user can not be updated', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - mockStudentUser.firstName = 'failToUpdate'; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if account can not be updated', async () => { - mockStudentUser.forcePasswordChange = false; - mockStudentUser.preferences = { firstLogin: false }; - mockStudentAccount.username = 'fail@to.update'; - jest.spyOn(accountService, 'validatePassword').mockResolvedValueOnce(false); - await expect( - accountUc.replaceMyTemporaryPassword(mockStudentAccount.userId?.toString() ?? '', otherPassword, otherPassword) - ).rejects.toThrow(EntityNotFoundError); + expect(accountService.replaceMyTemporaryPassword).toBeCalledTimes(1); + }); }); }); describe('searchAccounts', () => { - it('should return one account, if search type is userId', async () => { - const accounts = await accountUc.searchAccounts( - { userId: mockSuperheroUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams - ); - const expected = new AccountSearchListResponse( - [AccountResponseMapper.mapToResponseFromEntity(mockStudentAccount)], - 1, - 0, - 1 - ); - expect(accounts).toStrictEqual(expected); - }); - it('should return empty list, if account is not found', async () => { - const accounts = await accountUc.searchAccounts( - { userId: mockSuperheroUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockUserWithoutAccount.id } as AccountSearchQueryParams - ); - const expected = new AccountSearchListResponse([], 0, 0, 0); - expect(accounts).toStrictEqual(expected); - }); - it('should return one or more accounts, if search type is username', async () => { - const accounts = await accountUc.searchAccounts( - { userId: mockSuperheroUser.id } as ICurrentUser, - { type: AccountSearchType.USERNAME, value: '' } as AccountSearchQueryParams - ); - expect(accounts.skip).toEqual(0); - expect(accounts.limit).toEqual(10); - expect(accounts.total).toBeGreaterThan(1); - expect(accounts.data.length).toBeGreaterThan(1); + describe('When search type is userId', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockSuperheroUser) + .mockResolvedValueOnce(mockStudentUser); + accountService.findByUserId.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + + return { mockSuperheroUser, mockStudentUser, mockStudentAccount }; + }; + it('should return one account', async () => { + const { mockSuperheroUser, mockStudentUser, mockStudentAccount } = setup(); + const accounts = await accountUc.searchAccounts( + { userId: mockSuperheroUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchDto + ); + const expected = new ResolvedSearchListAccountDto( + [ + new ResolvedAccountDto({ + id: mockStudentAccount.id, + userId: mockStudentAccount.userId?.toString(), + activated: mockStudentAccount.activated, + username: mockStudentAccount.username, + updatedAt: mockStudentAccount.updatedAt, + password: mockStudentAccount.password, + createdAt: mockStudentAccount.createdAt, + systemId: mockStudentAccount.systemId?.toString(), + }), + ], + 1, + 0, + 1 + ); + expect(accounts).toStrictEqual(expected); + }); }); - it('should throw, if user has not the right permissions', async () => { - await expect( - accountUc.searchAccounts( - { userId: mockTeacherUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams - ) - ).rejects.toThrow(ForbiddenOperationError); - - await expect( - accountUc.searchAccounts( - { userId: mockStudentUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockOtherStudentUser.id } as AccountSearchQueryParams - ) - ).rejects.toThrow(ForbiddenOperationError); - - await expect( - accountUc.searchAccounts( - { userId: mockStudentUser.id } as ICurrentUser, - { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams - ) - ).rejects.toThrow(ForbiddenOperationError); + + describe('When account is not found', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockUserWithoutAccount = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockSuperheroUser) + .mockResolvedValueOnce(mockUserWithoutAccount); + authorizationService.hasAllPermissions.mockReturnValue(true); + accountService.findByUserId.mockResolvedValueOnce(null); + + return { mockSuperheroUser, mockUserWithoutAccount }; + }; + it('should return empty list', async () => { + const { mockSuperheroUser, mockUserWithoutAccount } = setup(); + const accounts = await accountUc.searchAccounts( + { userId: mockSuperheroUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockUserWithoutAccount.id } as AccountSearchDto + ); + const expected = new ResolvedSearchListAccountDto([], 0, 0, 0); + expect(accounts).toStrictEqual(expected); + }); }); - it('should throw, if search type is unknown', async () => { - await expect( - accountUc.searchAccounts( + describe('When search type is username', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockSuperheroUser) + .mockResolvedValueOnce(mockSuperheroUser); + accountService.searchByUsernamePartialMatch.mockResolvedValueOnce([ + [AccountEntityToDoMapper.mapToDo(mockStudentAccount), AccountEntityToDoMapper.mapToDo(mockStudentAccount)], + 2, + ]); + + return { mockSuperheroUser }; + }; + it('should return one or more accounts, ', async () => { + const { mockSuperheroUser } = setup(); + const accounts = await accountUc.searchAccounts( { userId: mockSuperheroUser.id } as ICurrentUser, - { type: '' as AccountSearchType } as AccountSearchQueryParams - ) - ).rejects.toThrow('Invalid search type.'); + { type: AccountSearchType.USERNAME, value: '' } as AccountSearchDto + ); + expect(accounts.skip).toEqual(0); + expect(accounts.limit).toEqual(10); + expect(accounts.total).toBeGreaterThan(1); + expect(accounts.data.length).toBeGreaterThan(1); + }); }); - it('should throw, if user is no superhero', async () => { - await expect( - accountUc.searchAccounts( - { userId: mockTeacherUser.id } as ICurrentUser, - { type: AccountSearchType.USERNAME, value: mockStudentUser.id } as AccountSearchQueryParams - ) - ).rejects.toThrow(ForbiddenOperationError); + + describe('When user has not the right permissions', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockTeacherUser) + .mockResolvedValueOnce(mockAdminUser); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockStudentUser) + .mockResolvedValueOnce(mockOtherStudentUser); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockStudentUser) + .mockResolvedValueOnce(mockTeacherUser); + authorizationService.hasAllPermissions.mockReturnValue(false); + + return { mockTeacherUser, mockAdminUser, mockStudentUser, mockOtherStudentUser }; + }; + it('should throw UnauthorizedException', async () => { + const { mockTeacherUser, mockAdminUser, mockStudentUser, mockOtherStudentUser } = setup(); + await expect( + accountUc.searchAccounts( + { userId: mockTeacherUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchDto + ) + ).rejects.toThrow(UnauthorizedException); + + await expect( + accountUc.searchAccounts( + { userId: mockStudentUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockOtherStudentUser.id } as AccountSearchDto + ) + ).rejects.toThrow(UnauthorizedException); + + await expect( + accountUc.searchAccounts( + { userId: mockStudentUser.id } as ICurrentUser, + { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchDto + ) + ).rejects.toThrow(UnauthorizedException); + }); }); - describe('hasPermissionsToAccessAccount', () => { - beforeEach(() => { - configService.get.mockReturnValue(false); + describe('When search type is unknown', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(mockSuperheroUser); + + return { mockSuperheroUser }; + }; + it('should throw Invalid search type', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.searchAccounts( + { userId: mockSuperheroUser.id } as ICurrentUser, + { type: '' as AccountSearchType } as AccountSearchDto + ) + ).rejects.toThrow('Invalid search type.'); }); - it('admin can access teacher of the same school via user id', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + + describe('When user does not have view permission', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockTeacherUser) + .mockResolvedValueOnce(mockStudentUser); + authorizationService.checkAllPermissions.mockImplementation(() => { + throw new UnauthorizedException(); + }); + + return { mockStudentUser, mockTeacherUser }; + }; + it('should throw UnauthorizedException', async () => { + const { mockTeacherUser, mockStudentUser } = setup(); + await expect( + accountUc.searchAccounts( + { userId: mockTeacherUser.id } as ICurrentUser, + { type: AccountSearchType.USERNAME, value: mockStudentUser.id } as AccountSearchDto + ) + ).rejects.toThrow(UnauthorizedException); }); - it('admin can access student of the same school via user id', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + describe('hasPermissionsToAccessAccount', () => { + describe('When using an admin', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + configService.get.mockReturnValue(false); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockAdminUser) + .mockResolvedValueOnce(mockTeacherUser); + authorizationService.hasAllPermissions.mockReturnValue(true); + + return { mockAdminUser, mockTeacherUser }; + }; + it('should be able to access teacher of the same school via user id', async () => { + const { mockAdminUser, mockTeacherUser } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); }); - it('admin can not access admin of the same school via user id', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + describe('When using an admin', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockAdminUser) + .mockResolvedValueOnce(mockStudentUser); + authorizationService.hasAllPermissions.mockReturnValue(true); + + return { mockAdminUser, mockStudentUser }; + }; + it('should be able to access student of the same school via user id', async () => { + const { mockAdminUser, mockStudentUser } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); }); - it('admin can not access any account of a foreign school via user id', async () => { - const currentUser = { userId: mockDifferentSchoolAdminUser.id } as ICurrentUser; - let params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + describe('When using an admin', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); - params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + configService.get.mockReturnValue(false); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockAdminUser) + .mockResolvedValueOnce(mockAdminUser); + + return { mockAdminUser }; + }; + + it('should not be able to access admin of the same school via user id', async () => { + const { mockAdminUser } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); }); - it('teacher can access teacher of the same school via user id', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockOtherTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + describe('When using an admin', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + const mockOtherSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockDifferentSchoolAdminUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockAdminUser.roles], + }); + + configService.get.mockReturnValue(false); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockDifferentSchoolAdminUser) + .mockResolvedValueOnce(mockTeacherUser); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockDifferentSchoolAdminUser) + .mockResolvedValueOnce(mockStudentUser); + + return { mockDifferentSchoolAdminUser, mockTeacherUser, mockStudentUser }; + }; + it('should not be able to access any account of a foreign school via user id', async () => { + const { mockDifferentSchoolAdminUser, mockTeacherUser, mockStudentUser } = setup(); + const currentUser = { userId: mockDifferentSchoolAdminUser.id } as ICurrentUser; + + let params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); }); - it('teacher can access student of the same school via user id', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + configService.get.mockReturnValue(false); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockTeacherUser) + .mockResolvedValueOnce(mockOtherTeacherUser); + authorizationService.hasAllPermissions.mockReturnValue(true); + + return { mockTeacherUser, mockOtherTeacherUser }; + }; + it('should be able to access teacher of the same school via user id', async () => { + const { mockTeacherUser, mockOtherTeacherUser } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { + type: AccountSearchType.USER_ID, + value: mockOtherTeacherUser.id, + } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); }); - it('teacher can not access admin of the same school via user id', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockTeacherUser) + .mockResolvedValueOnce(mockStudentUser); + authorizationService.hasAllPermissions.mockReturnValue(true); + + return { mockTeacherUser, mockStudentUser }; + }; + it('should be able to access student of the same school via user id', async () => { + const { mockTeacherUser, mockStudentUser } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); }); - it('teacher can not access any account of a foreign school via user id', async () => { - const currentUser = { userId: mockDifferentSchoolTeacherUser.id } as ICurrentUser; - let params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); - params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + configService.get.mockReturnValue(false); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockTeacherUser) + .mockResolvedValueOnce(mockAdminUser); + + return { mockTeacherUser, mockAdminUser }; + }; + it('should not be able to access admin of the same school via user id', async () => { + const { mockTeacherUser, mockAdminUser } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); }); - it('teacher can access student of the same school via user id if school has global permission', async () => { - configService.get.mockReturnValue(true); - const currentUser = { userId: mockTeacherNoUserPermissionUser.id } as ICurrentUser; - const params = { - type: AccountSearchType.USER_ID, - value: mockStudentSchoolPermissionUser.id, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + const mockOtherSchool = schoolEntityFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockDifferentSchoolTeacherUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockTeacherUser.roles], + }); + + configService.get.mockReturnValue(false); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockDifferentSchoolTeacherUser) + .mockResolvedValueOnce(mockTeacherUser); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockDifferentSchoolTeacherUser) + .mockResolvedValueOnce(mockStudentUser); + + return { mockDifferentSchoolTeacherUser, mockTeacherUser, mockStudentUser }; + }; + it('should not be able to access any account of a foreign school via user id', async () => { + const { mockDifferentSchoolTeacherUser, mockTeacherUser, mockStudentUser } = setup(); + const currentUser = { userId: mockDifferentSchoolTeacherUser.id } as ICurrentUser; + + let params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); }); - it('teacher can not access student of the same school if school has no global permission', async () => { - configService.get.mockReturnValue(true); - const currentUser = { userId: mockTeacherNoUserNoSchoolPermissionUser.id } as ICurrentUser; - const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(ForbiddenOperationError); + describe('When using a teacher', () => { + const setup = () => { + const mockSchoolWithStudentVisibility = schoolEntityFactory.buildWithId({ + permissions: { + teacher: { + STUDENT_LIST: true, + }, + }, + }); + + const mockTeacherNoUserPermissionUser = userFactory.buildWithId({ + school: mockSchoolWithStudentVisibility, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [], + }), + ], + }); + const mockStudentSchoolPermissionUser = userFactory.buildWithId({ + school: mockSchoolWithStudentVisibility, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(true); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockTeacherNoUserPermissionUser) + .mockResolvedValueOnce(mockStudentSchoolPermissionUser); + + return { mockTeacherNoUserPermissionUser, mockStudentSchoolPermissionUser }; + }; + it('should be able to access student of the same school via user id if school has global permission', async () => { + const { mockTeacherNoUserPermissionUser, mockStudentSchoolPermissionUser } = setup(); + + const currentUser = { userId: mockTeacherNoUserPermissionUser.id } as ICurrentUser; + const params = { + type: AccountSearchType.USER_ID, + value: mockStudentSchoolPermissionUser.id, + } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); + }); + + describe('When using a teacher', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockTeacherNoUserNoSchoolPermissionUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockTeacherNoUserNoSchoolPermissionUser) + .mockResolvedValueOnce(mockStudentUser); + + return { mockTeacherNoUserNoSchoolPermissionUser, mockStudentUser }; + }; + it('should not be able to access student of the same school if school has no global permission', async () => { + const { mockTeacherNoUserNoSchoolPermissionUser, mockStudentUser } = setup(); + configService.get.mockReturnValue(true); + const currentUser = { userId: mockTeacherNoUserNoSchoolPermissionUser.id } as ICurrentUser; + const params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(UnauthorizedException); + }); }); - it('student can not access student of the same school if school has global permission', async () => { - configService.get.mockReturnValue(true); - const currentUser = { userId: mockStudentSchoolPermissionUser.id } as ICurrentUser; - const params = { - type: AccountSearchType.USER_ID, - value: mockOtherStudentSchoolPermissionUser.id, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(ForbiddenOperationError); + describe('When using a student', () => { + const setup = () => { + const mockSchoolWithStudentVisibility = schoolEntityFactory.buildWithId({ + permissions: { + teacher: { + STUDENT_LIST: true, + }, + }, + }); + + const mockStudentSchoolPermissionUser = userFactory.buildWithId({ + school: mockSchoolWithStudentVisibility, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockOtherStudentSchoolPermissionUser = userFactory.buildWithId({ + school: mockSchoolWithStudentVisibility, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockStudentSchoolPermissionUser) + .mockResolvedValueOnce(mockOtherStudentSchoolPermissionUser); + + return { mockStudentSchoolPermissionUser, mockOtherStudentSchoolPermissionUser }; + }; + it('should not be able to access student of the same school if school has global permission', async () => { + const { mockStudentSchoolPermissionUser, mockOtherStudentSchoolPermissionUser } = setup(); + configService.get.mockReturnValue(true); + const currentUser = { userId: mockStudentSchoolPermissionUser.id } as ICurrentUser; + const params = { + type: AccountSearchType.USER_ID, + value: mockOtherStudentSchoolPermissionUser.id, + } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(UnauthorizedException); + }); }); - it('student can not access any other account via user id', async () => { - const currentUser = { userId: mockStudentUser.id } as ICurrentUser; - let params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + describe('When using a student', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + configService.get.mockReturnValue(false); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockStudentUser) + .mockResolvedValueOnce(mockAdminUser); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockStudentUser) + .mockResolvedValueOnce(mockTeacherUser); + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockStudentUser) + .mockResolvedValueOnce(mockStudentUser); - params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + return { mockStudentUser, mockAdminUser, mockTeacherUser }; + }; + it('should not be able to access any other account via user id', async () => { + const { mockStudentUser, mockAdminUser, mockTeacherUser } = setup(); + const currentUser = { userId: mockStudentUser.id } as ICurrentUser; - params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + let params = { type: AccountSearchType.USER_ID, value: mockAdminUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + params = { type: AccountSearchType.USER_ID, value: mockTeacherUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + + params = { type: AccountSearchType.USER_ID, value: mockStudentUser.id } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).rejects.toThrow(); + }); }); - it('superhero can access any account via username', async () => { - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - - let params = { type: AccountSearchType.USERNAME, value: mockAdminAccount.username } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); - - params = { type: AccountSearchType.USERNAME, value: mockTeacherAccount.username } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); - - params = { type: AccountSearchType.USERNAME, value: mockStudentAccount.username } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); - - params = { - type: AccountSearchType.USERNAME, - value: mockDifferentSchoolAdminAccount.username, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); - - params = { - type: AccountSearchType.USERNAME, - value: mockDifferentSchoolTeacherAccount.username, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); - - params = { - type: AccountSearchType.USERNAME, - value: mockDifferentSchoolStudentAccount.username, - } as AccountSearchQueryParams; - await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + describe('When using a superhero', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + const mockOtherSchool = schoolEntityFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockDifferentSchoolAdminUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockAdminUser.roles], + }); + const mockDifferentSchoolTeacherUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockTeacherUser.roles], + }); + const mockDifferentSchoolStudentUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockStudentUser.roles], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + const mockDifferentSchoolAdminAccount = accountFactory.buildWithId({ + userId: mockDifferentSchoolAdminUser.id, + password: defaultPasswordHash, + }); + const mockDifferentSchoolTeacherAccount = accountFactory.buildWithId({ + userId: mockDifferentSchoolTeacherUser.id, + password: defaultPasswordHash, + }); + const mockAdminAccount = accountFactory.buildWithId({ + userId: mockAdminUser.id, + password: defaultPasswordHash, + }); + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); + + const mockDifferentSchoolStudentAccount = accountFactory.buildWithId({ + userId: mockDifferentSchoolStudentUser.id, + password: defaultPasswordHash, + }); + + configService.get.mockReturnValue(false); + authorizationService.getUserWithPermissions.mockResolvedValue(mockSuperheroUser); + accountService.searchByUsernamePartialMatch.mockResolvedValue([[], 0]); + + return { + mockSuperheroUser, + mockAdminAccount, + mockTeacherAccount, + mockStudentAccount, + mockDifferentSchoolAdminAccount, + mockDifferentSchoolTeacherAccount, + mockDifferentSchoolStudentAccount, + }; + }; + it('should be able to access any account via username', async () => { + const { + mockSuperheroUser, + mockAdminAccount, + mockTeacherAccount, + mockStudentAccount, + mockDifferentSchoolAdminAccount, + mockDifferentSchoolTeacherAccount, + mockDifferentSchoolStudentAccount, + } = setup(); + + const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; + + let params = { + type: AccountSearchType.USERNAME, + value: mockAdminAccount.username, + } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { type: AccountSearchType.USERNAME, value: mockTeacherAccount.username } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { type: AccountSearchType.USERNAME, value: mockStudentAccount.username } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { + type: AccountSearchType.USERNAME, + value: mockDifferentSchoolAdminAccount.username, + } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { + type: AccountSearchType.USERNAME, + value: mockDifferentSchoolTeacherAccount.username, + } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + + params = { + type: AccountSearchType.USERNAME, + value: mockDifferentSchoolStudentAccount.username, + } as AccountSearchDto; + await expect(accountUc.searchAccounts(currentUser, params)).resolves.not.toThrow(); + }); }); }); }); describe('findAccountById', () => { - it('should return an account, if the current user is a superhero', async () => { - const account = await accountUc.findAccountById( - { userId: mockSuperheroUser.id } as ICurrentUser, - { id: mockStudentAccount.id } as AccountByIdParams - ); - expect(account).toStrictEqual( - expect.objectContaining({ - id: mockStudentAccount.id, - username: mockStudentAccount.username, + describe('When the current user is a superhero', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ userId: mockStudentUser.id, - activated: mockStudentAccount.activated, - }) - ); + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(mockSuperheroUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + + return { mockSuperheroUser, mockStudentUser, mockStudentAccount }; + }; + it('should return an account', async () => { + const { mockSuperheroUser, mockStudentUser, mockStudentAccount } = setup(); + const account = await accountUc.findAccountById( + { userId: mockSuperheroUser.id } as ICurrentUser, + mockStudentAccount.id + ); + expect(account).toStrictEqual( + expect.objectContaining({ + id: mockStudentAccount.id, + username: mockStudentAccount.username, + userId: mockStudentUser.id, + activated: mockStudentAccount.activated, + }) + ); + }); }); - it('should throw, if the current user is no superhero', async () => { - await expect( - accountUc.findAccountById( - { userId: mockTeacherUser.id } as ICurrentUser, - { id: mockStudentAccount.id } as AccountByIdParams - ) - ).rejects.toThrow(ForbiddenOperationError); + + describe('When the current user is no superhero', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(mockTeacherUser); + authorizationService.checkAllPermissions.mockImplementation(() => { + throw new UnauthorizedException(); + }); + + return { mockTeacherUser, mockStudentAccount }; + }; + it('should throw UnauthorizedException', async () => { + const { mockTeacherUser, mockStudentAccount } = setup(); + await expect( + accountUc.findAccountById({ userId: mockTeacherUser.id } as ICurrentUser, mockStudentAccount.id) + ).rejects.toThrow(UnauthorizedException); + }); }); - it('should throw, if no account matches the search term', async () => { - await expect( - accountUc.findAccountById({ userId: mockSuperheroUser.id } as ICurrentUser, { id: 'xxx' } as AccountByIdParams) - ).rejects.toThrow(EntityNotFoundError); + + describe('When no account matches the search term', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(mockSuperheroUser); + accountService.findById.mockImplementation((): Promise => { + throw new EntityNotFoundError(AccountEntity.name); + }); + + return { mockSuperheroUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.findAccountById({ userId: mockSuperheroUser.id } as ICurrentUser, 'xxx') + ).rejects.toThrow(EntityNotFoundError); + }); }); - it('should throw, if target account has no user', async () => { - await expect( - accountUc.findAccountById({ userId: mockSuperheroUser.id } as ICurrentUser, { id: 'xxx' } as AccountByIdParams) - ).rejects.toThrow(EntityNotFoundError); + + describe('When target account has no user', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(mockSuperheroUser); + accountService.findById.mockImplementation((): Promise => { + throw new EntityNotFoundError(AccountEntity.name); + }); + + return { mockSuperheroUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.findAccountById({ userId: mockSuperheroUser.id } as ICurrentUser, 'xxx') + ).rejects.toThrow(EntityNotFoundError); + }); }); }); describe('saveAccount', () => { - afterEach(() => { - jest.clearAllMocks(); - }); + describe('When saving an account', () => { + const setup = () => { + const spy = jest.spyOn(accountService, 'saveWithValidation'); - it('should call account service', async () => { - const spy = jest.spyOn(accountService, 'saveWithValidation'); - const params: AccountSaveDto = { - username: 'john.doe@domain.tld', - password: defaultPassword, + return { spy }; }; - await accountUc.saveAccount(params); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ + it('should call account service', async () => { + const { spy } = setup(); + + const params: AccountSave = { username: 'john.doe@domain.tld', - }) - ); + password: defaultPassword, + } as AccountSave; + await accountUc.saveAccount(params); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + username: 'john.doe@domain.tld', + }) + ); + }); }); }); describe('updateAccountById', () => { - it('should throw if executing user does not exist', async () => { - const currentUser = { userId: '000000000000000' } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if target account does not exist', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: '000000000000000' } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); - }); - it('should update target account password', async () => { - const previousPasswordHash = mockStudentAccount.password; - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { password: defaultPassword } as AccountByIdBodyParams; - expect(mockStudentUser.forcePasswordChange).toBeFalsy(); - await accountUc.updateAccountById(currentUser, params, body); - expect(mockStudentAccount.password).not.toBe(previousPasswordHash); - expect(mockStudentUser.forcePasswordChange).toBeTruthy(); - }); - it('should update target account username', async () => { - const newUsername = 'newUsername'; - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { username: newUsername } as AccountByIdBodyParams; - expect(mockStudentAccount.username).not.toBe(newUsername); - await accountUc.updateAccountById(currentUser, params, body); - expect(mockStudentAccount.username).toBe(newUsername.toLowerCase()); - }); - it('should update target account activation state', async () => { - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { activated: false } as AccountByIdBodyParams; - await accountUc.updateAccountById(currentUser, params, body); - expect(mockStudentAccount.activated).toBeFalsy(); - }); - it('should throw if account can not be updated', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { username: 'fail@to.update' } as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); - }); - it('should throw if user can not be updated', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { username: 'user-fail@to.update' } as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(EntityNotFoundError); + describe('when updating a user that does not exist', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions.mockImplementation((): Promise => { + throw new EntityNotFoundError(User.name); + }); + + return { mockStudentAccount }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockStudentAccount } = setup(); + const currentUser = { userId: '000000000000000' } as ICurrentUser; + const body = {} as UpdateAccountDto; + await expect(accountUc.updateAccountById(currentUser, mockStudentAccount.id, body)).rejects.toThrow( + EntityNotFoundError + ); + }); }); - it('should throw if target account has no user', async () => { - await expect( - accountUc.updateAccountById( - { userId: mockSuperheroUser.id } as ICurrentUser, - { id: mockAccountWithoutUser.id } as AccountByIdParams, - { username: 'user-fail@to.update' } as AccountByIdBodyParams - ) - ).rejects.toThrow(EntityNotFoundError); + + describe('When target account does not exist', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + + const mockAccountWithoutUser = accountFactory.build({ + userId: undefined, + password: defaultPasswordHash, + systemId: faker.database.mongodbObjectId(), + }); + + authorizationService.getUserWithPermissions.mockResolvedValue(mockAdminUser); + accountService.findById.mockResolvedValue(AccountEntityToDoMapper.mapToDo(mockAccountWithoutUser)); + + return { mockAdminUser }; + }; + it('should throw EntityNotFoundError', async () => { + const { mockAdminUser } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const body = {} as UpdateAccountDto; + await expect(accountUc.updateAccountById(currentUser, '000000000000000', body)).rejects.toThrow( + EntityNotFoundError + ); + }); }); - it('should throw if new username already in use', async () => { - const accountIsUniqueEmailSpy = jest.spyOn(accountValidationService, 'isUniqueEmail'); - accountIsUniqueEmailSpy.mockResolvedValueOnce(false); - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = { username: mockOtherTeacherAccount.username } as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ValidationError); + + describe('if target account has no user', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockAccountWithoutUser = accountFactory.build({ + userId: undefined, + password: defaultPasswordHash, + systemId: faker.database.mongodbObjectId(), + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(mockSuperheroUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockAccountWithoutUser)); + + return { mockSuperheroUser, mockAccountWithoutUser }; + }; + + it('should throw EntityNotFoundError', async () => { + const { mockSuperheroUser, mockAccountWithoutUser } = setup(); + await expect( + accountUc.updateAccountById({ userId: mockSuperheroUser.id } as ICurrentUser, mockAccountWithoutUser.id, { + username: 'user-fail@to.update', + } as UpdateAccountDto) + ).rejects.toThrow(EntityNotFoundError); + }); }); describe('hasPermissionsToUpdateAccount', () => { - it('admin can edit teacher', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockTeacherAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + describe('When using an admin user', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockAdminUser) + .mockResolvedValueOnce(mockTeacherUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockTeacherAccount)); + authorizationService.hasAllPermissions.mockReturnValue(true); + + return { mockAdminUser, mockTeacherAccount }; + }; + it('should not throw error when editing a teacher', async () => { + const { mockAdminUser, mockTeacherAccount } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const body = {} as UpdateAccountDto; + await expect(accountUc.updateAccountById(currentUser, mockTeacherAccount.id, body)).resolves.not.toThrow(); + }); }); - it('teacher can edit student', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + + describe('When using a teacher user', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockTeacherUser) + .mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + accountService.updateAccount.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + authorizationService.hasAllPermissions.mockReturnValue(true); + + return { mockStudentAccount, mockTeacherUser }; + }; + it('should not throw error when editing a student', async () => { + const { mockTeacherUser, mockStudentAccount } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const body = {} as UpdateAccountDto; + await expect(accountUc.updateAccountById(currentUser, mockStudentAccount.id, body)).resolves.not.toThrow(); + }); }); - it('admin can edit student', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockStudentAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + describe('When using an admin user', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockAdminUser) + .mockResolvedValueOnce(mockStudentUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + accountService.updateAccount.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + authorizationService.hasAllPermissions.mockReturnValue(true); + + return { mockStudentAccount, mockAdminUser }; + }; + it('should not throw error when editing a student', async () => { + const { mockAdminUser, mockStudentAccount } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const body = {} as UpdateAccountDto; + await expect(accountUc.updateAccountById(currentUser, mockStudentAccount.id, body)).resolves.not.toThrow(); + }); }); - it('teacher cannot edit other teacher', async () => { - const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; - const params = { id: mockOtherTeacherAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + + describe('When using a teacher user to edit another teacher', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockOtherTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockOtherTeacherAccount = accountFactory.buildWithId({ + userId: mockOtherTeacherUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockTeacherUser) + .mockResolvedValueOnce(mockOtherTeacherUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockOtherTeacherAccount)); + + return { mockOtherTeacherAccount, mockTeacherUser }; + }; + it('should throw UnauthorizedException', async () => { + const { mockTeacherUser, mockOtherTeacherAccount } = setup(); + const currentUser = { userId: mockTeacherUser.id } as ICurrentUser; + const body = {} as UpdateAccountDto; + await expect(accountUc.updateAccountById(currentUser, mockOtherTeacherAccount.id, body)).rejects.toThrow( + UnauthorizedException + ); + }); }); - it("other school's admin cannot edit teacher", async () => { - const currentUser = { userId: mockDifferentSchoolAdminUser.id } as ICurrentUser; - const params = { id: mockTeacherAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + + describe('When using an admin user of other school', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + const mockOtherSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockTeacherUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.TEACHER, + permissions: [Permission.STUDENT_EDIT, Permission.STUDENT_LIST, Permission.TEACHER_LIST], + }), + ], + }); + const mockDifferentSchoolAdminUser = userFactory.buildWithId({ + school: mockOtherSchool, + roles: [...mockAdminUser.roles], + }); + + const mockTeacherAccount = accountFactory.buildWithId({ + userId: mockTeacherUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockDifferentSchoolAdminUser) + .mockResolvedValueOnce(mockTeacherUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockTeacherAccount)); + + return { mockDifferentSchoolAdminUser, mockTeacherAccount }; + }; + it('should throw UnauthorizedException', async () => { + const { mockDifferentSchoolAdminUser, mockTeacherAccount } = setup(); + const currentUser = { userId: mockDifferentSchoolAdminUser.id } as ICurrentUser; + const body = {} as UpdateAccountDto; + await expect(accountUc.updateAccountById(currentUser, mockTeacherAccount.id, body)).rejects.toThrow( + UnauthorizedException + ); + }); }); - it('superhero can edit admin', async () => { - const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; - const params = { id: mockAdminAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).resolves.not.toThrow(); + + describe('When using a superhero user', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockAdminAccount = accountFactory.buildWithId({ + userId: mockAdminUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockSuperheroUser) + .mockResolvedValueOnce(mockAdminUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockAdminAccount)); + accountService.updateAccount.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockAdminAccount)); + authorizationService.hasAllPermissions.mockReturnValue(true); + + return { mockAdminAccount, mockSuperheroUser }; + }; + it('should not throw error when editing a admin', async () => { + const { mockSuperheroUser, mockAdminAccount } = setup(); + const currentUser = { userId: mockSuperheroUser.id } as ICurrentUser; + const body = {} as UpdateAccountDto; + await expect(accountUc.updateAccountById(currentUser, mockAdminAccount.id, body)).resolves.not.toThrow(); + }); }); - it('undefined user role fails by default', async () => { - const currentUser = { userId: mockUnknownRoleUser.id } as ICurrentUser; - const params = { id: mockAccountWithoutRole.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + + describe('When using an user with undefined role', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockUserWithoutRole = userFactory.buildWithId({ + school: mockSchool, + roles: [], + }); + const mockUnknownRoleUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: 'undefinedRole' as RoleName, permissions: ['' as Permission] })], + }); + const mockAccountWithoutRole = accountFactory.buildWithId({ + userId: mockUserWithoutRole.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockUnknownRoleUser) + .mockResolvedValueOnce(mockUserWithoutRole); + accountService.findById.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockAccountWithoutRole)); + + return { mockAccountWithoutRole, mockUnknownRoleUser }; + }; + it('should fail by default', async () => { + const { mockUnknownRoleUser, mockAccountWithoutRole } = setup(); + const currentUser = { userId: mockUnknownRoleUser.id } as ICurrentUser; + const body = {} as UpdateAccountDto; + await expect(accountUc.updateAccountById(currentUser, mockAccountWithoutRole.id, body)).rejects.toThrow( + UnauthorizedException + ); + }); }); - it('user without role cannot be edited', async () => { - const currentUser = { userId: mockAdminUser.id } as ICurrentUser; - const params = { id: mockUnknownRoleUserAccount.id } as AccountByIdParams; - const body = {} as AccountByIdBodyParams; - await expect(accountUc.updateAccountById(currentUser, params, body)).rejects.toThrow(ForbiddenOperationError); + + describe('When editing an user without role', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + const mockUnknownRoleUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: 'undefinedRole' as RoleName, permissions: ['' as Permission] })], + }); + const mockUnknownRoleUserAccount = accountFactory.buildWithId({ + userId: mockUnknownRoleUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions + .mockResolvedValueOnce(mockAdminUser) + .mockResolvedValueOnce(mockUnknownRoleUser); + accountService.findById.mockResolvedValueOnce(AccountEntityToDoMapper.mapToDo(mockUnknownRoleUserAccount)); + + return { mockAdminUser, mockUnknownRoleUserAccount }; + }; + it('should throw UnauthorizedException', async () => { + const { mockAdminUser, mockUnknownRoleUserAccount } = setup(); + const currentUser = { userId: mockAdminUser.id } as ICurrentUser; + const body = {} as UpdateAccountDto; + await expect(accountUc.updateAccountById(currentUser, mockUnknownRoleUserAccount.id, body)).rejects.toThrow( + UnauthorizedException + ); + }); }); }); }); describe('deleteAccountById', () => { - it('should delete an account, if current user is authorized', async () => { - await expect( - accountUc.deleteAccountById( - { userId: mockSuperheroUser.id } as ICurrentUser, - { id: mockStudentAccount.id } as AccountByIdParams - ) - ).resolves.not.toThrow(); - }); - it('should throw, if the current user is no superhero', async () => { - await expect( - accountUc.deleteAccountById( - { userId: mockAdminUser.id } as ICurrentUser, - { id: mockStudentAccount.id } as AccountByIdParams - ) - ).rejects.toThrow(ForbiddenOperationError); - }); - it('should throw, if no account matches the search term', async () => { - await expect( - accountUc.deleteAccountById( - { userId: mockSuperheroUser.id } as ICurrentUser, - { id: 'xxx' } as AccountByIdParams - ) - ).rejects.toThrow(EntityNotFoundError); - }); - }); + describe('When current user has the delete permission', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); - describe('checkBrutForce', () => { - let updateMock: jest.Mock; - beforeAll(() => { - configService.get.mockReturnValue(LOGIN_BLOCK_TIME); - }); - afterAll(() => { - configService.get.mockRestore(); - }); - beforeEach(() => { - // eslint-disable-next-line jest/unbound-method - updateMock = accountService.updateLastTriedFailedLogin as jest.Mock; - updateMock.mockClear(); - }); - it('should throw, if time difference < the allowed time', async () => { - await expect( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - accountUc.checkBrutForce(mockAccountWithLastFailedLogin.username, mockAccountWithLastFailedLogin.systemId!) - ).rejects.toThrow(BruteForcePrevention); + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.ACCOUNT_DELETE], + }), + ], + }); + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions.mockResolvedValue(mockSuperheroUser); + accountService.findById.mockResolvedValue(AccountEntityToDoMapper.mapToDo(mockStudentAccount)); + + return { mockSuperheroUser, mockStudentAccount }; + }; + it('should delete an account', async () => { + const { mockSuperheroUser, mockStudentAccount } = setup(); + await expect( + accountUc.deleteAccountById({ userId: mockSuperheroUser.id } as ICurrentUser, mockStudentAccount.id) + ).resolves.not.toThrow(); + }); }); - it('should not throw Error, if the time difference > the allowed time', async () => { - await expect( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - accountUc.checkBrutForce(mockAccountWithSystemId.username, mockAccountWithSystemId.systemId!) - ).resolves.not.toThrow(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(updateMock.mock.calls[0][0]).toEqual(mockAccountWithSystemId.id); - const newDate = new Date().getTime() - 10000; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect((updateMock.mock.calls[0][1] as Date).getTime()).toBeGreaterThan(newDate); + + describe('When the current user does not have the delete permission', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockAdminUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.ADMINISTRATOR, + permissions: [ + Permission.TEACHER_EDIT, + Permission.STUDENT_EDIT, + Permission.STUDENT_LIST, + Permission.TEACHER_LIST, + Permission.TEACHER_CREATE, + Permission.STUDENT_CREATE, + Permission.TEACHER_DELETE, + Permission.STUDENT_DELETE, + ], + }), + ], + }); + + const mockStudentUser = userFactory.buildWithId({ + school: mockSchool, + roles: [new Role({ name: RoleName.STUDENT, permissions: [] })], + }); + + const mockStudentAccount = accountFactory.buildWithId({ + userId: mockStudentUser.id, + password: defaultPasswordHash, + }); + + authorizationService.getUserWithPermissions.mockImplementation((userId: EntityId): Promise => { + if (mockAdminUser.id === userId) { + return Promise.resolve(mockAdminUser); + } + throw new EntityNotFoundError(User.name); + }); + authorizationService.checkAllPermissions.mockImplementation((): boolean => { + throw new UnauthorizedException(); + }); + + return { mockAdminUser, mockStudentAccount }; + }; + it('should throw UnauthorizedException', async () => { + const { mockAdminUser, mockStudentAccount } = setup(); + await expect( + accountUc.deleteAccountById({ userId: mockAdminUser.id } as ICurrentUser, mockStudentAccount.id) + ).rejects.toThrow(UnauthorizedException); + }); }); - it('should not throw, if lasttriedFailedLogin is undefined', async () => { - await expect( - accountUc.checkBrutForce( - mockAccountWithNoLastFailedLogin.username, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - mockAccountWithNoLastFailedLogin.systemId! - ) - ).resolves.not.toThrow(); + + describe('When no account matches the search term', () => { + const setup = () => { + const mockSchool = schoolEntityFactory.buildWithId(); + + const mockSuperheroUser = userFactory.buildWithId({ + school: mockSchool, + roles: [ + new Role({ + name: RoleName.SUPERHERO, + permissions: [Permission.TEACHER_EDIT, Permission.STUDENT_EDIT], + }), + ], + }); + + authorizationService.getUserWithPermissions.mockImplementation((userId: EntityId): Promise => { + if (mockSuperheroUser.id === userId) { + return Promise.resolve(mockSuperheroUser); + } + throw new EntityNotFoundError(User.name); + }); + + accountService.findById.mockImplementation((id: EntityId): Promise => { + if (id === 'xxx') { + throw new EntityNotFoundError(AccountEntity.name); + } + return Promise.reject(); + }); + + return { mockSuperheroUser }; + }; + it('should throw, if no account matches the search term', async () => { + const { mockSuperheroUser } = setup(); + await expect( + accountUc.deleteAccountById({ userId: mockSuperheroUser.id } as ICurrentUser, 'xxx') + ).rejects.toThrow(EntityNotFoundError); + }); }); }); }); diff --git a/apps/server/src/modules/account/uc/account.uc.ts b/apps/server/src/modules/account/uc/account.uc.ts index 3dda5f67f2c..db73e7c34f0 100644 --- a/apps/server/src/modules/account/uc/account.uc.ts +++ b/apps/server/src/modules/account/uc/account.uc.ts @@ -1,255 +1,192 @@ -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto/account.dto'; -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { - AuthorizationError, - EntityNotFoundError, - ForbiddenOperationError, - ValidationError, -} from '@shared/common/error'; -import { Account, Role, SchoolEntity, User } from '@shared/domain/entity'; +import { ICurrentUser } from '@modules/authentication'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { EntityNotFoundError, ValidationError } from '@shared/common/error'; +import { Role, SchoolEntity, User } from '@shared/domain/entity'; import { Permission, RoleName } from '@shared/domain/interface'; -import { PermissionService } from '@shared/domain/service'; import { EntityId } from '@shared/domain/types'; -import { UserRepo } from '@shared/repo'; - -import { ICurrentUser } from '@modules/authentication'; -import { BruteForcePrevention } from '@src/imports-from-feathers'; -import { ObjectId } from 'bson'; -import { AccountConfig } from '../account-config'; +import { AuthorizationService } from '@modules/authorization'; +import { AccountService } from '..'; +import { AccountSearchType } from '../controller/dto'; +import { Account, AccountSave, UpdateAccount, UpdateMyAccount } from '../domain'; import { - AccountByIdBodyParams, - AccountByIdParams, - AccountResponse, - AccountSearchListResponse, - AccountSearchQueryParams, - AccountSearchType, - PatchMyAccountParams, -} from '../controller/dto'; -import { AccountResponseMapper } from '../mapper'; -import { AccountValidationService } from '../services/account.validation.service'; -import { AccountSaveDto } from '../services/dto'; - -type UserPreferences = { - // first login completed - firstLogin: boolean; -}; + AccountSearchDto, + ResolvedAccountDto, + ResolvedSearchListAccountDto, + UpdateAccountDto, + UpdateMyAccountDto, +} from './dto'; +import { AccountUcMapper } from './mapper/account-uc.mapper'; + @Injectable() export class AccountUc { constructor( private readonly accountService: AccountService, - private readonly userRepo: UserRepo, - private readonly permissionService: PermissionService, - private readonly accountValidationService: AccountValidationService, - private readonly configService: ConfigService + private readonly authorizationService: AuthorizationService ) {} /** * This method processes the request on the GET account search endpoint from the account controller. * * @param currentUser the request user - * @param query the request query + * @param search the search request * @throws {ValidationError} * @throws {ForbiddenOperationError} */ - async searchAccounts(currentUser: ICurrentUser, query: AccountSearchQueryParams): Promise { - const skip = query.skip ?? 0; - const limit = query.limit ?? 10; - const executingUser = await this.userRepo.findById(currentUser.userId, true); - - if (query.type === AccountSearchType.USERNAME) { - if (!(await this.isSuperhero(currentUser))) { - throw new ForbiddenOperationError('Current user is not authorized to search for accounts.'); - } - const [accounts, total] = await this.accountService.searchByUsernamePartialMatch(query.value, skip, limit); - const accountList = accounts.map((tempAccount) => AccountResponseMapper.mapToResponse(tempAccount)); - return new AccountSearchListResponse(accountList, total, skip, limit); + public async searchAccounts( + currentUser: ICurrentUser, + search: AccountSearchDto + ): Promise { + const executingUser: User = await this.authorizationService.getUserWithPermissions(currentUser.userId); + + switch (search.type) { + case AccountSearchType.USERNAME: + return this.searchByUsername(executingUser, search.value, search.skip, search.limit); + case AccountSearchType.USER_ID: + return this.searchByUserId(executingUser, search.value, search.skip, search.limit); + default: + throw new ValidationError('Invalid search type.'); } - if (query.type === AccountSearchType.USER_ID) { - const targetUser = await this.userRepo.findById(query.value, true); - const permission = this.hasPermissionsToAccessAccount(executingUser, targetUser, 'READ'); + } - if (!permission) { - throw new ForbiddenOperationError('Current user is not authorized to search for accounts by user id.'); - } - const account = await this.accountService.findByUserId(query.value); - if (account) { - return new AccountSearchListResponse([AccountResponseMapper.mapToResponse(account)], 1, 0, 1); - } - return new AccountSearchListResponse([], 0, 0, 0); + private async searchByUsername( + executingUser: User, + usernameQuery: string, + skip?: number, + limit?: number + ): Promise { + this.authorizationService.checkAllPermissions(executingUser, [Permission.ACCOUNT_VIEW]); + + const searchDoCounted = await this.accountService.searchByUsernamePartialMatch( + usernameQuery, + skip ?? 0, + limit ?? 10 + ); + const [searchResult, total] = AccountUcMapper.mapSearchResult(searchDoCounted); + + return new ResolvedSearchListAccountDto(searchResult, total, skip ?? 0, limit ?? 10); + } + + private async searchByUserId( + executingUser: User, + targetUserId: string, + skip?: number, + limit?: number + ): Promise { + const targetUser = await this.authorizationService.getUserWithPermissions(targetUserId); + const permission = this.hasPermissionsToAccessAccount(executingUser, targetUser, 'READ'); + if (!permission) { + throw new UnauthorizedException('Current user is not authorized to search for accounts by user id.'); } - throw new ValidationError('Invalid search type.'); + const account = await this.accountService.findByUserId(targetUserId); + + if (account) { + return new ResolvedSearchListAccountDto( + [AccountUcMapper.mapToResolvedAccountDto(account)], + 1, + skip ?? 0, + limit ?? 1 + ); + } + return new ResolvedSearchListAccountDto([], 0, skip ?? 0, limit ?? 0); } /** * This method processes the request on the GET account with id endpoint from the account controller. * * @param currentUser the request user - * @param params the request parameters + * @param accountId the account id * @throws {ForbiddenOperationError} * @throws {EntityNotFoundError} */ - async findAccountById(currentUser: ICurrentUser, params: AccountByIdParams): Promise { - if (!(await this.isSuperhero(currentUser))) { - throw new ForbiddenOperationError('Current user is not authorized to search for accounts.'); - } - const account = await this.accountService.findById(params.id); - return AccountResponseMapper.mapToResponse(account); + public async findAccountById(currentUser: ICurrentUser, accountId: string): Promise { + const user = await this.authorizationService.getUserWithPermissions(currentUser.userId); + this.authorizationService.checkAllPermissions(user, [Permission.ACCOUNT_VIEW]); + + const account = await this.accountService.findById(accountId); + + return AccountUcMapper.mapToResolvedAccountDto(account); } - async saveAccount(dto: AccountSaveDto): Promise { - await this.accountService.saveWithValidation(dto); + /** + * Saves the given account with validation. + * + * @param accountSave the account to save + */ + public async saveAccount(accountSave: AccountSave): Promise { + await this.accountService.saveWithValidation(accountSave); } /** * This method processes the request on the PATCH account with id endpoint from the account controller. * * @param currentUser the request user - * @param params the request parameters - * @param body the request body + * @param accountId the account id + * @param updateDto the update request * @throws {ForbiddenOperationError} * @throws {EntityNotFoundError} */ - async updateAccountById( + public async updateAccountById( currentUser: ICurrentUser, - params: AccountByIdParams, - body: AccountByIdBodyParams - ): Promise { - const executingUser = await this.userRepo.findById(currentUser.userId, true); - const targetAccount = await this.accountService.findById(params.id); + accountId: string, + updateDto: UpdateAccountDto + ): Promise { + const executingUser = await this.authorizationService.getUserWithPermissions(currentUser.userId); + const targetAccount = await this.accountService.findById(accountId); if (!targetAccount.userId) { throw new EntityNotFoundError(User.name); } - const targetUser = await this.userRepo.findById(targetAccount.userId, true); - - let updateUser = false; - let updateAccount = false; + const targetUser = await this.authorizationService.getUserWithPermissions(targetAccount.userId); if (!this.hasPermissionsToAccessAccount(executingUser, targetUser, 'UPDATE')) { - throw new ForbiddenOperationError('Current user is not authorized to update target account.'); - } - if (body.password !== undefined) { - targetAccount.password = body.password; - targetUser.forcePasswordChange = true; - updateUser = true; - updateAccount = true; - } - if (body.username !== undefined) { - const newMail = body.username.toLowerCase(); - await this.checkUniqueEmail(targetAccount, targetUser, newMail); - targetUser.email = newMail; - targetAccount.username = newMail; - updateUser = true; - updateAccount = true; - } - if (body.activated !== undefined) { - targetAccount.activated = body.activated; - updateAccount = true; + throw new UnauthorizedException('Current user is not authorized to update target account.'); } - if (updateUser) { - try { - await this.userRepo.save(targetUser); - } catch (err) { - throw new EntityNotFoundError(User.name); - } - } - if (updateAccount) { - try { - await this.accountService.save(targetAccount); - } catch (err) { - throw new EntityNotFoundError(Account.name); - } - } - return AccountResponseMapper.mapToResponse(targetAccount); + const updateData = new UpdateAccount(updateDto); + const updated: Account = await this.accountService.updateAccount(targetUser, targetAccount, updateData); + + return AccountUcMapper.mapToResolvedAccountDto(updated); } /** * This method processes the request on the DELETE account with id endpoint from the account controller. * * @param currentUser the request user - * @param params the request parameters + * @param accountId the account id * @throws {ForbiddenOperationError} * @throws {EntityNotFoundError} */ - async deleteAccountById(currentUser: ICurrentUser, params: AccountByIdParams): Promise { - if (!(await this.isSuperhero(currentUser))) { - throw new ForbiddenOperationError('Current user is not authorized to delete an account.'); - } - const account: AccountDto = await this.accountService.findById(params.id); + public async deleteAccountById(currentUser: ICurrentUser, accountId: string): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(currentUser.userId); + this.authorizationService.checkAllPermissions(user, [Permission.ACCOUNT_DELETE]); + + const account: Account = await this.accountService.findById(accountId); + await this.accountService.delete(account.id); - return AccountResponseMapper.mapToResponse(account); + return AccountUcMapper.mapToResolvedAccountDto(account); } /** * This method allows to update my (currentUser) account details. * * @param currentUserId the current user - * @param params account details + * @param updateMyAccountDto account details */ - async updateMyAccount(currentUserId: EntityId, params: PatchMyAccountParams) { - const user = await this.userRepo.findById(currentUserId, true); - const account: AccountDto = await this.accountService.findByUserIdOrFail(currentUserId); - - if (account.systemId) { - throw new ForbiddenOperationError('External account details can not be changed.'); - } - - if (!params.passwordOld || !(await this.accountService.validatePassword(account, params.passwordOld))) { - throw new AuthorizationError('Dein Passwort ist nicht korrekt!'); + public async updateMyAccount(currentUserId: EntityId, updateMyAccountDto: UpdateMyAccountDto) { + const user = await this.authorizationService.getUserWithPermissions(currentUserId); + if ( + (updateMyAccountDto.firstName && user.firstName !== updateMyAccountDto.firstName) || + (updateMyAccountDto.lastName && user.lastName !== updateMyAccountDto.lastName) + ) { + this.authorizationService.checkAllPermissions(user, [Permission.USER_CHANGE_OWN_NAME]); } - let updateUser = false; - let updateAccount = false; - if (params.passwordNew) { - account.password = params.passwordNew; - updateAccount = true; - } else { - account.password = undefined; - } + const account: Account = await this.accountService.findByUserIdOrFail(currentUserId); + const updateData = new UpdateMyAccount(updateMyAccountDto); - if (params.email && user.email !== params.email) { - const newMail = params.email.toLowerCase(); - await this.checkUniqueEmail(account, user, newMail); - user.email = newMail; - account.username = newMail; - updateUser = true; - updateAccount = true; - } - - if (params.firstName && user.firstName !== params.firstName) { - if (!this.hasPermissionsToChangeOwnName(user)) { - throw new ForbiddenOperationError('No permission to change first name'); - } - user.firstName = params.firstName; - updateUser = true; - } - if (params.lastName && user.lastName !== params.lastName) { - if (!this.hasPermissionsToChangeOwnName(user)) { - throw new ForbiddenOperationError('No permission to change last name'); - } - user.lastName = params.lastName; - updateUser = true; - } - - if (updateUser) { - try { - await this.userRepo.save(user); - } catch (err) { - throw new EntityNotFoundError(User.name); - } - } - if (updateAccount) { - try { - await this.accountService.save(account); - } catch (err) { - throw new EntityNotFoundError(Account.name); - } - } + await this.accountService.updateMyAccount(user, account, updateData); } /** @@ -260,90 +197,8 @@ export class AccountUc { * @param password the new password * @param confirmPassword the new password (has to match password) */ - async replaceMyTemporaryPassword(userId: EntityId, password: string, confirmPassword: string): Promise { - if (password !== confirmPassword) { - throw new ForbiddenOperationError('Password and confirm password do not match.'); - } - - let user: User; - try { - user = await this.userRepo.findById(userId); - } catch (err) { - throw new EntityNotFoundError(User.name); - } - - const userPreferences = user.preferences; - const firstLoginPassed = userPreferences ? userPreferences.firstLogin : false; - - if (!user.forcePasswordChange && firstLoginPassed) { - throw new ForbiddenOperationError('The password is not temporary, hence can not be changed.'); - } // Password change was forces or this is a first logon for the user - - const account: AccountDto = await this.accountService.findByUserIdOrFail(userId); - - if (account.systemId) { - throw new ForbiddenOperationError('External account details can not be changed.'); - } - - if (await this.accountService.validatePassword(account, password)) { - throw new ForbiddenOperationError('New password can not be same as old password.'); - } - - try { - account.password = password; - await this.accountService.save(account); - } catch (err) { - throw new EntityNotFoundError(Account.name); - } - try { - user.forcePasswordChange = false; - await this.userRepo.save(user); - } catch (err) { - throw new EntityNotFoundError(User.name); - } - } - - /** - * - * @deprecated this is for legacy login strategies only. Login strategies in Nest.js should use {@link AuthenticationService} - */ - async checkBrutForce(username: string, systemId: EntityId | ObjectId): Promise { - const account = await this.accountService.findByUsernameAndSystemId(username, systemId); - // missing Account is ignored as in legacy feathers Impl. - if (account) { - if (account.lasttriedFailedLogin) { - const timeDifference = (new Date().getTime() - account.lasttriedFailedLogin.getTime()) / 1000; - if (timeDifference < this.configService.get('LOGIN_BLOCK_TIME')) { - throw new BruteForcePrevention('Brute Force Prevention!', { - timeToWait: this.configService.get('LOGIN_BLOCK_TIME') - Math.ceil(timeDifference), - }); - } - } - await this.accountService.updateLastTriedFailedLogin(account.id, new Date()); - } - } - - private async checkUniqueEmail(account: AccountDto, user: User, email: string): Promise { - if (!(await this.accountValidationService.isUniqueEmail(email, user.id, account.id, account.systemId))) { - throw new ValidationError(`The email address is already in use!`); - } - } - - private hasRole(user: User, roleName: string) { - return user.roles.getItems().some((role) => role.name === roleName); - } - - private async isSuperhero(currentUser: ICurrentUser): Promise { - const user = await this.userRepo.findById(currentUser.userId, true); - return user.roles.getItems().some((role) => role.name === RoleName.SUPERHERO); - } - - private hasPermissionsToChangeOwnName(currentUser: User) { - return ( - this.hasRole(currentUser, RoleName.SUPERHERO) || - this.hasRole(currentUser, RoleName.TEACHER) || - this.hasRole(currentUser, RoleName.ADMINISTRATOR) - ); + public async replaceMyTemporaryPassword(userId: EntityId, password: string, confirmPassword: string): Promise { + await this.accountService.replaceMyTemporaryPassword(userId, password, confirmPassword); } private hasPermissionsToAccessAccount( @@ -403,7 +258,7 @@ export class AccountUc { } return ( - this.permissionService.hasUserAllSchoolPermissions(currentUser, permissionsToCheck) || + this.authorizationService.hasAllPermissions(currentUser, permissionsToCheck) || this.schoolPermissionExists( this.extractRoles(currentUser.roles.getItems()), currentUser.school, @@ -412,6 +267,10 @@ export class AccountUc { ); } + private hasRole(user: User, roleName: string) { + return user.roles.getItems().some((role) => role.name === roleName); + } + private schoolPermissionExists(roles: string[], school: SchoolEntity, permissions: string[]): boolean { if ( roles.find((role) => role === RoleName.TEACHER) && diff --git a/apps/server/src/modules/account/uc/dto/account-search.dto.ts b/apps/server/src/modules/account/uc/dto/account-search.dto.ts new file mode 100644 index 00000000000..1a0cf11daf9 --- /dev/null +++ b/apps/server/src/modules/account/uc/dto/account-search.dto.ts @@ -0,0 +1,18 @@ +import { AccountSearchType } from '../../controller/dto'; + +export class AccountSearchDto { + type!: AccountSearchType; + + value!: string; + + skip?: number = 0; + + limit?: number = 10; + + constructor(search: AccountSearchDto) { + this.type = search.type; + this.value = search.value; + this.skip = search.skip; + this.limit = search.limit; + } +} diff --git a/apps/server/src/modules/account/uc/dto/index.ts b/apps/server/src/modules/account/uc/dto/index.ts new file mode 100644 index 00000000000..09eb9495a61 --- /dev/null +++ b/apps/server/src/modules/account/uc/dto/index.ts @@ -0,0 +1,4 @@ +export * from './account-search.dto'; +export * from './update-account.dto'; +export * from './update-my-account.dto'; +export * from './resolved-account.dto'; diff --git a/apps/server/src/modules/account/services/dto/account-save.dto.ts b/apps/server/src/modules/account/uc/dto/resolved-account.dto.ts similarity index 51% rename from apps/server/src/modules/account/services/dto/account-save.dto.ts rename to apps/server/src/modules/account/uc/dto/resolved-account.dto.ts index 5b2cfb5fe31..595ea2bd1bb 100644 --- a/apps/server/src/modules/account/services/dto/account-save.dto.ts +++ b/apps/server/src/modules/account/uc/dto/resolved-account.dto.ts @@ -1,12 +1,12 @@ -import { PrivacyProtect } from '@shared/controller'; import { EntityId } from '@shared/domain/types'; import { IsBoolean, IsDate, IsMongoId, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; +import { PrivacyProtect } from '@shared/controller'; import { passwordPattern } from '../../controller/dto/password-pattern'; -export class AccountSaveDto { +export class ResolvedAccountDto { @IsOptional() @IsMongoId() - readonly id?: EntityId; + readonly id: EntityId; @IsOptional() @IsDate() @@ -56,19 +56,36 @@ export class AccountSaveDto { @IsOptional() idmReferenceId?: string; - constructor(props: AccountSaveDto) { - this.id = props.id; - this.createdAt = props.createdAt; - this.updatedAt = props.updatedAt; - this.username = props.username; - this.password = props.password; - this.token = props.token; - this.credentialHash = props.credentialHash; - this.userId = props.userId; - this.systemId = props.systemId; - this.lasttriedFailedLogin = props.lasttriedFailedLogin; - this.expiresAt = props.expiresAt; - this.activated = props.activated; - this.idmReferenceId = props.idmReferenceId; + constructor(account: ResolvedAccountDto) { + this.id = account.id; + this.username = account.username; + this.userId = account.userId; + this.activated = account.activated; + this.updatedAt = account.updatedAt; + this.createdAt = account.createdAt; + this.systemId = account.systemId; + this.password = account.password; + this.token = account.token; + this.credentialHash = account.credentialHash; + this.lasttriedFailedLogin = account.lasttriedFailedLogin; + this.expiresAt = account.expiresAt; + this.idmReferenceId = account.idmReferenceId; + } +} + +export class ResolvedSearchListAccountDto { + data: ResolvedAccountDto[]; + + total: number; + + skip?: number; + + limit?: number; + + constructor(data: ResolvedAccountDto[], total: number, skip?: number, limit?: number) { + this.data = data; + this.total = total; + this.skip = skip; + this.limit = limit; } } diff --git a/apps/server/src/modules/account/uc/dto/update-account.dto.ts b/apps/server/src/modules/account/uc/dto/update-account.dto.ts new file mode 100644 index 00000000000..def37dd08ef --- /dev/null +++ b/apps/server/src/modules/account/uc/dto/update-account.dto.ts @@ -0,0 +1,13 @@ +export class UpdateAccountDto { + username?: string; + + password?: string; + + activated?: boolean; + + constructor(props: UpdateAccountDto) { + this.username = props.username; + this.password = props.password; + this.activated = props.activated; + } +} diff --git a/apps/server/src/modules/account/uc/dto/update-my-account.dto.ts b/apps/server/src/modules/account/uc/dto/update-my-account.dto.ts new file mode 100644 index 00000000000..6f66340bc37 --- /dev/null +++ b/apps/server/src/modules/account/uc/dto/update-my-account.dto.ts @@ -0,0 +1,19 @@ +export class UpdateMyAccountDto { + passwordOld!: string; + + passwordNew?: string; + + email?: string; + + firstName?: string; + + lastName?: string; + + constructor(props: UpdateMyAccountDto) { + this.passwordOld = props.passwordOld; + this.passwordNew = props.passwordNew; + this.email = props.email; + this.firstName = props.firstName; + this.lastName = props.lastName; + } +} diff --git a/apps/server/src/modules/account/uc/mapper/account-uc.mapper.spec.ts b/apps/server/src/modules/account/uc/mapper/account-uc.mapper.spec.ts new file mode 100644 index 00000000000..4c7e6249f67 --- /dev/null +++ b/apps/server/src/modules/account/uc/mapper/account-uc.mapper.spec.ts @@ -0,0 +1,68 @@ +import { Counted } from '@shared/domain/types'; +import { accountDoFactory } from '@shared/testing'; +import { Account } from '../../domain/account'; +import { AccountUcMapper } from './account-uc.mapper'; + +describe('AccountUcMapper', () => { + describe('mapToResolvedAccountDto', () => { + describe('When mapping Account to ResolvedAccountDto', () => { + const setup = () => { + const testDos: Account = accountDoFactory.build(); + return testDos; + }; + + it('should map all fields', () => { + const testDos = setup(); + + const ret = AccountUcMapper.mapToResolvedAccountDto(testDos); + + expect(ret.id).toBe(testDos.id); + expect(ret.userId).toBe(testDos.userId?.toString()); + expect(ret.activated).toBe(testDos.activated); + expect(ret.username).toBe(testDos.username); + }); + }); + }); + + describe('mapSearchResult', () => { + describe('When mapping Counted to Counted', () => { + const setup = () => { + const testDos: Counted = [accountDoFactory.buildList(3), 3]; + return testDos; + }; + + it('should map all fields', () => { + const testDos = setup(); + + const ret = AccountUcMapper.mapSearchResult(testDos); + + expect(ret[0].length).toBe(testDos[0].length); + expect(ret[0][0].id).toBe(testDos[0][0].id); + expect(ret[0][0].userId).toBe(testDos[0][0].userId?.toString()); + expect(ret[0][0].activated).toBe(testDos[0][0].activated); + expect(ret[0][0].username).toBe(testDos[0][0].username); + }); + }); + }); + + describe('mapAccountsToDo', () => { + describe('When mapping Account[] to ResolvedAccountDto[]', () => { + const setup = () => { + const testDos: Account[] = accountDoFactory.buildList(3); + return testDos; + }; + + it('should map all fields', () => { + const testDos = setup(); + + const ret = AccountUcMapper.mapAccountsToDo(testDos); + + expect(ret.length).toBe(testDos.length); + expect(ret[0].id).toBe(testDos[0].id); + expect(ret[0].userId).toBe(testDos[0].userId?.toString()); + expect(ret[0].activated).toBe(testDos[0].activated); + expect(ret[0].username).toBe(testDos[0].username); + }); + }); + }); +}); diff --git a/apps/server/src/modules/account/uc/mapper/account-uc.mapper.ts b/apps/server/src/modules/account/uc/mapper/account-uc.mapper.ts new file mode 100644 index 00000000000..c8b64573fa7 --- /dev/null +++ b/apps/server/src/modules/account/uc/mapper/account-uc.mapper.ts @@ -0,0 +1,21 @@ +import { Counted } from '@shared/domain/types'; +import { Account } from '../../domain'; +import { ResolvedAccountDto } from '../dto/resolved-account.dto'; + +export class AccountUcMapper { + static mapToResolvedAccountDto(account: Account): ResolvedAccountDto { + return new ResolvedAccountDto({ + ...account.getProps(), + }); + } + + static mapSearchResult(accounts: Counted): Counted { + const foundAccounts = accounts[0]; + const accountDos: ResolvedAccountDto[] = AccountUcMapper.mapAccountsToDo(foundAccounts); + return [accountDos, accounts[1]]; + } + + static mapAccountsToDo(accounts: Account[]): ResolvedAccountDto[] { + return accounts.map((account) => AccountUcMapper.mapToResolvedAccountDto(account)); + } +} diff --git a/apps/server/src/modules/authentication/authentication-config.ts b/apps/server/src/modules/authentication/authentication-config.ts new file mode 100644 index 00000000000..9ed4f7250e5 --- /dev/null +++ b/apps/server/src/modules/authentication/authentication-config.ts @@ -0,0 +1,6 @@ +import { AccountConfig } from '@modules/account'; +import { XApiKeyConfig } from './config'; + +export interface AuthenticationConfig extends AccountConfig, XApiKeyConfig { + LOGIN_BLOCK_TIME: number; +} diff --git a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts index 5ebf33df21f..d7b1b6a1e98 100644 --- a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts +++ b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts @@ -3,14 +3,15 @@ import { OauthTokenResponse } from '@modules/oauth/service/dto'; import { ServerTestModule } from '@modules/server/server.module'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Account, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; -import { accountFactory, roleFactory, schoolFactory, systemEntityFactory, userFactory } from '@shared/testing'; +import { accountFactory, roleFactory, schoolEntityFactory, systemEntityFactory, userFactory } from '@shared/testing'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import crypto, { KeyPairKeyObjectResult } from 'crypto'; import jwt from 'jsonwebtoken'; import request, { Response } from 'supertest'; +import { AccountEntity } from '@modules/account/entity/account.entity'; import { ICurrentUser } from '../../interface'; import { LdapAuthorizationBodyParams, LocalAuthorizationBodyParams, OauthLoginResponse } from '../dto'; @@ -91,11 +92,11 @@ describe('Login Controller (api)', () => { }); describe('loginLocal', () => { - let account: Account; + let account: AccountEntity; let user: User; beforeAll(async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); user = userFactory.buildWithId({ school, roles: [studentRoles] }); @@ -150,12 +151,15 @@ describe('Login Controller (api)', () => { const setup = async () => { const schoolExternalId = 'mockSchoolExternalId'; const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({}); - const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [system], + externalId: schoolExternalId, + }); const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); const user: User = userFactory.buildWithId({ school, roles: [studentRoles], ldapDn: mockUserLdapDN }); - const account: Account = accountFactory.buildWithId({ + const account: AccountEntity = accountFactory.buildWithId({ userId: user.id, username: `${schoolExternalId}/${ldapAccountUserName}`.toLowerCase(), systemId: system.id, @@ -201,12 +205,15 @@ describe('Login Controller (api)', () => { const setup = async () => { const schoolExternalId = 'mockSchoolExternalId'; const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({}); - const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [system], + externalId: schoolExternalId, + }); const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); const user: User = userFactory.buildWithId({ school, roles: [studentRoles], ldapDn: mockUserLdapDN }); - const account: Account = accountFactory.buildWithId({ + const account: AccountEntity = accountFactory.buildWithId({ userId: user.id, username: `${schoolExternalId}/${ldapAccountUserName}`.toLowerCase(), systemId: system.id, @@ -239,7 +246,7 @@ describe('Login Controller (api)', () => { const setup = async () => { const officialSchoolNumber = '01234'; const system: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({}); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [system], externalId: officialSchoolNumber, officialSchoolNumber, @@ -248,7 +255,7 @@ describe('Login Controller (api)', () => { const user: User = userFactory.buildWithId({ school, roles: [studentRole], ldapDn: mockUserLdapDN }); - const account: Account = accountFactory.buildWithId({ + const account: AccountEntity = accountFactory.buildWithId({ userId: user.id, username: `${officialSchoolNumber}/${ldapAccountUserName}`.toLowerCase(), systemId: system.id, @@ -302,7 +309,7 @@ describe('Login Controller (api)', () => { const userExternalId = 'userExternalId'; const system = systemEntityFactory.withOauthConfig().buildWithId({}); - const school = schoolFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); + const school = schoolEntityFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); const user = userFactory.buildWithId({ school, roles: [studentRoles], externalId: userExternalId }); const account = accountFactory.buildWithId({ @@ -385,42 +392,5 @@ describe('Login Controller (api)', () => { expect(decodedToken).not.toHaveProperty('externalIdToken'); }); }); - - describe('when an error is provided', () => { - const setup = async () => { - const schoolExternalId = 'schoolExternalId'; - const userExternalId = 'userExternalId'; - - const system = systemEntityFactory.withOauthConfig().buildWithId({}); - const school = schoolFactory.buildWithId({ systems: [system], externalId: schoolExternalId }); - const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); - const user = userFactory.buildWithId({ school, roles: [studentRoles], externalId: userExternalId }); - const account = accountFactory.buildWithId({ - userId: user.id, - systemId: system.id, - }); - - await em.persistAndFlush([system, school, studentRoles, user, account]); - em.clear(); - - return { - system, - }; - }; - - it('should throw a InternalServerErrorException', async () => { - const { system } = await setup(); - - await request(app.getHttpServer()) - .post(`${basePath}/oauth2`) - .send({ - redirectUri: 'redirectUri', - error: 'sso_login_failed', - systemId: system.id, - }) - // TODO N21-820: change this to UNAUTHORIZED when refactoring exceptions - .expect(HttpStatus.INTERNAL_SERVER_ERROR); - }); - }); }); }); diff --git a/apps/server/src/modules/authentication/index.ts b/apps/server/src/modules/authentication/index.ts index fba2d4332d2..30b503e11c5 100644 --- a/apps/server/src/modules/authentication/index.ts +++ b/apps/server/src/modules/authentication/index.ts @@ -3,3 +3,4 @@ export { Authenticate, CurrentUser, JWT } from './decorator'; export { ICurrentUser } from './interface'; export { AuthenticationService } from './services'; export { XApiKeyConfig } from './config'; +export { AuthenticationConfig } from './authentication-config'; diff --git a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts index 312bfeb3800..c852b725fd3 100644 --- a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts +++ b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts @@ -1,7 +1,7 @@ import { ValidationError } from '@shared/common'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { Permission, RoleName } from '@shared/domain/interface'; -import { roleFactory, schoolFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; +import { roleFactory, schoolEntityFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; import { ICurrentUser, OauthCurrentUser } from '../interface'; import { CreateJwtPayload, JwtPayload } from '../interface/jwt-payload'; import { CurrentUserMapper } from './current-user.mapper'; @@ -65,7 +65,7 @@ describe('CurrentUserMapper', () => { describe('when systemId is provided', () => { const setup = () => { const user = userFactory.buildWithId({ - school: schoolFactory.buildWithId(), + school: schoolEntityFactory.buildWithId(), }); const systemId = 'mockSystemId'; diff --git a/apps/server/src/modules/authentication/services/authentication.service.spec.ts b/apps/server/src/modules/authentication/services/authentication.service.spec.ts index de80540a65a..baff54c7904 100644 --- a/apps/server/src/modules/authentication/services/authentication.service.spec.ts +++ b/apps/server/src/modules/authentication/services/authentication.service.spec.ts @@ -1,6 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto'; +import { AccountService, Account } from '@modules/account'; import { ICurrentUser } from '@modules/authentication'; import { UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -21,12 +20,12 @@ describe('AuthenticationService', () => { let accountService: DeepMocked; let jwtService: DeepMocked; - const mockAccount: AccountDto = { + const mockAccount: Account = new Account({ id: 'mockAccountId', createdAt: new Date(), updatedAt: new Date(), username: 'mockedUsername', - }; + }); beforeAll(async () => { module = await Test.createTestingModule({ @@ -65,7 +64,7 @@ describe('AuthenticationService', () => { describe('when resolving an account without system id', () => { it('should find an account', async () => { accountService.searchByUsernameExactMatch.mockResolvedValueOnce([ - [{ ...mockAccount, systemId: 'mockSystemId' }, mockAccount], + [{ ...mockAccount, systemId: 'mockSystemId' } as Account, mockAccount], 2, ]); const account = await authenticationService.loadAccount('username'); @@ -75,7 +74,10 @@ describe('AuthenticationService', () => { describe('when resolving an account with system id', () => { it('should find an account', async () => { - accountService.findByUsernameAndSystemId.mockResolvedValueOnce({ ...mockAccount, systemId: 'mockSystemId' }); + accountService.findByUsernameAndSystemId.mockResolvedValueOnce({ + ...mockAccount, + systemId: 'mockSystemId', + } as Account); const account = await authenticationService.loadAccount('username', 'mockSystemId'); expect(account).toEqual({ ...mockAccount, systemId: 'mockSystemId' }); }); @@ -146,14 +148,14 @@ describe('AuthenticationService', () => { it('should fail for account with recently failed login', () => { const lasttriedFailedLogin = setup(14); expect(() => - authenticationService.checkBrutForce({ id: 'mockAccountId', lasttriedFailedLogin } as AccountDto) + authenticationService.checkBrutForce({ id: 'mockAccountId', lasttriedFailedLogin } as Account) ).toThrow(BruteForceError); }); it('should not fail for account with failed login above threshold', () => { const lasttriedFailedLogin = setup(16); expect(() => - authenticationService.checkBrutForce({ id: 'mockAccountId', lasttriedFailedLogin } as AccountDto) + authenticationService.checkBrutForce({ id: 'mockAccountId', lasttriedFailedLogin } as Account) ).not.toThrow(); }); }); diff --git a/apps/server/src/modules/authentication/services/authentication.service.ts b/apps/server/src/modules/authentication/services/authentication.service.ts index efa8ec35b27..7b6fadfd9b1 100644 --- a/apps/server/src/modules/authentication/services/authentication.service.ts +++ b/apps/server/src/modules/authentication/services/authentication.service.ts @@ -1,9 +1,7 @@ -import { AccountService } from '@modules/account'; +import { AccountService, Account } from '@modules/account'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -// invalid import -import { AccountDto } from '@modules/account/services/dto'; // invalid import, can produce dependency cycles import type { ServerConfig } from '@modules/server'; import { randomUUID } from 'crypto'; @@ -22,8 +20,8 @@ export class AuthenticationService { private readonly configService: ConfigService ) {} - async loadAccount(username: string, systemId?: string): Promise { - let account: AccountDto | undefined | null; + async loadAccount(username: string, systemId?: string): Promise { + let account: Account | undefined | null; if (systemId) { account = await this.accountService.findByUsernameAndSystemId(username, systemId); @@ -62,7 +60,7 @@ export class AuthenticationService { } } - checkBrutForce(account: AccountDto): void { + checkBrutForce(account: Account): void { if (account.lasttriedFailedLogin) { const timeDifference = (new Date().getTime() - account.lasttriedFailedLogin.getTime()) / 1000; diff --git a/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.spec.ts b/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.spec.ts index 6638e0470b2..28803389ae5 100644 --- a/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.spec.ts +++ b/apps/server/src/modules/authentication/strategy/jwt-validation.adapter.spec.ts @@ -31,7 +31,7 @@ describe('jwt strategy', () => { ], }).compile(); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access - const redisClientMock = RedisMock.createClient(); + const redisClientMock = new RedisMock(); // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access feathersRedis.setRedisClient(redisClientMock); diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts index 7740b56b44e..0432459b931 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.spec.ts @@ -1,5 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { AccountDto } from '@modules/account/services/dto'; +import { Account } from '@modules/account'; import { UnauthorizedException } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; import { Test, TestingModule } from '@nestjs/testing'; @@ -9,11 +9,11 @@ import { RoleName } from '@shared/domain/interface'; import { LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { - accountDtoFactory, + accountDoFactory, defaultTestPassword, defaultTestPasswordHash, legacySchoolDoFactory, - schoolFactory, + schoolEntityFactory, setupEntities, systemEntityFactory, userFactory, @@ -96,7 +96,7 @@ describe('LdapStrategy', () => { const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ systems: [system.id] }, user.school.id); - const account: AccountDto = accountDtoFactory.build({ + const account: Account = accountDoFactory.build({ systemId: system.id, username, password: defaultTestPasswordHash, @@ -143,7 +143,7 @@ describe('LdapStrategy', () => { const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ systems: [] }, user.school.id); - const account: AccountDto = accountDtoFactory.build({ + const account: Account = accountDoFactory.build({ systemId: system.id, username, password: defaultTestPasswordHash, @@ -190,7 +190,7 @@ describe('LdapStrategy', () => { const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ systems: undefined }, user.school.id); - const account: AccountDto = accountDtoFactory.build({ + const account: Account = accountDoFactory.build({ systemId: system.id, username, password: defaultTestPasswordHash, @@ -237,7 +237,7 @@ describe('LdapStrategy', () => { const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ systems: [system.id] }, user.school.id); - const account: AccountDto = accountDtoFactory.build({ + const account: Account = accountDoFactory.build({ systemId: system.id, username, password: defaultTestPasswordHash, @@ -284,7 +284,7 @@ describe('LdapStrategy', () => { const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ systems: [system.id] }, user.school.id); - const account: AccountDto = accountDtoFactory.build({ + const account: Account = accountDoFactory.build({ systemId: system.id, username, password: defaultTestPasswordHash, @@ -336,7 +336,7 @@ describe('LdapStrategy', () => { const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ systems: [system.id] }, user.school.id); - const account: AccountDto = accountDtoFactory.build({ + const account: Account = accountDoFactory.build({ systemId: system.id, username, password: defaultTestPasswordHash, @@ -389,14 +389,14 @@ describe('LdapStrategy', () => { const user: User = userFactory .withRoleByName(RoleName.STUDENT) - .buildWithId({ ldapDn: 'mockLdapDn', school: schoolFactory.buildWithId() }); + .buildWithId({ ldapDn: 'mockLdapDn', school: schoolEntityFactory.buildWithId() }); const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId( { systems: [system.id], previousExternalId: undefined }, user.school.id ); - const account: AccountDto = accountDtoFactory.build({ + const account: Account = accountDoFactory.build({ systemId: system.id, username, password: defaultTestPasswordHash, @@ -452,14 +452,14 @@ describe('LdapStrategy', () => { const user: User = userFactory .withRoleByName(RoleName.STUDENT) - .buildWithId({ ldapDn: 'mockLdapDn', school: schoolFactory.buildWithId() }); + .buildWithId({ ldapDn: 'mockLdapDn', school: schoolEntityFactory.buildWithId() }); const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId( { systems: [system.id], previousExternalId: 'previousExternalId' }, user.school.id ); - const account: AccountDto = accountDtoFactory.build({ + const account: Account = accountDoFactory.build({ systemId: system.id, username, password: defaultTestPasswordHash, diff --git a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts index c11ea3818be..d6f8bd7438c 100644 --- a/apps/server/src/modules/authentication/strategy/ldap.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/ldap.strategy.ts @@ -1,4 +1,4 @@ -import { AccountDto } from '@modules/account/services/dto'; +import { Account } from '@modules/account'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { LegacySchoolDo } from '@shared/domain/domainobject'; @@ -37,7 +37,7 @@ export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { throw new UnauthorizedException(`School ${schoolId} does not have the selected system ${systemId}`); } - const account: AccountDto = await this.loadAccount(username, system.id, school); + const account: Account = await this.loadAccount(username, system.id, school); const userId: string = this.checkValue(account.userId); @@ -74,7 +74,7 @@ export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { } private async checkCredentials( - account: AccountDto, + account: Account, system: SystemEntity, ldapDn: string, password: string @@ -89,10 +89,10 @@ export class LdapStrategy extends PassportStrategy(Strategy, 'ldap') { } } - private async loadAccount(username: string, systemId: string, school: LegacySchoolDo): Promise { + private async loadAccount(username: string, systemId: string, school: LegacySchoolDo): Promise { const externalSchoolId = this.checkValue(school.externalId); - let account: AccountDto; + let account: Account; // TODO having to check for two values in order to find an account is not optimal and should be changed. // The way the name field of Accounts is used for LDAP should be reconsidered, since diff --git a/apps/server/src/modules/authentication/strategy/local.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/local.strategy.spec.ts index 427b1adea39..010f20826ee 100644 --- a/apps/server/src/modules/authentication/strategy/local.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/local.strategy.spec.ts @@ -1,14 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { IdentityManagementOauthService } from '@infra/identity-management'; -import { AccountEntityToDtoMapper } from '@modules/account/mapper'; -import { AccountDto } from '@modules/account/services/dto'; +import { Account } from '@modules/account'; import { ServerConfig } from '@modules/server'; import { UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { UserRepo } from '@shared/repo'; -import { accountFactory, setupEntities, userFactory } from '@shared/testing'; +import { accountDoFactory, setupEntities, userFactory } from '@shared/testing'; import bcrypt from 'bcryptjs'; import { AuthenticationService } from '../services/authentication.service'; import { LocalStrategy } from './local.strategy'; @@ -16,7 +15,7 @@ import { LocalStrategy } from './local.strategy'; describe('LocalStrategy', () => { let strategy: LocalStrategy; let mockUser: User; - let mockAccount: AccountDto; + let mockAccount: Account; let userRepoMock: DeepMocked; let authenticationServiceMock: DeepMocked; let idmOauthServiceMock: DeepMocked; @@ -33,9 +32,7 @@ describe('LocalStrategy', () => { userRepoMock = createMock(); strategy = new LocalStrategy(authenticationServiceMock, idmOauthServiceMock, configServiceMock, userRepoMock); mockUser = userFactory.withRoleByName(RoleName.STUDENT).buildWithId(); - mockAccount = AccountEntityToDtoMapper.mapToDto( - accountFactory.buildWithId({ userId: mockUser.id, password: mockPasswordHash }) - ); + mockAccount = accountDoFactory.build({ userId: mockUser.id, password: mockPasswordHash }); }); beforeEach(() => { @@ -86,7 +83,7 @@ describe('LocalStrategy', () => { describe('when an account has no password', () => { it('should throw unauthorized error', async () => { - const accountNoPassword = { ...mockAccount }; + const accountNoPassword = { ...mockAccount } as Account; delete accountNoPassword.password; authenticationServiceMock.loadAccount.mockResolvedValueOnce(accountNoPassword); await expect(strategy.validate(mockAccount.username, mockPassword)).rejects.toThrow(UnauthorizedException); @@ -102,12 +99,10 @@ describe('LocalStrategy', () => { describe('when an account has no user id', () => { it('should throw error', async () => { - const accountNoUser = { ...mockAccount }; + const accountNoUser = { ...mockAccount } as Account; delete accountNoUser.userId; authenticationServiceMock.loadAccount.mockResolvedValueOnce(accountNoUser); - await expect(strategy.validate('mockUsername', mockPassword)).rejects.toThrow( - new Error(`login failing, because account ${mockAccount.id} has no userId`) - ); + await expect(strategy.validate('mockUsername', mockPassword)).rejects.toThrow(new UnauthorizedException()); }); }); }); diff --git a/apps/server/src/modules/authentication/strategy/local.strategy.ts b/apps/server/src/modules/authentication/strategy/local.strategy.ts index c423fc396ff..3e762219f7d 100644 --- a/apps/server/src/modules/authentication/strategy/local.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/local.strategy.ts @@ -1,5 +1,5 @@ import { IdentityManagementConfig, IdentityManagementOauthService } from '@infra/identity-management'; -import { AccountDto } from '@modules/account/services/dto'; +import { Account } from '@modules/account'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; @@ -54,7 +54,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) { private async checkCredentials( enteredPassword: string, savedPassword: string, - account: AccountDto + account: Account ): Promise { this.authenticationService.checkBrutForce(account); if (!(await bcrypt.compare(enteredPassword, savedPassword))) { diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts index 4b9cf685baa..6a0f02d04fe 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts @@ -1,6 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto'; +import { AccountService, Account } from '@modules/account'; import { OAuthService, OAuthTokenDto } from '@modules/oauth'; import { UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -56,7 +55,7 @@ describe('Oauth2Strategy', () => { const setup = () => { const systemId: EntityId = 'systemId'; const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).buildWithId(); - const account: AccountDto = new AccountDto({ + const account: Account = new Account({ id: 'accountId', createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts index 320387a7d00..cc986317a29 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts @@ -1,5 +1,4 @@ -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto'; +import { AccountService, Account } from '@modules/account'; import { OAuthService, OAuthTokenDto } from '@modules/oauth'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; @@ -27,7 +26,7 @@ export class Oauth2Strategy extends PassportStrategy(Strategy, 'oauth2') { throw new SchoolInMigrationLoggableException(); } - const account: AccountDto | null = await this.accountService.findByUserId(user.id); + const account: Account | null = await this.accountService.findByUserId(user.id); if (!account) { throw new UnauthorizedException('no account found'); } diff --git a/apps/server/src/modules/authentication/strategy/x-api-key.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/x-api-key.strategy.spec.ts index f71394f8b9d..fd59f5ad9b4 100644 --- a/apps/server/src/modules/authentication/strategy/x-api-key.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/x-api-key.strategy.spec.ts @@ -17,7 +17,9 @@ describe('XApiKeyStrategy', () => { XApiKeyStrategy, { provide: ConfigService, - useValue: createMock>({ get: () => ['1ab2c3d4e5f61ab2c3d4e5f6'] }), + useValue: createMock>({ + get: () => ['7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'], + }), }, ], }).compile(); @@ -39,7 +41,7 @@ describe('XApiKeyStrategy', () => { const done = jest.fn((error: Error | null, data: boolean | null) => {}); describe('when a valid api key is provided', () => { const setup = () => { - const CORRECT_API_KEY = '1ab2c3d4e5f61ab2c3d4e5f6'; + const CORRECT_API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'; return { CORRECT_API_KEY, done }; }; @@ -52,7 +54,7 @@ describe('XApiKeyStrategy', () => { describe('when a invalid api key is provided', () => { const setup = () => { - const INVALID_API_KEY = '1ab2c3d4e5f61ab2c3d4e5f6778173'; + const INVALID_API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4BAD'; return { INVALID_API_KEY, done }; }; diff --git a/apps/server/src/modules/authorization/authorization-reference.module.ts b/apps/server/src/modules/authorization/authorization-reference.module.ts index a115d0433c3..455d26fb27b 100644 --- a/apps/server/src/modules/authorization/authorization-reference.module.ts +++ b/apps/server/src/modules/authorization/authorization-reference.module.ts @@ -1,4 +1,5 @@ import { BoardModule } from '@modules/board'; +import { LessonModule } from '@modules/lesson'; import { ToolModule } from '@modules/tool'; import { forwardRef, Module } from '@nestjs/common'; import { @@ -12,7 +13,6 @@ import { UserRepo, } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { LessonModule } from '../lesson'; import { AuthorizationModule } from './authorization.module'; import { AuthorizationHelper, AuthorizationReferenceService, ReferenceLoader } from './domain'; @@ -22,7 +22,7 @@ import { AuthorizationHelper, AuthorizationReferenceService, ReferenceLoader } f * Avoid using this module and load the needed data in your use cases and then use the normal AuthorizationModule! */ @Module({ - // TODO: remove forwardRef to TooModule N21-1055 + // TODO: remove forwardRef imports: [ AuthorizationModule, LessonModule, diff --git a/apps/server/src/modules/authorization/domain/rules/board-do.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/board-do.rule.spec.ts index bda3680b460..a40d1c349c6 100644 --- a/apps/server/src/modules/authorization/domain/rules/board-do.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/board-do.rule.spec.ts @@ -1,10 +1,18 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; -import { BoardDoAuthorizable, BoardRoles, UserRoleEnum } from '@shared/domain/domainobject'; +import { BoardDoAuthorizable, BoardRoles } from '@shared/domain/domainobject'; import { Permission } from '@shared/domain/interface'; -import { Action } from '../type'; +import { + columnBoardFactory, + drawingElementFactory, + fileElementFactory, + roleFactory, + setupEntities, + submissionItemFactory, + userFactory, +} from '@shared/testing'; import { AuthorizationHelper } from '../service/authorization.helper'; +import { Action } from '../type'; import { BoardDoRule } from './board-do.rule'; describe(BoardDoRule.name, () => { @@ -26,7 +34,14 @@ describe(BoardDoRule.name, () => { describe('when entity is applicable', () => { const setup = () => { const user = userFactory.build(); - const boardDoAuthorizable = new BoardDoAuthorizable({ users: [], id: new ObjectId().toHexString() }); + const anyBoardDo = fileElementFactory.build(); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [], + id: new ObjectId().toHexString(), + boardDo: anyBoardDo, + rootDo: columnBoard, + }); return { user, boardDoAuthorizable }; }; @@ -47,7 +62,7 @@ describe(BoardDoRule.name, () => { it('should return false', () => { const { user } = setup(); - // @ts-expect-error test wrong entity + const result = service.isApplicable(user, user); expect(result).toStrictEqual(false); @@ -62,9 +77,13 @@ describe(BoardDoRule.name, () => { const permissionB = 'b' as Permission; const role = roleFactory.build({ permissions: [permissionA, permissionB] }); const user = userFactory.buildWithId({ roles: [role] }); + const anyBoardDo = fileElementFactory.build(); + const columnBoard = columnBoardFactory.build(); const boardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], id: new ObjectId().toHexString(), + boardDo: anyBoardDo, + rootDo: columnBoard, }); return { user, boardDoAuthorizable }; @@ -92,9 +111,13 @@ describe(BoardDoRule.name, () => { const setup = () => { const permissionA = 'a' as Permission; const user = userFactory.buildWithId(); + const anyBoardDo = fileElementFactory.build(); + const columnBoard = columnBoardFactory.build(); const boardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }], + users: [{ userId: user.id, roles: [BoardRoles.READER] }], id: new ObjectId().toHexString(), + boardDo: anyBoardDo, + rootDo: columnBoard, }); return { user, permissionA, boardDoAuthorizable }; @@ -117,9 +140,13 @@ describe(BoardDoRule.name, () => { const role = roleFactory.build(); const user = userFactory.buildWithId({ roles: [role] }); const userWithoutPermision = userFactory.buildWithId({ roles: [role] }); + const anyBoardDo = fileElementFactory.build(); + const columnBoard = columnBoardFactory.build(); const boardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], id: new ObjectId().toHexString(), + boardDo: anyBoardDo, + rootDo: columnBoard, }); return { userWithoutPermision, boardDoAuthorizable }; @@ -140,10 +167,13 @@ describe(BoardDoRule.name, () => { describe('when user does not have the desired role', () => { const setup = () => { const user = userFactory.buildWithId(); - + const anyBoardDo = fileElementFactory.build(); + const columnBoard = columnBoardFactory.build(); const boardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [], userRoleEnum: UserRoleEnum.TEACHER }], + users: [{ userId: user.id, roles: [] }], id: new ObjectId().toHexString(), + boardDo: anyBoardDo, + rootDo: columnBoard, }); return { user, boardDoAuthorizable }; @@ -161,29 +191,492 @@ describe(BoardDoRule.name, () => { }); }); - describe('when user has not the required userRoleEnum', () => { - const setup = () => { - const user = userFactory.buildWithId(); + describe('when boardDoAuthorizable.rootDo is not visible', () => { + describe('when user is Editor', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const anyBoardDo = fileElementFactory.build(); + const columnBoard = columnBoardFactory.build({ isVisible: false }); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + id: new ObjectId().toHexString(), + boardDo: anyBoardDo, + rootDo: columnBoard, + }); + + return { user, boardDoAuthorizable }; + }; + it('it should return true if trying to "write" ', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(true); + }); + it('it should return true if trying to "read" ', () => { + const { user, boardDoAuthorizable } = setup(); - const boardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], - id: new ObjectId().toHexString(), + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(true); }); + }); + describe('when user is Reader', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const anyBoardDo = fileElementFactory.build(); + const columnBoard = columnBoardFactory.build({ isVisible: false }); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.READER] }], + id: new ObjectId().toHexString(), + boardDo: anyBoardDo, + rootDo: columnBoard, + }); + + return { user, boardDoAuthorizable }; + }; + it('it should return false if trying to "write" ', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + it('it should return false if trying to "read" ', () => { + const { user, boardDoAuthorizable } = setup(); - boardDoAuthorizable.requiredUserRole = UserRoleEnum.STUDENT; + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); - return { user, boardDoAuthorizable }; - }; + expect(res).toBe(false); + }); + }); + }); - it('should return "false"', () => { - const { user, boardDoAuthorizable } = setup(); + describe('when boardDoAuthorizable.boardDo is a submissionItem', () => { + describe('when user is Editor', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const submissionItem = submissionItemFactory.build(); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + id: new ObjectId().toHexString(), + boardDo: submissionItem, + rootDo: columnBoard, + }); + + return { user, boardDoAuthorizable }; + }; + it('it should return false if trying to "write" ', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + it('it should return true if trying to "read"', () => { + const { user, boardDoAuthorizable } = setup(); - const res = service.hasPermission(user, boardDoAuthorizable, { - action: Action.read, - requiredPermissions: [], + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(true); + }); + }); + describe('when user is Reader and creator of the submissionItem', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const submissionItem = submissionItemFactory.build({ userId: user.id }); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.READER] }], + id: new ObjectId().toHexString(), + boardDo: submissionItem, + rootDo: columnBoard, + }); + + return { user, boardDoAuthorizable }; + }; + it('it should return "true" if trying to "write" ', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(true); }); + it('it should return "true" if trying to "read"', () => { + const { user, boardDoAuthorizable } = setup(); - expect(res).toBe(false); + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(true); + }); + }); + describe('when user is Reader and not creator of the submissionItem', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const submissionItem = submissionItemFactory.build({ userId: new ObjectId().toHexString() }); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.READER] }], + id: new ObjectId().toHexString(), + boardDo: submissionItem, + rootDo: columnBoard, + }); + + return { user, boardDoAuthorizable }; + }; + it('it should return "false" if trying to "write" ', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + it('it should return "false" if trying to "read"', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + }); + }); + + describe('when boardDoAuthorizable.parentDo is a submissionItem', () => { + describe('when user is Editor', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const submissionItem = submissionItemFactory.build(); + const fileElement = fileElementFactory.build(); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + id: new ObjectId().toHexString(), + boardDo: fileElement, + parentDo: submissionItem, + rootDo: columnBoard, + }); + + return { user, boardDoAuthorizable }; + }; + it('it should return false if trying to "write" ', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + it('it should return true if trying to "read"', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(true); + }); + }); + describe('when user is Reader and creator of the submissionItem', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const submissionItem = submissionItemFactory.build({ userId: user.id }); + const fileElement = fileElementFactory.build(); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.READER] }], + id: new ObjectId().toHexString(), + boardDo: fileElement, + parentDo: submissionItem, + rootDo: columnBoard, + }); + + return { user, boardDoAuthorizable }; + }; + it('it should return "true" if trying to "write" ', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(true); + }); + it('it should return "true" if trying to "read"', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(true); + }); + }); + describe('when user is Reader and not creator of the submissionItem', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const anyBoardDo = fileElementFactory.build(); + const submissionItem = submissionItemFactory.build({ userId: new ObjectId().toHexString() }); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.READER] }], + id: new ObjectId().toHexString(), + boardDo: anyBoardDo, + parentDo: submissionItem, + rootDo: columnBoard, + }); + + return { user, boardDoAuthorizable }; + }; + it('it should return "false" if trying to "write" ', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + it('it should return "false" if trying to "read"', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + }); + describe('when bordDo is wrong type', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const notAllowedChildElement = drawingElementFactory.build(); + const submissionItem = submissionItemFactory.build(); + + return { user, notAllowedChildElement, submissionItem }; + }; + it('when boardDo is undefined, it should return false', () => { + const { user, submissionItem } = setup(); + const anyBoardDo = fileElementFactory.build(); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + id: new ObjectId().toHexString(), + boardDo: anyBoardDo, + parentDo: submissionItem, + rootDo: columnBoard, + }); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + + it('when boardDo is not allowed type, it should return false', () => { + const { user, submissionItem, notAllowedChildElement } = setup(); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + id: new ObjectId().toHexString(), + parentDo: submissionItem, + boardDo: notAllowedChildElement, + rootDo: columnBoard, + }); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + }); + }); + + describe('when boardDoAuthorizable.board is a drawingElement', () => { + describe('when required permissions do not include FILESTORAGE_CREATE or FILESTORAGE_VIEW', () => { + describe('when user is Editor', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const drawingElement = drawingElementFactory.build(); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + id: new ObjectId().toHexString(), + boardDo: drawingElement, + rootDo: columnBoard, + }); + + return { user, boardDoAuthorizable }; + }; + it('should return true if trying to "read"', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(true); + }); + it('should return true if trying to "write" ', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(true); + }); + }); + describe('when user is Reader', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const drawingElement = drawingElementFactory.build(); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.READER] }], + id: new ObjectId().toHexString(), + boardDo: drawingElement, + rootDo: columnBoard, + }); + + return { user, boardDoAuthorizable }; + }; + it('should return true if trying to "read"', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.read, + requiredPermissions: [], + }); + + expect(res).toBe(true); + }); + it('should return false if trying to "write" ', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [], + }); + + expect(res).toBe(false); + }); + }); + }); + describe('when required permissions include FILESTORAGE_CREATE or FILESTORAGE_VIEW', () => { + describe('when user is Editor', () => { + const setup = () => { + const user = userFactory.asTeacher().buildWithId(); + const drawingElement = drawingElementFactory.build(); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + id: new ObjectId().toHexString(), + boardDo: drawingElement, + rootDo: columnBoard, + }); + + return { user, boardDoAuthorizable }; + }; + it('should return true if trying to "read"', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.read, + requiredPermissions: [Permission.FILESTORAGE_VIEW], + }); + + expect(res).toBe(true); + }); + it('should return true if trying to "write" ', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [Permission.FILESTORAGE_CREATE], + }); + + expect(res).toBe(true); + }); + }); + describe('when user is Reader', () => { + const setup = () => { + const user = userFactory.asStudent().buildWithId(); + const drawingElement = drawingElementFactory.build(); + const columnBoard = columnBoardFactory.build(); + const boardDoAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.READER] }], + id: new ObjectId().toHexString(), + boardDo: drawingElement, + rootDo: columnBoard, + }); + + return { user, boardDoAuthorizable }; + }; + it('should return true if trying to "read"', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.read, + requiredPermissions: [Permission.FILESTORAGE_VIEW], + }); + + expect(res).toBe(true); + }); + it('should ALSO return true if trying to "write" ', () => { + const { user, boardDoAuthorizable } = setup(); + + const res = service.hasPermission(user, boardDoAuthorizable, { + action: Action.write, + requiredPermissions: [Permission.FILESTORAGE_CREATE], + }); + + expect(res).toBe(true); + }); + }); }); }); }); diff --git a/apps/server/src/modules/authorization/domain/rules/board-do.rule.ts b/apps/server/src/modules/authorization/domain/rules/board-do.rule.ts index 062f5970554..e3856781d09 100644 --- a/apps/server/src/modules/authorization/domain/rules/board-do.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/board-do.rule.ts @@ -1,14 +1,23 @@ import { Injectable } from '@nestjs/common'; -import { BoardDoAuthorizable, BoardRoles } from '@shared/domain/domainobject/board/types'; +import { + ColumnBoard, + isDrawingElement, + isSubmissionItem, + isSubmissionItemContent, + SubmissionItem, +} from '@shared/domain/domainobject'; +import { BoardDoAuthorizable, BoardRoles, UserWithBoardRoles } from '@shared/domain/domainobject/board/types'; import { User } from '@shared/domain/entity/user.entity'; -import { Action, AuthorizationContext, Rule } from '../type'; +import { Permission } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; import { AuthorizationHelper } from '../service/authorization.helper'; +import { Action, AuthorizationContext, Rule } from '../type'; @Injectable() export class BoardDoRule implements Rule { constructor(private readonly authorizationHelper: AuthorizationHelper) {} - public isApplicable(user: User, boardDoAuthorizable: BoardDoAuthorizable): boolean { + public isApplicable(user: User, boardDoAuthorizable: unknown): boolean { const isMatched = boardDoAuthorizable instanceof BoardDoAuthorizable; return isMatched; @@ -16,30 +25,152 @@ export class BoardDoRule implements Rule { public hasPermission(user: User, boardDoAuthorizable: BoardDoAuthorizable, context: AuthorizationContext): boolean { const hasPermission = this.authorizationHelper.hasAllPermissions(user, context.requiredPermissions); - if (hasPermission === false) { + if (!hasPermission) { return false; } - const userBoardRole = boardDoAuthorizable.users.find(({ userId }) => userId === user.id); - if (!userBoardRole) { + const userWithBoardRoles = boardDoAuthorizable.users.find(({ userId }) => userId === user.id); + if (!userWithBoardRoles) { + return false; + } + + if ( + boardDoAuthorizable.rootDo instanceof ColumnBoard && + !boardDoAuthorizable.rootDo.isVisible && + !this.isBoardEditor(userWithBoardRoles) + ) { return false; } - if (boardDoAuthorizable.requiredUserRole && boardDoAuthorizable.requiredUserRole !== userBoardRole.userRoleEnum) { + if (this.shouldProcessSubmissionItem(boardDoAuthorizable)) { + return this.hasPermissionForSubmissionItem(user, userWithBoardRoles, boardDoAuthorizable, context); + } + + if (this.shouldProcessDrawingElementFile(boardDoAuthorizable, context)) { + return this.hasPermissionForDrawingElementFile(userWithBoardRoles); + } + + if (this.shouldProcessDrawingElement(boardDoAuthorizable)) { + return this.hasPermissionForDrawingElement(userWithBoardRoles, context); + } + + if (context.action === Action.write) { + return this.isBoardEditor(userWithBoardRoles); + } + + return this.isBoardReader(userWithBoardRoles); + } + + private isBoardEditor(userWithBoardRoles: UserWithBoardRoles): boolean { + return userWithBoardRoles.roles.includes(BoardRoles.EDITOR); + } + + private isBoardReader(userWithBoardRoles: UserWithBoardRoles): boolean { + return userWithBoardRoles.roles.includes(BoardRoles.READER) || userWithBoardRoles.roles.includes(BoardRoles.EDITOR); + } + + private shouldProcessDrawingElementFile( + boardDoAuthorizable: BoardDoAuthorizable, + context: AuthorizationContext + ): boolean { + const requiresFileStoragePermission = + context.requiredPermissions.includes(Permission.FILESTORAGE_CREATE) || + context.requiredPermissions.includes(Permission.FILESTORAGE_VIEW); + + return isDrawingElement(boardDoAuthorizable.boardDo) && requiresFileStoragePermission; + } + + private shouldProcessDrawingElement(boardDoAuthorizable: BoardDoAuthorizable): boolean { + return isDrawingElement(boardDoAuthorizable.boardDo); + } + + private hasPermissionForDrawingElementFile(userWithBoardRoles: UserWithBoardRoles): boolean { + // check if user has read permissions with no account for the context.action + // because everyone should be able to upload files to a drawing element + return this.isBoardReader(userWithBoardRoles); + } + + private hasPermissionForDrawingElement( + userWithBoardRoles: UserWithBoardRoles, + context: AuthorizationContext + ): boolean { + if (context.action === Action.write) { + return this.isBoardEditor(userWithBoardRoles); + } + + return this.isBoardReader(userWithBoardRoles); + } + + private shouldProcessSubmissionItem(boardDoAuthorizable: BoardDoAuthorizable): boolean { + return isSubmissionItem(boardDoAuthorizable.boardDo) || isSubmissionItem(boardDoAuthorizable.parentDo); + } + + private hasPermissionForSubmissionItem( + user: User, + userWithBoardRoles: UserWithBoardRoles, + boardDoAuthorizable: BoardDoAuthorizable, + context: AuthorizationContext + ): boolean { + // permission for elements under a submission item, are handled by the parent submission item + if (isSubmissionItem(boardDoAuthorizable.parentDo)) { + if (!isSubmissionItemContent(boardDoAuthorizable.boardDo)) { + return false; + } + boardDoAuthorizable.boardDo = boardDoAuthorizable.parentDo; + boardDoAuthorizable.parentDo = undefined; + } + + if (!isSubmissionItem(boardDoAuthorizable.boardDo)) { + /* istanbul ignore next */ + throw new Error('BoardDoAuthorizable.boardDo is not a submission item'); + } + + if (context.action === Action.write) { + return this.hasSubmissionItemWritePermission(userWithBoardRoles, boardDoAuthorizable.boardDo); + } + + return this.hasSubmissionItemReadPermission(userWithBoardRoles, boardDoAuthorizable.boardDo); + } + + private hasSubmissionItemWritePermission( + userWithBoardRoless: UserWithBoardRoles, + submissionItem: SubmissionItem + ): boolean { + // teacher don't have write access + if (this.isBoardEditor(userWithBoardRoless)) { return false; } - if (context.action === Action.write && userBoardRole.roles.includes(BoardRoles.EDITOR)) { + // student has write access only for his own submission item + if ( + this.isBoardReader(userWithBoardRoless) && + this.isSubmissionItemCreator(userWithBoardRoless.userId, submissionItem) + ) { + return true; + } + + return false; + } + + private hasSubmissionItemReadPermission( + userWithBoardRoless: UserWithBoardRoles, + submissionItem: SubmissionItem + ): boolean { + if (this.isBoardEditor(userWithBoardRoless)) { return true; } if ( - context.action === Action.read && - (userBoardRole.roles.includes(BoardRoles.EDITOR) || userBoardRole.roles.includes(BoardRoles.READER)) + this.isBoardReader(userWithBoardRoless) && + this.isSubmissionItemCreator(userWithBoardRoless.userId, submissionItem) ) { return true; } return false; } + + private isSubmissionItemCreator(userId: EntityId, submissionItem: SubmissionItem): boolean { + return submissionItem.userId === userId; + } } diff --git a/apps/server/src/modules/authorization/domain/rules/context-external-tool.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/context-external-tool.rule.spec.ts index c47f5c0ef1e..132bf8fcaf6 100644 --- a/apps/server/src/modules/authorization/domain/rules/context-external-tool.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/context-external-tool.rule.spec.ts @@ -1,21 +1,16 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { - contextExternalToolEntityFactory, - roleFactory, - schoolExternalToolEntityFactory, - schoolFactory, - setupEntities, - userFactory, -} from '@shared/testing'; import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; +import { contextExternalToolEntityFactory } from '@modules/tool/context-external-tool/testing'; import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; +import { schoolExternalToolEntityFactory } from '@modules/tool/school-external-tool/testing'; +import { Test, TestingModule } from '@nestjs/testing'; import { Role, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { ContextExternalToolRule } from './context-external-tool.rule'; -import { Action } from '../type'; +import { roleFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { AuthorizationHelper } from '../service/authorization.helper'; +import { Action } from '../type'; +import { ContextExternalToolRule } from './context-external-tool.rule'; describe('ContextExternalToolRule', () => { let service: ContextExternalToolRule; @@ -41,7 +36,7 @@ describe('ContextExternalToolRule', () => { const role: Role = roleFactory.build({ permissions: [permissionA, permissionB] }); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const schoolExternalToolEntity: SchoolExternalToolEntity | SchoolExternalTool = schoolExternalToolEntityFactory.build({ school, diff --git a/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts index 1c4dcc7d670..d66b7856ca9 100644 --- a/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/course.rule.spec.ts @@ -1,10 +1,11 @@ +import { courseFactory } from '@modules/learnroom/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { Course, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { courseFactory, roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { CourseRule } from './course.rule'; -import { Action } from '../type'; +import { courseFactory as courseEntityFactory, roleFactory, setupEntities, userFactory } from '@shared/testing'; import { AuthorizationHelper } from '../service/authorization.helper'; +import { Action } from '../type'; +import { CourseRule } from './course.rule'; describe('CourseRule', () => { let service: CourseRule; @@ -31,42 +32,88 @@ describe('CourseRule', () => { user = userFactory.build({ roles: [role] }); }); - it('should call hasAllPermissions on AuthorizationHelper', () => { - entity = courseFactory.build({ teachers: [user] }); - const spy = jest.spyOn(authorizationHelper, 'hasAllPermissions'); - service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [] }); - expect(spy).toBeCalledWith(user, []); - }); + describe('when validating an entity', () => { + it('should call hasAllPermissions on AuthorizationHelper', () => { + entity = courseEntityFactory.build({ teachers: [user] }); + const spy = jest.spyOn(authorizationHelper, 'hasAllPermissions'); + service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [] }); + expect(spy).toBeCalledWith(user, []); + }); - it('should call hasAccessToEntity on AuthorizationHelper if action = "read"', () => { - entity = courseFactory.build({ teachers: [user] }); - const spy = jest.spyOn(authorizationHelper, 'hasAccessToEntity'); - service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [] }); - expect(spy).toBeCalledWith(user, entity, ['teachers', 'substitutionTeachers', 'students']); - }); + it('should call hasAccessToEntity on AuthorizationHelper if action = "read"', () => { + entity = courseEntityFactory.build({ teachers: [user] }); + const spy = jest.spyOn(authorizationHelper, 'hasAccessToEntity'); + service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [] }); + expect(spy).toBeCalledWith(user, entity, ['teachers', 'substitutionTeachers', 'students']); + }); - it('should call hasAccessToEntity on AuthorizationHelper if action = "write"', () => { - entity = courseFactory.build({ teachers: [user] }); - const spy = jest.spyOn(authorizationHelper, 'hasAccessToEntity'); - service.hasPermission(user, entity, { action: Action.write, requiredPermissions: [] }); - expect(spy).toBeCalledWith(user, entity, ['teachers', 'substitutionTeachers']); - }); + it('should call hasAccessToEntity on AuthorizationHelper if action = "write"', () => { + entity = courseEntityFactory.build({ teachers: [user] }); + const spy = jest.spyOn(authorizationHelper, 'hasAccessToEntity'); + service.hasPermission(user, entity, { action: Action.write, requiredPermissions: [] }); + expect(spy).toBeCalledWith(user, entity, ['teachers', 'substitutionTeachers']); + }); - it('should return "true" if user in scope', () => { - entity = courseFactory.build({ teachers: [user] }); - const res = service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [] }); - expect(res).toBe(true); - }); + it('should return "true" if user in scope', () => { + entity = courseEntityFactory.build({ teachers: [user] }); + const res = service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [] }); + expect(res).toBe(true); + }); + + it('should return "false" if user has not permission', () => { + entity = courseEntityFactory.build({ teachers: [user] }); + const res = service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [permissionC] }); + expect(res).toBe(false); + }); - it('should return "false" if user has not permission', () => { - entity = courseFactory.build({ teachers: [user] }); - const res = service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [permissionC] }); - expect(res).toBe(false); + it('should return "false" if user has not access to entity', () => { + entity = courseEntityFactory.build(); + const res = service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [permissionC] }); + expect(res).toBe(false); + }); }); - it('should return "false" if user has not access to entity', () => { - entity = courseFactory.build(); - const res = service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [permissionC] }); - expect(res).toBe(false); + describe('when validating a domain object', () => { + describe('when the user is authorized', () => { + const setup = () => { + const course = courseFactory.build({ teacherIds: [user.id] }); + + return { + course, + }; + }; + + it('should return true', () => { + const { course } = setup(); + + const result: boolean = service.hasPermission(user, course, { + action: Action.read, + requiredPermissions: [permissionA], + }); + + expect(result).toEqual(true); + }); + }); + + describe('when the user is not authorized', () => { + const setup = () => { + const course = courseFactory.build({ studentIds: [user.id] }); + + return { + course, + }; + }; + + it('should return false', () => { + const { course } = setup(); + + const result: boolean = service.hasPermission(user, course, { + action: Action.write, + requiredPermissions: [permissionA], + }); + + expect(result).toEqual(false); + }); + }); }); }); diff --git a/apps/server/src/modules/authorization/domain/rules/course.rule.ts b/apps/server/src/modules/authorization/domain/rules/course.rule.ts index e923e1ab967..e1155d0587b 100644 --- a/apps/server/src/modules/authorization/domain/rules/course.rule.ts +++ b/apps/server/src/modules/authorization/domain/rules/course.rule.ts @@ -1,19 +1,20 @@ +import { Course } from '@modules/learnroom/domain'; import { Injectable } from '@nestjs/common'; -import { Course, User } from '@shared/domain/entity'; -import { Action, AuthorizationContext, Rule } from '../type'; +import { Course as CourseEntity, User } from '@shared/domain/entity'; import { AuthorizationHelper } from '../service/authorization.helper'; +import { Action, AuthorizationContext, Rule } from '../type'; @Injectable() export class CourseRule implements Rule { constructor(private readonly authorizationHelper: AuthorizationHelper) {} - public isApplicable(user: User, entity: Course): boolean { - const isMatched = entity instanceof Course; + public isApplicable(user: User, entity: unknown): boolean { + const isMatched = entity instanceof CourseEntity || entity instanceof Course; return isMatched; } - public hasPermission(user: User, entity: Course, context: AuthorizationContext): boolean { + public hasPermission(user: User, entity: CourseEntity | Course, context: AuthorizationContext): boolean { const { action, requiredPermissions } = context; const hasPermission = this.authorizationHelper.hasAllPermissions(user, requiredPermissions) && diff --git a/apps/server/src/modules/authorization/domain/rules/group.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/group.rule.spec.ts index 8229e81ec10..dcc1f39b753 100644 --- a/apps/server/src/modules/authorization/domain/rules/group.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/group.rule.spec.ts @@ -2,10 +2,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { Role, SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { groupFactory, roleFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { groupFactory, roleFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { Action, AuthorizationContext, AuthorizationHelper } from '@src/modules/authorization'; import { Group } from '@src/modules/group'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { GroupRule } from './group.rule'; describe('GroupRule', () => { @@ -92,7 +92,7 @@ describe('GroupRule', () => { describe('when the user has all required permissions and is at the same school then the group', () => { const setup = () => { const role: Role = roleFactory.buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const user: User = userFactory.buildWithId({ school, roles: [role] }); const group: Group = groupFactory.build({ users: [ @@ -137,7 +137,7 @@ describe('GroupRule', () => { describe('when the user has not the required permission', () => { const setup = () => { const role: Role = roleFactory.buildWithId({ permissions: [] }); - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const user: User = userFactory.buildWithId({ school, roles: [role] }); const group: Group = groupFactory.build({ users: [ @@ -174,7 +174,7 @@ describe('GroupRule', () => { describe('when the user is at another school then the group', () => { const setup = () => { const role: Role = roleFactory.buildWithId({ permissions: [] }); - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const user: User = userFactory.buildWithId({ school, roles: [role] }); const group: Group = groupFactory.build({ users: [ diff --git a/apps/server/src/modules/authorization/domain/rules/legacy-school.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/legacy-school.rule.spec.ts index 489def39318..732c6625ce5 100644 --- a/apps/server/src/modules/authorization/domain/rules/legacy-school.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/legacy-school.rule.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; import { roleFactory, legacySchoolDoFactory, setupEntities, userFactory } from '@shared/testing'; -import { ObjectID } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Action } from '../type'; import { AuthorizationHelper } from '../service/authorization.helper'; import { LegacySchoolRule } from './legacy-school.rule'; @@ -25,7 +25,7 @@ describe('LegacySchoolRule', () => { }); const setupSchoolAndUser = () => { - const school = legacySchoolDoFactory.build({ id: new ObjectID().toString() }); + const school = legacySchoolDoFactory.build({ id: new ObjectId().toString() }); const role = roleFactory.build({ permissions: [permissionA, permissionB] }); const user = userFactory.build({ roles: [role], diff --git a/apps/server/src/modules/authorization/domain/rules/lesson.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/lesson.rule.spec.ts index a8e4bdd8038..433d4b26db6 100644 --- a/apps/server/src/modules/authorization/domain/rules/lesson.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/lesson.rule.spec.ts @@ -1,4 +1,5 @@ import { DeepPartial } from '@mikro-orm/core'; +import { NotImplementedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { LessonEntity, User } from '@shared/domain/entity'; import { Permission, RoleName } from '@shared/domain/interface'; @@ -10,13 +11,12 @@ import { setupEntities, userFactory, } from '@shared/testing'; -import { NotImplementedException } from '@nestjs/common'; -import { Action, AuthorizationContext } from '../type'; +import { AuthorizationContextBuilder } from '../mapper'; import { AuthorizationHelper } from '../service/authorization.helper'; +import { Action, AuthorizationContext } from '../type'; import { CourseGroupRule } from './course-group.rule'; import { CourseRule } from './course.rule'; import { LessonRule } from './lesson.rule'; -import { AuthorizationContextBuilder } from '../mapper'; describe('LessonRule', () => { let rule: LessonRule; diff --git a/apps/server/src/modules/authorization/domain/rules/school-external-tool.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/school-external-tool.rule.spec.ts index 2a29ef23c9f..2259d8d42c3 100644 --- a/apps/server/src/modules/authorization/domain/rules/school-external-tool.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/school-external-tool.rule.spec.ts @@ -1,13 +1,13 @@ import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; +import { schoolExternalToolEntityFactory } from '@modules/tool/school-external-tool/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { Role, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { roleFactory, - schoolExternalToolEntityFactory, + schoolEntityFactory, schoolExternalToolFactory, - schoolFactory, setupEntities, userFactory, } from '@shared/testing'; @@ -39,7 +39,7 @@ describe('SchoolExternalToolRule', () => { const role: Role = roleFactory.build({ permissions: [permissionA, permissionB] }); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const entity: SchoolExternalToolEntity | SchoolExternalTool = schoolExternalToolEntityFactory.build(); entity.school = school; const user: User = userFactory.build({ roles: [role], school }); diff --git a/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.spec.ts index 8f35bf0d1ee..0d937c165a2 100644 --- a/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/school-system-options.rule.spec.ts @@ -5,7 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { - schoolFactory, + schoolEntityFactory, schoolSystemOptionsFactory, setupEntities, systemEntityFactory, @@ -90,7 +90,7 @@ describe(SchoolSystemOptionsRule.name, () => { describe('when the user accesses a system at his school with the required permissions', () => { const setup = () => { const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity], }); const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ @@ -132,7 +132,7 @@ describe(SchoolSystemOptionsRule.name, () => { describe('when the user accesses a system at his school, but does not have the required permissions', () => { const setup = () => { const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity], }); const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ @@ -163,7 +163,7 @@ describe(SchoolSystemOptionsRule.name, () => { describe('when the system is not part of the users school', () => { const setup = () => { const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity], }); const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ @@ -195,7 +195,7 @@ describe(SchoolSystemOptionsRule.name, () => { const setup = () => { const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build(); const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity], }); const user: User = userFactory.buildWithId({ school }); diff --git a/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts index 922ac71b20a..8fb4d0173ba 100644 --- a/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/system.rule.spec.ts @@ -3,7 +3,7 @@ import { System } from '@modules/system'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { schoolFactory, setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; +import { schoolEntityFactory, setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; import { AuthorizationContextBuilder } from '../mapper'; import { AuthorizationHelper } from '../service/authorization.helper'; import { SystemRule } from './system.rule'; @@ -84,7 +84,7 @@ describe(SystemRule.name, () => { const setup = () => { const system: System = systemFactory.build(); const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity], }); const user: User = userFactory.buildWithId({ school }); @@ -123,7 +123,7 @@ describe(SystemRule.name, () => { const setup = () => { const system: System = systemFactory.build(); const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity], }); const user: User = userFactory.buildWithId({ school }); @@ -150,7 +150,7 @@ describe(SystemRule.name, () => { describe('when the user reads a system that is not at his school', () => { const setup = () => { const system: System = systemFactory.build(); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [], }); const user: User = userFactory.buildWithId({ school }); @@ -178,7 +178,7 @@ describe(SystemRule.name, () => { const setup = () => { const system: System = systemFactory.build({ ldapConfig: { provider: 'general' } }); const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity], }); const user: User = userFactory.buildWithId({ school }); @@ -206,7 +206,7 @@ describe(SystemRule.name, () => { const setup = () => { const system: System = systemFactory.build({ ldapConfig: { provider: 'other provider' } }); const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity], }); const user: User = userFactory.buildWithId({ school }); @@ -234,7 +234,7 @@ describe(SystemRule.name, () => { const setup = () => { const system: System = systemFactory.build({ ldapConfig: undefined }); const systemEntity: SystemEntity = systemEntityFactory.buildWithId(undefined, system.id); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity], }); const user: User = userFactory.buildWithId({ school }); diff --git a/apps/server/src/modules/authorization/domain/rules/task.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/task.rule.spec.ts index 31d68661ff0..aa32062c7af 100644 --- a/apps/server/src/modules/authorization/domain/rules/task.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/task.rule.spec.ts @@ -2,12 +2,12 @@ import { DeepPartial } from '@mikro-orm/core'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission, RoleName } from '@shared/domain/interface'; import { courseFactory, lessonFactory, roleFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; -import { Action } from '../type'; import { AuthorizationHelper } from '../service/authorization.helper'; +import { Action } from '../type'; import { CourseGroupRule } from './course-group.rule'; -import { TaskRule } from './task.rule'; import { CourseRule } from './course.rule'; import { LessonRule } from './lesson.rule'; +import { TaskRule } from './task.rule'; describe('TaskRule', () => { let service: TaskRule; diff --git a/apps/server/src/modules/authorization/domain/rules/user-login-migration.rule.spec.ts b/apps/server/src/modules/authorization/domain/rules/user-login-migration.rule.spec.ts index f7fe9d3c53f..6ee893fc9d2 100644 --- a/apps/server/src/modules/authorization/domain/rules/user-login-migration.rule.spec.ts +++ b/apps/server/src/modules/authorization/domain/rules/user-login-migration.rule.spec.ts @@ -1,11 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { schoolFactory, setupEntities, userFactory, userLoginMigrationDOFactory } from '@shared/testing'; import { UserLoginMigrationDO } from '@shared/domain/domainobject'; import { Permission } from '@shared/domain/interface'; -import { Action, AuthorizationContext } from '../type'; +import { schoolEntityFactory, setupEntities, userFactory, userLoginMigrationDOFactory } from '@shared/testing'; import { AuthorizationHelper } from '../service/authorization.helper'; +import { Action, AuthorizationContext } from '../type'; import { UserLoginMigrationRule } from './user-login-migration.rule'; describe('UserLoginMigrationRule', () => { @@ -82,7 +82,7 @@ describe('UserLoginMigrationRule', () => { const setup = () => { const schoolId = new ObjectId().toHexString(); const user = userFactory.buildWithId({ - school: schoolFactory.buildWithId(undefined, schoolId), + school: schoolEntityFactory.buildWithId(undefined, schoolId), }); const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ schoolId }); const context: AuthorizationContext = { @@ -119,7 +119,7 @@ describe('UserLoginMigrationRule', () => { describe('when the user has all permissions, but is at a different school', () => { const setup = () => { const user = userFactory.buildWithId({ - school: schoolFactory.buildWithId(undefined, new ObjectId().toHexString()), + school: schoolEntityFactory.buildWithId(undefined, new ObjectId().toHexString()), }); const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ schoolId: new ObjectId().toHexString() }); const context: AuthorizationContext = { @@ -149,7 +149,7 @@ describe('UserLoginMigrationRule', () => { const setup = () => { const schoolId = new ObjectId().toHexString(); const user = userFactory.buildWithId({ - school: schoolFactory.buildWithId(undefined, schoolId), + school: schoolEntityFactory.buildWithId(undefined, schoolId), }); const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ schoolId }); const context: AuthorizationContext = { diff --git a/apps/server/src/modules/authorization/domain/service/authorization-reference.service.spec.ts b/apps/server/src/modules/authorization/domain/service/authorization-reference.service.spec.ts index 8ab1719a72d..61fd573fb88 100644 --- a/apps/server/src/modules/authorization/domain/service/authorization-reference.service.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/authorization-reference.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { courseFactory, setupEntities, userFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { AuthorizableReferenceType } from '../type'; import { AuthorizationService } from './authorization.service'; import { ReferenceLoader } from './reference.loader'; diff --git a/apps/server/src/modules/authorization/domain/service/authorization.helper.spec.ts b/apps/server/src/modules/authorization/domain/service/authorization.helper.spec.ts index d2506ac8709..994d7833e4c 100644 --- a/apps/server/src/modules/authorization/domain/service/authorization.helper.spec.ts +++ b/apps/server/src/modules/authorization/domain/service/authorization.helper.spec.ts @@ -1,5 +1,12 @@ +import { courseFactory } from '@modules/learnroom/testing'; import { Permission } from '@shared/domain/interface'; -import { courseFactory, roleFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; +import { + courseFactory as courseEntityFactory, + roleFactory, + setupEntities, + taskFactory, + userFactory, +} from '@shared/testing'; import { AuthorizationHelper } from './authorization.helper'; describe('AuthorizationHelper', () => { @@ -123,7 +130,7 @@ describe('AuthorizationHelper', () => { describe('when only one prop is given and prop is instance of Collection', () => { it('should return true if user is contained in prop', () => { const user = userFactory.build(); - const course = courseFactory.build({ students: [user] }); + const course = courseEntityFactory.build({ students: [user] }); const permissions = service.hasAccessToEntity(user, course, ['students']); @@ -132,7 +139,7 @@ describe('AuthorizationHelper', () => { it('should return false if user is not contained in prop', () => { const user = userFactory.build(); - const course = courseFactory.build({ students: [user] }); + const course = courseEntityFactory.build({ students: [user] }); const permissions = service.hasAccessToEntity(user, course, ['teachers']); @@ -161,6 +168,26 @@ describe('AuthorizationHelper', () => { }); }); + describe('when only one prop is given and prop is an Array', () => { + it('should return true if user is contained in prop', () => { + const user = userFactory.build(); + const course = courseFactory.build({ studentIds: [user.id] }); + + const permissions = service.hasAccessToEntity(user, course, ['students']); + + expect(permissions).toEqual(true); + }); + + it('should return false if user is not contained in prop', () => { + const user = userFactory.build(); + const course = courseFactory.build({ studentIds: [user.id] }); + + const permissions = service.hasAccessToEntity(user, course, ['teachers']); + + expect(permissions).toEqual(false); + }); + }); + describe('when only one prop is given and prop is instance of ObjectId', () => { it('should return true if userId is equal to id in prop', () => { const user = userFactory.build(); diff --git a/apps/server/src/modules/authorization/domain/service/authorization.helper.ts b/apps/server/src/modules/authorization/domain/service/authorization.helper.ts index a3138dd48b6..df4d297b9f0 100644 --- a/apps/server/src/modules/authorization/domain/service/authorization.helper.ts +++ b/apps/server/src/modules/authorization/domain/service/authorization.helper.ts @@ -35,17 +35,19 @@ export class AuthorizationHelper { return result; } - private isUserReferenced(user: User, entity: T, prop: K) { + private isUserReferenced(user: User, entity: T, prop: K): boolean { let result = false; - const reference = entity[prop]; + const reference: T[K] = entity[prop]; if (reference instanceof Collection) { result = reference.contains(user); } else if (reference instanceof User) { result = reference === user; + } else if (Array.isArray(reference)) { + result = reference.includes(user.id); } else { - result = (reference as unknown as string) === user.id; + result = reference === user.id; } return result; diff --git a/apps/server/src/modules/authorization/domain/service/reference.loader.ts b/apps/server/src/modules/authorization/domain/service/reference.loader.ts index 2bce4ab98ce..066d737cfcb 100644 --- a/apps/server/src/modules/authorization/domain/service/reference.loader.ts +++ b/apps/server/src/modules/authorization/domain/service/reference.loader.ts @@ -19,17 +19,17 @@ import { import { AuthorizableReferenceType } from '../type'; type RepoType = - | TaskRepo + | BoardDoAuthorizableService + | ContextExternalToolAuthorizableService + | CourseGroupRepo | CourseRepo - | UserRepo | LegacySchoolRepo - | TeamsRepo - | CourseGroupRepo - | SubmissionRepo + | LessonService | SchoolExternalToolRepo - | BoardDoAuthorizableService - | ContextExternalToolAuthorizableService - | LessonService; + | SubmissionRepo + | TaskRepo + | TeamsRepo + | UserRepo; interface RepoLoader { repo: RepoType; diff --git a/apps/server/src/modules/authorization/domain/service/rule-manager.ts b/apps/server/src/modules/authorization/domain/service/rule-manager.ts index e495c8b722f..5330ccac8f5 100644 --- a/apps/server/src/modules/authorization/domain/service/rule-manager.ts +++ b/apps/server/src/modules/authorization/domain/service/rule-manager.ts @@ -27,40 +27,40 @@ export class RuleManager { private readonly rules: Rule[]; constructor( - private readonly courseRule: CourseRule, + private readonly boardDoRule: BoardDoRule, + private readonly contextExternalToolRule: ContextExternalToolRule, private readonly courseGroupRule: CourseGroupRule, - private readonly lessonRule: LessonRule, + private readonly courseRule: CourseRule, + private readonly groupRule: GroupRule, private readonly legaySchoolRule: LegacySchoolRule, - private readonly taskRule: TaskRule, - private readonly userRule: UserRule, - private readonly teamRule: TeamRule, - private readonly submissionRule: SubmissionRule, + private readonly lessonRule: LessonRule, private readonly schoolExternalToolRule: SchoolExternalToolRule, - private readonly boardDoRule: BoardDoRule, - private readonly contextExternalToolRule: ContextExternalToolRule, - private readonly userLoginMigrationRule: UserLoginMigrationRule, private readonly schoolRule: SchoolRule, - private readonly groupRule: GroupRule, + private readonly schoolSystemOptionsRule: SchoolSystemOptionsRule, + private readonly submissionRule: SubmissionRule, private readonly systemRule: SystemRule, - private readonly schoolSystemOptionsRule: SchoolSystemOptionsRule + private readonly taskRule: TaskRule, + private readonly teamRule: TeamRule, + private readonly userLoginMigrationRule: UserLoginMigrationRule, + private readonly userRule: UserRule ) { this.rules = [ - this.courseRule, + this.boardDoRule, + this.contextExternalToolRule, this.courseGroupRule, - this.lessonRule, - this.taskRule, - this.teamRule, - this.userRule, + this.courseRule, + this.groupRule, this.legaySchoolRule, - this.submissionRule, + this.lessonRule, this.schoolExternalToolRule, - this.boardDoRule, - this.contextExternalToolRule, - this.userLoginMigrationRule, this.schoolRule, - this.groupRule, - this.systemRule, this.schoolSystemOptionsRule, + this.submissionRule, + this.systemRule, + this.taskRule, + this.teamRule, + this.userLoginMigrationRule, + this.userRule, ]; } diff --git a/apps/server/src/modules/board/board-api.module.ts b/apps/server/src/modules/board/board-api.module.ts index 6eb9ad678dc..3a66845fa45 100644 --- a/apps/server/src/modules/board/board-api.module.ts +++ b/apps/server/src/modules/board/board-api.module.ts @@ -1,6 +1,7 @@ +import { AuthorizationModule } from '@modules/authorization'; import { forwardRef, Module } from '@nestjs/common'; +import { CourseRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { AuthorizationModule } from '@modules/authorization'; import { BoardModule } from './board.module'; import { BoardController, @@ -16,6 +17,6 @@ import { SubmissionItemUc } from './uc/submission-item.uc'; @Module({ imports: [BoardModule, LoggerModule, forwardRef(() => AuthorizationModule)], controllers: [BoardController, ColumnController, CardController, ElementController, BoardSubmissionController], - providers: [BoardUc, ColumnUc, CardUc, ElementUc, SubmissionItemUc], + providers: [BoardUc, ColumnUc, CardUc, ElementUc, SubmissionItemUc, CourseRepo], }) export class BoardApiModule {} diff --git a/apps/server/src/modules/board/board.config.ts b/apps/server/src/modules/board/board.config.ts new file mode 100644 index 00000000000..0398bcef708 --- /dev/null +++ b/apps/server/src/modules/board/board.config.ts @@ -0,0 +1,3 @@ +export interface BoardConfig { + SC_THEME: string; // should be enum +} diff --git a/apps/server/src/modules/board/board.module.ts b/apps/server/src/modules/board/board.module.ts index 514d6fc1f19..825fe65cefe 100644 --- a/apps/server/src/modules/board/board.module.ts +++ b/apps/server/src/modules/board/board.module.ts @@ -1,14 +1,16 @@ import { ConsoleWriterModule } from '@infra/console'; +import { CopyHelperModule } from '@modules/copy-helper'; import { FilesStorageClientModule } from '@modules/files-storage-client'; +import { TldrawClientModule } from '@modules/tldraw-client'; import { ContextExternalToolModule } from '@modules/tool/context-external-tool'; +import { ToolConfigModule } from '@modules/tool/tool-config.module'; import { UserModule } from '@modules/user'; +import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; import { ContentElementFactory } from '@shared/domain/domainobject'; import { CourseRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; -import { HttpModule } from '@nestjs/axios'; -import { ToolConfigModule } from '@modules/tool/tool-config.module'; import { BoardDoRepo, BoardNodeRepo, RecursiveDeleteVisitor } from './repo'; import { BoardDoAuthorizableService, @@ -17,7 +19,11 @@ import { ColumnBoardService, ColumnService, ContentElementService, + MediaBoardService, + MediaElementService, + MediaLineService, SubmissionItemService, + UserDeletedEventHandlerService, } from './service'; import { BoardDoCopyService, SchoolSpecificFileCopyServiceFactory } from './service/board-do-copy-service'; import { ColumnBoardCopyService } from './service/column-board-copy.service'; @@ -25,12 +31,15 @@ import { ColumnBoardCopyService } from './service/column-board-copy.service'; @Module({ imports: [ ConsoleWriterModule, + CopyHelperModule, FilesStorageClientModule, LoggerModule, UserModule, ContextExternalToolModule, HttpModule, ToolConfigModule, + TldrawClientModule, + CqrsModule, ], providers: [ BoardDoAuthorizableService, @@ -48,7 +57,10 @@ import { ColumnBoardCopyService } from './service/column-board-copy.service'; BoardDoCopyService, ColumnBoardCopyService, SchoolSpecificFileCopyServiceFactory, - DrawingElementAdapterService, + MediaBoardService, + MediaLineService, + MediaElementService, + UserDeletedEventHandlerService, ], exports: [ BoardDoAuthorizableService, @@ -58,6 +70,13 @@ import { ColumnBoardCopyService } from './service/column-board-copy.service'; ContentElementService, SubmissionItemService, ColumnBoardCopyService, + /** + * @deprecated - exported only deprecated learnraum module + */ + BoardNodeRepo, + MediaBoardService, + MediaLineService, + MediaElementService, ], }) export class BoardModule {} diff --git a/apps/server/src/modules/board/controller/api-test/board-copy.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-copy.api.spec.ts new file mode 100644 index 00000000000..646aad24947 --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/board-copy.api.spec.ts @@ -0,0 +1,137 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server/server.module'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { ColumnBoardNode } from '@shared/domain/entity'; +import { + TestApiClient, + UserAndAccountTestFactory, + cleanupCollections, + columnBoardNodeFactory, + courseFactory, +} from '@shared/testing'; +import { CopyApiResponse, CopyElementType, CopyStatusEnum } from '@src/modules/copy-helper'; + +const baseRouteName = '/boards'; + +describe(`board copy (api)`, () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + describe('with valid user', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherUser, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + await em.persistAndFlush([teacherAccount, teacherUser, columnBoardNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, columnBoardNode }; + }; + + it('should return status 201', async () => { + const { loggedInClient, columnBoardNode } = await setup(); + + const response = await loggedInClient.post(`${columnBoardNode.id}/copy`); + + expect(response.status).toEqual(201); + }); + + it('should actually copy the board', async () => { + const { loggedInClient, columnBoardNode } = await setup(); + + const response = await loggedInClient.post(`${columnBoardNode.id}/copy`); + const body = response.body as CopyApiResponse; + + const expectedBody: CopyApiResponse = { + id: expect.any(String), + type: CopyElementType.COLUMNBOARD, + status: CopyStatusEnum.SUCCESS, + }; + + expect(body).toEqual(expectedBody); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const result = await em.findOneOrFail(ColumnBoardNode, body.id!); + + expect(result).toBeDefined(); + }); + + describe('with invalid id', () => { + it('should return status 400', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.post(`invalid-id/copy`); + + expect(response.status).toEqual(400); + }); + }); + + describe('with unknown id', () => { + it('should return status 404', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.post(`65e84684e43ba80204598425/copy`); + + expect(response.status).toEqual(404); + }); + }); + }); + + describe('with invalid user', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const course = courseFactory.build({ students: [studentUser] }); + await em.persistAndFlush([studentUser, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + await em.persistAndFlush([studentAccount, studentUser, columnBoardNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, columnBoardNode }; + }; + + it('should return status 403', async () => { + const { loggedInClient, columnBoardNode } = await setup(); + + const response = await loggedInClient.post(`${columnBoardNode.id}/copy`); + + expect(response.status).toEqual(403); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/api-test/board-create.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-create.api.spec.ts new file mode 100644 index 00000000000..03bb025ed7e --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/board-create.api.spec.ts @@ -0,0 +1,231 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server/server.module'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { ColumnBoardNode } from '@shared/domain/entity'; +import { TestApiClient, UserAndAccountTestFactory, cleanupCollections, courseFactory } from '@shared/testing'; + +const baseRouteName = '/boards'; + +describe(`create board (api)`, () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + describe('When request is valid', () => { + describe('When user is teacher and has course permission', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherUser, course, teacherAccount]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, course }; + }; + + it('should return status 204 and board', async () => { + const { loggedInClient, course } = await setup(); + const title = 'new board'; + + const response = await loggedInClient.post(undefined, { + title, + parentId: course.id, + parentType: BoardExternalReferenceType.Course, + }); + + const boardId = (response.body as { id: string }).id; + expect(response.status).toEqual(201); + expect(boardId).toBeDefined(); + + const dbResult = await em.findOneOrFail(ColumnBoardNode, boardId); + expect(dbResult.title).toEqual(title); + }); + }); + + describe('When user is teacher and has no course permission', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const course = courseFactory.build(); + await em.persistAndFlush([teacherUser, course, teacherAccount]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, course }; + }; + + it('should return status 204 and board', async () => { + const { loggedInClient, course } = await setup(); + const title = 'new board'; + + const response = await loggedInClient.post(undefined, { + title, + parentId: course.id, + parentType: BoardExternalReferenceType.Course, + }); + + expect(response.status).toEqual(403); + }); + }); + + describe('When user is student and has course permission', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const course = courseFactory.build({ students: [studentUser] }); + await em.persistAndFlush([studentUser, course, studentAccount]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, course }; + }; + + it('should return status 204 and board', async () => { + const { loggedInClient, course } = await setup(); + const title = 'new board'; + + const response = await loggedInClient.post(undefined, { + title, + parentId: course.id, + parentType: BoardExternalReferenceType.Course, + }); + + expect(response.status).toEqual(403); + }); + }); + }); + + describe('When request is invalid', () => { + describe('When title is empty', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherUser, course, teacherAccount]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, course }; + }; + + it('should return status 400', async () => { + const { loggedInClient, course } = await setup(); + const title = ''; + + const response = await loggedInClient.post(undefined, { + title, + parentId: course.id, + parentType: BoardExternalReferenceType.Course, + }); + + expect(response.status).toEqual(400); + }); + }); + + describe('When title is too long', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherUser, course, teacherAccount]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, course }; + }; + + it('should return status 400', async () => { + const { loggedInClient, course } = await setup(); + const title = 'a'.repeat(101); + + const response = await loggedInClient.post(undefined, { + title, + parentId: course.id, + parentType: BoardExternalReferenceType.Course, + }); + + expect(response.status).toEqual(400); + }); + }); + + describe('When course does not exist', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + await em.persistAndFlush([teacherUser, teacherAccount]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient }; + }; + + it('should return status 400', async () => { + const { loggedInClient } = await setup(); + const title = 'new board'; + + const response = await loggedInClient.post(undefined, { + title, + parentId: '123', + parentType: BoardExternalReferenceType.Course, + }); + + expect(response.status).toEqual(400); + }); + }); + + describe('When parent type is invalid', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherUser, course, teacherAccount]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, course }; + }; + + it('should return status 400', async () => { + const { loggedInClient, course } = await setup(); + const title = 'new board'; + + const response = await loggedInClient.post(undefined, { + title, + parentId: course.id, + parentType: 'invalid', + }); + + expect(response.status).toEqual(400); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/api-test/board-update-title.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-update-title.api.spec.ts index 2504715dcc6..f90654fd22d 100644 --- a/apps/server/src/modules/board/controller/api-test/board-update-title.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/board-update-title.api.spec.ts @@ -2,6 +2,7 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server/server.module'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { ApiValidationError } from '@shared/common'; import { BoardExternalReferenceType } from '@shared/domain/domainobject'; import { ColumnBoardNode } from '@shared/domain/entity'; import { @@ -90,6 +91,38 @@ describe(`board update title (api)`, () => { expect(result.title).toEqual(sanitizedTitle); }); + + it('should return status 400 when title is too long', async () => { + const { loggedInClient, columnBoardNode } = await setup(); + + const newTitle = 'a'.repeat(101); + + const response = await loggedInClient.patch(`${columnBoardNode.id}/title`, { title: newTitle }); + + expect((response.body as ApiValidationError).validationErrors).toEqual([ + { + errors: ['title must be shorter than or equal to 100 characters'], + field: ['title'], + }, + ]); + expect(response.status).toEqual(400); + }); + + it('should return status 400 when title is empty string', async () => { + const { loggedInClient, columnBoardNode } = await setup(); + + const newTitle = ''; + + const response = await loggedInClient.patch(`${columnBoardNode.id}/title`, { title: newTitle }); + + expect((response.body as ApiValidationError).validationErrors).toEqual([ + { + errors: ['title must be longer than or equal to 1 characters'], + field: ['title'], + }, + ]); + expect(response.status).toEqual(400); + }); }); describe('with invalid user', () => { diff --git a/apps/server/src/modules/board/controller/api-test/board-visibility.api.spec.ts b/apps/server/src/modules/board/controller/api-test/board-visibility.api.spec.ts new file mode 100644 index 00000000000..5b94f16f2db --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/board-visibility.api.spec.ts @@ -0,0 +1,121 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server/server.module'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ColumnBoardNode } from '@shared/domain/entity'; +import { + TestApiClient, + UserAndAccountTestFactory, + cleanupCollections, + columnBoardNodeFactory, + courseFactory, +} from '@shared/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; + +const baseRouteName = '/boards'; + +describe(`board update visibility (api)`, () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + describe('with valid user', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherUser, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + isVisible: false, + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + await em.persistAndFlush([teacherAccount, teacherUser, columnBoardNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, columnBoardNode }; + }; + + it('should return status 204', async () => { + const { loggedInClient, columnBoardNode } = await setup(); + + const isVisible = true; + + const response = await loggedInClient.patch(`${columnBoardNode.id}/visibility`, { isVisible }); + + expect(response.status).toEqual(204); + }); + + it('should actually change the board visibility', async () => { + const { loggedInClient, columnBoardNode } = await setup(); + + const isVisible = true; + + await loggedInClient.patch(`${columnBoardNode.id}/visibility`, { isVisible }); + + const result = await em.findOneOrFail(ColumnBoardNode, columnBoardNode.id); + + expect(result.isVisible).toEqual(isVisible); + }); + }); + + describe('with unauthorized user', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const course = courseFactory.build({ students: [studentUser] }); + await em.persistAndFlush([studentUser, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + isVisible: false, + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + await em.persistAndFlush([studentAccount, studentUser, columnBoardNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, columnBoardNode }; + }; + + it('should return status 403', async () => { + const { loggedInClient, columnBoardNode } = await setup(); + + const isVisible = true; + + const response = await loggedInClient.patch(`${columnBoardNode.id}/visibility`, { isVisible }); + + expect(response.status).toEqual(403); + }); + it('should not change the board visibility', async () => { + const { loggedInClient, columnBoardNode } = await setup(); + const isVisible = true; + await loggedInClient.patch(`${columnBoardNode.id}/visibility`, { isVisible }); + const result = await em.findOneOrFail(ColumnBoardNode, columnBoardNode.id); + expect(result.isVisible).toEqual(false); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/api-test/card-lookup.api.spec.ts b/apps/server/src/modules/board/controller/api-test/card-lookup.api.spec.ts index 6f6412376ee..89f2d5fd816 100644 --- a/apps/server/src/modules/board/controller/api-test/card-lookup.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/card-lookup.api.spec.ts @@ -15,7 +15,7 @@ import { mapUserToCurrentUser, richTextElementNodeFactory, roleFactory, - schoolFactory, + schoolEntityFactory, userFactory, } from '@shared/testing'; import { Request } from 'express'; @@ -77,7 +77,7 @@ describe(`card lookup (api)`, () => { const setup = async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { // permissions: [Permission.COURSE_CREATE], }); diff --git a/apps/server/src/modules/board/controller/api-test/card-update-title.api.spec.ts b/apps/server/src/modules/board/controller/api-test/card-update-title.api.spec.ts index 8067b17eb3a..91077ed9476 100644 --- a/apps/server/src/modules/board/controller/api-test/card-update-title.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/card-update-title.api.spec.ts @@ -5,13 +5,13 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BoardExternalReferenceType } from '@shared/domain/domainobject'; import { CardNode } from '@shared/domain/entity'; import { - TestApiClient, - UserAndAccountTestFactory, cardNodeFactory, cleanupCollections, columnBoardNodeFactory, columnNodeFactory, courseFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; const baseRouteName = '/cards'; diff --git a/apps/server/src/modules/board/controller/api-test/content-element-create.api.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-create.api.spec.ts index 42a8d331ed2..065e3bcabda 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-create.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-create.api.spec.ts @@ -3,7 +3,7 @@ import { ServerTestModule } from '@modules/server/server.module'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { BoardExternalReferenceType, ContentElementType } from '@shared/domain/domainobject'; -import { RichTextElementNode } from '@shared/domain/entity'; +import { DrawingElementNode, RichTextElementNode } from '@shared/domain/entity'; import { TestApiClient, UserAndAccountTestFactory, @@ -142,6 +142,24 @@ describe(`content element create (api)`, () => { expect(response.statusCode).toEqual(400); }); + + it('should return the created content element of type DRAWING', async () => { + const { loggedInClient, cardNode } = await setup(); + + const response = await loggedInClient.post(`${cardNode.id}/elements`, { type: ContentElementType.DRAWING }); + + expect((response.body as AnyContentElementResponse).type).toEqual(ContentElementType.DRAWING); + }); + + it('should actually create the DRAWING element', async () => { + const { loggedInClient, cardNode } = await setup(); + const response = await loggedInClient.post(`${cardNode.id}/elements`, { type: ContentElementType.DRAWING }); + + const elementId = (response.body as AnyContentElementResponse).id; + + const result = await em.findOneOrFail(DrawingElementNode, elementId); + expect(result.id).toEqual(elementId); + }); }); describe('with invalid user', () => { describe('with teacher not belonging to course', () => { @@ -207,6 +225,16 @@ describe(`content element create (api)`, () => { expect(response.statusCode).toEqual(403); }); + + it('should return status 403 for DRAWING', async () => { + const { cardNode, loggedInClient } = await setup(); + + const response = await loggedInClient.post(`${cardNode.id}/elements`, { + type: ContentElementType.DRAWING, + }); + + expect(response.statusCode).toEqual(403); + }); }); describe('when the parent of the element is a submission item', () => {}); }); diff --git a/apps/server/src/modules/board/controller/api-test/content-element-delete.api.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-delete.api.spec.ts index 0507039a3ff..e3264a45a15 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-delete.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-delete.api.spec.ts @@ -6,7 +6,7 @@ import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { BoardExternalReferenceType } from '@shared/domain/domainobject'; -import { RichTextElementNode } from '@shared/domain/entity'; +import { DrawingElementNode, RichTextElementNode } from '@shared/domain/entity'; import { cardNodeFactory, cleanupCollections, @@ -19,6 +19,10 @@ import { } from '@shared/testing'; import { Request } from 'express'; import request from 'supertest'; +import { drawingElementNodeFactory } from '@shared/testing/factory/boardnode/drawing-element-node.factory'; +import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; +import { DrawingElementAdapterService } from '@modules/tldraw-client'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; const baseRouteName = '/elements'; @@ -45,12 +49,18 @@ describe(`content element delete (api)`, () => { let app: INestApplication; let em: EntityManager; let currentUser: ICurrentUser; + let filesStorageClientAdapterService: DeepMocked; + let drawingElementAdapterService: DeepMocked; let api: API; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], }) + .overrideProvider(FilesStorageClientAdapterService) + .useValue(createMock()) + .overrideProvider(DrawingElementAdapterService) + .useValue(createMock()) .overrideGuard(JwtAuthGuard) .useValue({ canActivate(context: ExecutionContext) { @@ -64,6 +74,8 @@ describe(`content element delete (api)`, () => { app = module.createNestApplication(); await app.init(); em = module.get(EntityManager); + filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); + drawingElementAdapterService = module.get(DrawingElementAdapterService); api = new API(app); }); @@ -121,7 +133,7 @@ describe(`content element delete (api)`, () => { }); }); - describe('with valid user', () => { + describe('with invalid user', () => { it('should return status 403', async () => { const { element } = await setup(); @@ -134,4 +146,60 @@ describe(`content element delete (api)`, () => { expect(response.status).toEqual(403); }); }); + + describe('for drawing element', () => { + const drawingSetup = async () => { + await cleanupCollections(em); + const teacher = userFactory.asTeacher().build(); + const student = userFactory.asStudent().build(); + const course = courseFactory.build({ teachers: [teacher], students: [student] }); + await em.persistAndFlush([teacher, student, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + const element = drawingElementNodeFactory.buildWithId({ parent: cardNode }); + + filesStorageClientAdapterService.deleteFilesOfParent.mockResolvedValueOnce([]); + drawingElementAdapterService.deleteDrawingBinData.mockResolvedValueOnce(); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, element]); + em.clear(); + + return { teacher, student, columnBoardNode, columnNode, cardNode, element }; + }; + + describe('with valid user', () => { + it('should return status 204', async () => { + const { teacher, element } = await drawingSetup(); + currentUser = mapUserToCurrentUser(teacher); + + const response = await api.delete(element.id); + + expect(response.status).toEqual(204); + }); + + it('should actually delete element', async () => { + const { teacher, element } = await drawingSetup(); + currentUser = mapUserToCurrentUser(teacher); + + await api.delete(element.id); + + await expect(em.findOneOrFail(DrawingElementNode, element.id)).rejects.toThrow(); + }); + }); + + describe('with invalid user', () => { + it('should return status 403', async () => { + const { element, student } = await drawingSetup(); + currentUser = mapUserToCurrentUser(student); + + const response = await api.delete(element.id); + + expect(response.status).toEqual(403); + }); + }); + }); }); diff --git a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts index 77678a3ac0c..b05b453cdf4 100644 --- a/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/content-element-update-content.spec.ts @@ -7,8 +7,6 @@ import { BoardExternalReferenceType, ContentElementType } from '@shared/domain/d import { FileElementNode, RichTextElementNode, SubmissionContainerElementNode } from '@shared/domain/entity'; import { InputFormat } from '@shared/domain/types'; import { - TestApiClient, - UserAndAccountTestFactory, cardNodeFactory, cleanupCollections, columnBoardNodeFactory, @@ -17,6 +15,8 @@ import { fileElementNodeFactory, richTextElementNodeFactory, submissionContainerElementNodeFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; describe(`content element update content (api)`, () => { diff --git a/apps/server/src/modules/board/controller/api-test/submission-item-delete.api.spec.ts b/apps/server/src/modules/board/controller/api-test/submission-item-delete.api.spec.ts new file mode 100644 index 00000000000..ed65c70cc06 --- /dev/null +++ b/apps/server/src/modules/board/controller/api-test/submission-item-delete.api.spec.ts @@ -0,0 +1,235 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { SubmissionItemNode } from '@shared/domain/entity'; +import { + TestApiClient, + UserAndAccountTestFactory, + cardNodeFactory, + cleanupCollections, + columnBoardNodeFactory, + columnNodeFactory, + courseFactory, + submissionContainerElementNodeFactory, + submissionItemNodeFactory, +} from '@shared/testing'; +import { SubmissionItemResponse } from '../dto'; + +const baseRouteName = '/board-submissions'; +describe('submission item delete (api)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('when user is a valid teacher', () => { + const setup = async () => { + await cleanupCollections(em); + + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherAccount, teacherUser, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const submissionContainerNode = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + const submissionItemNode = submissionItemNodeFactory.buildWithId({ + userId: 'foo', + parent: submissionContainerNode, + completed: true, + }); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, submissionContainerNode, submissionItemNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, submissionItemNode }; + }; + it('should return status 403', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + const response = await loggedInClient.delete(`${submissionItemNode.id}`); + + expect(response.status).toEqual(403); + }); + it('should not actually delete submission item entity', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + await loggedInClient.delete(`${submissionItemNode.id}`); + + const result = await em.findOneOrFail(SubmissionItemNode, submissionItemNode.id); + expect(result.completed).toEqual(submissionItemNode.completed); + }); + }); + + describe('when user is a student trying to delete his own submission item', () => { + const setup = async () => { + await cleanupCollections(em); + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.build({ students: [studentUser] }); + await em.persistAndFlush([studentAccount, studentUser, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const submissionContainerNode = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + + const submissionItemNode = submissionItemNodeFactory.buildWithId({ + userId: studentUser.id, + parent: submissionContainerNode, + completed: true, + }); + + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, submissionContainerNode, submissionItemNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, studentUser, submissionItemNode }; + }; + it('should return status 204', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + const response = await loggedInClient.delete(`${submissionItemNode.id}`); + + expect(response.status).toEqual(204); + }); + + it('should actually delete the submission item', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + const response = await loggedInClient.delete(`${submissionItemNode.id}`); + + const submissionItemResponse = response.body as SubmissionItemResponse; + + await expect(em.findOneOrFail(SubmissionItemNode, submissionItemResponse.id)).rejects.toThrow(); + }); + }); + + describe('when user is a student from same course, and tries to delete a submission item he did not create himself', () => { + const setup = async () => { + await cleanupCollections(em); + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const { studentAccount: studentAccount2, studentUser: studentUser2 } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.build({ students: [studentUser, studentUser2] }); + await em.persistAndFlush([studentAccount, studentUser, studentAccount2, studentUser2, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const submissionContainerNode = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + + const submissionItemNode = submissionItemNodeFactory.buildWithId({ + userId: studentUser.id, + parent: submissionContainerNode, + completed: true, + }); + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, submissionContainerNode, submissionItemNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount2); + + return { loggedInClient, submissionItemNode }; + }; + + it('should return status 403', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + const response = await loggedInClient.delete(`${submissionItemNode.id}`); + + expect(response.status).toEqual(403); + }); + it('should not actually delete submission item entity', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + await loggedInClient.delete(`${submissionItemNode.id}`); + + const result = await em.findOneOrFail(SubmissionItemNode, submissionItemNode.id); + expect(result.completed).toEqual(submissionItemNode.completed); + }); + }); + + describe('when user is a student not in course', () => { + const setup = async () => { + await cleanupCollections(em); + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const { studentAccount: studentAccount2, studentUser: studentUser2 } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.build({ students: [studentUser] }); + await em.persistAndFlush([studentAccount, studentUser, studentAccount2, studentUser2, course]); + + const columnBoardNode = columnBoardNodeFactory.buildWithId({ + context: { id: course.id, type: BoardExternalReferenceType.Course }, + }); + + const columnNode = columnNodeFactory.buildWithId({ parent: columnBoardNode }); + + const cardNode = cardNodeFactory.buildWithId({ parent: columnNode }); + + const submissionContainerNode = submissionContainerElementNodeFactory.buildWithId({ parent: cardNode }); + + const submissionItemNode = submissionItemNodeFactory.buildWithId({ + userId: studentUser.id, + parent: submissionContainerNode, + completed: true, + }); + await em.persistAndFlush([columnBoardNode, columnNode, cardNode, submissionContainerNode, submissionItemNode]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount2); + + return { loggedInClient, submissionItemNode }; + }; + + it('should return status 403', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + const response = await loggedInClient.delete(`${submissionItemNode.id}`); + + expect(response.status).toEqual(403); + }); + + it('should not actually delete submission item entity', async () => { + const { loggedInClient, submissionItemNode } = await setup(); + + await loggedInClient.delete(`${submissionItemNode.id}`); + + const result = await em.findOneOrFail(SubmissionItemNode, submissionItemNode.id); + expect(result.completed).toEqual(submissionItemNode.completed); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts b/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts index 65cad7285fe..727fbdf4cba 100644 --- a/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts +++ b/apps/server/src/modules/board/controller/api-test/submission-item-lookup.api.spec.ts @@ -208,7 +208,7 @@ describe('submission item lookup (api)', () => { expect(response.status).toEqual(200); }); - it('should return only submission item of student 1', async () => { + it('should return only own submission item', async () => { const { loggedInClient, submissionContainerNode, item1 } = await setup(); const response = await loggedInClient.get(`${submissionContainerNode.id}`); diff --git a/apps/server/src/modules/board/controller/board-submission.controller.ts b/apps/server/src/modules/board/controller/board-submission.controller.ts index bc6f7298668..f62ecd8a65f 100644 --- a/apps/server/src/modules/board/controller/board-submission.controller.ts +++ b/apps/server/src/modules/board/controller/board-submission.controller.ts @@ -2,6 +2,7 @@ import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication import { Body, Controller, + Delete, ForbiddenException, Get, HttpCode, @@ -74,6 +75,17 @@ export class BoardSubmissionController { ); } + @ApiOperation({ summary: 'Delete a single submission item.' }) + @ApiResponse({ status: 204 }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @HttpCode(204) + @Delete(':submissionItemId') + async deleteSubmissionItem(@CurrentUser() currentUser: ICurrentUser, @Param() urlParams: SubmissionItemUrlParams) { + await this.submissionItemUc.deleteSubmissionItem(currentUser.userId, urlParams.submissionItemId); + } + @ApiOperation({ summary: 'Create a new element in a submission item.' }) @ApiExtraModels(RichTextElementResponse, FileElementResponse) @ApiResponse({ diff --git a/apps/server/src/modules/board/controller/board.controller.ts b/apps/server/src/modules/board/controller/board.controller.ts index 55a54e8f77b..c4cfd078a4b 100644 --- a/apps/server/src/modules/board/controller/board.controller.ts +++ b/apps/server/src/modules/board/controller/board.controller.ts @@ -12,11 +12,20 @@ import { Post, } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { ApiValidationError } from '@shared/common'; +import { ApiValidationError, RequestTimeout } from '@shared/common'; +import { CopyApiResponse, CopyMapper } from '@src/modules/copy-helper'; import { BoardUc } from '../uc'; -import { BoardResponse, BoardUrlParams, ColumnResponse, RenameBodyParams } from './dto'; +import { + BoardResponse, + BoardUrlParams, + ColumnResponse, + CreateBoardBodyParams, + CreateBoardResponse, + UpdateBoardTitleParams, + VisibilityBodyParams, +} from './dto'; import { BoardContextResponse } from './dto/board/board-context.reponse'; -import { BoardResponseMapper, ColumnResponseMapper } from './mapper'; +import { BoardResponseMapper, ColumnResponseMapper, CreateBoardResponseMapper } from './mapper'; @ApiTags('Board') @Authenticate('jwt') @@ -24,6 +33,23 @@ import { BoardResponseMapper, ColumnResponseMapper } from './mapper'; export class BoardController { constructor(private readonly boardUc: BoardUc) {} + @ApiOperation({ summary: 'Create a new board.' }) + @ApiResponse({ status: 201, type: CreateBoardResponse }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @Post() + async createBoard( + @Body() bodyParams: CreateBoardBodyParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + const board = await this.boardUc.createBoard(currentUser.userId, bodyParams); + + const response = CreateBoardResponseMapper.mapToResponse(board); + + return response; + } + @ApiOperation({ summary: 'Get the skeleton of a a board.' }) @ApiResponse({ status: 200, type: BoardResponse }) @ApiResponse({ status: 400, type: ApiValidationError }) @@ -67,7 +93,7 @@ export class BoardController { @Patch(':boardId/title') async updateBoardTitle( @Param() urlParams: BoardUrlParams, - @Body() bodyParams: RenameBodyParams, + @Body() bodyParams: UpdateBoardTitleParams, @CurrentUser() currentUser: ICurrentUser ): Promise { await this.boardUc.updateBoardTitle(currentUser.userId, urlParams.boardId, bodyParams.title); @@ -100,4 +126,35 @@ export class BoardController { return response; } + + @ApiOperation({ summary: 'Create a board copy.' }) + @ApiResponse({ status: 201, type: CopyApiResponse }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @Post(':boardId/copy') + @RequestTimeout('INCOMING_REQUEST_TIMEOUT_COPY_API') + async copyBoard( + @Param() urlParams: BoardUrlParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + const copyStatus = await this.boardUc.copyBoard(currentUser.userId, urlParams.boardId); + const dto = CopyMapper.mapToResponse(copyStatus); + return dto; + } + + @ApiOperation({ summary: 'Update the visibility of a board.' }) + @ApiResponse({ status: 204 }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @HttpCode(204) + @Patch(':boardId/visibility') + async updateVisibility( + @Param() urlParams: BoardUrlParams, + @Body() bodyParams: VisibilityBodyParams, + @CurrentUser() currentUser: ICurrentUser + ) { + await this.boardUc.updateVisibility(currentUser.userId, urlParams.boardId, bodyParams.isVisible); + } } diff --git a/apps/server/src/modules/board/controller/dto/board/board.response.ts b/apps/server/src/modules/board/controller/dto/board/board.response.ts index 7fd1481327d..c36ddf657c8 100644 --- a/apps/server/src/modules/board/controller/dto/board/board.response.ts +++ b/apps/server/src/modules/board/controller/dto/board/board.response.ts @@ -4,11 +4,12 @@ import { ColumnResponse } from './column.response'; import { TimestampsResponse } from '../timestamps.response'; export class BoardResponse { - constructor({ id, title, columns, timestamps }: BoardResponse) { + constructor({ id, title, columns, timestamps, isVisible }: BoardResponse) { this.id = id; this.title = title; this.columns = columns; this.timestamps = timestamps; + this.isVisible = isVisible; } @ApiProperty({ @@ -27,4 +28,7 @@ export class BoardResponse { @ApiProperty() timestamps: TimestampsResponse; + + @ApiProperty() + isVisible: boolean; } diff --git a/apps/server/src/modules/board/controller/dto/board/create-board.body.params.ts b/apps/server/src/modules/board/controller/dto/board/create-board.body.params.ts new file mode 100644 index 00000000000..08f70761ef4 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/board/create-board.body.params.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { SanitizeHtml } from '@shared/controller'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { IsEnum, IsMongoId, MaxLength, MinLength } from 'class-validator'; + +export class CreateBoardBodyParams { + @ApiProperty({ + description: 'The title of the board', + required: true, + }) + @MinLength(1) + @MaxLength(100) + @SanitizeHtml() + title!: string; + + @IsMongoId() + @ApiProperty({ + description: 'The id of the parent', + required: true, + }) + parentId!: string; + + @ApiProperty({ + description: 'The type of the parent', + required: true, + enum: BoardExternalReferenceType, + enumName: 'BoardParentType', + }) + @IsEnum(BoardExternalReferenceType) + parentType!: BoardExternalReferenceType; +} diff --git a/apps/server/src/modules/board/controller/dto/board/create-board.response.ts b/apps/server/src/modules/board/controller/dto/board/create-board.response.ts new file mode 100644 index 00000000000..caf831eaf5e --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/board/create-board.response.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateBoardResponse { + constructor({ id }: CreateBoardResponse) { + this.id = id; + } + + @ApiProperty({ + pattern: '[a-f0-9]{24}', + }) + id: string; +} diff --git a/apps/server/src/modules/board/controller/dto/board/index.ts b/apps/server/src/modules/board/controller/dto/board/index.ts index b8a148f8cbd..ac8d1713624 100644 --- a/apps/server/src/modules/board/controller/dto/board/index.ts +++ b/apps/server/src/modules/board/controller/dto/board/index.ts @@ -4,6 +4,10 @@ export * from './card-skeleton.response'; export * from './column.response'; export * from './column.url.params'; export * from './content-element.url.params'; +export * from './create-board.body.params'; +export * from './create-board.response'; export * from './move-card.body.params'; export * from './move-column.body.params'; export * from './rename.body.params'; +export * from './update-board-title.body.params'; +export * from './visibility.body.params'; diff --git a/apps/server/src/modules/board/controller/dto/board/rename.body.params.ts b/apps/server/src/modules/board/controller/dto/board/rename.body.params.ts index ed8a9bb27ed..7d00a9f6d6b 100644 --- a/apps/server/src/modules/board/controller/dto/board/rename.body.params.ts +++ b/apps/server/src/modules/board/controller/dto/board/rename.body.params.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString } from 'class-validator'; import { SanitizeHtml } from '@shared/controller'; +import { IsString } from 'class-validator'; export class RenameBodyParams { @IsString() diff --git a/apps/server/src/modules/board/controller/dto/board/update-board-title.body.params.ts b/apps/server/src/modules/board/controller/dto/board/update-board-title.body.params.ts new file mode 100644 index 00000000000..40430dfdd04 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/board/update-board-title.body.params.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { SanitizeHtml } from '@shared/controller'; +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class UpdateBoardTitleParams { + @IsString() + @ApiProperty({ + required: true, + }) + @SanitizeHtml() + @MaxLength(100) + @MinLength(1) + title!: string; +} diff --git a/apps/server/src/modules/board/controller/dto/board/visibility.body.params.ts b/apps/server/src/modules/board/controller/dto/board/visibility.body.params.ts new file mode 100644 index 00000000000..abe74b286e0 --- /dev/null +++ b/apps/server/src/modules/board/controller/dto/board/visibility.body.params.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean } from 'class-validator'; + +export class VisibilityBodyParams { + @IsBoolean() + @ApiProperty({ + required: true, + type: 'boolean', + }) + isVisible!: boolean; +} diff --git a/apps/server/src/modules/board/controller/element.controller.ts b/apps/server/src/modules/board/controller/element.controller.ts index 2f766ea1d5b..ec1fbd74aed 100644 --- a/apps/server/src/modules/board/controller/element.controller.ts +++ b/apps/server/src/modules/board/controller/element.controller.ts @@ -96,7 +96,7 @@ export class ElementController { @Body() bodyParams: UpdateElementContentBodyParams, @CurrentUser() currentUser: ICurrentUser ): Promise { - const element = await this.elementUc.updateElementContent( + const element = await this.elementUc.updateElement( currentUser.userId, urlParams.contentElementId, bodyParams.data.content diff --git a/apps/server/src/modules/board/controller/index.ts b/apps/server/src/modules/board/controller/index.ts index 185949304a2..705097636a8 100644 --- a/apps/server/src/modules/board/controller/index.ts +++ b/apps/server/src/modules/board/controller/index.ts @@ -3,3 +3,4 @@ export * from './board.controller'; export * from './card.controller'; export * from './column.controller'; export * from './element.controller'; +export * from './media-board'; diff --git a/apps/server/src/modules/board/controller/mapper/board-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/board-response.mapper.ts index 8d87144c2f9..4c951d047ac 100644 --- a/apps/server/src/modules/board/controller/mapper/board-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/board-response.mapper.ts @@ -19,6 +19,7 @@ export class BoardResponseMapper { return ColumnResponseMapper.mapToResponse(column); }), timestamps: new TimestampsResponse({ lastUpdatedAt: board.updatedAt, createdAt: board.createdAt }), + isVisible: board.isVisible, }); return result; } diff --git a/apps/server/src/modules/board/controller/mapper/create-board-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/create-board-response.mapper.ts new file mode 100644 index 00000000000..bb4ee33f014 --- /dev/null +++ b/apps/server/src/modules/board/controller/mapper/create-board-response.mapper.ts @@ -0,0 +1,12 @@ +import { ColumnBoard } from '@shared/domain/domainobject'; +import { CreateBoardResponse } from '../dto'; + +export class CreateBoardResponseMapper { + static mapToResponse(board: ColumnBoard): CreateBoardResponse { + const result = new CreateBoardResponse({ + id: board.id, + }); + + return result; + } +} diff --git a/apps/server/src/modules/board/controller/mapper/index.ts b/apps/server/src/modules/board/controller/mapper/index.ts index a24a905ae3f..34980b96de3 100644 --- a/apps/server/src/modules/board/controller/mapper/index.ts +++ b/apps/server/src/modules/board/controller/mapper/index.ts @@ -2,6 +2,7 @@ export * from './board-response.mapper'; export * from './card-response.mapper'; export * from './column-response.mapper'; export * from './content-element-response.factory'; +export * from './create-board-response.mapper'; export * from './external-tool-element-response.mapper'; export * from './file-element-response.mapper'; export * from './link-element-response.mapper'; diff --git a/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts b/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts index abb91cb00ea..4145cc02306 100644 --- a/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts +++ b/apps/server/src/modules/board/controller/mapper/submission-item-response.mapper.ts @@ -3,7 +3,7 @@ import { isSubmissionItemContent, RichTextElement, SubmissionItem, - UserBoardRoles, + UserWithBoardRoles, } from '@shared/domain/domainobject'; import { SubmissionItemResponse, SubmissionsResponse, TimestampsResponse, UserDataResponse } from '../dto'; import { ContentElementResponseFactory } from './content-element-response.factory'; @@ -19,7 +19,7 @@ export class SubmissionItemResponseMapper { return SubmissionItemResponseMapper.instance; } - public mapToResponse(submissionItems: SubmissionItem[], users: UserBoardRoles[]): SubmissionsResponse { + public mapToResponse(submissionItems: SubmissionItem[], users: UserWithBoardRoles[]): SubmissionsResponse { const submissionItemsResponse: SubmissionItemResponse[] = submissionItems.map((item) => this.mapSubmissionItemToResponse(item) ); @@ -46,7 +46,7 @@ export class SubmissionItemResponseMapper { return result; } - private mapUsersToResponse(user: UserBoardRoles) { + private mapUsersToResponse(user: UserWithBoardRoles) { const result = new UserDataResponse({ userId: user.userId, firstName: user.firstName || '', diff --git a/apps/server/src/modules/board/controller/media-board/api-test/media-board.api.spec.ts b/apps/server/src/modules/board/controller/media-board/api-test/media-board.api.spec.ts new file mode 100644 index 00000000000..eb280b6f420 --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/api-test/media-board.api.spec.ts @@ -0,0 +1,276 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { type ServerConfig, serverConfig, ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { + type DatesToStrings, + mediaBoardNodeFactory, + mediaExternalToolElementNodeFactory, + mediaLineNodeFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { type MediaBoardResponse, MediaLineResponse } from '../dto'; + +const baseRouteName = '/media-boards'; + +describe('Media Board (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('[GET] /media-boards/me', () => { + describe('when a valid user accesses their media board', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = true; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: studentUser.id, + type: BoardExternalReferenceType.User, + }, + }); + const mediaLine = mediaLineNodeFactory.buildWithId({ parent: mediaBoard }); + const mediaElement = mediaExternalToolElementNodeFactory.buildWithId({ parent: mediaLine }); + + await em.persistAndFlush([studentAccount, studentUser, mediaBoard, mediaLine, mediaElement]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + mediaBoard, + mediaLine, + mediaElement, + }; + }; + + it('should return the media board of the user', async () => { + const { studentClient, mediaBoard, mediaLine, mediaElement } = await setup(); + + const response = await studentClient.get('me'); + + expect(response.body).toEqual>({ + id: mediaBoard.id, + timestamps: { + createdAt: mediaBoard.createdAt.toISOString(), + lastUpdatedAt: mediaBoard.updatedAt.toISOString(), + }, + lines: [ + { + id: mediaLine.id, + timestamps: { + createdAt: mediaLine.createdAt.toISOString(), + lastUpdatedAt: mediaLine.updatedAt.toISOString(), + }, + title: mediaLine.title, + elements: [ + { + id: mediaElement.id, + timestamps: { + createdAt: mediaElement.createdAt.toISOString(), + lastUpdatedAt: mediaElement.updatedAt.toISOString(), + }, + content: { + contextExternalToolId: mediaElement.contextExternalTool.id, + }, + }, + ], + }, + ], + }); + }); + }); + + describe('when the media board feature is disabled', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = false; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + }; + }; + + it('should return forbidden', async () => { + const { studentClient } = await setup(); + + const response = await studentClient.get('me'); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + expect(response.body).toEqual({ + code: HttpStatus.FORBIDDEN, + message: 'Forbidden', + title: 'Feature Disabled', + type: 'FEATURE_DISABLED', + }); + }); + }); + + describe('when the user is invalid', () => { + const setup = () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = true; + }; + + it('should return unauthorized', async () => { + setup(); + + const response = await testApiClient.get('me'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); + + describe('[POST] /media-boards/:boardId/media-lines', () => { + describe('when a valid user creates a line on their media board', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = true; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: studentUser.id, + type: BoardExternalReferenceType.User, + }, + }); + + await em.persistAndFlush([studentAccount, studentUser, mediaBoard]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + mediaBoard, + }; + }; + + it('should return the created line', async () => { + const { studentClient, mediaBoard } = await setup(); + + const response = await studentClient.post(`${mediaBoard.id}/media-lines`); + + expect(response.body).toEqual>({ + id: expect.any(String), + timestamps: { + createdAt: expect.any(String), + lastUpdatedAt: expect.any(String), + }, + elements: [], + title: '', + }); + }); + }); + + describe('when the media board feature is disabled', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = false; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: studentUser.id, + type: BoardExternalReferenceType.User, + }, + }); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + mediaBoard, + }; + }; + + it('should return forbidden', async () => { + const { studentClient, mediaBoard } = await setup(); + + const response = await studentClient.post(`${mediaBoard.id}/media-lines`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + expect(response.body).toEqual({ + code: HttpStatus.FORBIDDEN, + message: 'Forbidden', + title: 'Feature Disabled', + type: 'FEATURE_DISABLED', + }); + }); + }); + + describe('when the user is invalid', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = true; + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: new ObjectId().toHexString(), + type: BoardExternalReferenceType.User, + }, + }); + + await em.persistAndFlush([mediaBoard]); + em.clear(); + + return { + mediaBoard, + }; + }; + + it('should return unauthorized', async () => { + const { mediaBoard } = await setup(); + + const response = await testApiClient.post(`${mediaBoard.id}/media-lines`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/media-board/api-test/media-element.api.spec.ts b/apps/server/src/modules/board/controller/media-board/api-test/media-element.api.spec.ts new file mode 100644 index 00000000000..d9e05a6d4ae --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/api-test/media-element.api.spec.ts @@ -0,0 +1,192 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { type ServerConfig, serverConfig, ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { BoardNode } from '@shared/domain/entity'; +import { + mediaBoardNodeFactory, + mediaExternalToolElementNodeFactory, + mediaLineNodeFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { MoveElementBodyParams } from '../dto'; + +const baseRouteName = '/media-elements'; + +describe('Media Element (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('[PUT] /media-elements/:lineId/position', () => { + describe('when a valid user moves a element on their media board', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = true; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: studentUser.id, + type: BoardExternalReferenceType.User, + }, + }); + const mediaLine = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + }); + const mediaElementA = mediaExternalToolElementNodeFactory.buildWithId({ + parent: mediaLine, + position: 0, + }); + const mediaElementB = mediaExternalToolElementNodeFactory.buildWithId({ + parent: mediaLine, + position: 1, + }); + + await em.persistAndFlush([studentAccount, studentUser, mediaBoard, mediaLine, mediaElementA, mediaElementB]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + mediaLine, + mediaElementA, + mediaElementB, + }; + }; + + it('should move the element', async () => { + const { studentClient, mediaLine, mediaElementA, mediaElementB } = await setup(); + + const response = await studentClient.put(`${mediaElementA.id}/position`, { + toLineId: mediaLine.id, + toPosition: 1, + }); + + expect(response.status).toEqual(HttpStatus.NO_CONTENT); + const modifiedElementA = await em.findOneOrFail(BoardNode, mediaElementA.id); + const modifiedElementB = await em.findOneOrFail(BoardNode, mediaElementB.id); + expect(modifiedElementA.position).toEqual(1); + expect(modifiedElementB.position).toEqual(0); + }); + }); + + describe('when the media board feature is disabled', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = false; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: studentUser.id, + type: BoardExternalReferenceType.User, + }, + }); + const mediaLine = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + }); + const mediaElement = mediaExternalToolElementNodeFactory.buildWithId({ + parent: mediaLine, + position: 0, + }); + + await em.persistAndFlush([studentAccount, studentUser, mediaBoard, mediaLine, mediaElement]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + mediaBoard, + mediaLine, + mediaElement, + }; + }; + + it('should return forbidden', async () => { + const { studentClient, mediaLine, mediaElement } = await setup(); + + const response = await studentClient.put(`${mediaElement.id}/position`, { + toLineId: mediaLine.id, + toPosition: 0, + }); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + expect(response.body).toEqual({ + code: HttpStatus.FORBIDDEN, + message: 'Forbidden', + title: 'Feature Disabled', + type: 'FEATURE_DISABLED', + }); + }); + }); + + describe('when the user is invalid', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = true; + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: new ObjectId().toHexString(), + type: BoardExternalReferenceType.User, + }, + }); + const mediaLine = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + }); + const mediaElement = mediaExternalToolElementNodeFactory.buildWithId({ + parent: mediaLine, + position: 0, + }); + + await em.persistAndFlush([mediaBoard, mediaLine]); + em.clear(); + + return { + mediaBoard, + mediaLine, + mediaElement, + }; + }; + + it('should return unauthorized', async () => { + const { mediaLine, mediaElement } = await setup(); + + const response = await testApiClient.put(`${mediaElement.id}/position`, { + toLineId: mediaLine.id, + toPosition: 0, + }); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/media-board/api-test/media-line.api.spec.ts b/apps/server/src/modules/board/controller/media-board/api-test/media-line.api.spec.ts new file mode 100644 index 00000000000..5acbde0b818 --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/api-test/media-line.api.spec.ts @@ -0,0 +1,430 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { type ServerConfig, serverConfig, ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { BoardNode } from '@shared/domain/entity'; +import { mediaBoardNodeFactory, mediaLineNodeFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { MoveColumnBodyParams, RenameBodyParams } from '../../dto'; + +const baseRouteName = '/media-lines'; + +describe('Media Line (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('[POST] /media-lines/:lineId/position', () => { + describe('when a valid user moves a line on their media board', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = true; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: studentUser.id, + type: BoardExternalReferenceType.User, + }, + }); + const mediaLineA = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + position: 0, + }); + const mediaLineB = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + position: 1, + }); + + await em.persistAndFlush([studentAccount, studentUser, mediaBoard, mediaLineA, mediaLineB]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + mediaBoard, + mediaLineA, + mediaLineB, + }; + }; + + it('should move the line', async () => { + const { studentClient, mediaBoard, mediaLineA, mediaLineB } = await setup(); + + const response = await studentClient.put(`${mediaLineA.id}/position`, { + toBoardId: mediaBoard.id, + toPosition: 1, + }); + + expect(response.status).toEqual(HttpStatus.NO_CONTENT); + const modifiedLineA = await em.findOneOrFail(BoardNode, mediaLineA.id); + const modifiedLineB = await em.findOneOrFail(BoardNode, mediaLineB.id); + expect(modifiedLineA.position).toEqual(1); + expect(modifiedLineB.position).toEqual(0); + }); + }); + + describe('when the media board feature is disabled', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = false; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: studentUser.id, + type: BoardExternalReferenceType.User, + }, + }); + const mediaLine = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + position: 0, + }); + + await em.persistAndFlush([studentAccount, studentUser, mediaBoard, mediaLine]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + mediaBoard, + mediaLine, + }; + }; + + it('should return forbidden', async () => { + const { studentClient, mediaBoard, mediaLine } = await setup(); + + const response = await studentClient.put(`${mediaLine.id}/position`, { + toBoardId: mediaBoard.id, + toPosition: 0, + }); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + expect(response.body).toEqual({ + code: HttpStatus.FORBIDDEN, + message: 'Forbidden', + title: 'Feature Disabled', + type: 'FEATURE_DISABLED', + }); + }); + }); + + describe('when the user is invalid', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = true; + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: new ObjectId().toHexString(), + type: BoardExternalReferenceType.User, + }, + }); + const mediaLine = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + position: 0, + }); + + await em.persistAndFlush([mediaBoard, mediaLine]); + em.clear(); + + return { + mediaBoard, + mediaLine, + }; + }; + + it('should return unauthorized', async () => { + const { mediaBoard, mediaLine } = await setup(); + + const response = await testApiClient.put(`${mediaLine.id}/position`, { + toBoardId: mediaBoard.id, + toPosition: 0, + }); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); + + describe('[PATCH] /media-lines/:lineId/title', () => { + describe('when a valid user renames a line on their media board', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = true; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: studentUser.id, + type: BoardExternalReferenceType.User, + }, + }); + const mediaLine = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + title: '', + }); + + await em.persistAndFlush([studentAccount, studentUser, mediaBoard, mediaLine]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + mediaBoard, + mediaLine, + }; + }; + + it('should rename the line', async () => { + const { studentClient, mediaLine } = await setup(); + + const response = await studentClient.patch(`${mediaLine.id}/title`, { + title: 'newTitle', + }); + + expect(response.status).toEqual(HttpStatus.NO_CONTENT); + const modifiedLine = await em.findOneOrFail(BoardNode, mediaLine.id); + expect(modifiedLine.title).toEqual('newTitle'); + }); + }); + + describe('when the media board feature is disabled', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = false; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: studentUser.id, + type: BoardExternalReferenceType.User, + }, + }); + const mediaLine = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + title: '', + }); + + await em.persistAndFlush([studentAccount, studentUser, mediaBoard, mediaLine]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + mediaLine, + }; + }; + + it('should return forbidden', async () => { + const { studentClient, mediaLine } = await setup(); + + const response = await studentClient.patch(`${mediaLine.id}/title`, { + title: 'newTitle', + }); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + expect(response.body).toEqual({ + code: HttpStatus.FORBIDDEN, + message: 'Forbidden', + title: 'Feature Disabled', + type: 'FEATURE_DISABLED', + }); + }); + }); + + describe('when the user is invalid', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = true; + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: new ObjectId().toHexString(), + type: BoardExternalReferenceType.User, + }, + }); + const mediaLine = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + title: '', + }); + + await em.persistAndFlush([mediaBoard, mediaLine]); + em.clear(); + + return { + mediaLine, + }; + }; + + it('should return unauthorized', async () => { + const { mediaLine } = await setup(); + + const response = await testApiClient.patch(`${mediaLine.id}/title`, { + title: 'newTitle', + }); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); + + describe('[DELETE] /media-lines/:lineId', () => { + describe('when a valid user deletes a line on their media board', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = true; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: studentUser.id, + type: BoardExternalReferenceType.User, + }, + }); + const mediaLine = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + }); + + await em.persistAndFlush([studentAccount, studentUser, mediaBoard, mediaLine]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + mediaBoard, + mediaLine, + }; + }; + + it('should delete the line', async () => { + const { studentClient, mediaLine } = await setup(); + + const response = await studentClient.delete(`${mediaLine.id}`); + + expect(response.status).toEqual(HttpStatus.NO_CONTENT); + const modifiedLine = await em.findOne(BoardNode, mediaLine.id); + expect(modifiedLine).toBeNull(); + }); + }); + + describe('when the media board feature is disabled', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = false; + + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: studentUser.id, + type: BoardExternalReferenceType.User, + }, + }); + const mediaLine = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + }); + + await em.persistAndFlush([studentAccount, studentUser, mediaBoard, mediaLine]); + em.clear(); + + const studentClient = await testApiClient.login(studentAccount); + + return { + studentClient, + mediaLine, + }; + }; + + it('should return forbidden', async () => { + const { studentClient, mediaLine } = await setup(); + + const response = await studentClient.delete(`${mediaLine.id}`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + expect(response.body).toEqual({ + code: HttpStatus.FORBIDDEN, + message: 'Forbidden', + title: 'Feature Disabled', + type: 'FEATURE_DISABLED', + }); + }); + }); + + describe('when the user is invalid', () => { + const setup = async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_MEDIA_SHELF_ENABLED = true; + + const mediaBoard = mediaBoardNodeFactory.buildWithId({ + context: { + id: new ObjectId().toHexString(), + type: BoardExternalReferenceType.User, + }, + }); + const mediaLine = mediaLineNodeFactory.buildWithId({ + parent: mediaBoard, + }); + + await em.persistAndFlush([mediaBoard, mediaLine]); + em.clear(); + + return { + mediaLine, + }; + }; + + it('should return unauthorized', async () => { + const { mediaLine } = await setup(); + + const response = await testApiClient.delete(`${mediaLine.id}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/controller/media-board/dto/element.url.params.ts b/apps/server/src/modules/board/controller/media-board/dto/element.url.params.ts new file mode 100644 index 00000000000..eab40d9accb --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/dto/element.url.params.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; + +export class ElementUrlParams { + @IsMongoId() + @ApiProperty({ + description: 'The id of the element.', + required: true, + nullable: false, + }) + elementId!: string; +} diff --git a/apps/server/src/modules/board/controller/media-board/dto/index.ts b/apps/server/src/modules/board/controller/media-board/dto/index.ts new file mode 100644 index 00000000000..2664d2ef996 --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/dto/index.ts @@ -0,0 +1,9 @@ +export { MediaLineResponse } from './media-line.response'; +export { MediaBoardResponse } from './media-board.response'; +export { + MediaExternalToolElementContent, + MediaExternalToolElementResponse, +} from './media-external-tool-element.response'; +export { LineUrlParams } from './line.url.params'; +export { ElementUrlParams } from './element.url.params'; +export { MoveElementBodyParams } from './move-element.body.params'; diff --git a/apps/server/src/modules/board/controller/media-board/dto/line.url.params.ts b/apps/server/src/modules/board/controller/media-board/dto/line.url.params.ts new file mode 100644 index 00000000000..b6c2b3f655d --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/dto/line.url.params.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; + +export class LineUrlParams { + @IsMongoId() + @ApiProperty({ + description: 'The id of the line.', + required: true, + nullable: false, + }) + lineId!: string; +} diff --git a/apps/server/src/modules/board/controller/media-board/dto/media-board.response.ts b/apps/server/src/modules/board/controller/media-board/dto/media-board.response.ts new file mode 100644 index 00000000000..fcff1788724 --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/dto/media-board.response.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TimestampsResponse } from '../../dto'; +import { MediaLineResponse } from './media-line.response'; + +export class MediaBoardResponse { + @ApiProperty() + id: string; + + @ApiProperty({ + type: [MediaLineResponse], + }) + lines: MediaLineResponse[]; + + @ApiProperty() + timestamps: TimestampsResponse; + + constructor(props: MediaBoardResponse) { + this.id = props.id; + this.lines = props.lines; + this.timestamps = props.timestamps; + } +} diff --git a/apps/server/src/modules/board/controller/media-board/dto/media-external-tool-element.response.ts b/apps/server/src/modules/board/controller/media-board/dto/media-external-tool-element.response.ts new file mode 100644 index 00000000000..06480bd6015 --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/dto/media-external-tool-element.response.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TimestampsResponse } from '../../dto'; + +export class MediaExternalToolElementContent { + @ApiProperty() + contextExternalToolId: string; + + constructor(props: MediaExternalToolElementContent) { + this.contextExternalToolId = props.contextExternalToolId; + } +} + +export class MediaExternalToolElementResponse { + @ApiProperty() + id: string; + + @ApiProperty() + content: MediaExternalToolElementContent; + + @ApiProperty() + timestamps: TimestampsResponse; + + constructor(props: MediaExternalToolElementResponse) { + this.id = props.id; + this.content = props.content; + this.timestamps = props.timestamps; + } +} diff --git a/apps/server/src/modules/board/controller/media-board/dto/media-line.response.ts b/apps/server/src/modules/board/controller/media-board/dto/media-line.response.ts new file mode 100644 index 00000000000..646d9e9a6f1 --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/dto/media-line.response.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { DecodeHtmlEntities } from '@shared/controller'; +import { TimestampsResponse } from '../../dto'; +import { MediaExternalToolElementResponse } from './media-external-tool-element.response'; + +export class MediaLineResponse { + @ApiProperty() + id: string; + + @ApiProperty() + @DecodeHtmlEntities() + title: string; + + @ApiProperty({ + type: [MediaExternalToolElementResponse], + }) + elements: MediaExternalToolElementResponse[]; + + @ApiProperty() + timestamps: TimestampsResponse; + + constructor(props: MediaLineResponse) { + this.id = props.id; + this.title = props.title; + this.elements = props.elements; + this.timestamps = props.timestamps; + } +} diff --git a/apps/server/src/modules/board/controller/media-board/dto/move-element.body.params.ts b/apps/server/src/modules/board/controller/media-board/dto/move-element.body.params.ts new file mode 100644 index 00000000000..0379c9cf88b --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/dto/move-element.body.params.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId, IsNumber, Min } from 'class-validator'; + +export class MoveElementBodyParams { + @IsMongoId() + @ApiProperty({ + required: true, + nullable: false, + }) + toLineId!: string; + + @IsNumber() + @Min(0) + @ApiProperty({ + required: true, + nullable: false, + }) + toPosition!: number; +} diff --git a/apps/server/src/modules/board/controller/media-board/index.ts b/apps/server/src/modules/board/controller/media-board/index.ts new file mode 100644 index 00000000000..da7cf6d822f --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/index.ts @@ -0,0 +1,3 @@ +export { MediaLineController } from './media-line.controller'; +export { MediaBoardController } from './media-board.controller'; +export { MediaElementController } from './media-element.controller'; diff --git a/apps/server/src/modules/board/controller/media-board/mapper/index.ts b/apps/server/src/modules/board/controller/media-board/mapper/index.ts new file mode 100644 index 00000000000..5fc31e899c3 --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/mapper/index.ts @@ -0,0 +1,3 @@ +export { MediaLineResponseMapper } from './media-line-response.mapper'; +export { MediaBoardResponseMapper } from './media-board-response.mapper'; +export { MediaElementResponseMapper } from './media-element-response.mapper'; diff --git a/apps/server/src/modules/board/controller/media-board/mapper/media-board-response.mapper.ts b/apps/server/src/modules/board/controller/media-board/mapper/media-board-response.mapper.ts new file mode 100644 index 00000000000..ae5b8d01202 --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/mapper/media-board-response.mapper.ts @@ -0,0 +1,23 @@ +import { type AnyBoardDo, isMediaLine, type MediaBoard, type MediaLine } from '@shared/domain/domainobject'; +import { TimestampsResponse } from '../../dto'; +import { MediaBoardResponse, MediaLineResponse } from '../dto'; +import { MediaLineResponseMapper } from './media-line-response.mapper'; + +export class MediaBoardResponseMapper { + static mapToResponse(board: MediaBoard): MediaBoardResponse { + const lines: MediaLineResponse[] = board.children + .filter((line: AnyBoardDo): line is MediaLine => isMediaLine(line)) + .map((line: MediaLine) => MediaLineResponseMapper.mapToResponse(line)); + + const boardResponse: MediaBoardResponse = new MediaBoardResponse({ + id: board.id, + lines, + timestamps: new TimestampsResponse({ + lastUpdatedAt: board.updatedAt, + createdAt: board.createdAt, + }), + }); + + return boardResponse; + } +} diff --git a/apps/server/src/modules/board/controller/media-board/mapper/media-element-response.mapper.ts b/apps/server/src/modules/board/controller/media-board/mapper/media-element-response.mapper.ts new file mode 100644 index 00000000000..dd5a8ceec2a --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/mapper/media-element-response.mapper.ts @@ -0,0 +1,20 @@ +import type { MediaExternalToolElement } from '@shared/domain/domainobject'; +import { TimestampsResponse } from '../../dto'; +import { MediaExternalToolElementContent, MediaExternalToolElementResponse } from '../dto'; + +export class MediaElementResponseMapper { + static mapToResponse(element: MediaExternalToolElement): MediaExternalToolElementResponse { + const elementResponse: MediaExternalToolElementResponse = new MediaExternalToolElementResponse({ + id: element.id, + content: new MediaExternalToolElementContent({ + contextExternalToolId: element.contextExternalToolId, + }), + timestamps: new TimestampsResponse({ + lastUpdatedAt: element.updatedAt, + createdAt: element.createdAt, + }), + }); + + return elementResponse; + } +} diff --git a/apps/server/src/modules/board/controller/media-board/mapper/media-line-response.mapper.ts b/apps/server/src/modules/board/controller/media-board/mapper/media-line-response.mapper.ts new file mode 100644 index 00000000000..dd3902dc215 --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/mapper/media-line-response.mapper.ts @@ -0,0 +1,29 @@ +import { + type AnyBoardDo, + isMediaExternalToolElement, + MediaExternalToolElement, + MediaLine, +} from '@shared/domain/domainobject'; +import { TimestampsResponse } from '../../dto'; +import { type MediaExternalToolElementResponse, MediaLineResponse } from '../dto'; +import { MediaElementResponseMapper } from './media-element-response.mapper'; + +export class MediaLineResponseMapper { + static mapToResponse(line: MediaLine): MediaLineResponse { + const elements: MediaExternalToolElementResponse[] = line.children + .filter((element: AnyBoardDo): element is MediaExternalToolElement => isMediaExternalToolElement(element)) + .map((element: MediaExternalToolElement) => MediaElementResponseMapper.mapToResponse(element)); + + const lineResponse: MediaLineResponse = new MediaLineResponse({ + id: line.id, + title: line.title, + elements, + timestamps: new TimestampsResponse({ + lastUpdatedAt: line.updatedAt, + createdAt: line.createdAt, + }), + }); + + return lineResponse; + } +} diff --git a/apps/server/src/modules/board/controller/media-board/media-board.controller.ts b/apps/server/src/modules/board/controller/media-board/media-board.controller.ts new file mode 100644 index 00000000000..21d5f7ab2c5 --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/media-board.controller.ts @@ -0,0 +1,54 @@ +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { Controller, ForbiddenException, Get, NotFoundException, Param, Post } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiCreatedResponse, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { ApiValidationError } from '@shared/common'; +import type { MediaBoard, MediaLine } from '@shared/domain/domainobject'; +import { MediaBoardUc } from '../../uc'; +import { BoardUrlParams } from '../dto'; +import { MediaBoardResponse, MediaLineResponse } from './dto'; +import { MediaBoardResponseMapper, MediaLineResponseMapper } from './mapper'; + +@ApiTags('Media Board') +@Authenticate('jwt') +@Controller('media-boards') +export class MediaBoardController { + constructor(private readonly mediaBoardUc: MediaBoardUc) {} + + @ApiOperation({ summary: 'Get the media shelf of the user.' }) + @ApiOkResponse({ type: MediaBoardResponse }) + @ApiBadRequestResponse({ type: ApiValidationError }) + @ApiForbiddenResponse({ type: ForbiddenException }) + @Get('me') + public async getMediaBoardForUser(@CurrentUser() currentUser: ICurrentUser): Promise { + const board: MediaBoard = await this.mediaBoardUc.getMediaBoardForUser(currentUser.userId); + + const response: MediaBoardResponse = MediaBoardResponseMapper.mapToResponse(board); + + return response; + } + + @ApiOperation({ summary: 'Create a new line on a media board.' }) + @ApiCreatedResponse({ type: MediaLineResponse }) + @ApiBadRequestResponse({ type: ApiValidationError }) + @ApiForbiddenResponse({ type: ForbiddenException }) + @ApiNotFoundResponse({ type: NotFoundException }) + @Post(':boardId/media-lines') + public async createLine( + @Param() urlParams: BoardUrlParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + const line: MediaLine = await this.mediaBoardUc.createLine(currentUser.userId, urlParams.boardId); + + const response: MediaLineResponse = MediaLineResponseMapper.mapToResponse(line); + + return response; + } +} diff --git a/apps/server/src/modules/board/controller/media-board/media-element.controller.ts b/apps/server/src/modules/board/controller/media-board/media-element.controller.ts new file mode 100644 index 00000000000..f45f47ede5d --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/media-element.controller.ts @@ -0,0 +1,49 @@ +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Put, +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiForbiddenResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { ApiValidationError } from '@shared/common'; +import { MediaElementUc } from '../../uc'; +import { ElementUrlParams, MoveElementBodyParams } from './dto'; + +@ApiTags('Media Element') +@Authenticate('jwt') +@Controller('media-elements') +export class MediaElementController { + constructor(private readonly mediaElementUc: MediaElementUc) {} + + @ApiOperation({ summary: 'Move a single element.' }) + @ApiNoContentResponse() + @ApiBadRequestResponse({ type: ApiValidationError }) + @ApiForbiddenResponse({ type: ForbiddenException }) + @ApiNotFoundResponse({ type: NotFoundException }) + @HttpCode(HttpStatus.NO_CONTENT) + @Put(':elementId/position') + public async moveElement( + @Param() urlParams: ElementUrlParams, + @Body() bodyParams: MoveElementBodyParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + await this.mediaElementUc.moveElement( + currentUser.userId, + urlParams.elementId, + bodyParams.toLineId, + bodyParams.toPosition + ); + } +} diff --git a/apps/server/src/modules/board/controller/media-board/media-line.controller.ts b/apps/server/src/modules/board/controller/media-board/media-line.controller.ts new file mode 100644 index 00000000000..3b97e7ed88e --- /dev/null +++ b/apps/server/src/modules/board/controller/media-board/media-line.controller.ts @@ -0,0 +1,73 @@ +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { + Body, + Controller, + Delete, + ForbiddenException, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Patch, + Put, +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiForbiddenResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { ApiValidationError } from '@shared/common'; +import { MediaLineUc } from '../../uc'; +import { MoveColumnBodyParams, RenameBodyParams } from '../dto'; +import { LineUrlParams } from './dto'; + +@ApiTags('Media Line') +@Authenticate('jwt') +@Controller('media-lines') +export class MediaLineController { + constructor(private readonly mediaLineUc: MediaLineUc) {} + + @ApiOperation({ summary: 'Move a single line.' }) + @ApiNoContentResponse() + @ApiBadRequestResponse({ type: ApiValidationError }) + @ApiForbiddenResponse({ type: ForbiddenException }) + @ApiNotFoundResponse({ type: NotFoundException }) + @HttpCode(HttpStatus.NO_CONTENT) + @Put(':lineId/position') + public async moveLine( + @Param() urlParams: LineUrlParams, + @Body() bodyParams: MoveColumnBodyParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + await this.mediaLineUc.moveLine(currentUser.userId, urlParams.lineId, bodyParams.toBoardId, bodyParams.toPosition); + } + + @ApiOperation({ summary: 'Update the title of a single line.' }) + @ApiNoContentResponse() + @ApiBadRequestResponse({ type: ApiValidationError }) + @ApiForbiddenResponse({ type: ForbiddenException }) + @ApiNotFoundResponse({ type: NotFoundException }) + @HttpCode(HttpStatus.NO_CONTENT) + @Patch(':lineId/title') + public async updateLineTitle( + @Param() urlParams: LineUrlParams, + @Body() bodyParams: RenameBodyParams, + @CurrentUser() currentUser: ICurrentUser + ): Promise { + await this.mediaLineUc.updateLineTitle(currentUser.userId, urlParams.lineId, bodyParams.title); + } + + @ApiOperation({ summary: 'Delete a single line.' }) + @ApiNoContentResponse() + @ApiBadRequestResponse({ type: ApiValidationError }) + @ApiForbiddenResponse({ type: ForbiddenException }) + @ApiNotFoundResponse({ type: NotFoundException }) + @HttpCode(HttpStatus.NO_CONTENT) + @Delete(':lineId') + async deleteLine(@Param() urlParams: LineUrlParams, @CurrentUser() currentUser: ICurrentUser): Promise { + await this.mediaLineUc.deleteLine(currentUser.userId, urlParams.lineId); + } +} diff --git a/apps/server/src/modules/board/index.ts b/apps/server/src/modules/board/index.ts index 663e27964f5..151284bf10b 100644 --- a/apps/server/src/modules/board/index.ts +++ b/apps/server/src/modules/board/index.ts @@ -1,7 +1,8 @@ -export * from './board.module'; +export { BoardModule } from './board.module'; export * from './service/board-do-authorizable.service'; export * from './service/card.service'; export * from './service/column-board.service'; export * from './service/column.service'; export * from './service/content-element.service'; export * from './service/column-board-copy.service'; +export { BoardConfig } from './board.config'; diff --git a/apps/server/src/modules/board/media-board-api.module.ts b/apps/server/src/modules/board/media-board-api.module.ts new file mode 100644 index 00000000000..47a8ff1003d --- /dev/null +++ b/apps/server/src/modules/board/media-board-api.module.ts @@ -0,0 +1,13 @@ +import { AuthorizationModule } from '@modules/authorization'; +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { BoardModule } from './board.module'; +import { MediaBoardController, MediaElementController, MediaLineController } from './controller'; +import { MediaBoardUc, MediaElementUc, MediaLineUc } from './uc'; + +@Module({ + imports: [BoardModule, LoggerModule, AuthorizationModule], + controllers: [MediaBoardController, MediaLineController, MediaElementController], + providers: [MediaBoardUc, MediaLineUc, MediaElementUc], +}) +export class MediaBoardApiModule {} diff --git a/apps/server/src/modules/board/media-board.config.ts b/apps/server/src/modules/board/media-board.config.ts new file mode 100644 index 00000000000..e3045ad12b5 --- /dev/null +++ b/apps/server/src/modules/board/media-board.config.ts @@ -0,0 +1,3 @@ +export interface MediaBoardConfig { + FEATURE_MEDIA_SHELF_ENABLED: boolean; +} diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts index 5d9c0a0e7d2..1e86ec1b408 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.spec.ts @@ -1,4 +1,10 @@ -import { ExternalToolElement, LinkElement } from '@shared/domain/domainobject'; +import { + ExternalToolElement, + LinkElement, + MediaBoard, + MediaExternalToolElement, + MediaLine, +} from '@shared/domain/domainobject'; import { BoardNodeType } from '@shared/domain/entity'; import { cardNodeFactory, @@ -7,6 +13,9 @@ import { externalToolElementNodeFactory, fileElementNodeFactory, linkElementNodeFactory, + mediaBoardNodeFactory, + mediaExternalToolElementNodeFactory, + mediaLineNodeFactory, richTextElementNodeFactory, setupEntities, submissionContainerElementNodeFactory, @@ -246,6 +255,114 @@ describe(BoardDoBuilderImpl.name, () => { }); }); + describe('when building a media board', () => { + it('should work without descendants', () => { + const mediaBoardNode = mediaBoardNodeFactory.build(); + + const domainObject = new BoardDoBuilderImpl().buildMediaBoard(mediaBoardNode); + + expect(domainObject.constructor.name).toBe(MediaBoard.name); + }); + + it('should throw error with wrong type of children', () => { + const mediaBoardNode1 = mediaBoardNodeFactory.buildWithId(); + const mediaBoardNode2 = mediaBoardNodeFactory.buildWithId({ parent: mediaBoardNode1 }); + + expect(() => { + new BoardDoBuilderImpl([mediaBoardNode2]).buildMediaBoard(mediaBoardNode1); + }).toThrowError(); + }); + + it('should assign the children', () => { + const mediaBoardNode = mediaBoardNodeFactory.buildWithId(); + const lineNode1 = mediaLineNodeFactory.buildWithId({ parent: mediaBoardNode }); + const lineNode2 = mediaLineNodeFactory.buildWithId({ parent: mediaBoardNode }); + + const domainObject = new BoardDoBuilderImpl([lineNode1, lineNode2]).buildMediaBoard(mediaBoardNode); + + expect(domainObject.children.map((el) => el.id).sort()).toEqual([lineNode1.id, lineNode2.id]); + }); + + it('should sort the children by their node position', () => { + const mediaBoardNode = mediaBoardNodeFactory.buildWithId(); + const lineNode1 = mediaLineNodeFactory.buildWithId({ parent: mediaBoardNode, position: 3 }); + const lineNode2 = mediaLineNodeFactory.buildWithId({ parent: mediaBoardNode, position: 2 }); + const lineNode3 = mediaLineNodeFactory.buildWithId({ parent: mediaBoardNode, position: 1 }); + + const domainObject = new BoardDoBuilderImpl([lineNode1, lineNode2, lineNode3]).buildMediaBoard(mediaBoardNode); + + const elementIds = domainObject.children.map((el) => el.id); + expect(elementIds).toEqual([lineNode3.id, lineNode2.id, lineNode1.id]); + }); + + it('should be able to use the builder', () => { + const mediaBoardNode = mediaBoardNodeFactory.buildWithId(); + const builder = new BoardDoBuilderImpl(); + const domainObject = mediaBoardNode.useDoBuilder(builder); + expect(domainObject.id).toEqual(mediaBoardNode.id); + }); + }); + + describe('when building a media line', () => { + it('should work without descendants', () => { + const columnNode = mediaLineNodeFactory.build(); + + const domainObject = new BoardDoBuilderImpl().buildMediaLine(columnNode); + + expect(domainObject.constructor.name).toBe(MediaLine.name); + }); + + it('should throw error with wrong type of children', () => { + const lineNode1 = mediaLineNodeFactory.buildWithId(); + const lineNode2 = mediaLineNodeFactory.buildWithId({ parent: lineNode1 }); + + expect(() => { + new BoardDoBuilderImpl([lineNode2]).buildMediaLine(lineNode1); + }).toThrowError(); + }); + + it('should assign the children', () => { + const lineNode = mediaLineNodeFactory.buildWithId(); + const elementNode1 = mediaExternalToolElementNodeFactory.buildWithId({ parent: lineNode }); + const elementNode2 = mediaExternalToolElementNodeFactory.buildWithId({ parent: lineNode }); + + const domainObject = new BoardDoBuilderImpl([elementNode1, elementNode2]).buildMediaLine(lineNode); + + expect(domainObject.children.map((el) => el.id).sort()).toEqual([elementNode1.id, elementNode2.id]); + }); + + it('should sort the children by their node position', () => { + const lineNode = mediaLineNodeFactory.buildWithId(); + const elementNode1 = mediaExternalToolElementNodeFactory.buildWithId({ parent: lineNode, position: 3 }); + const elementNode2 = mediaExternalToolElementNodeFactory.buildWithId({ parent: lineNode, position: 2 }); + const elementNode3 = mediaExternalToolElementNodeFactory.buildWithId({ parent: lineNode, position: 1 }); + + const domainObject = new BoardDoBuilderImpl([elementNode1, elementNode2, elementNode3]).buildMediaLine(lineNode); + + const cardIds = domainObject.children.map((el) => el.id); + expect(cardIds).toEqual([elementNode3.id, elementNode2.id, elementNode1.id]); + }); + }); + + describe('when building a media external tool element', () => { + it('should work without descendants', () => { + const mediaExternalToolElementNode = mediaExternalToolElementNodeFactory.build(); + + const domainObject = new BoardDoBuilderImpl().buildMediaExternalToolElement(mediaExternalToolElementNode); + + expect(domainObject.constructor.name).toBe(MediaExternalToolElement.name); + }); + + it('should throw error if externalToolElement is not a leaf', () => { + const mediaExternalToolElementNode = mediaExternalToolElementNodeFactory.buildWithId(); + const columnNode = columnNodeFactory.buildWithId({ parent: mediaExternalToolElementNode }); + + expect(() => { + new BoardDoBuilderImpl([columnNode]).buildMediaExternalToolElement(mediaExternalToolElementNode); + }).toThrowError(); + }); + }); + describe('ensure board node types', () => { it('should do nothing if type is correct', () => { const card = cardNodeFactory.build(); diff --git a/apps/server/src/modules/board/repo/board-do.builder-impl.ts b/apps/server/src/modules/board/repo/board-do.builder-impl.ts index 1e052f39d3c..79b1ce51851 100644 --- a/apps/server/src/modules/board/repo/board-do.builder-impl.ts +++ b/apps/server/src/modules/board/repo/board-do.builder-impl.ts @@ -4,29 +4,35 @@ import { Card, Column, ColumnBoard, + DrawingElement, ExternalToolElement, FileElement, LinkElement, + MediaBoard, + MediaExternalToolElement, + MediaLine, RichTextElement, SubmissionContainerElement, SubmissionItem, } from '@shared/domain/domainobject'; -import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; import { - BoardNodeType, type BoardDoBuilder, type BoardNode, + BoardNodeType, type CardNode, type ColumnBoardNode, type ColumnNode, + type DrawingElementNode, type ExternalToolElementNodeEntity, type FileElementNode, type LinkElementNode, + type MediaBoardNode, + type MediaExternalToolElementNode, + type MediaLineNode, type RichTextElementNode, type SubmissionContainerElementNode, type SubmissionItemNode, } from '@shared/domain/entity'; -import { DrawingElementNode } from '@shared/domain/entity/boardnode/drawing-element-node.entity'; export class BoardDoBuilderImpl implements BoardDoBuilder { private childrenMap: Record = {}; @@ -54,6 +60,7 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { createdAt: boardNode.createdAt, updatedAt: boardNode.updatedAt, context: boardNode.context, + isVisible: boardNode.isVisible ?? false, }); return columnBoard; @@ -232,4 +239,50 @@ export class BoardDoBuilderImpl implements BoardDoBuilder { single(boardNode, type); } } + + buildMediaBoard(boardNode: MediaBoardNode): MediaBoard { + this.ensureBoardNodeType(this.getChildren(boardNode), BoardNodeType.MEDIA_LINE); + + const lines: MediaLine[] = this.buildChildren(boardNode); + + const mediaBoard: MediaBoard = new MediaBoard({ + id: boardNode.id, + createdAt: boardNode.createdAt, + updatedAt: boardNode.updatedAt, + children: lines, + context: boardNode.context, + }); + + return mediaBoard; + } + + buildMediaLine(boardNode: MediaLineNode): MediaLine { + this.ensureBoardNodeType(this.getChildren(boardNode), BoardNodeType.MEDIA_EXTERNAL_TOOL_ELEMENT); + + const elements: MediaExternalToolElement[] = this.buildChildren(boardNode); + + const mediaLine: MediaLine = new MediaLine({ + id: boardNode.id, + createdAt: boardNode.createdAt, + updatedAt: boardNode.updatedAt, + children: elements, + title: boardNode.title, + }); + + return mediaLine; + } + + buildMediaExternalToolElement(boardNode: MediaExternalToolElementNode): MediaExternalToolElement { + this.ensureLeafNode(boardNode); + + const element: MediaExternalToolElement = new MediaExternalToolElement({ + id: boardNode.id, + children: [], + createdAt: boardNode.createdAt, + updatedAt: boardNode.updatedAt, + contextExternalToolId: boardNode.contextExternalTool.id, + }); + + return element; + } } diff --git a/apps/server/src/modules/board/repo/board-do.repo.spec.ts b/apps/server/src/modules/board/repo/board-do.repo.spec.ts index 1d79f961ae8..2e18e9cdf9e 100644 --- a/apps/server/src/modules/board/repo/board-do.repo.spec.ts +++ b/apps/server/src/modules/board/repo/board-do.repo.spec.ts @@ -1,13 +1,29 @@ import { createMock } from '@golevelup/ts-jest'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { NotFoundError } from '@mikro-orm/core'; -import { EntityManager } from '@mikro-orm/mongodb'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; +import { DrawingElementAdapterService } from '@modules/tldraw-client'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; +import { contextExternalToolEntityFactory } from '@modules/tool/context-external-tool/testing'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AnyBoardDo, BoardExternalReferenceType, Card, Column, ColumnBoard } from '@shared/domain/domainobject'; -import { CardNode, RichTextElementNode } from '@shared/domain/entity'; +import { + AnyBoardDo, + BoardExternalReference, + BoardExternalReferenceType, + Card, + Column, + ColumnBoard, +} from '@shared/domain/domainobject'; +import { + BoardNode, + CardNode, + ColumnBoardNode, + ExternalToolElementNodeEntity, + RichTextElementNode, +} from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; import { cardFactory, cardNodeFactory, @@ -16,12 +32,18 @@ import { columnBoardNodeFactory, columnFactory, columnNodeFactory, + contextExternalToolFactory, courseFactory, + externalToolElementNodeFactory, fileElementFactory, + mediaBoardNodeFactory, + mediaExternalToolElementNodeFactory, + mediaLineNodeFactory, richTextElementFactory, richTextElementNodeFactory, } from '@shared/testing'; -import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; +import { ContextExternalTool } from '../../tool/context-external-tool/domain'; +import { ContextExternalToolEntity } from '../../tool/context-external-tool/entity'; import { BoardDoRepo } from './board-do.repo'; import { BoardNodeRepo } from './board-node.repo'; import { RecursiveDeleteVisitor } from './recursive-delete.vistor'; @@ -267,6 +289,62 @@ describe(BoardDoRepo.name, () => { }); }); + describe('countBoardUsageForExternalTools', () => { + describe('when counting the amount of boards used by the selected tools', () => { + const setup = async () => { + const contextExternalToolId: EntityId = new ObjectId().toHexString(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId( + undefined, + contextExternalToolId + ); + const contextExternalToolEntity: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId( + undefined, + contextExternalToolId + ); + const otherContextExternalToolEntity: ContextExternalToolEntity = + contextExternalToolEntityFactory.buildWithId(); + + const board: ColumnBoardNode = columnBoardNodeFactory.buildWithId(); + const otherBoard: ColumnBoardNode = columnBoardNodeFactory.buildWithId(); + const card: CardNode = cardNodeFactory.buildWithId({ parent: board }); + const otherCard: CardNode = cardNodeFactory.buildWithId({ parent: otherBoard }); + const externalToolElements: ExternalToolElementNodeEntity[] = externalToolElementNodeFactory.buildListWithId( + 2, + { + parent: card, + contextExternalTool: contextExternalToolEntity, + } + ); + const otherExternalToolElement: ExternalToolElementNodeEntity = externalToolElementNodeFactory.buildWithId({ + parent: otherCard, + contextExternalTool: otherContextExternalToolEntity, + }); + + await em.persistAndFlush([ + board, + otherBoard, + card, + otherCard, + ...externalToolElements, + otherExternalToolElement, + contextExternalToolEntity, + ]); + + return { + contextExternalTool, + }; + }; + + it('should return the amount of boards used by the selected tools', async () => { + const { contextExternalTool } = await setup(); + + const result: number = await repo.countBoardUsageForExternalTools([contextExternalTool]); + + expect(result).toEqual(1); + }); + }); + }); + describe('getAncestorIds', () => { describe('when having only a root boardnode', () => { const setup = async () => { @@ -509,4 +587,71 @@ describe(BoardDoRepo.name, () => { }); }); }); + + describe('deleteByExternalReference', () => { + describe('when deleting a board by its external reference', () => { + const setup = async () => { + const courseContext: BoardExternalReference = { + id: new ObjectId().toHexString(), + type: BoardExternalReferenceType.Course, + }; + const courseBoard = columnBoardNodeFactory.buildWithId({ context: courseContext }); + const courseColumn = columnNodeFactory.buildWithId({ parent: courseBoard }); + const courseCard = cardNodeFactory.buildWithId({ parent: courseColumn }); + const courseElement = richTextElementNodeFactory.buildWithId({ parent: courseCard }); + + const userContext: BoardExternalReference = { + id: new ObjectId().toHexString(), + type: BoardExternalReferenceType.User, + }; + const userMediaBoard = mediaBoardNodeFactory.buildWithId({ context: userContext }); + const userMediaLine = mediaLineNodeFactory.buildWithId({ parent: userMediaBoard }); + const userMediaElement = mediaExternalToolElementNodeFactory.buildWithId({ parent: userMediaLine }); + + await em.persistAndFlush([ + courseBoard, + courseColumn, + courseCard, + courseElement, + userMediaBoard, + userMediaLine, + userMediaElement, + ]); + em.clear(); + + return { + courseContext, + courseBoard, + courseColumn, + courseCard, + courseElement, + userMediaBoard, + userMediaLine, + userMediaElement, + }; + }; + + it('should delete a board with the given reference', async () => { + const { courseContext, courseBoard, courseColumn, courseCard, courseElement } = await setup(); + + await repo.deleteByExternalReference(courseContext); + em.clear(); + + await expect( + em.find(BoardNode, { id: { $in: [courseBoard.id, courseColumn.id, courseCard.id, courseElement.id] } }) + ).resolves.toHaveLength(0); + }); + + it('should not delete a board without the given reference', async () => { + const { courseContext, userMediaBoard, userMediaLine, userMediaElement } = await setup(); + + await repo.deleteByExternalReference(courseContext); + em.clear(); + + await expect( + em.find(BoardNode, { id: { $in: [userMediaBoard.id, userMediaLine.id, userMediaElement.id] } }) + ).resolves.toHaveLength(3); + }); + }); + }); }); diff --git a/apps/server/src/modules/board/repo/board-do.repo.ts b/apps/server/src/modules/board/repo/board-do.repo.ts index 79ab3dc3c48..27d5f69d0e3 100644 --- a/apps/server/src/modules/board/repo/board-do.repo.ts +++ b/apps/server/src/modules/board/repo/board-do.repo.ts @@ -1,8 +1,9 @@ -import { Utils } from '@mikro-orm/core'; +import { type FilterQuery, Utils } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import type { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; import { Injectable, NotFoundException } from '@nestjs/common'; -import { AnyBoardDo, BoardExternalReference } from '@shared/domain/domainobject'; -import { BoardNode, ColumnBoardNode } from '@shared/domain/entity'; +import type { AnyBoardDo, BoardExternalReference } from '@shared/domain/domainobject'; +import { BoardNode, ExternalToolElementNodeEntity } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; import { BoardDoBuilderImpl } from './board-do.builder-impl'; import { BoardNodeRepo } from './board-node.repo'; @@ -65,11 +66,13 @@ export class BoardDoRepo { } async findIdsByExternalReference(reference: BoardExternalReference): Promise { - const boardNodes = await this.em.find(ColumnBoardNode, { + // TODO Use an abstract base class for root nodes that have a contextId and a contextType. Multiple STI abstract base classes are blocked by MikroORM 6.1.2 (issue #3745) + const boardNodes: BoardNode[] = await this.em.find(BoardNode, { _contextId: new ObjectId(reference.id), _contextType: reference.type, - }); - const ids = boardNodes.map((o) => o.id); + } as FilterQuery); + + const ids: EntityId[] = boardNodes.map((node) => node.id); return ids; } @@ -81,6 +84,21 @@ export class BoardDoRepo { return domainObject; } + async countBoardUsageForExternalTools(contextExternalTools: ContextExternalTool[]) { + const toolIds: EntityId[] = contextExternalTools + .map((tool: ContextExternalTool): EntityId | undefined => tool.id) + .filter((id: EntityId | undefined): id is EntityId => !!id); + + const boardNodes: ExternalToolElementNodeEntity[] = await this.em.find(ExternalToolElementNodeEntity, { + contextExternalTool: { $in: toolIds }, + }); + + const boardIds: EntityId[] = boardNodes.map((node: ExternalToolElementNodeEntity): EntityId => node.ancestorIds[0]); + const boardCount: number = new Set(boardIds).size; + + return boardCount; + } + async getAncestorIds(boardDo: AnyBoardDo): Promise { const boardNode = await this.boardNodeRepo.findById(boardDo.id); return boardNode.ancestorIds; @@ -96,4 +114,26 @@ export class BoardDoRepo { await domainObject.acceptAsync(this.deleteVisitor); await this.em.flush(); } + + async deleteByExternalReference(reference: BoardExternalReference): Promise { + // TODO Use an abstract base class for root nodes that have a contextId and a contextType. Multiple STI abstract base classes are blocked by MikroORM 6.1.2 (issue #3745) + const boardNodes: BoardNode[] = await this.em.find(BoardNode, { + _contextId: new ObjectId(reference.id), + _contextType: reference.type, + } as FilterQuery); + + const boardDeletionPromises: Promise[] = boardNodes.map(async (boardNode: BoardNode): Promise => { + const descendants: BoardNode[] = await this.boardNodeRepo.findDescendants(boardNode); + + const domainObject: AnyBoardDo = new BoardDoBuilderImpl(descendants).buildDomainObject(boardNode); + + await domainObject.acceptAsync(this.deleteVisitor); + }); + + await Promise.all(boardDeletionPromises); + + await this.em.flush(); + + return boardNodes.length; + } } diff --git a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts index e8f3aa762a5..46ebf91634e 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { FileRecordParentType } from '@infra/rabbitmq'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { FileDto, FilesStorageClientAdapterService } from '@modules/files-storage-client'; -import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; +import { DrawingElementAdapterService } from '@modules/tldraw-client'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { Test, TestingModule } from '@nestjs/testing'; import { @@ -13,6 +13,9 @@ import { externalToolElementFactory, fileElementFactory, linkElementFactory, + mediaBoardFactory, + mediaExternalToolElementFactory, + mediaLineFactory, setupEntities, submissionContainerElementFactory, submissionItemFactory, @@ -81,6 +84,31 @@ describe(RecursiveDeleteVisitor.name, () => { }); }); + describe('when used as a visitor on a media board composite', () => { + describe('acceptAsync', () => { + it('should delete the board node', async () => { + const board = mediaBoardFactory.build(); + + await board.acceptAsync(service); + + expect(em.remove).toHaveBeenCalled(); + }); + + it('should make the children accept the service', async () => { + const lines = mediaLineFactory.buildList(2).map((lin) => { + lin.acceptAsync = jest.fn(); + return lin; + }); + const board = mediaBoardFactory.build({ children: lines }); + + await board.acceptAsync(service); + + expect(lines[0].acceptAsync).toHaveBeenCalledWith(service); + expect(lines[1].acceptAsync).toHaveBeenCalledWith(service); + }); + }); + }); + describe('visitFileElementAsync', () => { describe('WHEN file element, child and files are deleted successfully', () => { const setup = () => { @@ -333,4 +361,84 @@ describe(RecursiveDeleteVisitor.name, () => { }); }); }); + + describe('visitMediaExternalToolElementAsync', () => { + describe('when the linked context external tool exists', () => { + const setup = () => { + const contextExternalTool = contextExternalToolFactory.buildWithId(); + const childMediaExternalToolElement = mediaExternalToolElementFactory.build(); + const mediaExternalToolElement = mediaExternalToolElementFactory.build({ + children: [childMediaExternalToolElement], + contextExternalToolId: contextExternalTool.id, + }); + + contextExternalToolService.findById.mockResolvedValue(contextExternalTool); + + return { + mediaExternalToolElement, + childMediaExternalToolElement, + contextExternalTool, + }; + }; + + it('should delete the context external tool that is linked to the element', async () => { + const { mediaExternalToolElement, contextExternalTool } = setup(); + + await service.visitMediaExternalToolElementAsync(mediaExternalToolElement); + + expect(contextExternalToolService.deleteContextExternalTool).toHaveBeenCalledWith(contextExternalTool); + }); + + it('should call entity remove', async () => { + const { mediaExternalToolElement, childMediaExternalToolElement } = setup(); + + await service.visitMediaExternalToolElementAsync(mediaExternalToolElement); + + expect(em.remove).toHaveBeenCalledWith( + em.getReference(mediaExternalToolElement.constructor, mediaExternalToolElement.id) + ); + expect(em.remove).toHaveBeenCalledWith( + em.getReference(childMediaExternalToolElement.constructor, childMediaExternalToolElement.id) + ); + }); + }); + + describe('when the external tool does not exist anymore', () => { + const setup = () => { + const childMediaExternalToolElement = mediaExternalToolElementFactory.build(); + const mediaExternalToolElement = mediaExternalToolElementFactory.build({ + children: [childMediaExternalToolElement], + contextExternalToolId: new ObjectId().toHexString(), + }); + + contextExternalToolService.findById.mockResolvedValue(null); + + return { + mediaExternalToolElement, + childMediaExternalToolElement, + }; + }; + + it('should not try to delete the context external tool that is linked to the element', async () => { + const { mediaExternalToolElement } = setup(); + + await service.visitMediaExternalToolElementAsync(mediaExternalToolElement); + + expect(contextExternalToolService.deleteContextExternalTool).not.toHaveBeenCalled(); + }); + + it('should call entity remove', async () => { + const { mediaExternalToolElement, childMediaExternalToolElement } = setup(); + + await service.visitMediaExternalToolElementAsync(mediaExternalToolElement); + + expect(em.remove).toHaveBeenCalledWith( + em.getReference(mediaExternalToolElement.constructor, mediaExternalToolElement.id) + ); + expect(em.remove).toHaveBeenCalledWith( + em.getReference(childMediaExternalToolElement.constructor, childMediaExternalToolElement.id) + ); + }); + }); + }); }); diff --git a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts index 3993d9da87c..7e73321eaa2 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts @@ -1,23 +1,26 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; -import { DrawingElementAdapterService } from '@modules/tldraw-client/service/drawing-element-adapter.service'; -import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; +import { DrawingElementAdapterService } from '@modules/tldraw-client'; +import type { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { Injectable } from '@nestjs/common'; -import { +import type { AnyBoardDo, BoardCompositeVisitorAsync, Card, Column, ColumnBoard, + DrawingElement, ExternalToolElement, FileElement, + LinkElement, + MediaBoard, + MediaExternalToolElement, + MediaLine, RichTextElement, SubmissionContainerElement, SubmissionItem, } from '@shared/domain/domainobject'; -import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; -import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { BoardNode } from '@shared/domain/entity'; @Injectable() @@ -65,6 +68,7 @@ export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { async visitDrawingElementAsync(drawingElement: DrawingElement): Promise { await this.drawingElementAdapterService.deleteDrawingBinData(drawingElement.id); + await this.filesStorageClientAdapterService.deleteFilesOfParent(drawingElement.id); this.deleteNode(drawingElement); await this.visitChildrenAsync(drawingElement); @@ -96,6 +100,30 @@ export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { await this.visitChildrenAsync(externalToolElement); } + async visitMediaBoardAsync(mediaBoard: MediaBoard): Promise { + this.deleteNode(mediaBoard); + await this.visitChildrenAsync(mediaBoard); + } + + async visitMediaLineAsync(mediaLine: MediaLine): Promise { + this.deleteNode(mediaLine); + await this.visitChildrenAsync(mediaLine); + } + + async visitMediaExternalToolElementAsync(mediaElement: MediaExternalToolElement): Promise { + this.deleteNode(mediaElement); + + const linkedTool: ContextExternalTool | null = await this.contextExternalToolService.findById( + mediaElement.contextExternalToolId + ); + + if (linkedTool) { + await this.contextExternalToolService.deleteContextExternalTool(linkedTool); + } + + await this.visitChildrenAsync(mediaElement); + } + deleteNode(domainObject: AnyBoardDo): void { this.em.remove(this.em.getReference(BoardNode, domainObject.id)); } diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts index 23129cd2288..b3a68c25e62 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.spec.ts @@ -1,15 +1,19 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { EntityManager } from '@mikro-orm/mongodb'; +import { contextExternalToolEntityFactory } from '@modules/tool/context-external-tool/testing'; import { BoardNodeType, CardNode, ColumnBoardNode, ColumnNode, + DrawingElementNode, ExternalToolElementNodeEntity, FileElementNode, LinkElementNode, + MediaBoardNode, + MediaExternalToolElementNode, + MediaLineNode, RichTextElementNode, - DrawingElementNode, SubmissionContainerElementNode, SubmissionItemNode, } from '@shared/domain/entity'; @@ -18,13 +22,15 @@ import { columnBoardFactory, columnBoardNodeFactory, columnFactory, - contextExternalToolEntityFactory, + drawingElementFactory, externalToolElementFactory, fileElementFactory, linkElementFactory, + mediaBoardFactory, + mediaExternalToolElementFactory, + mediaLineFactory, richTextElementFactory, setupEntities, - drawingElementFactory, submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; @@ -249,6 +255,106 @@ describe(RecursiveSaveVisitor.name, () => { }); }); + describe('when visiting a media board composite', () => { + const setup = () => { + const line = mediaLineFactory.build(); + const board = mediaBoardFactory.build({ children: [line] }); + + jest.spyOn(line, 'accept'); + jest.spyOn(visitor, 'createOrUpdateBoardNode'); + + return { + board, + line, + }; + }; + + it('should create or update the node', () => { + const { board } = setup(); + + visitor.visitMediaBoard(board); + + const expectedNode: Partial = { + id: board.id, + type: BoardNodeType.MEDIA_BOARD, + }; + expect(visitor.createOrUpdateBoardNode).toHaveBeenCalledWith(expect.objectContaining(expectedNode)); + }); + + it('should visit the children', () => { + const { board, line } = setup(); + + board.accept(visitor); + + expect(line.accept).toHaveBeenCalledWith(visitor); + }); + }); + + describe('when visiting a media line composite', () => { + const setup = () => { + const element = mediaExternalToolElementFactory.build(); + const line = mediaLineFactory.build({ children: [element] }); + + jest.spyOn(element, 'accept'); + jest.spyOn(visitor, 'createOrUpdateBoardNode'); + + return { + line, + element, + }; + }; + + it('should create or update the node', () => { + const { line } = setup(); + + visitor.visitMediaLine(line); + + const expectedNode: Partial = { + id: line.id, + type: BoardNodeType.MEDIA_LINE, + title: line.title, + }; + expect(visitor.createOrUpdateBoardNode).toHaveBeenCalledWith(expect.objectContaining(expectedNode)); + }); + + it('should visit the children', () => { + const { line, element } = setup(); + + line.accept(visitor); + + expect(element.accept).toHaveBeenCalledWith(visitor); + }); + }); + + describe('when visiting a media external tool element', () => { + const setup = () => { + const contextExternalTool = contextExternalToolEntityFactory.buildWithId(); + const mediaExternalToolElement = mediaExternalToolElementFactory.build({ + contextExternalToolId: contextExternalTool.id, + }); + + jest.spyOn(visitor, 'createOrUpdateBoardNode'); + + return { + contextExternalTool, + mediaExternalToolElement, + }; + }; + + it('should create or update the node', () => { + const { contextExternalTool, mediaExternalToolElement } = setup(); + + visitor.visitMediaExternalToolElement(mediaExternalToolElement); + + const expectedNode: Partial = { + id: mediaExternalToolElement.id, + type: BoardNodeType.MEDIA_EXTERNAL_TOOL_ELEMENT, + contextExternalTool, + }; + expect(visitor.createOrUpdateBoardNode).toHaveBeenCalledWith(expect.objectContaining(expectedNode)); + }); + }); + describe('createOrUpdateBoardNode', () => { describe('when the board is new', () => { it('should persist the board node', () => { @@ -265,6 +371,7 @@ describe(RecursiveSaveVisitor.name, () => { it('should persist the board node', () => { const board = columnBoardFactory.build(); const boardNode = columnBoardNodeFactory.build(); + em.getUnitOfWork().getById.mockReturnValue(boardNode); visitor.visitColumnBoard(board); diff --git a/apps/server/src/modules/board/repo/recursive-save.visitor.ts b/apps/server/src/modules/board/repo/recursive-save.visitor.ts index f08e87fd620..9516809a748 100644 --- a/apps/server/src/modules/board/repo/recursive-save.visitor.ts +++ b/apps/server/src/modules/board/repo/recursive-save.visitor.ts @@ -1,33 +1,39 @@ import { Utils } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; -import { +import type { AnyBoardDo, BoardCompositeVisitor, Card, Column, ColumnBoard, + DrawingElement, ExternalToolElement, FileElement, + LinkElement, + MediaBoard, + MediaExternalToolElement, + MediaLine, RichTextElement, SubmissionContainerElement, SubmissionItem, } from '@shared/domain/domainobject'; -import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; -import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { BoardNode, CardNode, ColumnBoardNode, ColumnNode, + DrawingElementNode, ExternalToolElementNodeEntity, FileElementNode, + LinkElementNode, + MediaBoardNode, + MediaExternalToolElementNode, + MediaLineNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, } from '@shared/domain/entity'; -import { DrawingElementNode } from '@shared/domain/entity/boardnode/drawing-element-node.entity'; -import { LinkElementNode } from '@shared/domain/entity/boardnode/link-element-node.entity'; import { EntityId } from '@shared/domain/types'; import { BoardNodeRepo } from './board-node.repo'; @@ -65,6 +71,7 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { parent: parentData?.boardNode, position: parentData?.position, context: columnBoard.context, + isVisible: columnBoard.isVisible, }); this.saveRecursive(boardNode, columnBoard); @@ -225,4 +232,43 @@ export class RecursiveSaveVisitor implements BoardCompositeVisitor { this.em.persist(boardNode); } } + + visitMediaBoard(mediaBoard: MediaBoard): void { + const parentData: ParentData | undefined = this.parentsMap.get(mediaBoard.id); + + const boardNode: MediaBoardNode = new MediaBoardNode({ + id: mediaBoard.id, + parent: parentData?.boardNode, + position: parentData?.position, + context: mediaBoard.context, + }); + + this.saveRecursive(boardNode, mediaBoard); + } + + visitMediaLine(mediaLine: MediaLine): void { + const parentData: ParentData | undefined = this.parentsMap.get(mediaLine.id); + + const boardNode: MediaLineNode = new MediaLineNode({ + id: mediaLine.id, + parent: parentData?.boardNode, + position: parentData?.position, + title: mediaLine.title, + }); + + this.saveRecursive(boardNode, mediaLine); + } + + visitMediaExternalToolElement(mediaElement: MediaExternalToolElement): void { + const parentData: ParentData | undefined = this.parentsMap.get(mediaElement.id); + + const boardNode: MediaExternalToolElementNode = new MediaExternalToolElementNode({ + id: mediaElement.id, + parent: parentData?.boardNode, + position: parentData?.position, + contextExternalTool: this.em.getReference(ContextExternalToolEntity, mediaElement.contextExternalToolId), + }); + + this.saveRecursive(boardNode, mediaElement); + } } diff --git a/apps/server/src/modules/board/service/board-do-authorizable.service.spec.ts b/apps/server/src/modules/board/service/board-do-authorizable.service.spec.ts index 25bfb26a25d..7712a4a1a13 100644 --- a/apps/server/src/modules/board/service/board-do-authorizable.service.spec.ts +++ b/apps/server/src/modules/board/service/board-do-authorizable.service.spec.ts @@ -1,9 +1,17 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardExternalReferenceType, BoardRoles, UserRoleEnum } from '@shared/domain/domainobject'; +import { BoardDoAuthorizableProps, BoardExternalReferenceType, BoardRoles } from '@shared/domain/domainobject'; import { CourseRepo } from '@shared/repo'; -import { courseFactory, roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { columnBoardFactory, columnFactory } from '@shared/testing/factory/domainobject'; +import { + cardFactory, + columnBoardFactory, + columnFactory, + courseFactory, + mediaBoardFactory, + roleFactory, + setupEntities, + userFactory, +} from '@shared/testing'; import { BoardDoRepo } from '../repo'; import { BoardDoAuthorizableService } from './board-do-authorizable.service'; @@ -34,6 +42,10 @@ describe(BoardDoAuthorizableService.name, () => { await setupEntities(); }); + afterEach(() => { + jest.resetAllMocks(); + }); + afterAll(async () => { await module.close(); }); @@ -41,8 +53,12 @@ describe(BoardDoAuthorizableService.name, () => { describe('findById', () => { describe('when finding a board domainobject', () => { const setup = () => { + const course = courseFactory.build(); const columnBoard = columnBoardFactory.build(); - boardDoRepo.findById.mockResolvedValue(columnBoard); + boardDoRepo.findById.mockResolvedValueOnce(columnBoard); + boardDoRepo.findById.mockResolvedValueOnce(columnBoard); + courseRepo.findById.mockResolvedValueOnce(course); + boardDoRepo.getAncestorIds.mockResolvedValueOnce([columnBoard.id]); return { columnBoardId: columnBoard.id }; }; @@ -68,13 +84,16 @@ describe(BoardDoAuthorizableService.name, () => { describe('getBoardAuthorizable', () => { describe('when having an empty board', () => { const setup = () => { + const course = courseFactory.build(); const board = columnBoardFactory.build(); - return { board }; + return { board, course }; }; it('should return an empty usergroup', async () => { - const { board } = setup(); + const { board, course } = setup(); boardDoRepo.findById.mockResolvedValueOnce(board); + courseRepo.findById.mockResolvedValueOnce(course); + boardDoRepo.getAncestorIds.mockResolvedValueOnce([board.id]); const userGroup = await service.getBoardAuthorizable(board); @@ -97,6 +116,7 @@ describe(BoardDoAuthorizableService.name, () => { const board = columnBoardFactory.build({ context: { type: BoardExternalReferenceType.Course, id: course.id } }); boardDoRepo.findById.mockResolvedValueOnce(board); courseRepo.findById.mockResolvedValueOnce(course); + boardDoRepo.getAncestorIds.mockResolvedValueOnce([board.id]); return { board, teacherId: teacher.id, @@ -117,22 +137,12 @@ describe(BoardDoAuthorizableService.name, () => { return map; }, {}); - const userRoleEnums = boardDoAuthorizable.users.reduce((map, user) => { - map[user.userId] = user.userRoleEnum; - return map; - }, {}); - expect(boardDoAuthorizable.users).toHaveLength(5); expect(userPermissions[teacherId]).toEqual([BoardRoles.EDITOR]); - expect(userRoleEnums[teacherId]).toEqual(UserRoleEnum.TEACHER); expect(userPermissions[substitutionTeacherId]).toEqual([BoardRoles.EDITOR]); - expect(userRoleEnums[substitutionTeacherId]).toEqual(UserRoleEnum.SUBSTITUTION_TEACHER); expect(userPermissions[studentIds[0]]).toEqual([BoardRoles.READER]); - expect(userRoleEnums[studentIds[0]]).toEqual(UserRoleEnum.STUDENT); expect(userPermissions[studentIds[1]]).toEqual([BoardRoles.READER]); - expect(userRoleEnums[studentIds[1]]).toEqual(UserRoleEnum.STUDENT); expect(userPermissions[studentIds[2]]).toEqual([BoardRoles.READER]); - expect(userRoleEnums[studentIds[2]]).toEqual(UserRoleEnum.STUDENT); }); it('should return the users with their names', async () => { @@ -161,6 +171,37 @@ describe(BoardDoAuthorizableService.name, () => { expect(firstNames[students[2].id]).toEqual(students[2].firstName); expect(lastNames[students[2].id]).toEqual(students[2].lastName); }); + + it('should return the boardDo', async () => { + const { board } = setup(); + + const boardDoAuthorizable = await service.getBoardAuthorizable(board); + + expect(boardDoAuthorizable.boardDo).toEqual(board); + }); + + it('should return the parentDo', async () => { + setup(); + const column = columnFactory.build(); + const card = cardFactory.build(); + + boardDoRepo.findParentOfId.mockResolvedValueOnce(column); + + const boardDoAuthorizable = await service.getBoardAuthorizable(card); + + expect(boardDoAuthorizable.parentDo).toEqual(column); + }); + + it('should return the rootDo', async () => { + const { board } = setup(); + const column = columnFactory.build(); + boardDoRepo.getAncestorIds.mockResolvedValueOnce([column.id, board.id]); + boardDoRepo.findById.mockResolvedValueOnce(board); + + const boardDoAuthorizable = await service.getBoardAuthorizable(board); + + expect(boardDoAuthorizable.rootDo).toEqual(board); + }); }); describe('when trying to create a boardDoAuthorizable on a column without a columnboard as root', () => { @@ -169,7 +210,10 @@ describe(BoardDoAuthorizableService.name, () => { const teacher = userFactory.buildWithId({ roles }); const students = userFactory.buildListWithId(3); const column = columnFactory.build(); + + boardDoRepo.getAncestorIds.mockResolvedValueOnce([]); boardDoRepo.findById.mockResolvedValueOnce(column); + return { column, teacherId: teacher.id, studentIds: students.map((s) => s.id) }; }; @@ -185,6 +229,7 @@ describe(BoardDoAuthorizableService.name, () => { const teacher = userFactory.buildWithId(); const board = columnBoardFactory.withoutContext().build(); boardDoRepo.findById.mockResolvedValueOnce(board); + boardDoRepo.getAncestorIds.mockResolvedValueOnce([board.id]); return { board, teacherId: teacher.id }; }; @@ -196,4 +241,37 @@ describe(BoardDoAuthorizableService.name, () => { }); }); }); + + describe('when having a media board bound to a user', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const board = mediaBoardFactory.build({ context: { type: BoardExternalReferenceType.User, id: user.id } }); + + boardDoRepo.findById.mockResolvedValueOnce(board); + boardDoRepo.getAncestorIds.mockResolvedValueOnce([board.id]); + + return { + user, + board, + }; + }; + + it('should return the boardDoAuthorizable', async () => { + const { board, user } = setup(); + + const boardDoAuthorizable = await service.getBoardAuthorizable(board); + + expect(boardDoAuthorizable.getProps()).toEqual({ + id: board.id, + boardDo: board, + users: [ + { + userId: user.id, + roles: [BoardRoles.EDITOR], + }, + ], + rootDo: board, + }); + }); + }); }); diff --git a/apps/server/src/modules/board/service/board-do-authorizable.service.ts b/apps/server/src/modules/board/service/board-do-authorizable.service.ts index bcdc08adfee..f3f85edceaa 100644 --- a/apps/server/src/modules/board/service/board-do-authorizable.service.ts +++ b/apps/server/src/modules/board/service/board-do-authorizable.service.ts @@ -6,8 +6,8 @@ import { BoardExternalReferenceType, BoardRoles, ColumnBoard, - UserBoardRoles, - UserRoleEnum, + MediaBoard, + UserWithBoardRoles, } from '@shared/domain/domainobject'; import { Course } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; @@ -23,31 +23,35 @@ export class BoardDoAuthorizableService implements AuthorizationLoaderService { async findById(id: EntityId): Promise { const boardDo = await this.boardDoRepo.findById(id, 1); - const { users } = await this.getBoardAuthorizable(boardDo); - const boardDoAuthorizable = new BoardDoAuthorizable({ users, id }); + const boardDoAuthorizable = await this.getBoardAuthorizable(boardDo); return boardDoAuthorizable; } async getBoardAuthorizable(boardDo: AnyBoardDo): Promise { - const ancestorIds = await this.boardDoRepo.getAncestorIds(boardDo); - const ids = [...ancestorIds, boardDo.id]; - const rootId = ids[0]; - const rootBoardDo = await this.boardDoRepo.findById(rootId, 1); - if (rootBoardDo instanceof ColumnBoard) { - if (rootBoardDo.context?.type === BoardExternalReferenceType.Course) { - const course = await this.courseRepo.findById(rootBoardDo.context.id); - const users = this.mapCourseUsersToUsergroup(course); - return new BoardDoAuthorizable({ users, id: boardDo.id }); - } - } else { - throw new Error('root boardnode was expected to be a ColumnBoard'); + const rootDo = await this.getRootBoardDo(boardDo); + // TODO used only for SubmissionItem; for rest BoardDo avoid extra call to improve performance + const parentDo = await this.getParentDo(boardDo); + let users: UserWithBoardRoles[] = []; + + if (rootDo.context?.type === BoardExternalReferenceType.Course) { + const course = await this.courseRepo.findById(rootDo.context.id); + users = this.mapCourseUsersToUserBoardRoles(course); + } else if (rootDo.context?.type === BoardExternalReferenceType.User) { + users = [ + { + userId: rootDo.context.id, + roles: [BoardRoles.EDITOR], + }, + ]; } - return new BoardDoAuthorizable({ users: [], id: boardDo.id }); + const boardDoAuthorizable = new BoardDoAuthorizable({ users, id: boardDo.id, boardDo, rootDo, parentDo }); + + return boardDoAuthorizable; } - private mapCourseUsersToUsergroup(course: Course): UserBoardRoles[] { + private mapCourseUsersToUserBoardRoles(course: Course): UserWithBoardRoles[] { const users = [ ...course.getTeachersList().map((user) => { return { @@ -55,7 +59,6 @@ export class BoardDoAuthorizableService implements AuthorizationLoaderService { firstName: user.firstName, lastName: user.lastName, roles: [BoardRoles.EDITOR], - userRoleEnum: UserRoleEnum.TEACHER, }; }), ...course.getSubstitutionTeachersList().map((user) => { @@ -64,7 +67,6 @@ export class BoardDoAuthorizableService implements AuthorizationLoaderService { firstName: user.firstName, lastName: user.lastName, roles: [BoardRoles.EDITOR], - userRoleEnum: UserRoleEnum.SUBSTITUTION_TEACHER, }; }), ...course.getStudentsList().map((user) => { @@ -73,10 +75,30 @@ export class BoardDoAuthorizableService implements AuthorizationLoaderService { firstName: user.firstName, lastName: user.lastName, roles: [BoardRoles.READER], - userRoleEnum: UserRoleEnum.STUDENT, }; }), ]; + // TODO check unique return users; } + + private async getParentDo(boardDo: AnyBoardDo): Promise | undefined> { + const parentDo = await this.boardDoRepo.findParentOfId(boardDo.id); + return parentDo; + } + + // TODO there is a similar method in board-do.service.ts + private async getRootBoardDo(boardDo: AnyBoardDo): Promise { + const ancestorIds = await this.boardDoRepo.getAncestorIds(boardDo); + const ids = [...ancestorIds, boardDo.id]; + const rootId = ids[0]; + const rootBoardDo = await this.boardDoRepo.findById(rootId, 1); + + // TODO Use an abstract base class for root nodes. Multiple STI abstract base classes are blocked by MikroORM 6.1.2 (issue #3745) + if (!(rootBoardDo instanceof ColumnBoard || rootBoardDo instanceof MediaBoard)) { + throw new Error('root boardnode was expected to be a ColumnBoard or MediaBoard'); + } + + return rootBoardDo; + } } diff --git a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts index b41e7d3e811..0023ece7ad2 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/board-do-copy.service.spec.ts @@ -38,7 +38,7 @@ import { submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { ToolFeatures } from '@modules/tool/tool-config'; import { BoardDoCopyService } from './board-do-copy.service'; import { SchoolSpecificFileCopyService } from './school-specific-file-copy.interface'; @@ -146,6 +146,15 @@ describe('recursive board copy visitor', () => { expect(result.type).toEqual(CopyElementType.COLUMNBOARD); }); + + it('should set the copy to unpublished', async () => { + const { original, fileCopyService } = setup(); + + const result = await service.copy({ original, fileCopyService }); + const copy = getColumnBoardCopyFromStatus(result); + + expect(copy.isVisible).toEqual(false); + }); }); describe('when copying a columnboard with children', () => { diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.spec.ts index 6cbea74cda3..d5f701a4c3c 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.spec.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.spec.ts @@ -1,10 +1,17 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; +import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; import { Test, TestingModule } from '@nestjs/testing'; import { LinkElement } from '@shared/domain/domainobject'; -import { linkElementFactory, setupEntities } from '@shared/testing'; +import { + linkElementFactory, + mediaBoardFactory, + mediaExternalToolElementFactory, + mediaLineFactory, + setupEntities, +} from '@shared/testing'; import { CopyFileDto } from '@src/modules/files-storage-client/dto'; -import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; -import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; +import { CopyElementType, CopyStatus, CopyStatusEnum } from '../../../copy-helper'; import { RecursiveCopyVisitor } from './recursive-copy.visitor'; import { SchoolSpecificFileCopyServiceFactory } from './school-specific-file-copy-service.factory'; import { SchoolSpecificFileCopyService } from './school-specific-file-copy.interface'; @@ -123,4 +130,46 @@ describe(RecursiveCopyVisitor.name, () => { }); }); }); + + describe('when copying a media board', () => { + const setup = () => { + const element = mediaExternalToolElementFactory.build(); + const line = mediaLineFactory.build({ children: [element] }); + const board = mediaBoardFactory.build({ children: [line] }); + + const visitor = new RecursiveCopyVisitor( + createMock(), + contextExternalToolService, + toolFeatures + ); + + return { + board, + visitor, + }; + }; + + it('should fail', async () => { + const { visitor, board } = setup(); + + await visitor.visitMediaBoardAsync(board); + + expect(visitor.resultMap.get(board.id)).toEqual({ + type: CopyElementType.MEDIA_BOARD, + status: CopyStatusEnum.NOT_DOING, + elements: [ + { + type: CopyElementType.MEDIA_LINE, + status: CopyStatusEnum.NOT_DOING, + elements: [ + { + type: CopyElementType.MEDIA_EXTERNAL_TOOL_ELEMENT, + status: CopyStatusEnum.NOT_DOING, + }, + ], + }, + ], + }); + }); + }); }); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts index b2a4e6a652d..5895d2139e3 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts @@ -1,4 +1,5 @@ import { FileRecordParentType } from '@infra/rabbitmq'; +import { ObjectId } from '@mikro-orm/mongodb'; import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; @@ -12,13 +13,15 @@ import { DrawingElement, ExternalToolElement, FileElement, + MediaBoard, + MediaExternalToolElement, + MediaLine, RichTextElement, SubmissionContainerElement, SubmissionItem, } from '@shared/domain/domainobject'; import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; import { SchoolSpecificFileCopyService } from './school-specific-file-copy.interface'; export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { @@ -53,6 +56,7 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { createdAt: new Date(), updatedAt: new Date(), children: this.getCopiesForChildrenOf(original), + isVisible: false, }); this.resultMap.set(original.id, { @@ -280,7 +284,36 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { return Promise.resolve(); } - async visitChildrenOf(boardDo: AnyBoardDo) { + async visitMediaBoardAsync(original: MediaBoard): Promise { + await this.visitChildrenOf(original); + + this.resultMap.set(original.id, { + type: CopyElementType.MEDIA_BOARD, + status: CopyStatusEnum.NOT_DOING, + elements: this.getCopyStatusesForChildrenOf(original), + }); + } + + async visitMediaLineAsync(original: MediaLine): Promise { + await this.visitChildrenOf(original); + + this.resultMap.set(original.id, { + type: CopyElementType.MEDIA_LINE, + status: CopyStatusEnum.NOT_DOING, + elements: this.getCopyStatusesForChildrenOf(original), + }); + } + + visitMediaExternalToolElementAsync(original: MediaExternalToolElement): Promise { + this.resultMap.set(original.id, { + type: CopyElementType.MEDIA_EXTERNAL_TOOL_ELEMENT, + status: CopyStatusEnum.NOT_DOING, + }); + + return Promise.resolve(); + } + + async visitChildrenOf(boardDo: AnyBoardDo): Promise[]> { return Promise.allSettled(boardDo.children.map((child) => child.acceptAsync(this))); } diff --git a/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy-service.factory.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy-service.factory.spec.ts index c780f9b9c50..4271949bd16 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy-service.factory.spec.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/school-specific-file-copy-service.factory.spec.ts @@ -3,7 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities } from '@shared/testing'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { FileRecordParentType } from '@modules/files-storage/entity'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { SchoolSpecificFileCopyServiceFactory } from './school-specific-file-copy-service.factory'; import { SchoolSpecificFileCopyServiceImpl } from './school-specific-file-copy.service'; diff --git a/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.spec.ts index 5d4693f44d1..34972e397c5 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.spec.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.spec.ts @@ -1,18 +1,21 @@ -import { LinkElement } from '@shared/domain/domainobject'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { LinkElement, MediaBoard, MediaExternalToolElement, MediaLine } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; import { cardFactory, columnBoardFactory, columnFactory, + drawingElementFactory, externalToolElementFactory, fileElementFactory, linkElementFactory, + mediaBoardFactory, + mediaExternalToolElementFactory, + mediaLineFactory, richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, } from '@shared/testing'; -import { drawingElementFactory } from '@shared/testing/factory/domainobject/board/drawing-element.do.factory'; -import { ObjectId } from 'bson'; import { SwapInternalLinksVisitor } from './swap-internal-links.visitor'; describe('swap internal links visitor', () => { @@ -127,4 +130,37 @@ describe('swap internal links visitor', () => { expect(linkElements[1].url).toEqual(pairs[1].expectedUrl); }); }); + + describe('when it is a media board', () => { + const setup = () => { + const element = mediaExternalToolElementFactory.build(); + const elementCopy = new MediaExternalToolElement(element.getProps()); + const line = mediaLineFactory.build({ children: [element] }); + const lineCopy = new MediaLine(line.getProps()); + const board = mediaBoardFactory.build({ children: [line] }); + const boardCopy = new MediaBoard(board.getProps()); + + const visitor = new SwapInternalLinksVisitor(new Map()); + + return { + element, + elementCopy, + line, + lineCopy, + board, + boardCopy, + visitor, + }; + }; + + it('should do nothing', () => { + const { visitor, element, elementCopy, line, lineCopy, board, boardCopy } = setup(); + + visitor.visitMediaBoard(board); + + expect(element).toEqual(elementCopy); + expect(line).toEqual(lineCopy); + expect(board).toEqual(boardCopy); + }); + }); }); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.ts index c4f3c0ae88c..d9edaf75291 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/swap-internal-links.visitor.ts @@ -4,11 +4,13 @@ import { Card, Column, ColumnBoard, + DrawingElement, LinkElement, + MediaBoard, + MediaLine, SubmissionContainerElement, SubmissionItem, } from '@shared/domain/domainobject'; -import { DrawingElement } from '@shared/domain/domainobject/board/drawing-element.do'; import { EntityId } from '@shared/domain/types'; export class SwapInternalLinksVisitor implements BoardCompositeVisitor { @@ -61,4 +63,16 @@ export class SwapInternalLinksVisitor implements BoardCompositeVisitor { } private doNothing() {} + + visitMediaBoard(mediaBoard: MediaBoard): void { + this.visitChildrenOf(mediaBoard); + } + + visitMediaLine(mediaLine: MediaLine): void { + this.visitChildrenOf(mediaLine); + } + + visitMediaExternalToolElement(): void { + this.doNothing(); + } } diff --git a/apps/server/src/modules/board/service/board-do.service.spec.ts b/apps/server/src/modules/board/service/board-do.service.spec.ts index 1173454c57c..399f4e49e88 100644 --- a/apps/server/src/modules/board/service/board-do.service.spec.ts +++ b/apps/server/src/modules/board/service/board-do.service.spec.ts @@ -1,6 +1,13 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { cardFactory, columnFactory, richTextElementFactory } from '@shared/testing/factory/domainobject'; +import { + cardFactory, + columnBoardFactory, + columnFactory, + richTextElementFactory, +} from '@shared/testing/factory/domainobject'; +import { ColumnBoard } from '@shared/domain/domainobject'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; @@ -185,4 +192,69 @@ describe(BoardDoService.name, () => { }); }); }); + + describe('getRootBoardDo', () => { + describe('when searching a board for an element', () => { + const setup2 = () => { + const element = richTextElementFactory.build(); + const board = columnBoardFactory.build({ children: [element] }); + + boardDoRepo.getAncestorIds.mockResolvedValue([board.id]); + boardDoRepo.findById.mockResolvedValue(board); + + return { + element, + board, + }; + }; + + it('should return the board', async () => { + const { element, board } = setup2(); + + const result = await service.getRootBoardDo(element); + + expect(result).toEqual(board); + }); + }); + + describe('when searching a board by itself', () => { + const setup2 = () => { + const board: ColumnBoard = columnBoardFactory.build({ children: [] }); + + boardDoRepo.getAncestorIds.mockResolvedValue([]); + boardDoRepo.findById.mockResolvedValue(board); + + return { + board, + }; + }; + + it('should return the board', async () => { + const { board } = setup2(); + + const result = await service.getRootBoardDo(board); + + expect(result).toEqual(board); + }); + }); + + describe('when the root node is not a board', () => { + const setup2 = () => { + const element = richTextElementFactory.build(); + + boardDoRepo.getAncestorIds.mockResolvedValue([]); + boardDoRepo.findById.mockResolvedValue(element); + + return { + element, + }; + }; + + it('should throw a NotFoundLoggableException', async () => { + const { element } = setup2(); + + await expect(service.getRootBoardDo(element)).rejects.toThrow(NotFoundLoggableException); + }); + }); + }); }); diff --git a/apps/server/src/modules/board/service/board-do.service.ts b/apps/server/src/modules/board/service/board-do.service.ts index 4ac578069e6..dff19f5e8e8 100644 --- a/apps/server/src/modules/board/service/board-do.service.ts +++ b/apps/server/src/modules/board/service/board-do.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { AnyBoardDo } from '@shared/domain/domainobject'; +import { AnyBoardDo, ColumnBoard } from '@shared/domain/domainobject'; +import { EntityId } from '@shared/domain/types'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { BoardDoRepo } from '../repo'; @Injectable() @@ -30,4 +32,18 @@ export class BoardDoService { targetParent.addChild(child, targetPosition); await this.boardDoRepo.save(targetParent.children, targetParent); } + + // TODO there is a similar method in board-do-authorizable.service.ts + async getRootBoardDo(boardDo: AnyBoardDo): Promise { + const ancestorIds: EntityId[] = await this.boardDoRepo.getAncestorIds(boardDo); + const idHierarchy: EntityId[] = [...ancestorIds, boardDo.id]; + const rootId: EntityId = idHierarchy[0]; + const rootBoardDo: AnyBoardDo = await this.boardDoRepo.findById(rootId, 1); + + if (rootBoardDo instanceof ColumnBoard) { + return rootBoardDo; + } + + throw new NotFoundLoggableException(ColumnBoard.name, { id: rootId }); + } } diff --git a/apps/server/src/modules/board/service/card.service.spec.ts b/apps/server/src/modules/board/service/card.service.spec.ts index ffef98c53ad..9c39c5fe0d5 100644 --- a/apps/server/src/modules/board/service/card.service.spec.ts +++ b/apps/server/src/modules/board/service/card.service.spec.ts @@ -51,6 +51,10 @@ describe(CardService.name, () => { await module.close(); }); + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('findById', () => { describe('when finding one specific card', () => { const setup = () => { @@ -162,6 +166,26 @@ describe(CardService.name, () => { }); }); + describe('createMany', () => { + describe('when creating many cards', () => { + const setup = () => { + const column = columnFactory.build(); + const cardInitProps = cardFactory.buildList(3); + + return { column, cardInitProps }; + }; + + it('should save a list of cards using the boardDo repo', async () => { + const { column, cardInitProps } = setup(); + + const result = await service.createMany(column, cardInitProps); + + expect(result).toHaveLength(3); + expect(boardDoRepo.save).toHaveBeenCalledTimes(1); + }); + }); + }); + describe('delete', () => { describe('when deleting a card', () => { it('should call the service', async () => { diff --git a/apps/server/src/modules/board/service/card.service.ts b/apps/server/src/modules/board/service/card.service.ts index a9d49982ad2..e27a513d196 100644 --- a/apps/server/src/modules/board/service/card.service.ts +++ b/apps/server/src/modules/board/service/card.service.ts @@ -1,7 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { Card, Column, ContentElementType } from '@shared/domain/domainobject'; +import { Card, CardInitProps, Column, ContentElementType } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { ContentElementService } from './content-element.service'; @@ -48,6 +48,27 @@ export class CardService { return card; } + async createMany(parent: Column, props: CardInitProps[]): Promise { + const cards = props.map((prop) => { + const card = new Card({ + id: new ObjectId().toHexString(), + title: prop.title, + height: prop.height, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + parent.addChild(card); + + return card; + }); + + await this.boardDoRepo.save(parent.children, parent); + + return cards; + } + async delete(card: Card): Promise { await this.boardDoService.deleteWithDescendants(card); } diff --git a/apps/server/src/modules/board/service/column-board-copy.service.spec.ts b/apps/server/src/modules/board/service/column-board-copy.service.spec.ts index 268c65297e7..e53df6797bc 100644 --- a/apps/server/src/modules/board/service/column-board-copy.service.spec.ts +++ b/apps/server/src/modules/board/service/column-board-copy.service.spec.ts @@ -1,5 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; +import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; import { BoardExternalReferenceType, ColumnBoard, UserDO } from '@shared/domain/domainobject'; @@ -11,11 +11,11 @@ import { columnFactory, courseFactory, linkElementFactory, - schoolFactory, + schoolEntityFactory, setupEntities, userFactory, } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BoardDoRepo } from '../repo'; import { BoardDoCopyService, @@ -32,6 +32,7 @@ describe('column board copy service', () => { let userService: DeepMocked; let courseRepo: DeepMocked; let fileCopyServiceFactory: DeepMocked; + let copyHelperService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -56,6 +57,10 @@ describe('column board copy service', () => { provide: SchoolSpecificFileCopyServiceFactory, useValue: createMock(), }, + { + provide: CopyHelperService, + useValue: createMock(), + }, ColumnBoardCopyService, ], }).compile(); @@ -66,14 +71,20 @@ describe('column board copy service', () => { userService = module.get(UserService); courseRepo = module.get(CourseRepo); fileCopyServiceFactory = module.get(SchoolSpecificFileCopyServiceFactory); + copyHelperService = module.get(CopyHelperService); await setupEntities(); }); + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + describe('when copying a column board', () => { const setup = () => { - const originalSchool = schoolFactory.buildWithId(); - const targetSchool = schoolFactory.buildWithId(); + const originalSchool = schoolEntityFactory.buildWithId(); + const targetSchool = schoolEntityFactory.buildWithId(); const course = courseFactory.buildWithId({ school: originalSchool }); const originalExternalReference = { id: course.id, @@ -112,12 +123,21 @@ describe('column board copy service', () => { const user = userFactory.buildWithId({ school: targetSchool }); const fileCopyServiceMock = createMock(); - fileCopyServiceFactory.build.mockReturnValue(fileCopyServiceMock); + fileCopyServiceFactory.build.mockReturnValueOnce(fileCopyServiceMock); + + boardRepo.findByClassAndId.mockResolvedValueOnce(originalBoard); + courseRepo.findById.mockResolvedValueOnce(course); + userService.findById.mockResolvedValueOnce({ schoolId: user.school.id } as UserDO); + doCopyService.copy.mockResolvedValueOnce(resultCopyStatus); + + const existingBoardIds = [new ObjectId().toHexString()]; + boardRepo.findIdsByExternalReference.mockResolvedValueOnce(existingBoardIds); + + const existingTitle = 'existingTitle'; + boardRepo.getTitlesByIds.mockResolvedValueOnce({ [existingBoardIds[0]]: existingTitle }); - boardRepo.findByClassAndId.mockResolvedValue(originalBoard); - courseRepo.findById.mockResolvedValue(course); - userService.findById.mockResolvedValue({ schoolId: user.school.id } as UserDO); - doCopyService.copy.mockResolvedValue(resultCopyStatus); + const derivedCopyTitle = 'derivedCopyTitle (1)'; + copyHelperService.deriveCopyName.mockReturnValueOnce(derivedCopyTitle); return { course, @@ -129,6 +149,9 @@ describe('column board copy service', () => { expectedBoardCopy, expectedCopyStatus, fileCopyServiceMock, + existingBoardIds, + existingTitle, + derivedCopyTitle, }; }; @@ -149,6 +172,7 @@ describe('column board copy service', () => { originalColumnBoardId: originalBoard.id, destinationExternalReference, userId: user.id, + copyTitle: 'newTitle', }); expect(doCopyService.copy).toHaveBeenCalledWith({ @@ -163,6 +187,7 @@ describe('column board copy service', () => { originalColumnBoardId: originalBoard.id, destinationExternalReference, userId: user.id, + copyTitle: 'newTitle', }); expect(boardRepo.save).toHaveBeenCalledWith(expectedBoardCopy); @@ -174,10 +199,95 @@ describe('column board copy service', () => { originalColumnBoardId: originalBoard.id, destinationExternalReference, userId: user.id, + copyTitle: 'newTitle', }); expect(result).toEqual(expectedCopyStatus); }); + + describe('when copyTitle is not provided', () => { + it('should get board ids for reference', async () => { + const { originalBoard, destinationExternalReference, user } = setup(); + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference, + userId: user.id, + }); + + expect(boardRepo.findIdsByExternalReference).toHaveBeenCalledWith(destinationExternalReference); + }); + + it('should get board titles for reference', async () => { + const { existingBoardIds, originalBoard, destinationExternalReference, user } = setup(); + + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference, + userId: user.id, + }); + + expect(boardRepo.getTitlesByIds).toHaveBeenCalledWith([existingBoardIds[0]]); + }); + + it('should call helper to obtain copy name', async () => { + const { originalBoard, destinationExternalReference, user, existingTitle } = setup(); + const originalTitle = originalBoard.title; + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference, + userId: user.id, + }); + + expect(copyHelperService.deriveCopyName).toHaveBeenCalledWith(originalTitle, [existingTitle]); + }); + + it('should call copyService with the derived title', async () => { + const { derivedCopyTitle, originalBoard, destinationExternalReference, user } = setup(); + + const copyBoard = originalBoard; + copyBoard.title = derivedCopyTitle; + + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference, + userId: user.id, + }); + + expect(doCopyService.copy).toHaveBeenCalledWith(expect.objectContaining({ original: copyBoard })); + }); + }); + + describe('when copyTitle is provided', () => { + it('should not call deriveCopyName if copyTitle is provided', async () => { + const { originalBoard, destinationExternalReference, user } = setup(); + const copyTitle = 'copyTitle'; + + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference, + userId: user.id, + copyTitle, + }); + expect(copyHelperService.deriveCopyName).not.toHaveBeenCalled(); + }); + it('should call copyService with given copyTitle', async () => { + const { fileCopyServiceMock, originalBoard, destinationExternalReference, user } = setup(); + const copyTitle = 'copyTitle'; + + await service.copyColumnBoard({ + originalColumnBoardId: originalBoard.id, + destinationExternalReference, + userId: user.id, + copyTitle, + }); + const copyBoard = originalBoard; + copyBoard.title = copyTitle; + expect(doCopyService.copy).toHaveBeenCalledWith({ + fileCopyService: fileCopyServiceMock, + original: copyBoard, + }); + }); + }); }); describe('when changing linked ids', () => { diff --git a/apps/server/src/modules/board/service/column-board-copy.service.ts b/apps/server/src/modules/board/service/column-board-copy.service.ts index 1317f4b5c22..540b65b0406 100644 --- a/apps/server/src/modules/board/service/column-board-copy.service.ts +++ b/apps/server/src/modules/board/service/column-board-copy.service.ts @@ -1,4 +1,4 @@ -import { CopyStatus } from '@modules/copy-helper'; +import { CopyHelperService, CopyStatus } from '@modules/copy-helper'; import { UserService } from '@modules/user'; import { Injectable, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; import { @@ -17,6 +17,7 @@ import { SwapInternalLinksVisitor } from './board-do-copy-service/swap-internal- export class ColumnBoardCopyService { constructor( private readonly boardDoRepo: BoardDoRepo, + private readonly copyHelperService: CopyHelperService, private readonly courseRepo: CourseRepo, private readonly userService: UserService, private readonly boardDoCopyService: BoardDoCopyService, @@ -27,8 +28,18 @@ export class ColumnBoardCopyService { originalColumnBoardId: EntityId; destinationExternalReference: BoardExternalReference; userId: EntityId; + copyTitle?: string; }): Promise { - const originalBoard = await this.boardDoRepo.findByClassAndId(ColumnBoard, props.originalColumnBoardId); + const originalBoard: ColumnBoard = await this.boardDoRepo.findByClassAndId( + ColumnBoard, + props.originalColumnBoardId + ); + + if (props.copyTitle) { + originalBoard.title = props.copyTitle; + } else { + originalBoard.title = await this.deriveColumnBoardTitle(originalBoard.title, props.destinationExternalReference); + } const user = await this.userService.findById(props.userId); /* istanbul ignore next */ @@ -56,6 +67,16 @@ export class ColumnBoardCopyService { return copyStatus; } + private async deriveColumnBoardTitle( + originalTitle: string, + destinationExternalReference: BoardExternalReference + ): Promise { + const existingBoardIds = await this.boardDoRepo.findIdsByExternalReference(destinationExternalReference); + const existingTitles = await this.boardDoRepo.getTitlesByIds(existingBoardIds); + const copyName = this.copyHelperService.deriveCopyName(originalTitle, Object.values(existingTitles)); + return copyName; + } + public async swapLinkedIds(boardId: EntityId, idMap: Map) { const board = await this.boardDoRepo.findById(boardId); diff --git a/apps/server/src/modules/board/service/column-board.service.spec.ts b/apps/server/src/modules/board/service/column-board.service.spec.ts index 0c308afc4ef..d5609285f87 100644 --- a/apps/server/src/modules/board/service/column-board.service.spec.ts +++ b/apps/server/src/modules/board/service/column-board.service.spec.ts @@ -2,19 +2,15 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { BoardExternalReference, BoardExternalReferenceType, - Card, ColumnBoard, ContentElementFactory, - RichTextElement, } from '@shared/domain/domainobject'; -import { InputFormat } from '@shared/domain/types'; import { columnBoardNodeFactory, setupEntities } from '@shared/testing'; import { columnBoardFactory, columnFactory, richTextElementFactory } from '@shared/testing/factory/domainobject'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; import { ColumnBoardService } from './column-board.service'; @@ -24,7 +20,6 @@ describe(ColumnBoardService.name, () => { let service: ColumnBoardService; let boardDoRepo: DeepMocked; let boardDoService: DeepMocked; - let contentElementFactory: DeepMocked; let configBefore: IConfig; beforeAll(async () => { @@ -49,7 +44,6 @@ describe(ColumnBoardService.name, () => { service = module.get(ColumnBoardService); boardDoRepo = module.get(BoardDoRepo); boardDoService = module.get(BoardDoService); - contentElementFactory = module.get(ContentElementFactory); configBefore = Configuration.toObject({ plainSecrets: true }); await setupEntities(); }); @@ -67,12 +61,13 @@ describe(ColumnBoardService.name, () => { const board = columnBoardFactory.build(); const boardId = board.id; const column = columnFactory.build(); + const courseId = new ObjectId().toHexString(); const externalReference: BoardExternalReference = { - id: new ObjectId().toHexString(), + id: courseId, type: BoardExternalReferenceType.Course, }; - return { board, boardId, column, externalReference }; + return { board, boardId, column, courseId, externalReference }; }; describe('findById', () => { @@ -108,83 +103,30 @@ describe(ColumnBoardService.name, () => { }); describe('findByDescendant', () => { - describe('when searching a board for an element', () => { - const setup2 = () => { - const element = richTextElementFactory.build(); - const board: ColumnBoard = columnBoardFactory.build({ children: [element] }); - - boardDoRepo.getAncestorIds.mockResolvedValue([board.id]); - boardDoRepo.findById.mockResolvedValue(board); - - return { - element, - board, - }; - }; - - it('should search by the root id', async () => { - const { element, board } = setup2(); - - await service.findByDescendant(element); - - expect(boardDoRepo.findById).toHaveBeenCalledWith(board.id, 1); - }); - - it('should return the board', async () => { - const { element, board } = setup2(); - - const result = await service.findByDescendant(element); - - expect(result).toEqual(board); - }); - }); - - describe('when searching a board by itself', () => { - const setup2 = () => { - const board: ColumnBoard = columnBoardFactory.build({ children: [] }); - - boardDoRepo.getAncestorIds.mockResolvedValue([]); - boardDoRepo.findById.mockResolvedValue(board); - - return { - board, - }; + const setup2 = () => { + const element = richTextElementFactory.build(); + const board = columnBoardFactory.build({ children: [element] }); + boardDoService.getRootBoardDo.mockResolvedValue(board); + return { + element, + board, }; + }; - it('should search by the root id', async () => { - const { board } = setup2(); - - await service.findByDescendant(board); - - expect(boardDoRepo.findById).toHaveBeenCalledWith(board.id, 1); - }); - - it('should return the board', async () => { - const { board } = setup2(); + it('should call board-do service to get the rootDo', async () => { + const { element } = setup2(); - const result = await service.findByDescendant(board); + await service.findByDescendant(element); - expect(result).toEqual(board); - }); + expect(boardDoService.getRootBoardDo).toHaveBeenCalledWith(element); }); - describe('when the root node is not a board', () => { - const setup2 = () => { - const element = richTextElementFactory.build(); - - boardDoRepo.getAncestorIds.mockResolvedValue([]); - boardDoRepo.findById.mockResolvedValue(element); + it('should return the root boardDo', async () => { + const { element, board } = setup2(); - return { - element, - }; - }; + const result = await service.findByDescendant(element); - it('should throw a NotFoundLoggableException', async () => { - const { element } = setup2(); - - await expect(service.findByDescendant(element)).rejects.toThrow(NotFoundLoggableException); - }); + expect(result).toEqual(board); }); }); @@ -239,6 +181,28 @@ describe(ColumnBoardService.name, () => { }); }); + describe('deleteByCourseId', () => { + describe('when deleting by courseId', () => { + it('should call boardDoRepo.findIdsByExternalReference to find the board ids', async () => { + const { boardId, courseId, externalReference } = setup(); + + boardDoRepo.findIdsByExternalReference.mockResolvedValue([boardId]); + + await service.deleteByCourseId(courseId); + + expect(boardDoRepo.findIdsByExternalReference).toHaveBeenCalledWith(externalReference); + }); + + it('should call boardDoService.deleteWithDescendants to delete the board', async () => { + const { board, courseId } = setup(); + + await service.deleteByCourseId(courseId); + + expect(boardDoService.deleteWithDescendants).toHaveBeenCalledWith(board); + }); + }); + }); + describe('updateTitle', () => { describe('when updating the title', () => { it('should call the service', async () => { @@ -260,97 +224,21 @@ describe(ColumnBoardService.name, () => { }); }); - describe('createWelcomeColumnBoard', () => { - beforeEach(() => { - contentElementFactory.build.mockImplementation(() => richTextElementFactory.build()); - }); - - it('should create a column board with initial content', async () => { - const { externalReference } = setup(); + describe('updateBoardVisibility', () => { + it('should call the boardDoRepo.save with the updated board', async () => { + const board = columnBoardFactory.build(); + const isVisible = true; - const columnBoard = await service.createWelcomeColumnBoard(externalReference); + await service.updateBoardVisibility(board, isVisible); - const column = columnBoard.children[0]; - const card = column.children[0] as Card; - const element = card.children[0] as RichTextElement; - expect(card.title).not.toHaveLength(0); - expect(element).toEqual( + expect(boardDoRepo.save).toHaveBeenCalledWith( expect.objectContaining({ - text: expect.any(String), - inputFormat: InputFormat.RICH_TEXT_CK5, + id: board.id, + isVisible, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), }) ); }); - - describe('when a help link is configured', () => { - beforeEach(() => { - Configuration.set('COLUMN_BOARD_HELP_LINK', 'http://example.com/help'); - }); - - it('should add a text element containing the link url', async () => { - const { externalReference } = setup(); - - const columnBoard = await service.createWelcomeColumnBoard(externalReference); - - const column = columnBoard.children[0]; - const card = column.children[0] as Card; - const element = card.children[1] as RichTextElement; - - expect(element.text).toEqual(expect.stringContaining(Configuration.get('COLUMN_BOARD_HELP_LINK') as string)); - }); - }); - - describe('when a feedback link is configured', () => { - beforeEach(() => { - Configuration.set('COLUMN_BOARD_FEEDBACK_LINK', 'http://example.com/feedback'); - }); - - it('should add a text element containing the link url', async () => { - const { externalReference } = setup(); - - const columnBoard = await service.createWelcomeColumnBoard(externalReference); - - const column = columnBoard.children[0]; - const card = column.children[0] as Card; - const element = card.children[2] as RichTextElement; - - expect(element.text).toEqual( - expect.stringContaining(Configuration.get('COLUMN_BOARD_FEEDBACK_LINK') as string) - ); - }); - }); - - describe('contact link text element', () => { - it('should add a text element containing the link url when theme is not default', async () => { - Configuration.set('SC_THEME', 'brb'); - const { externalReference } = setup(); - - const clientUrl = Configuration.get('HOST') as string; - const expectedContactUrl = `${clientUrl}/help/contact/`; - - const columnBoard = await service.createWelcomeColumnBoard(externalReference); - - const column = columnBoard.children[0]; - const card = column.children[0] as Card; - const element = card.children.find((child) => (child as RichTextElement).text.includes(clientUrl)); - - expect((element as RichTextElement).text).toEqual(expect.stringContaining(expectedContactUrl)); - }); - - it('should not add a text element when theme is default', async () => { - Configuration.set('SC_THEME', 'default'); - const { externalReference } = setup(); - - const clientUrl = Configuration.get('HOST') as string; - - const columnBoard = await service.createWelcomeColumnBoard(externalReference); - - const column = columnBoard.children[0]; - const card = column.children[0] as Card; - const element = card.children.find((child) => (child as RichTextElement).text.includes(clientUrl)); - - expect(element).toBeUndefined(); - }); - }); }); }); diff --git a/apps/server/src/modules/board/service/column-board.service.ts b/apps/server/src/modules/board/service/column-board.service.ts index 737b32797b9..8cf84ed2cb7 100644 --- a/apps/server/src/modules/board/service/column-board.service.ts +++ b/apps/server/src/modules/board/service/column-board.service.ts @@ -1,28 +1,18 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Injectable } from '@nestjs/common'; -import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { AnyBoardDo, BoardExternalReference, - Card, - Column, + BoardExternalReferenceType, ColumnBoard, - ContentElementFactory, - ContentElementType, - RichTextElement, } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; @Injectable() export class ColumnBoardService { - constructor( - private readonly boardDoRepo: BoardDoRepo, - private readonly boardDoService: BoardDoService, - private readonly contentElementFactory: ContentElementFactory - ) {} + constructor(private readonly boardDoRepo: BoardDoRepo, private readonly boardDoService: BoardDoService) {} async findById(boardId: EntityId): Promise { const board = await this.boardDoRepo.findByClassAndId(ColumnBoard, boardId); @@ -37,16 +27,9 @@ export class ColumnBoardService { } async findByDescendant(boardDo: AnyBoardDo): Promise { - const ancestorIds: EntityId[] = await this.boardDoRepo.getAncestorIds(boardDo); - const idHierarchy: EntityId[] = [...ancestorIds, boardDo.id]; - const rootId: EntityId = idHierarchy[0]; - const rootBoardDo: AnyBoardDo = await this.boardDoRepo.findById(rootId, 1); + const rootboardDo = this.boardDoService.getRootBoardDo(boardDo); - if (rootBoardDo instanceof ColumnBoard) { - return rootBoardDo; - } - - throw new NotFoundLoggableException(ColumnBoard.name, { id: rootId }); + return rootboardDo; } async getBoardObjectTitlesById(boardIds: EntityId[]): Promise> { @@ -62,6 +45,7 @@ export class ColumnBoardService { createdAt: new Date(), updatedAt: new Date(), context, + isVisible: false, }); await this.boardDoRepo.save(columnBoard); @@ -73,79 +57,32 @@ export class ColumnBoardService { await this.boardDoService.deleteWithDescendants(board); } - async updateTitle(board: ColumnBoard, title: string): Promise { - board.title = title; - await this.boardDoRepo.save(board); - } - - async createWelcomeColumnBoard(courseReference: BoardExternalReference) { - const columnBoard = new ColumnBoard({ - id: new ObjectId().toHexString(), - title: '', - children: [], - createdAt: new Date(), - updatedAt: new Date(), - context: courseReference, + async deleteByCourseId(courseId: EntityId): Promise { + const columnBoardsId = await this.findIdsByExternalReference({ + type: BoardExternalReferenceType.Course, + id: courseId, }); - const column = new Column({ - id: new ObjectId().toHexString(), - title: '', - children: [], - createdAt: new Date(), - updatedAt: new Date(), - }); - columnBoard.addChild(column); + const deletePromises = columnBoardsId.map((columnBoardId) => this.deleteColumnBoardById(columnBoardId)); - const card = new Card({ - id: new ObjectId().toHexString(), - title: 'Willkommen auf dem neuen Spalten-Board! đŸĨŗ', - height: 150, - children: [], - createdAt: new Date(), - updatedAt: new Date(), - }); - column.addChild(card); - - const text1 = this.createRichTextElement( - '

Wir erweitern das Board kontinuierlich um wichtige Funktionen. Der aktuelle Stand kann hier getestet werden.

' - ); - card.addChild(text1); - - if (Configuration.has('COLUMN_BOARD_HELP_LINK')) { - const helplink = Configuration.get('COLUMN_BOARD_HELP_LINK') as string; - const text2 = this.createRichTextElement( - `

Wichtige Informationen zu Berechtigungen und Informationen zum Einsatz des Boards sind im Hilfebereich zusammengefasst.

` - ); - card.addChild(text2); - } + await Promise.all(deletePromises); + } - if (Configuration.has('COLUMN_BOARD_FEEDBACK_LINK')) { - const feedbacklink = Configuration.get('COLUMN_BOARD_FEEDBACK_LINK') as string; - const text3 = this.createRichTextElement( - `

Wir freuen uns sehr Ãŧber Feedback zum Board unter folgendem Link.

` - ); - card.addChild(text3); - } + private async deleteColumnBoardById(id: EntityId): Promise { + const columnBoardToDeletion = await this.boardDoRepo.findByClassAndId(ColumnBoard, id); - const SC_THEME = Configuration.get('SC_THEME') as string; - if (SC_THEME !== 'default') { - const clientUrl = Configuration.get('HOST') as string; - const text4 = this.createRichTextElement( - `

Wir freuen uns Ãŧber Feedback und WÃŧnsche.

` - ); - card.addChild(text4); + if (columnBoardToDeletion) { + await this.boardDoService.deleteWithDescendants(columnBoardToDeletion); } - - await this.boardDoRepo.save(columnBoard); - - return columnBoard; } - private createRichTextElement(text: string): RichTextElement { - const element: RichTextElement = this.contentElementFactory.build(ContentElementType.RICH_TEXT) as RichTextElement; - element.text = text; + async updateTitle(board: ColumnBoard, title: string): Promise { + board.title = title; + await this.boardDoRepo.save(board); + } - return element; + async updateBoardVisibility(board: ColumnBoard, isVisible: boolean): Promise { + board.isVisible = isVisible; + await this.boardDoRepo.save(board); } } diff --git a/apps/server/src/modules/board/service/column.service.spec.ts b/apps/server/src/modules/board/service/column.service.spec.ts index 1ff2e1cc328..2411e6650ad 100644 --- a/apps/server/src/modules/board/service/column.service.spec.ts +++ b/apps/server/src/modules/board/service/column.service.spec.ts @@ -38,6 +38,10 @@ describe(ColumnService.name, () => { await module.close(); }); + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('findById', () => { describe('when finding a column', () => { const setup = () => { @@ -95,6 +99,44 @@ describe(ColumnService.name, () => { }); }); + describe('createMany', () => { + describe('when creating multiple columns', () => { + const setup = () => { + const board = columnBoardFactory.build(); + const props = [{ title: 'title-1' }, { title: 'title-2' }]; + + return { board, props }; + }; + + it('should save a list of columns using the repo in a batch', async () => { + const { board, props } = setup(); + + await service.createMany(board, props); + + expect(boardDoRepo.save).toHaveBeenCalledTimes(1); + expect(boardDoRepo.save).toHaveBeenCalledWith( + [ + expect.objectContaining({ + id: expect.any(String), + title: 'title-1', + children: [], + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }), + expect.objectContaining({ + id: expect.any(String), + title: 'title-2', + children: [], + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }), + ], + board + ); + }); + }); + }); + describe('delete', () => { describe('when deleting a column', () => { it('should call the service', async () => { diff --git a/apps/server/src/modules/board/service/column.service.ts b/apps/server/src/modules/board/service/column.service.ts index bc5ef9fd1b0..1b452012609 100644 --- a/apps/server/src/modules/board/service/column.service.ts +++ b/apps/server/src/modules/board/service/column.service.ts @@ -1,7 +1,7 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { Column, ColumnBoard } from '@shared/domain/domainobject'; +import { Column, ColumnBoard, ColumnInitProps } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; import { BoardDoRepo } from '../repo'; import { BoardDoService } from './board-do.service'; @@ -14,10 +14,10 @@ export class ColumnService { return column; } - async create(parent: ColumnBoard): Promise { + async create(parent: ColumnBoard, props?: ColumnInitProps): Promise { const column = new Column({ id: new ObjectId().toHexString(), - title: '', + title: props?.title || '', children: [], createdAt: new Date(), updatedAt: new Date(), @@ -30,6 +30,26 @@ export class ColumnService { return column; } + async createMany(parent: ColumnBoard, props: ColumnInitProps[]): Promise { + const columns = props.map((prop) => { + const column = new Column({ + id: new ObjectId().toHexString(), + title: prop.title, + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + parent.addChild(column); + + return column; + }); + + await this.boardDoRepo.save(parent.children, parent); + + return columns; + } + async delete(column: Column): Promise { await this.boardDoService.deleteWithDescendants(column); } diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts index e29397c6c7b..7ef2a1e8da2 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.spec.ts @@ -4,10 +4,13 @@ import { cardFactory, columnBoardFactory, columnFactory, - externalToolElementFactory, drawingElementFactory, + externalToolElementFactory, fileElementFactory, linkElementFactory, + mediaBoardFactory, + mediaExternalToolElementFactory, + mediaLineFactory, richTextElementFactory, submissionContainerElementFactory, submissionItemFactory, @@ -57,6 +60,34 @@ describe(ContentElementUpdateVisitor.name, () => { await expect(() => updater.visitSubmissionItemAsync(submissionItem)).rejects.toThrow(); }); }); + + describe('when component is a media board', () => { + it('should throw an error', async () => { + const { updater } = setup(); + const board = mediaBoardFactory.build(); + + await expect(updater.visitMediaBoardAsync(board)).rejects.toThrow(); + }); + }); + + describe('when component is a media line', () => { + it('should throw an error', async () => { + const { updater } = setup(); + const line = mediaLineFactory.build(); + + await expect(() => updater.visitMediaLineAsync(line)).rejects.toThrow(); + }); + }); + + describe('when component is a media external tool element', () => { + it('should throw an error', async () => { + const { updater } = setup(); + + const element = mediaExternalToolElementFactory.build(); + + await expect(() => updater.visitMediaExternalToolElementAsync(element)).rejects.toThrow(); + }); + }); }); describe('when visiting a file element using the wrong content', () => { diff --git a/apps/server/src/modules/board/service/content-element-update.visitor.ts b/apps/server/src/modules/board/service/content-element-update.visitor.ts index 18b38903ed3..2fd11842b9a 100644 --- a/apps/server/src/modules/board/service/content-element-update.visitor.ts +++ b/apps/server/src/modules/board/service/content-element-update.visitor.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { sanitizeRichText } from '@shared/controller'; -import { +import type { AnyBoardDo, BoardCompositeVisitorAsync, Card, @@ -8,6 +8,9 @@ import { ColumnBoard, ExternalToolElement, FileElement, + MediaBoard, + MediaExternalToolElement, + MediaLine, RichTextElement, SubmissionContainerElement, SubmissionItem, @@ -118,4 +121,16 @@ export class ContentElementUpdateVisitor implements BoardCompositeVisitorAsync { private rejectNotHandled(component: AnyBoardDo): Promise { return Promise.reject(new Error(`Cannot update element of type: '${component.constructor.name}'`)); } + + visitMediaBoardAsync(mediaBoard: MediaBoard): Promise { + return this.rejectNotHandled(mediaBoard); + } + + visitMediaLineAsync(mediaLine: MediaLine): Promise { + return this.rejectNotHandled(mediaLine); + } + + visitMediaExternalToolElementAsync(mediaElement: MediaExternalToolElement): Promise { + return this.rejectNotHandled(mediaElement); + } } diff --git a/apps/server/src/modules/board/service/content-element.service.spec.ts b/apps/server/src/modules/board/service/content-element.service.spec.ts index 5c638270218..5849debb8da 100644 --- a/apps/server/src/modules/board/service/content-element.service.spec.ts +++ b/apps/server/src/modules/board/service/content-element.service.spec.ts @@ -1,4 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { @@ -9,14 +10,16 @@ import { SubmissionContainerElement, } from '@shared/domain/domainobject'; import { InputFormat } from '@shared/domain/types'; -import { drawingElementFactory, setupEntities } from '@shared/testing'; import { cardFactory, + contextExternalToolFactory, + drawingElementFactory, fileElementFactory, linkElementFactory, richTextElementFactory, + setupEntities, submissionContainerElementFactory, -} from '@shared/testing/factory/domainobject'; +} from '@shared/testing'; import { DrawingContentBody, FileContentBody, @@ -118,7 +121,7 @@ describe(ContentElementService.name, () => { }); describe('findParentOfId', () => { - describe('when parent is a vaid node', () => { + describe('when parent is a valid node', () => { const setup = () => { const card = cardFactory.build(); const element = richTextElementFactory.build(); @@ -154,6 +157,36 @@ describe(ContentElementService.name, () => { }); }); + describe('countBoardUsageForExternalTools', () => { + describe('when counting the amount of boards used by tools', () => { + const setup = () => { + const contextExternalTools: ContextExternalTool[] = contextExternalToolFactory.buildListWithId(3); + + boardDoRepo.countBoardUsageForExternalTools.mockResolvedValueOnce(3); + + return { + contextExternalTools, + }; + }; + + it('should count the usages', async () => { + const { contextExternalTools } = setup(); + + await service.countBoardUsageForExternalTools(contextExternalTools); + + expect(boardDoRepo.countBoardUsageForExternalTools).toHaveBeenCalledWith(contextExternalTools); + }); + + it('should return the amount of boards', async () => { + const { contextExternalTools } = setup(); + + const result: number = await service.countBoardUsageForExternalTools(contextExternalTools); + + expect(result).toEqual(3); + }); + }); + }); + describe('create', () => { describe('when creating a content element of type', () => { const setup = () => { diff --git a/apps/server/src/modules/board/service/content-element.service.ts b/apps/server/src/modules/board/service/content-element.service.ts index b6ac32434f3..fd4e51029b8 100644 --- a/apps/server/src/modules/board/service/content-element.service.ts +++ b/apps/server/src/modules/board/service/content-element.service.ts @@ -1,3 +1,4 @@ +import type { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; import { Injectable, NotFoundException } from '@nestjs/common'; import { AnyBoardDo, @@ -40,10 +41,18 @@ export class ContentElementService { return parent; } + async countBoardUsageForExternalTools(contextExternalTools: ContextExternalTool[]): Promise { + const count: number = await this.boardDoRepo.countBoardUsageForExternalTools(contextExternalTools); + + return count; + } + async create(parent: Card | SubmissionItem, type: ContentElementType): Promise { const element = this.contentElementFactory.build(type); parent.addChild(element); + await this.boardDoRepo.save(parent.children, parent); + return element; } diff --git a/apps/server/src/modules/board/service/event/index.ts b/apps/server/src/modules/board/service/event/index.ts new file mode 100644 index 00000000000..fb9b581be6a --- /dev/null +++ b/apps/server/src/modules/board/service/event/index.ts @@ -0,0 +1 @@ +export { UserDeletedEventHandlerService } from './user-deleted-event-handler.service'; diff --git a/apps/server/src/modules/board/service/event/user-deleted-event-handler.service.spec.ts b/apps/server/src/modules/board/service/event/user-deleted-event-handler.service.spec.ts new file mode 100644 index 00000000000..f3fe22d211a --- /dev/null +++ b/apps/server/src/modules/board/service/event/user-deleted-event-handler.service.spec.ts @@ -0,0 +1,125 @@ +import { createMock, type DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { EventBus } from '@nestjs/cqrs'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { mediaBoardFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { + DataDeletedEvent, + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + UserDeletedEvent, +} from '../../../deletion'; +import { MediaBoardService } from '../media-board'; +import { UserDeletedEventHandlerService } from './user-deleted-event-handler.service'; + +describe(UserDeletedEventHandlerService.name, () => { + let module: TestingModule; + let service: UserDeletedEventHandlerService; + + let mediaBoardService: DeepMocked; + let eventBus: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + UserDeletedEventHandlerService, + { + provide: MediaBoardService, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + { + provide: EventBus, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(UserDeletedEventHandlerService); + mediaBoardService = module.get(MediaBoardService); + eventBus = module.get(EventBus); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('deleteUserData', () => { + describe('when deleting a user', () => { + const setup = () => { + const board = mediaBoardFactory.build(); + const userId = new ObjectId().toHexString(); + + mediaBoardService.findIdsByExternalReference.mockResolvedValueOnce([board.id]); + mediaBoardService.deleteByExternalReference.mockResolvedValueOnce(1); + + return { + board, + userId, + }; + }; + + it('should delete all user boards', async () => { + const { userId } = setup(); + + await service.deleteUserData(userId); + + expect(mediaBoardService.deleteByExternalReference).toHaveBeenCalledWith({ + type: BoardExternalReferenceType.User, + id: userId, + }); + }); + + it('should return a report report', async () => { + const { board, userId } = setup(); + + const result = await service.deleteUserData(userId); + + expect(result).toEqual( + DomainDeletionReportBuilder.build(DomainName.CLASS, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [board.id]), + ]) + ); + }); + }); + }); + + describe('handle', () => { + describe('when deleting a user', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const deletionRequestId = new ObjectId().toHexString(); + const report = DomainDeletionReportBuilder.build(DomainName.CLASS, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [new ObjectId().toHexString()]), + ]); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(report); + + return { + userId, + deletionRequestId, + report, + }; + }; + + it('should return a report report', async () => { + const { userId, deletionRequestId, report } = setup(); + + await service.handle(new UserDeletedEvent(deletionRequestId, userId)); + + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, report)); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/service/event/user-deleted-event-handler.service.ts b/apps/server/src/modules/board/service/event/user-deleted-event-handler.service.ts new file mode 100644 index 00000000000..0f049a7852e --- /dev/null +++ b/apps/server/src/modules/board/service/event/user-deleted-event-handler.service.ts @@ -0,0 +1,67 @@ +import { + DataDeletedEvent, + DataDeletionDomainOperationLoggable, + DeletionService, + DomainDeletionReport, + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + StatusModel, + UserDeletedEvent, +} from '@modules/deletion'; +import { Injectable } from '@nestjs/common'; +import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { EntityId } from '@shared/domain/types'; +import { Logger } from '@src/core/logger'; +import { MediaBoardService } from '../media-board'; + +@Injectable() +@EventsHandler(UserDeletedEvent) +export class UserDeletedEventHandlerService implements DeletionService, IEventHandler { + constructor( + private readonly mediaBoardService: MediaBoardService, + private readonly logger: Logger, + private readonly eventBus: EventBus + ) {} + + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted: DomainDeletionReport = await this.deleteUserData(targetRefId); + + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + + public async deleteUserData(userId: EntityId): Promise { + this.logger.info( + new DataDeletionDomainOperationLoggable('Deleting data from Board', DomainName.BOARD, userId, StatusModel.PENDING) + ); + + const boardIds: EntityId[] = await this.mediaBoardService.findIdsByExternalReference({ + type: BoardExternalReferenceType.User, + id: userId, + }); + + const numberOfDeletedBoards: number = await this.mediaBoardService.deleteByExternalReference({ + type: BoardExternalReferenceType.User, + id: userId, + }); + + const result: DomainDeletionReport = DomainDeletionReportBuilder.build(DomainName.CLASS, [ + DomainOperationReportBuilder.build(OperationType.DELETE, numberOfDeletedBoards, boardIds), + ]); + + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'Successfully removed user data from Board', + DomainName.BOARD, + userId, + StatusModel.FINISHED, + 0, + numberOfDeletedBoards + ) + ); + + return result; + } +} diff --git a/apps/server/src/modules/board/service/index.ts b/apps/server/src/modules/board/service/index.ts index 8ff2787f35d..b0934f59ad8 100644 --- a/apps/server/src/modules/board/service/index.ts +++ b/apps/server/src/modules/board/service/index.ts @@ -5,3 +5,5 @@ export * from './column-board.service'; export * from './column.service'; export * from './content-element.service'; export * from './submission-item.service'; +export * from './media-board'; +export * from './event'; diff --git a/apps/server/src/modules/board/service/media-board/index.ts b/apps/server/src/modules/board/service/media-board/index.ts new file mode 100644 index 00000000000..e4b0c9edaf9 --- /dev/null +++ b/apps/server/src/modules/board/service/media-board/index.ts @@ -0,0 +1,3 @@ +export { MediaLineService } from './media-line.service'; +export { MediaBoardService } from './media-board.service'; +export { MediaElementService } from './media-element.service'; diff --git a/apps/server/src/modules/board/service/media-board/media-board.service.spec.ts b/apps/server/src/modules/board/service/media-board/media-board.service.spec.ts new file mode 100644 index 00000000000..d8965d42b29 --- /dev/null +++ b/apps/server/src/modules/board/service/media-board/media-board.service.spec.ts @@ -0,0 +1,172 @@ +import { createMock, type DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { mediaBoardFactory } from '@shared/testing'; +import { BoardDoRepo } from '../../repo'; +import { BoardDoService } from '../board-do.service'; +import { MediaBoardService } from './media-board.service'; + +describe(MediaBoardService.name, () => { + let module: TestingModule; + let service: MediaBoardService; + + let boardDoRepo: DeepMocked; + let boardDoService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + MediaBoardService, + { + provide: BoardDoRepo, + useValue: createMock(), + }, + { + provide: BoardDoService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(MediaBoardService); + boardDoRepo = module.get(BoardDoRepo); + boardDoService = module.get(BoardDoService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('findById', () => { + describe('when a board with the id exists', () => { + const setup = () => { + const board = mediaBoardFactory.build(); + + boardDoRepo.findByClassAndId.mockResolvedValueOnce(board); + + return { + board, + }; + }; + + it('should return the board', async () => { + const { board } = setup(); + + const result = await service.findById(board.id); + + expect(result).toEqual(board); + }); + }); + }); + + describe('findIdsByExternalReference', () => { + describe('when a board for the context exists', () => { + const setup = () => { + const boardId = new ObjectId().toHexString(); + const userId = new ObjectId().toHexString(); + + boardDoRepo.findIdsByExternalReference.mockResolvedValueOnce([boardId]); + + return { + boardId, + userId, + }; + }; + + it('should return the board id', async () => { + const { boardId, userId } = setup(); + + const result = await service.findIdsByExternalReference({ type: BoardExternalReferenceType.User, id: userId }); + + expect(result).toEqual([boardId]); + }); + }); + }); + + describe('create', () => { + describe('when creating a new board', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + + return { + userId, + }; + }; + + it('should return the board', async () => { + const { userId } = setup(); + + const result = await service.create({ type: BoardExternalReferenceType.User, id: userId }); + + expect(result).toEqual( + expect.objectContaining({ + id: expect.any(String), + children: [], + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + context: { + type: BoardExternalReferenceType.User, + id: userId, + }, + }) + ); + }); + + it('should save the new board', async () => { + const { userId } = setup(); + + const result = await service.create({ type: BoardExternalReferenceType.User, id: userId }); + + expect(boardDoRepo.save).toHaveBeenCalledWith(result); + }); + }); + }); + + describe('delete', () => { + describe('when deleting a board', () => { + const setup = () => { + const board = mediaBoardFactory.build(); + + return { + board, + }; + }; + + it('should delete the board', async () => { + const { board } = setup(); + + await service.delete(board); + + expect(boardDoService.deleteWithDescendants).toHaveBeenCalledWith(board); + }); + }); + }); + + describe('deleteByExternalReference', () => { + describe('when deleting a board', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + + return { + userId, + }; + }; + + it('should delete the board', async () => { + const { userId } = setup(); + + await service.deleteByExternalReference({ type: BoardExternalReferenceType.User, id: userId }); + + expect(boardDoRepo.deleteByExternalReference).toHaveBeenCalledWith({ + type: BoardExternalReferenceType.User, + id: userId, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/service/media-board/media-board.service.ts b/apps/server/src/modules/board/service/media-board/media-board.service.ts new file mode 100644 index 00000000000..0fb45dc492e --- /dev/null +++ b/apps/server/src/modules/board/service/media-board/media-board.service.ts @@ -0,0 +1,46 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import type { AuthorizationLoaderServiceGeneric } from '@modules/authorization'; +import { Injectable } from '@nestjs/common'; +import { BoardExternalReference, MediaBoard } from '@shared/domain/domainobject'; +import type { EntityId } from '@shared/domain/types'; +import { BoardDoRepo } from '../../repo'; +import { BoardDoService } from '../board-do.service'; + +@Injectable() +export class MediaBoardService implements AuthorizationLoaderServiceGeneric { + constructor(private readonly boardDoRepo: BoardDoRepo, private readonly boardDoService: BoardDoService) {} + + public async findById(boardId: EntityId): Promise { + const board: MediaBoard = await this.boardDoRepo.findByClassAndId(MediaBoard, boardId); + + return board; + } + + public async findIdsByExternalReference(reference: BoardExternalReference): Promise { + const ids: EntityId[] = await this.boardDoRepo.findIdsByExternalReference(reference); + + return ids; + } + + public async create(context: BoardExternalReference): Promise { + const mediaBoard: MediaBoard = new MediaBoard({ + id: new ObjectId().toHexString(), + children: [], + createdAt: new Date(), + updatedAt: new Date(), + context, + }); + + await this.boardDoRepo.save(mediaBoard); + + return mediaBoard; + } + + public async delete(board: MediaBoard): Promise { + await this.boardDoService.deleteWithDescendants(board); + } + + public async deleteByExternalReference(reference: BoardExternalReference): Promise { + return this.boardDoRepo.deleteByExternalReference(reference); + } +} diff --git a/apps/server/src/modules/board/service/media-board/media-element.service.spec.ts b/apps/server/src/modules/board/service/media-board/media-element.service.spec.ts new file mode 100644 index 00000000000..8f415d6ea9e --- /dev/null +++ b/apps/server/src/modules/board/service/media-board/media-element.service.spec.ts @@ -0,0 +1,169 @@ +import { createMock, type DeepMocked } from '@golevelup/ts-jest'; +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + contextExternalToolFactory, + mediaBoardFactory, + mediaExternalToolElementFactory, + mediaLineFactory, +} from '@shared/testing'; +import type { ContextExternalToolWithId } from '../../../tool/context-external-tool/domain'; +import { BoardDoRepo } from '../../repo'; +import { BoardDoService } from '../board-do.service'; +import { MediaElementService } from './media-element.service'; + +describe(MediaElementService.name, () => { + let module: TestingModule; + let service: MediaElementService; + + let boardDoRepo: DeepMocked; + let boardDoService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + MediaElementService, + { + provide: BoardDoRepo, + useValue: createMock(), + }, + { + provide: BoardDoService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(MediaElementService); + boardDoRepo = module.get(BoardDoRepo); + boardDoService = module.get(BoardDoService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('findById', () => { + describe('when an element with the id exists', () => { + const setup = () => { + const element = mediaExternalToolElementFactory.build(); + + boardDoRepo.findById.mockResolvedValueOnce(element); + + return { + element, + }; + }; + + it('should return the element', async () => { + const { element } = setup(); + + const result = await service.findById(element.id); + + expect(result).toEqual(element); + }); + }); + + describe('when no element with the id exists', () => { + const setup = () => { + const board = mediaBoardFactory.build(); + + boardDoRepo.findById.mockResolvedValueOnce(board); + + return { + board, + }; + }; + + it('should return the element', async () => { + const { board } = setup(); + + await expect(service.findById(board.id)).rejects.toThrow(NotFoundException); + }); + }); + }); + + describe('createExternalToolElement', () => { + describe('when creating a new element', () => { + const setup = () => { + const line = mediaLineFactory.build(); + const contextExternalTool = contextExternalToolFactory.buildWithId() as ContextExternalToolWithId; + + return { + line, + contextExternalTool, + }; + }; + + it('should return the element', async () => { + const { line, contextExternalTool } = setup(); + + const result = await service.createExternalToolElement(line, contextExternalTool); + + expect(result).toEqual( + expect.objectContaining({ + id: expect.any(String), + children: [], + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + contextExternalToolId: contextExternalTool.id, + }) + ); + }); + + it('should save the new element', async () => { + const { line, contextExternalTool } = setup(); + + const result = await service.createExternalToolElement(line, contextExternalTool); + + expect(boardDoRepo.save).toHaveBeenCalledWith([result], line); + }); + }); + }); + + describe('delete', () => { + describe('when deleting an element', () => { + const setup = () => { + const element = mediaExternalToolElementFactory.build(); + + return { + element, + }; + }; + + it('should delete the element', async () => { + const { element } = setup(); + + await service.delete(element); + + expect(boardDoService.deleteWithDescendants).toHaveBeenCalledWith(element); + }); + }); + }); + + describe('move', () => { + describe('when deleting an element', () => { + const setup = () => { + const line = mediaLineFactory.build(); + const element = mediaExternalToolElementFactory.build(); + + return { + line, + element, + }; + }; + + it('should move the element', async () => { + const { line, element } = setup(); + + await service.move(element, line, 3); + + expect(boardDoService.move).toHaveBeenCalledWith(element, line, 3); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/service/media-board/media-element.service.ts b/apps/server/src/modules/board/service/media-board/media-element.service.ts new file mode 100644 index 00000000000..a7673955e9c --- /dev/null +++ b/apps/server/src/modules/board/service/media-board/media-element.service.ts @@ -0,0 +1,55 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import type { ContextExternalToolWithId } from '@modules/tool/context-external-tool/domain'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + AnyBoardDo, + type AnyMediaContentElementDo, + isAnyMediaContentElement, + MediaExternalToolElement, + MediaLine, +} from '@shared/domain/domainobject'; +import { EntityId } from '@shared/domain/types'; +import { BoardDoRepo } from '../../repo'; +import { BoardDoService } from '../board-do.service'; + +@Injectable() +export class MediaElementService { + constructor(private readonly boardDoRepo: BoardDoRepo, private readonly boardDoService: BoardDoService) {} + + public async findById(elementId: EntityId): Promise { + const element: AnyBoardDo = await this.boardDoRepo.findById(elementId); + + if (!isAnyMediaContentElement(element)) { + throw new NotFoundException(`There is no '${element.constructor.name}' with this id`); + } + + return element; + } + + public async createExternalToolElement( + parent: MediaLine, + contextExternalTool: ContextExternalToolWithId + ): Promise { + const element: MediaExternalToolElement = new MediaExternalToolElement({ + id: new ObjectId().toHexString(), + children: [], + createdAt: new Date(), + updatedAt: new Date(), + contextExternalToolId: contextExternalTool.id, + }); + + parent.addChild(element); + + await this.boardDoRepo.save(parent.children, parent); + + return element; + } + + public async delete(element: AnyMediaContentElementDo): Promise { + await this.boardDoService.deleteWithDescendants(element); + } + + public async move(element: AnyMediaContentElementDo, targetLine: MediaLine, targetPosition: number): Promise { + await this.boardDoService.move(element, targetLine, targetPosition); + } +} diff --git a/apps/server/src/modules/board/service/media-board/media-line.service.spec.ts b/apps/server/src/modules/board/service/media-board/media-line.service.spec.ts new file mode 100644 index 00000000000..dd15f1e0894 --- /dev/null +++ b/apps/server/src/modules/board/service/media-board/media-line.service.spec.ts @@ -0,0 +1,172 @@ +import { createMock, type DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { mediaBoardFactory, mediaLineFactory } from '@shared/testing'; +import { BoardDoRepo } from '../../repo'; +import { BoardDoService } from '../board-do.service'; +import { MediaLineService } from './media-line.service'; + +describe(MediaLineService.name, () => { + let module: TestingModule; + let service: MediaLineService; + + let boardDoRepo: DeepMocked; + let boardDoService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + MediaLineService, + { + provide: BoardDoRepo, + useValue: createMock(), + }, + { + provide: BoardDoService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(MediaLineService); + boardDoRepo = module.get(BoardDoRepo); + boardDoService = module.get(BoardDoService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('findById', () => { + describe('when a line with the id exists', () => { + const setup = () => { + const line = mediaLineFactory.build(); + + boardDoRepo.findByClassAndId.mockResolvedValueOnce(line); + + return { + line, + }; + }; + + it('should return the line', async () => { + const { line } = setup(); + + const result = await service.findById(line.id); + + expect(result).toEqual(line); + }); + }); + }); + + describe('create', () => { + describe('when creating a new line', () => { + const setup = () => { + const board = mediaBoardFactory.build(); + + return { + board, + }; + }; + + it('should return the line', async () => { + const { board } = setup(); + + const result = await service.create(board, { title: 'lineTitle' }); + + expect(result).toEqual( + expect.objectContaining({ + id: expect.any(String), + children: [], + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + title: 'lineTitle', + }) + ); + }); + + it('should save the new line', async () => { + const { board } = setup(); + + const result = await service.create(board); + + expect(boardDoRepo.save).toHaveBeenCalledWith([result], board); + }); + }); + }); + + describe('delete', () => { + describe('when deleting a line', () => { + const setup = () => { + const line = mediaLineFactory.build(); + + return { + line, + }; + }; + + it('should delete the element', async () => { + const { line } = setup(); + + await service.delete(line); + + expect(boardDoService.deleteWithDescendants).toHaveBeenCalledWith(line); + }); + }); + }); + + describe('move', () => { + describe('when deleting a line', () => { + const setup = () => { + const board = mediaBoardFactory.build(); + const line = mediaLineFactory.build(); + + return { + line, + board, + }; + }; + + it('should move the line', async () => { + const { line, board } = setup(); + + await service.move(line, board, 3); + + expect(boardDoService.move).toHaveBeenCalledWith(line, board, 3); + }); + }); + }); + + describe('updateTitle', () => { + describe('when updating the title', () => { + const setup = () => { + const board = mediaBoardFactory.build(); + const line = mediaLineFactory.build(); + + boardDoRepo.findParentOfId.mockResolvedValueOnce(board); + + return { + line, + board, + }; + }; + + it('should update the title', async () => { + const { line, board } = setup(); + + await service.updateTitle(line, 'newTitle'); + + expect(boardDoRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: line.id, + title: 'newTitle', + }), + board + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/service/media-board/media-line.service.ts b/apps/server/src/modules/board/service/media-board/media-line.service.ts new file mode 100644 index 00000000000..7cfc77f6592 --- /dev/null +++ b/apps/server/src/modules/board/service/media-board/media-line.service.ts @@ -0,0 +1,49 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { type AnyBoardDo, MediaBoard, MediaLine, MediaLineInitProps } from '@shared/domain/domainobject'; +import { EntityId } from '@shared/domain/types'; +import { BoardDoRepo } from '../../repo'; +import { BoardDoService } from '../board-do.service'; + +@Injectable() +export class MediaLineService { + constructor(private readonly boardDoRepo: BoardDoRepo, private readonly boardDoService: BoardDoService) {} + + public async findById(lineId: EntityId): Promise { + const line: MediaLine = await this.boardDoRepo.findByClassAndId(MediaLine, lineId); + + return line; + } + + public async create(parent: MediaBoard, props?: MediaLineInitProps): Promise { + const line: MediaLine = new MediaLine({ + id: new ObjectId().toHexString(), + title: props?.title ?? '', + children: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + parent.addChild(line); + + await this.boardDoRepo.save(parent.children, parent); + + return line; + } + + public async delete(line: MediaLine): Promise { + await this.boardDoService.deleteWithDescendants(line); + } + + public async move(line: MediaLine, targetBoard: MediaBoard, targetPosition?: number): Promise { + await this.boardDoService.move(line, targetBoard, targetPosition); + } + + public async updateTitle(line: MediaLine, title: string): Promise { + const parent: AnyBoardDo | undefined = await this.boardDoRepo.findParentOfId(line.id); + + line.title = title; + + await this.boardDoRepo.save(line, parent); + } +} diff --git a/apps/server/src/modules/board/service/submission-item.service.spec.ts b/apps/server/src/modules/board/service/submission-item.service.spec.ts index 77926d1b4f3..d95e7657cd2 100644 --- a/apps/server/src/modules/board/service/submission-item.service.spec.ts +++ b/apps/server/src/modules/board/service/submission-item.service.spec.ts @@ -150,4 +150,49 @@ describe(SubmissionItemService.name, () => { await expect(service.update(submissionItem, true)).rejects.toThrowError(ValidationError); }); }); + + describe('delete', () => { + const setup = () => { + const submissionContainer = submissionContainerElementFactory.build(); + const submissionItem = submissionItemFactory.build(); + + boardDoRepo.findParentOfId.mockResolvedValueOnce(submissionContainer); + + return { submissionContainer, submissionItem }; + }; + + it('should fetch the parent', async () => { + const { submissionItem } = setup(); + + await service.delete(submissionItem); + + expect(boardDoRepo.findParentOfId).toHaveBeenCalledWith(submissionItem.id); + }); + + it('should throw if parent is not SubmissionContainerElement', async () => { + const submissionItem = submissionItemFactory.build(); + const richTextElement = richTextElementFactory.build(); + boardDoRepo.findParentOfId.mockResolvedValueOnce(richTextElement); + + await expect(service.update(submissionItem, true)).rejects.toThrow(UnprocessableEntityException); + }); + + it('should call bord repo to delete submission item', async () => { + const { submissionItem } = setup(); + + await service.delete(submissionItem); + + expect(boardDoRepo.delete).toHaveBeenCalledWith(submissionItem); + }); + + it('should throw if parent SubmissionContainer dueDate is in the past', async () => { + const { submissionItem, submissionContainer } = setup(); + + const yesterday = new Date(Date.now() - 86400000); + submissionContainer.dueDate = yesterday; + boardDoRepo.findParentOfId.mockResolvedValue(submissionContainer); + + await expect(service.delete(submissionItem)).rejects.toThrowError(ValidationError); + }); + }); }); diff --git a/apps/server/src/modules/board/service/submission-item.service.ts b/apps/server/src/modules/board/service/submission-item.service.ts index 7e372b0d962..4ff79def319 100644 --- a/apps/server/src/modules/board/service/submission-item.service.ts +++ b/apps/server/src/modules/board/service/submission-item.service.ts @@ -1,5 +1,5 @@ import { Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { ValidationError } from '@shared/common'; import { isSubmissionContainerElement, SubmissionContainerElement, SubmissionItem } from '@shared/domain/domainobject'; @@ -27,6 +27,8 @@ export class SubmissionItemService { submissionContainer: SubmissionContainerElement, payload: { completed: boolean } ): Promise { + this.checkNotLocked(submissionContainer); + const submissionItem = new SubmissionItem({ id: new ObjectId().toHexString(), createdAt: new Date(), @@ -43,17 +45,35 @@ export class SubmissionItemService { } async update(submissionItem: SubmissionItem, completed: boolean): Promise { + const parent = await this.getParent(submissionItem); + + submissionItem.completed = completed; + + await this.boardDoRepo.save(submissionItem, parent); + } + + async delete(submissionItem: SubmissionItem): Promise { + await this.getParent(submissionItem); + + await this.boardDoRepo.delete(submissionItem); + } + + private async getParent(submissionItem: SubmissionItem): Promise { const submissionContainterElement = await this.boardDoRepo.findParentOfId(submissionItem.id); + if (!isSubmissionContainerElement(submissionContainterElement)) { throw new UnprocessableEntityException(); } + this.checkNotLocked(submissionContainterElement); + + return submissionContainterElement; + } + + private checkNotLocked(submissionContainterElement: SubmissionContainerElement): void { const now = new Date(); if (submissionContainterElement.dueDate && submissionContainterElement.dueDate < now) { throw new ValidationError('not allowed to save anymore'); } - submissionItem.completed = completed; - - await this.boardDoRepo.save(submissionItem, submissionContainterElement); } } diff --git a/apps/server/src/modules/board/uc/base.uc.ts b/apps/server/src/modules/board/uc/base.uc.ts index 8cf43e9745f..e4d8ec21fa0 100644 --- a/apps/server/src/modules/board/uc/base.uc.ts +++ b/apps/server/src/modules/board/uc/base.uc.ts @@ -1,7 +1,7 @@ import { Action, AuthorizationService } from '@modules/authorization'; -import { ForbiddenException } from '@nestjs/common'; -import { AnyBoardDo, SubmissionItem, UserRoleEnum } from '@shared/domain/domainobject'; +import { AnyBoardDo, BoardRoles, UserWithBoardRoles } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; +import { Permission } from '@shared/domain/interface'; import { BoardDoAuthorizableService } from '../service'; export abstract class BaseUc { @@ -10,42 +10,34 @@ export abstract class BaseUc { protected readonly boardDoAuthorizableService: BoardDoAuthorizableService ) {} - protected async checkPermission( - userId: EntityId, - anyBoardDo: AnyBoardDo, - action: Action, - requiredUserRole?: UserRoleEnum - ): Promise { + protected async checkPermission(userId: EntityId, anyBoardDo: AnyBoardDo, action: Action): Promise { + const requiredPermissions: Permission[] = []; const user = await this.authorizationService.getUserWithPermissions(userId); const boardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(anyBoardDo); - if (requiredUserRole) { - boardDoAuthorizable.requiredUserRole = requiredUserRole; - } - const context = { action, requiredPermissions: [] }; - return this.authorizationService.checkPermission(user, boardDoAuthorizable, context); + this.authorizationService.checkPermission(user, boardDoAuthorizable, { action, requiredPermissions }); } - protected async isAuthorizedStudent(userId: EntityId, boardDo: AnyBoardDo): Promise { - const boardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(boardDo); - const userRoleEnum = boardDoAuthorizable.users.find((u) => u.userId === userId)?.userRoleEnum; - - if (!userRoleEnum) { - throw new ForbiddenException('User not part of this board'); - } + protected isUserBoardEditor(userId: EntityId, userBoardRoles: UserWithBoardRoles[]): boolean { + const boardDoAuthorisedUser = userBoardRoles.find((user) => user.userId === userId); - // TODO do this with permission instead of role and using authorizable rules - if (userRoleEnum === UserRoleEnum.STUDENT) { - return true; + if (boardDoAuthorisedUser) { + return boardDoAuthorisedUser?.roles.includes(BoardRoles.EDITOR); } return false; } - protected async checkSubmissionItemWritePermission(userId: EntityId, submissionItem: SubmissionItem) { - if (submissionItem.userId !== userId) { - throw new ForbiddenException(); + protected isUserBoardReader(userId: EntityId, userBoardRoles: UserWithBoardRoles[]): boolean { + const boardDoAuthorisedUser = userBoardRoles.find((user) => user.userId === userId); + + if (boardDoAuthorisedUser) { + return ( + boardDoAuthorisedUser.roles.includes(BoardRoles.READER) && + !boardDoAuthorisedUser.roles.includes(BoardRoles.EDITOR) + ); } - await this.checkPermission(userId, submissionItem, Action.read, UserRoleEnum.STUDENT); + + return false; } } diff --git a/apps/server/src/modules/board/uc/board-management.uc.spec.ts b/apps/server/src/modules/board/uc/board-management.uc.spec.ts deleted file mode 100644 index 83948e0ae1e..00000000000 --- a/apps/server/src/modules/board/uc/board-management.uc.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ConsoleWriterService } from '@infra/console'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { courseFactory } from '@shared/testing'; -import { BoardManagementUc } from '@modules/management/uc/board-management.uc'; - -describe(BoardManagementUc.name, () => { - let module: TestingModule; - let uc: BoardManagementUc; - let em: EntityManager; - let consoleWriter: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot()], - providers: [ - BoardManagementUc, - { - provide: ConsoleWriterService, - useValue: createMock(), - }, - ], - }).compile(); - - uc = module.get(BoardManagementUc); - consoleWriter = module.get(ConsoleWriterService); - em = module.get(EntityManager); - }); - - afterAll(async () => { - await module.close(); - }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - const setup = async () => { - jest.clearAllMocks(); - const course = courseFactory.buildWithId(); - await em.persistAndFlush(course); - - return { course }; - }; - - describe('createBoard', () => { - describe('when course exists', () => { - it('should call the service', async () => { - const { course } = await setup(); - - const boardId = await uc.createBoard(course.id); - - expect(boardId).toBeDefined(); - }); - }); - - describe('when course does not exist', () => { - it('should write an error message if courseId is not valid', async () => { - const fakeId = new ObjectId().toHexString(); - - await uc.createBoard(fakeId); - - expect(consoleWriter.info).toHaveBeenCalledWith(expect.stringContaining('Error: course does not exist')); - }); - }); - }); -}); diff --git a/apps/server/src/modules/board/uc/board.uc.spec.ts b/apps/server/src/modules/board/uc/board.uc.spec.ts index f769f61574b..3fcde3b1754 100644 --- a/apps/server/src/modules/board/uc/board.uc.spec.ts +++ b/apps/server/src/modules/board/uc/board.uc.spec.ts @@ -1,19 +1,15 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { AuthorizationService } from '@modules/authorization'; +import { Action, AuthorizationService } from '@modules/authorization'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardDoAuthorizable, BoardRoles, ContentElementType, UserRoleEnum } from '@shared/domain/domainobject'; +import { BoardDoAuthorizable, BoardRoles, ContentElementType } from '@shared/domain/domainobject'; +import { CourseRepo } from '@shared/repo'; import { setupEntities, userFactory } from '@shared/testing'; import { columnBoardFactory, columnFactory } from '@shared/testing/factory/domainobject'; import { LegacyLogger } from '@src/core/logger'; -import { ObjectId } from 'bson'; -import { - BoardDoAuthorizableService, - CardService, - ColumnBoardService, - ColumnService, - ContentElementService, -} from '../service'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { BoardDoAuthorizableService, ColumnBoardService, ColumnService, ContentElementService } from '../service'; +import { ColumnBoardCopyService } from '../service/column-board-copy.service'; import { BoardUc } from './board.uc'; describe(BoardUc.name, () => { @@ -22,6 +18,7 @@ describe(BoardUc.name, () => { let authorizationService: DeepMocked; let boardDoAuthorizableService: DeepMocked; let columnBoardService: DeepMocked; + let columnBoardCopyService: DeepMocked; let columnService: DeepMocked; beforeAll(async () => { @@ -36,18 +33,22 @@ describe(BoardUc.name, () => { provide: BoardDoAuthorizableService, useValue: createMock(), }, - { - provide: CardService, - useValue: createMock(), - }, { provide: ColumnBoardService, useValue: createMock(), }, + { + provide: ColumnBoardCopyService, + useValue: createMock(), + }, { provide: ColumnService, useValue: createMock(), }, + { + provide: CourseRepo, + useValue: createMock(), + }, { provide: LegacyLogger, useValue: createMock(), @@ -56,6 +57,10 @@ describe(BoardUc.name, () => { provide: ContentElementService, useValue: createMock(), }, + { + provide: CourseRepo, + useValue: createMock(), + }, ], }).compile(); @@ -63,6 +68,7 @@ describe(BoardUc.name, () => { authorizationService = module.get(AuthorizationService); boardDoAuthorizableService = module.get(BoardDoAuthorizableService); columnBoardService = module.get(ColumnBoardService); + columnBoardCopyService = module.get(ColumnBoardCopyService); columnService = module.get(ColumnService); await setupEntities(); }); @@ -75,7 +81,7 @@ describe(BoardUc.name, () => { jest.clearAllMocks(); }); - const setup = () => { + const globalSetup = () => { jest.clearAllMocks(); const user = userFactory.buildWithId(); const board = columnBoardFactory.build(); @@ -84,8 +90,10 @@ describe(BoardUc.name, () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const authorizableMock: BoardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], id: board.id, + boardDo: column, + rootDo: board, }); const createCardBodyParams = { requiredEmptyElements: [ContentElementType.FILE, ContentElementType.RICH_TEXT], @@ -99,7 +107,7 @@ describe(BoardUc.name, () => { describe('findBoard', () => { describe('when loading a board and having required permission', () => { it('should call the service', async () => { - const { user, boardId } = setup(); + const { user, boardId } = globalSetup(); await uc.findBoard(user.id, boardId); @@ -107,7 +115,7 @@ describe(BoardUc.name, () => { }); it('should return the column board object', async () => { - const { user, board } = setup(); + const { user, board } = globalSetup(); columnBoardService.findById.mockResolvedValueOnce(board); const result = await uc.findBoard(user.id, board.id); @@ -118,7 +126,7 @@ describe(BoardUc.name, () => { describe('when loading a board without having permissions', () => { it('should return the column board object', async () => { - const { board } = setup(); + const { board } = globalSetup(); columnBoardService.findById.mockResolvedValueOnce(board); const fakeUserId = new ObjectId().toHexString(); @@ -134,7 +142,7 @@ describe(BoardUc.name, () => { describe('deleteBoard', () => { describe('when deleting a board', () => { it('should call the service to find the board', async () => { - const { user, board } = setup(); + const { user, board } = globalSetup(); await uc.deleteBoard(user.id, board.id); @@ -142,7 +150,7 @@ describe(BoardUc.name, () => { }); it('should call the service to delete the board', async () => { - const { user, board } = setup(); + const { user, board } = globalSetup(); await uc.deleteBoard(user.id, board.id); @@ -154,7 +162,7 @@ describe(BoardUc.name, () => { describe('updateBoardTitle', () => { describe('when updating a board title', () => { it('should call the service to find the board', async () => { - const { user, board } = setup(); + const { user, board } = globalSetup(); await uc.updateBoardTitle(user.id, board.id, 'new title'); @@ -162,7 +170,7 @@ describe(BoardUc.name, () => { }); it('should call the service to update the board title', async () => { - const { user, board } = setup(); + const { user, board } = globalSetup(); const newTitle = 'new title'; await uc.updateBoardTitle(user.id, board.id, newTitle); @@ -175,7 +183,7 @@ describe(BoardUc.name, () => { describe('createColumn', () => { describe('when creating a column', () => { it('should call the service to find the board', async () => { - const { user, board } = setup(); + const { user, board } = globalSetup(); await uc.createColumn(user.id, board.id); @@ -183,7 +191,7 @@ describe(BoardUc.name, () => { }); it('should call the service to create the column', async () => { - const { user, board } = setup(); + const { user, board } = globalSetup(); columnBoardService.findById.mockResolvedValueOnce(board); await uc.createColumn(user.id, board.id); @@ -192,7 +200,7 @@ describe(BoardUc.name, () => { }); it('should return the column board object', async () => { - const { user, board, column } = setup(); + const { user, board, column } = globalSetup(); columnService.create.mockResolvedValueOnce(column); const result = await uc.createColumn(user.id, board.id); @@ -205,7 +213,7 @@ describe(BoardUc.name, () => { describe('moveColumn', () => { describe('when moving a column', () => { it('should call the service to find the column', async () => { - const { user, board, column } = setup(); + const { user, board, column } = globalSetup(); await uc.moveColumn(user.id, column.id, board.id, 7); @@ -213,7 +221,7 @@ describe(BoardUc.name, () => { }); it('should call the service to find the target board', async () => { - const { user, board, column } = setup(); + const { user, board, column } = globalSetup(); await uc.moveColumn(user.id, column.id, board.id, 7); @@ -221,7 +229,7 @@ describe(BoardUc.name, () => { }); it('should call the service to move the column', async () => { - const { user, board, column } = setup(); + const { user, board, column } = globalSetup(); columnService.findById.mockResolvedValueOnce(column); columnBoardService.findById.mockResolvedValueOnce(board); @@ -231,4 +239,60 @@ describe(BoardUc.name, () => { }); }); }); + + describe('copyBoard', () => { + it('should call the service to copy the board', async () => { + const { user, boardId } = globalSetup(); + + await uc.copyBoard(user.id, boardId); + + expect(columnBoardCopyService.copyColumnBoard).toHaveBeenCalledWith( + expect.objectContaining({ userId: user.id, originalColumnBoardId: boardId }) + ); + }); + }); + + describe('updateVisibility', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const board = columnBoardFactory.build(); + + return { user, board }; + }; + + it('should call the service to find the board', async () => { + const { user, board } = setup(); + + await uc.updateVisibility(user.id, board.id, true); + + expect(columnBoardService.findById).toHaveBeenCalledWith(board.id); + }); + + it('should authorize', async () => { + const { user, board } = setup(); + + columnBoardService.findById.mockResolvedValueOnce(board); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + const mockAuthorizable = new BoardDoAuthorizable({ + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + id: board.id, + boardDo: board, + rootDo: board, + }); + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValueOnce(mockAuthorizable); + + await uc.updateVisibility(user.id, board.id, true); + + const context = { action: Action.write, requiredPermissions: [] }; + expect(authorizationService.checkPermission).toBeCalledWith(user, mockAuthorizable, context); + }); + + it('should call the service to update the board visibility', async () => { + const { user, board } = setup(); + + await uc.updateVisibility(user.id, board.id, true); + + expect(columnBoardService.updateBoardVisibility).toHaveBeenCalledWith(board.id, true); + }); + }); }); diff --git a/apps/server/src/modules/board/uc/board.uc.ts b/apps/server/src/modules/board/uc/board.uc.ts index e6a01ed93f5..363b7b3aa5d 100644 --- a/apps/server/src/modules/board/uc/board.uc.ts +++ b/apps/server/src/modules/board/uc/board.uc.ts @@ -1,11 +1,15 @@ -import { Action } from '@modules/authorization'; -import { AuthorizationService } from '@modules/authorization/domain'; +import { Action, AuthorizationService } from '@modules/authorization'; import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { BoardExternalReference, Column, ColumnBoard } from '@shared/domain/domainobject'; +import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; +import { CourseRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; -import { CardService, ColumnBoardService, ColumnService } from '../service'; +import { CopyStatus } from '@src/modules/copy-helper'; +import { CreateBoardBodyParams } from '../controller/dto'; +import { ColumnBoardService, ColumnService } from '../service'; import { BoardDoAuthorizableService } from '../service/board-do-authorizable.service'; +import { ColumnBoardCopyService } from '../service/column-board-copy.service'; import { BaseUc } from './base.uc'; @Injectable() @@ -14,15 +18,33 @@ export class BoardUc extends BaseUc { @Inject(forwardRef(() => AuthorizationService)) protected readonly authorizationService: AuthorizationService, protected readonly boardDoAuthorizableService: BoardDoAuthorizableService, - private readonly cardService: CardService, private readonly columnBoardService: ColumnBoardService, + private readonly columnBoardCopyService: ColumnBoardCopyService, private readonly columnService: ColumnService, - private readonly logger: LegacyLogger + private readonly logger: LegacyLogger, + private readonly courseRepo: CourseRepo ) { super(authorizationService, boardDoAuthorizableService); this.logger.setContext(BoardUc.name); } + async createBoard(userId: EntityId, params: CreateBoardBodyParams): Promise { + this.logger.debug({ action: 'createBoard', userId, title: params.title }); + + const user = await this.authorizationService.getUserWithPermissions(userId); + const course = await this.courseRepo.findById(params.parentId); + + this.authorizationService.checkPermission(user, course, { + action: Action.write, + requiredPermissions: [Permission.COURSE_EDIT], + }); + + const context = { type: params.parentType, id: params.parentId }; + const board = await this.columnBoardService.create(context, params.title); + + return board; + } + async findBoard(userId: EntityId, boardId: EntityId): Promise { this.logger.debug({ action: 'findBoard', userId, boardId }); @@ -85,4 +107,33 @@ export class BoardUc extends BaseUc { await this.columnService.move(column, targetBoard, targetPosition); } + + async copyBoard(userId: EntityId, boardId: EntityId): Promise { + this.logger.debug({ action: 'copyBoard', userId, boardId }); + + const user = await this.authorizationService.getUserWithPermissions(userId); + const board = await this.columnBoardService.findById(boardId); + const course = await this.courseRepo.findById(board.context.id); + + await this.checkPermission(userId, board, Action.read); + this.authorizationService.checkPermission(user, course, { + action: Action.write, + requiredPermissions: [], + }); + + const copyStatus = await this.columnBoardCopyService.copyColumnBoard({ + userId, + originalColumnBoardId: boardId, + destinationExternalReference: board.context, + }); + + return copyStatus; + } + + async updateVisibility(userId: EntityId, boardId: EntityId, isVisible: boolean): Promise { + const board = await this.columnBoardService.findById(boardId); + await this.checkPermission(userId, board, Action.write); + + await this.columnBoardService.updateBoardVisibility(board, isVisible); + } } diff --git a/apps/server/src/modules/board/uc/card.uc.spec.ts b/apps/server/src/modules/board/uc/card.uc.spec.ts index ec5daacd197..da42e9ec4aa 100644 --- a/apps/server/src/modules/board/uc/card.uc.spec.ts +++ b/apps/server/src/modules/board/uc/card.uc.spec.ts @@ -2,11 +2,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AuthorizationService } from '@modules/authorization'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardDoAuthorizable, BoardRoles, ContentElementType, UserRoleEnum } from '@shared/domain/domainobject'; +import { BoardDoAuthorizable, BoardRoles, ContentElementType } from '@shared/domain/domainobject'; import { columnBoardFactory, columnFactory, setupEntities, userFactory } from '@shared/testing'; import { cardFactory, richTextElementFactory } from '@shared/testing/factory/domainobject'; import { LegacyLogger } from '@src/core/logger'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BoardDoAuthorizableService, CardService, ContentElementService } from '../service'; import { CardUc } from './card.uc'; @@ -74,7 +74,12 @@ describe(CardUc.name, () => { const cardIds = cards.map((c) => c.id); boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( - new BoardDoAuthorizable({ users: [], id: new ObjectId().toHexString() }) + new BoardDoAuthorizable({ + users: [], + id: new ObjectId().toHexString(), + boardDo: cards[0], + rootDo: columnBoardFactory.build(), + }) ); return { user, cards, cardIds }; @@ -109,8 +114,10 @@ describe(CardUc.name, () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const authorizableMock: BoardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], id: board.id, + boardDo: card, + rootDo: board, }); const createCardBodyParams = { requiredEmptyElements: [ContentElementType.FILE, ContentElementType.RICH_TEXT], @@ -151,8 +158,10 @@ describe(CardUc.name, () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const authorizableMock: BoardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], id: board.id, + boardDo: card, + rootDo: board, }); const createCardBodyParams = { requiredEmptyElements: [ContentElementType.FILE, ContentElementType.RICH_TEXT], @@ -204,8 +213,10 @@ describe(CardUc.name, () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const authorizableMock: BoardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], - id: board.id, + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], + id: card.id, + boardDo: card, + rootDo: board, }); const createCardBodyParams = { requiredEmptyElements: [ContentElementType.FILE, ContentElementType.RICH_TEXT], @@ -248,7 +259,12 @@ describe(CardUc.name, () => { elementService.create.mockResolvedValueOnce(element); boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( - new BoardDoAuthorizable({ users: [], id: new ObjectId().toHexString() }) + new BoardDoAuthorizable({ + users: [], + id: new ObjectId().toHexString(), + boardDo: card, + rootDo: columnBoardFactory.build(), + }) ); return { user, card, element }; @@ -304,8 +320,10 @@ describe(CardUc.name, () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const authorizableMock: BoardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], id: element.id, + boardDo: element, + rootDo: columnBoardFactory.build(), }); boardDoAuthorizableService.findById.mockResolvedValueOnce(authorizableMock); diff --git a/apps/server/src/modules/board/uc/card.uc.ts b/apps/server/src/modules/board/uc/card.uc.ts index eac4498c096..c8751b570d8 100644 --- a/apps/server/src/modules/board/uc/card.uc.ts +++ b/apps/server/src/modules/board/uc/card.uc.ts @@ -83,7 +83,7 @@ export class CardUc extends BaseUc { targetCardId: EntityId, targetPosition: number ): Promise { - this.logger.debug({ action: 'moveCard', userId, elementId, targetCardId, targetPosition }); + this.logger.debug({ action: 'moveElement', userId, elementId, targetCardId, targetPosition }); const element = await this.elementService.findById(elementId); const targetCard = await this.cardService.findById(targetCardId); diff --git a/apps/server/src/modules/board/uc/column.uc.spec.ts b/apps/server/src/modules/board/uc/column.uc.spec.ts index 53345319b22..67111077911 100644 --- a/apps/server/src/modules/board/uc/column.uc.spec.ts +++ b/apps/server/src/modules/board/uc/column.uc.spec.ts @@ -1,7 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AuthorizationService } from '@modules/authorization'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardDoAuthorizable, BoardRoles, ContentElementType, UserRoleEnum } from '@shared/domain/domainobject'; +import { BoardDoAuthorizable, BoardRoles, ContentElementType } from '@shared/domain/domainobject'; import { setupEntities, userFactory } from '@shared/testing'; import { cardFactory, columnBoardFactory, columnFactory } from '@shared/testing/factory/domainobject'; import { LegacyLogger } from '@src/core/logger'; @@ -73,8 +73,10 @@ describe(ColumnUc.name, () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const authorizableMock: BoardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], id: board.id, + boardDo: board, + rootDo: columnBoardFactory.build(), }); const createCardBodyParams = { requiredEmptyElements: [ContentElementType.FILE, ContentElementType.RICH_TEXT], diff --git a/apps/server/src/modules/board/uc/column.uc.ts b/apps/server/src/modules/board/uc/column.uc.ts index b42f99e2d79..9e3d6123ace 100644 --- a/apps/server/src/modules/board/uc/column.uc.ts +++ b/apps/server/src/modules/board/uc/column.uc.ts @@ -42,7 +42,7 @@ export class ColumnUc extends BaseUc { this.logger.debug({ action: 'createCard', userId, columnId }); const column = await this.columnService.findById(columnId); - await this.checkPermission(userId, column, Action.read); + await this.checkPermission(userId, column, Action.write); const card = await this.cardService.create(column, requiredEmptyElements); diff --git a/apps/server/src/modules/board/uc/element.uc.spec.ts b/apps/server/src/modules/board/uc/element.uc.spec.ts index f520fc6444d..c3607fb0312 100644 --- a/apps/server/src/modules/board/uc/element.uc.spec.ts +++ b/apps/server/src/modules/board/uc/element.uc.spec.ts @@ -1,9 +1,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Action, AuthorizationService } from '@modules/authorization'; +import { AuthorizationService } from '@modules/authorization'; import { HttpService } from '@nestjs/axios'; -import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardDoAuthorizable, BoardRoles, UserRoleEnum } from '@shared/domain/domainobject'; +import { BoardDoAuthorizable, BoardRoles } from '@shared/domain/domainobject'; import { InputFormat } from '@shared/domain/types'; import { cardFactory, @@ -17,7 +16,8 @@ import { userFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { boardDoAuthorizableFactory } from '@shared/testing/factory/domainobject/board/board-do-authorizable.factory'; import { BoardDoAuthorizableService, ContentElementService, SubmissionItemService } from '../service'; import { ElementUc } from './element.uc'; @@ -63,9 +63,7 @@ describe(ElementUc.name, () => { authorizationService = module.get(AuthorizationService); authorizationService.checkPermission.mockImplementation(() => {}); boardDoAuthorizableService = module.get(BoardDoAuthorizableService); - boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( - new BoardDoAuthorizable({ users: [], id: new ObjectId().toHexString() }) - ); + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue(boardDoAuthorizableFactory.build()); elementService = module.get(ContentElementService); await setupEntities(); }); @@ -89,7 +87,7 @@ describe(ElementUc.name, () => { it('should get element', async () => { const { richTextElement, user, content, elementSpy } = setup(); - await uc.updateElementContent(user.id, richTextElement.id, content); + await uc.updateElement(user.id, richTextElement.id, content); expect(elementSpy).toHaveBeenCalledWith(richTextElement.id); }); @@ -97,7 +95,7 @@ describe(ElementUc.name, () => { it('should call the service', async () => { const { richTextElement, user, content } = setup(); - await uc.updateElementContent(user.id, richTextElement.id, content); + await uc.updateElement(user.id, richTextElement.id, content); expect(elementService.update).toHaveBeenCalledWith(richTextElement, content); }); @@ -117,7 +115,7 @@ describe(ElementUc.name, () => { it('should get element', async () => { const { fileElement, user, content, elementSpy } = setup(); - await uc.updateElementContent(user.id, fileElement.id, content); + await uc.updateElement(user.id, fileElement.id, content); expect(elementSpy).toHaveBeenCalledWith(fileElement.id); }); @@ -125,7 +123,7 @@ describe(ElementUc.name, () => { it('should call the service', async () => { const { fileElement, user, content } = setup(); - await uc.updateElementContent(user.id, fileElement.id, content); + await uc.updateElement(user.id, fileElement.id, content); expect(elementService.update).toHaveBeenCalledWith(fileElement, content); }); @@ -133,62 +131,6 @@ describe(ElementUc.name, () => { }); describe('deleteElement', () => { - describe('when deleting an element which has a submission item parent', () => { - const setup = () => { - const user = userFactory.build(); - const element = richTextElementFactory.build(); - const submissionItem = submissionItemFactory.build({ userId: user.id }); - - boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( - new BoardDoAuthorizable({ users: [], id: new ObjectId().toHexString() }) - ); - - elementService.findById.mockResolvedValueOnce(element); - return { element, user, submissionItem }; - }; - - it('should call the service to find the element', async () => { - const { element, user } = setup(); - await uc.deleteElement(user.id, element.id); - - expect(elementService.findById).toHaveBeenCalledWith(element.id); - }); - - it('should call the service to find the parent of the element', async () => { - const { element, user } = setup(); - await uc.deleteElement(user.id, element.id); - - expect(elementService.findParentOfId).toHaveBeenCalledWith(element.id); - }); - - it('should throw if the user is not the owner of the submission item', async () => { - const { element, user } = setup(); - const otherSubmissionItem = submissionItemFactory.build({ userId: new ObjectId().toHexString() }); - elementService.findParentOfId.mockResolvedValueOnce(otherSubmissionItem); - - await expect(uc.deleteElement(user.id, element.id)).rejects.toThrow(new ForbiddenException()); - }); - - it('should authorize the user to delete the element', async () => { - const { element, user, submissionItem } = setup(); - elementService.findParentOfId.mockResolvedValueOnce(submissionItem); - const boardDoAuthorizable = await boardDoAuthorizableService.getBoardAuthorizable(submissionItem); - const context = { action: Action.read, requiredPermissions: [] }; - await uc.deleteElement(user.id, element.id); - - expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, boardDoAuthorizable, context); - }); - - it('should call the service to delete the element', async () => { - const { user, element, submissionItem } = setup(); - elementService.findParentOfId.mockResolvedValueOnce(submissionItem); - - await uc.deleteElement(user.id, element.id); - - expect(elementService.delete).toHaveBeenCalledWith(element); - }); - }); - describe('when deleting a content element', () => { const setup = () => { const user = userFactory.build(); @@ -196,7 +138,12 @@ describe(ElementUc.name, () => { const drawingElement = drawingElementFactory.build(); boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( - new BoardDoAuthorizable({ users: [], id: new ObjectId().toHexString() }) + new BoardDoAuthorizable({ + users: [], + id: new ObjectId().toHexString(), + boardDo: element, + rootDo: columnBoardFactory.build(), + }) ); return { user, element, drawingElement }; @@ -293,8 +240,10 @@ describe(ElementUc.name, () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const authorizableMock: BoardDoAuthorizable = new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }], + users: [{ userId: user.id, roles: [BoardRoles.EDITOR] }], id: columnBoard.id, + boardDo: card, + rootDo: columnBoard, }); boardDoAuthorizableService.findById.mockResolvedValueOnce(authorizableMock); diff --git a/apps/server/src/modules/board/uc/element.uc.ts b/apps/server/src/modules/board/uc/element.uc.ts index a7f978a1fb3..521eb825480 100644 --- a/apps/server/src/modules/board/uc/element.uc.ts +++ b/apps/server/src/modules/board/uc/element.uc.ts @@ -1,12 +1,10 @@ import { Action, AuthorizationService } from '@modules/authorization'; import { ForbiddenException, forwardRef, Inject, Injectable, UnprocessableEntityException } from '@nestjs/common'; import { - AnyBoardDo, AnyContentElementDo, isSubmissionContainerElement, isSubmissionItem, SubmissionItem, - UserRoleEnum, } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; @@ -29,35 +27,23 @@ export class ElementUc extends BaseUc { this.logger.setContext(ElementUc.name); } - async updateElementContent( + async updateElement( userId: EntityId, elementId: EntityId, content: AnyElementContentBody ): Promise { - const element = await this.getElementWithWritePermission(userId, elementId); + const element = await this.elementService.findById(elementId); + await this.checkPermission(userId, element, Action.write); await this.elementService.update(element, content); return element; } async deleteElement(userId: EntityId, elementId: EntityId): Promise { - const element = await this.getElementWithWritePermission(userId, elementId); - - await this.elementService.delete(element); - } - - private async getElementWithWritePermission(userId: EntityId, elementId: EntityId): Promise { const element = await this.elementService.findById(elementId); + await this.checkPermission(userId, element, Action.write); - const parent: AnyBoardDo = await this.elementService.findParentOfId(elementId); - - if (isSubmissionItem(parent)) { - await this.checkSubmissionItemWritePermission(userId, parent); - } else { - await this.checkPermission(userId, element, Action.write); - } - - return element; + await this.elementService.delete(element); } async checkElementReadPermission(userId: EntityId, elementId: EntityId): Promise { @@ -91,7 +77,12 @@ export class ElementUc extends BaseUc { ); } - await this.checkPermission(userId, submissionContainerElement, Action.read, UserRoleEnum.STUDENT); + await this.checkPermission(userId, submissionContainerElement, Action.read); + + const boardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(submissionContainerElement); + if (this.isUserBoardEditor(userId, boardDoAuthorizable.users)) { + throw new ForbiddenException(); + } const submissionItem = await this.submissionItemService.create(userId, submissionContainerElement, { completed }); diff --git a/apps/server/src/modules/board/uc/index.ts b/apps/server/src/modules/board/uc/index.ts index b818c6753c0..2be241703c4 100644 --- a/apps/server/src/modules/board/uc/index.ts +++ b/apps/server/src/modules/board/uc/index.ts @@ -4,3 +4,4 @@ export * from './card.uc'; export * from './column.uc'; export * from './element.uc'; export * from './submission-item.uc'; +export * from './media-board'; diff --git a/apps/server/src/modules/board/uc/media-board/index.ts b/apps/server/src/modules/board/uc/media-board/index.ts new file mode 100644 index 00000000000..96805655608 --- /dev/null +++ b/apps/server/src/modules/board/uc/media-board/index.ts @@ -0,0 +1,3 @@ +export { MediaLineUc } from './media-line.uc'; +export { MediaBoardUc } from './media-board.uc'; +export { MediaElementUc } from './media-element.uc'; diff --git a/apps/server/src/modules/board/uc/media-board/media-board.uc.spec.ts b/apps/server/src/modules/board/uc/media-board/media-board.uc.spec.ts new file mode 100644 index 00000000000..728ac476e4d --- /dev/null +++ b/apps/server/src/modules/board/uc/media-board/media-board.uc.spec.ts @@ -0,0 +1,229 @@ +import { createMock, type DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; +import { + boardDoAuthorizableFactory, + mediaBoardFactory, + mediaLineFactory, + setupEntities, + userFactory as userEntityFactory, +} from '@shared/testing'; +import type { MediaBoardConfig } from '../../media-board.config'; +import { BoardDoAuthorizableService, MediaBoardService, MediaLineService } from '../../service'; +import { MediaBoardUc } from './media-board.uc'; + +describe(MediaBoardUc.name, () => { + let module: TestingModule; + let uc: MediaBoardUc; + + let authorizationService: DeepMocked; + let mediaBoardService: DeepMocked; + let mediaLineService: DeepMocked; + let boardDoAuthorizableService: DeepMocked; + let configService: DeepMocked>; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + MediaBoardUc, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: MediaBoardService, + useValue: createMock(), + }, + { + provide: MediaLineService, + useValue: createMock(), + }, + { + provide: BoardDoAuthorizableService, + useValue: createMock(), + }, + { + provide: ConfigService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(MediaBoardUc); + authorizationService = module.get(AuthorizationService); + mediaBoardService = module.get(MediaBoardService); + mediaLineService = module.get(MediaLineService); + boardDoAuthorizableService = module.get(BoardDoAuthorizableService); + configService = module.get(ConfigService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getMediaBoardForUser', () => { + describe('when the user has no media board', () => { + const setup = () => { + const user = userEntityFactory.build(); + const mediaBoard = mediaBoardFactory.build(); + + configService.get.mockReturnValueOnce(true); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + mediaBoardService.findIdsByExternalReference.mockResolvedValueOnce([]); + mediaBoardService.create.mockResolvedValueOnce(mediaBoard); + + return { + user, + mediaBoard, + }; + }; + + it('should check the authorization', async () => { + const { user } = setup(); + + await uc.getMediaBoardForUser(user.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + user, + AuthorizationContextBuilder.read([]) + ); + }); + + it('should return a new media board', async () => { + const { user, mediaBoard } = setup(); + + const result = await uc.getMediaBoardForUser(user.id); + + expect(result).toEqual(mediaBoard); + }); + }); + + describe('when the user has a media board', () => { + const setup = () => { + const user = userEntityFactory.build(); + const mediaBoard = mediaBoardFactory.build(); + + configService.get.mockReturnValueOnce(true); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + mediaBoardService.findIdsByExternalReference.mockResolvedValueOnce([mediaBoard.id]); + mediaBoardService.findById.mockResolvedValueOnce(mediaBoard); + + return { + user, + mediaBoard, + }; + }; + + it('should check the authorization', async () => { + const { user } = setup(); + + await uc.getMediaBoardForUser(user.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + user, + AuthorizationContextBuilder.read([]) + ); + }); + + it('should return the existing media board', async () => { + const { user, mediaBoard } = setup(); + + const result = await uc.getMediaBoardForUser(user.id); + + expect(result).toEqual(mediaBoard); + }); + }); + + describe('when the feature is disabled', () => { + const setup = () => { + const user = userEntityFactory.build(); + + configService.get.mockReturnValueOnce(false); + + return { + user, + }; + }; + + it('should throw an exception', async () => { + const { user } = setup(); + + await expect(uc.getMediaBoardForUser(user.id)).rejects.toThrow(FeatureDisabledLoggableException); + }); + }); + }); + + describe('createLine', () => { + describe('when the user creates a new media line', () => { + const setup = () => { + const user = userEntityFactory.build(); + const mediaBoard = mediaBoardFactory.build(); + const mediaLine = mediaLineFactory.build(); + const boardDoAuthorizable = boardDoAuthorizableFactory.build(); + + configService.get.mockReturnValueOnce(true); + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValueOnce(boardDoAuthorizable); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + mediaBoardService.findById.mockResolvedValueOnce(mediaBoard); + mediaLineService.create.mockResolvedValueOnce(mediaLine); + + return { + user, + mediaBoard, + mediaLine, + boardDoAuthorizable, + }; + }; + + it('should check the authorization', async () => { + const { user, mediaBoard, boardDoAuthorizable } = setup(); + + await uc.createLine(user.id, mediaBoard.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + boardDoAuthorizable, + AuthorizationContextBuilder.write([]) + ); + }); + + it('should return a new media line', async () => { + const { user, mediaBoard, mediaLine } = setup(); + + const result = await uc.createLine(user.id, mediaBoard.id); + + expect(result).toEqual(mediaLine); + }); + }); + + describe('when the feature is disabled', () => { + const setup = () => { + const user = userEntityFactory.build(); + const mediaBoard = mediaBoardFactory.build(); + + configService.get.mockReturnValueOnce(false); + + return { + user, + mediaBoard, + }; + }; + + it('should throw an exception', async () => { + const { user, mediaBoard } = setup(); + + await expect(uc.createLine(user.id, mediaBoard.id)).rejects.toThrow(FeatureDisabledLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/uc/media-board/media-board.uc.ts b/apps/server/src/modules/board/uc/media-board/media-board.uc.ts new file mode 100644 index 00000000000..ab35136d9a3 --- /dev/null +++ b/apps/server/src/modules/board/uc/media-board/media-board.uc.ts @@ -0,0 +1,69 @@ +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; +import { + BoardDoAuthorizable, + BoardExternalReferenceType, + type MediaBoard, + type MediaLine, +} from '@shared/domain/domainobject'; +import type { User as UserEntity } from '@shared/domain/entity'; +import type { EntityId } from '@shared/domain/types'; +import type { MediaBoardConfig } from '../../media-board.config'; +import { BoardDoAuthorizableService, MediaBoardService, MediaLineService } from '../../service'; + +@Injectable() +export class MediaBoardUc { + constructor( + private readonly authorizationService: AuthorizationService, + private readonly mediaBoardService: MediaBoardService, + private readonly mediaLineService: MediaLineService, + private readonly boardDoAuthorizableService: BoardDoAuthorizableService, + private readonly configService: ConfigService + ) {} + + public async getMediaBoardForUser(userId: EntityId): Promise { + this.checkFeatureEnabled(); + + const user: UserEntity = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission(user, user, AuthorizationContextBuilder.read([])); + + const boardIds: EntityId[] = await this.mediaBoardService.findIdsByExternalReference({ + type: BoardExternalReferenceType.User, + id: user.id, + }); + + let board: MediaBoard; + if (!boardIds.length) { + board = await this.mediaBoardService.create({ + type: BoardExternalReferenceType.User, + id: user.id, + }); + } else { + board = await this.mediaBoardService.findById(boardIds[0]); + } + + return board; + } + + public async createLine(userId: EntityId, boardId: EntityId): Promise { + this.checkFeatureEnabled(); + + const board: MediaBoard = await this.mediaBoardService.findById(boardId); + + const user: UserEntity = await this.authorizationService.getUserWithPermissions(userId); + const boardDoAuthorizable: BoardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(board); + this.authorizationService.checkPermission(user, boardDoAuthorizable, AuthorizationContextBuilder.write([])); + + const line: MediaLine = await this.mediaLineService.create(board); + + return line; + } + + private checkFeatureEnabled() { + if (!this.configService.get('FEATURE_MEDIA_SHELF_ENABLED')) { + throw new FeatureDisabledLoggableException('FEATURE_MEDIA_SHELF_ENABLED'); + } + } +} diff --git a/apps/server/src/modules/board/uc/media-board/media-element.uc.spec.ts b/apps/server/src/modules/board/uc/media-board/media-element.uc.spec.ts new file mode 100644 index 00000000000..d49b8252d83 --- /dev/null +++ b/apps/server/src/modules/board/uc/media-board/media-element.uc.spec.ts @@ -0,0 +1,139 @@ +import { createMock, type DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; +import { + boardDoAuthorizableFactory, + mediaExternalToolElementFactory, + mediaLineFactory, + setupEntities, + userFactory as userEntityFactory, +} from '@shared/testing'; +import type { MediaBoardConfig } from '../../media-board.config'; +import { BoardDoAuthorizableService, MediaElementService, MediaLineService } from '../../service'; +import { MediaElementUc } from './media-element.uc'; + +describe(MediaElementUc.name, () => { + let module: TestingModule; + let uc: MediaElementUc; + + let authorizationService: DeepMocked; + let mediaLineService: DeepMocked; + let mediaElementService: DeepMocked; + let boardDoAuthorizableService: DeepMocked; + let configService: DeepMocked>; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + MediaElementUc, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: MediaLineService, + useValue: createMock(), + }, + { + provide: MediaElementService, + useValue: createMock(), + }, + { + provide: BoardDoAuthorizableService, + useValue: createMock(), + }, + { + provide: ConfigService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(MediaElementUc); + authorizationService = module.get(AuthorizationService); + mediaLineService = module.get(MediaLineService); + mediaElementService = module.get(MediaElementService); + boardDoAuthorizableService = module.get(BoardDoAuthorizableService); + configService = module.get(ConfigService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('moveElement', () => { + describe('when the user moves a media element', () => { + const setup = () => { + const user = userEntityFactory.build(); + const mediaLine = mediaLineFactory.build(); + const mediaElement = mediaExternalToolElementFactory.build(); + const boardDoAuthorizable = boardDoAuthorizableFactory.build(); + + configService.get.mockReturnValueOnce(true); + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValueOnce(boardDoAuthorizable); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + mediaLineService.findById.mockResolvedValueOnce(mediaLine); + mediaElementService.findById.mockResolvedValueOnce(mediaElement); + + return { + user, + mediaElement, + mediaLine, + boardDoAuthorizable, + }; + }; + + it('should check the authorization', async () => { + const { user, mediaLine, mediaElement, boardDoAuthorizable } = setup(); + + await uc.moveElement(user.id, mediaElement.id, mediaLine.id, 1); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + boardDoAuthorizable, + AuthorizationContextBuilder.write([]) + ); + }); + + it('should move the element', async () => { + const { user, mediaLine, mediaElement } = setup(); + + await uc.moveElement(user.id, mediaElement.id, mediaLine.id, 1); + + expect(mediaElementService.move).toHaveBeenCalledWith(mediaElement, mediaLine, 1); + }); + }); + + describe('when the feature is disabled', () => { + const setup = () => { + const user = userEntityFactory.build(); + const mediaLine = mediaLineFactory.build(); + const mediaElement = mediaExternalToolElementFactory.build(); + + configService.get.mockReturnValueOnce(false); + + return { + user, + mediaElement, + mediaLine, + }; + }; + + it('should throw an exception', async () => { + const { user, mediaLine, mediaElement } = setup(); + + await expect(uc.moveElement(user.id, mediaElement.id, mediaLine.id, 1)).rejects.toThrow( + FeatureDisabledLoggableException + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/uc/media-board/media-element.uc.ts b/apps/server/src/modules/board/uc/media-board/media-element.uc.ts new file mode 100644 index 00000000000..b6fbfaa0639 --- /dev/null +++ b/apps/server/src/modules/board/uc/media-board/media-element.uc.ts @@ -0,0 +1,47 @@ +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; +import { type AnyMediaContentElementDo, BoardDoAuthorizable, type MediaLine } from '@shared/domain/domainobject'; +import type { User as UserEntity } from '@shared/domain/entity'; +import type { EntityId } from '@shared/domain/types'; +import type { MediaBoardConfig } from '../../media-board.config'; +import { BoardDoAuthorizableService, MediaElementService, MediaLineService } from '../../service'; + +@Injectable() +export class MediaElementUc { + constructor( + private readonly authorizationService: AuthorizationService, + private readonly mediaLineService: MediaLineService, + private readonly mediaElementService: MediaElementService, + private readonly boardDoAuthorizableService: BoardDoAuthorizableService, + private readonly configService: ConfigService + ) {} + + public async moveElement( + userId: EntityId, + elementId: EntityId, + targetLineId: EntityId, + targetPosition: number + ): Promise { + this.checkFeatureEnabled(); + + const targetLine: MediaLine = await this.mediaLineService.findById(targetLineId); + + const user: UserEntity = await this.authorizationService.getUserWithPermissions(userId); + const boardDoAuthorizable: BoardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable( + targetLine + ); + this.authorizationService.checkPermission(user, boardDoAuthorizable, AuthorizationContextBuilder.write([])); + + const element: AnyMediaContentElementDo = await this.mediaElementService.findById(elementId); + + await this.mediaElementService.move(element, targetLine, targetPosition); + } + + private checkFeatureEnabled() { + if (!this.configService.get('FEATURE_MEDIA_SHELF_ENABLED')) { + throw new FeatureDisabledLoggableException('FEATURE_MEDIA_SHELF_ENABLED'); + } + } +} diff --git a/apps/server/src/modules/board/uc/media-board/media-line.uc.spec.ts b/apps/server/src/modules/board/uc/media-board/media-line.uc.spec.ts new file mode 100644 index 00000000000..b58462de7a3 --- /dev/null +++ b/apps/server/src/modules/board/uc/media-board/media-line.uc.spec.ts @@ -0,0 +1,263 @@ +import { createMock, type DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; +import { + boardDoAuthorizableFactory, + mediaBoardFactory, + mediaLineFactory, + setupEntities, + userFactory as userEntityFactory, +} from '@shared/testing'; +import type { MediaBoardConfig } from '../../media-board.config'; +import { BoardDoAuthorizableService, MediaBoardService, MediaLineService } from '../../service'; +import { MediaLineUc } from './media-line.uc'; + +describe(MediaLineUc.name, () => { + let module: TestingModule; + let uc: MediaLineUc; + + let authorizationService: DeepMocked; + let mediaBoardService: DeepMocked; + let mediaLineService: DeepMocked; + let boardDoAuthorizableService: DeepMocked; + let configService: DeepMocked>; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + MediaLineUc, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: MediaBoardService, + useValue: createMock(), + }, + { + provide: MediaLineService, + useValue: createMock(), + }, + { + provide: BoardDoAuthorizableService, + useValue: createMock(), + }, + { + provide: ConfigService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(MediaLineUc); + authorizationService = module.get(AuthorizationService); + mediaBoardService = module.get(MediaBoardService); + mediaLineService = module.get(MediaLineService); + boardDoAuthorizableService = module.get(BoardDoAuthorizableService); + configService = module.get(ConfigService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('moveLine', () => { + describe('when the user moves a media line', () => { + const setup = () => { + const user = userEntityFactory.build(); + const mediaBoard = mediaBoardFactory.build(); + const mediaLine = mediaLineFactory.build(); + const boardDoAuthorizable = boardDoAuthorizableFactory.build(); + + configService.get.mockReturnValueOnce(true); + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValueOnce(boardDoAuthorizable); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + mediaBoardService.findById.mockResolvedValueOnce(mediaBoard); + mediaLineService.findById.mockResolvedValueOnce(mediaLine); + + return { + user, + mediaBoard, + mediaLine, + boardDoAuthorizable, + }; + }; + + it('should check the authorization', async () => { + const { user, mediaLine, mediaBoard, boardDoAuthorizable } = setup(); + + await uc.moveLine(user.id, mediaLine.id, mediaBoard.id, 1); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + boardDoAuthorizable, + AuthorizationContextBuilder.write([]) + ); + }); + + it('should move the line', async () => { + const { user, mediaLine, mediaBoard } = setup(); + + await uc.moveLine(user.id, mediaLine.id, mediaBoard.id, 1); + + expect(mediaLineService.move).toHaveBeenCalledWith(mediaLine, mediaBoard, 1); + }); + }); + + describe('when the feature is disabled', () => { + const setup = () => { + const user = userEntityFactory.build(); + const mediaBoard = mediaBoardFactory.build(); + const mediaLine = mediaLineFactory.build(); + + configService.get.mockReturnValueOnce(false); + + return { + user, + mediaBoard, + mediaLine, + }; + }; + + it('should throw an exception', async () => { + const { user, mediaLine, mediaBoard } = setup(); + + await expect(uc.moveLine(user.id, mediaLine.id, mediaBoard.id, 1)).rejects.toThrow( + FeatureDisabledLoggableException + ); + }); + }); + }); + + describe('updateLineTitle', () => { + describe('when the user renames a media line', () => { + const setup = () => { + const user = userEntityFactory.build(); + const mediaLine = mediaLineFactory.build(); + const boardDoAuthorizable = boardDoAuthorizableFactory.build(); + + configService.get.mockReturnValueOnce(true); + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValueOnce(boardDoAuthorizable); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + mediaLineService.findById.mockResolvedValueOnce(mediaLine); + + return { + user, + mediaLine, + boardDoAuthorizable, + }; + }; + + it('should check the authorization', async () => { + const { user, mediaLine, boardDoAuthorizable } = setup(); + + await uc.updateLineTitle(user.id, mediaLine.id, 'newTitle'); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + boardDoAuthorizable, + AuthorizationContextBuilder.write([]) + ); + }); + + it('should rename the line', async () => { + const { user, mediaLine } = setup(); + + await uc.updateLineTitle(user.id, mediaLine.id, 'newTitle'); + + expect(mediaLineService.updateTitle).toHaveBeenCalledWith(mediaLine, 'newTitle'); + }); + }); + + describe('when the feature is disabled', () => { + const setup = () => { + const user = userEntityFactory.build(); + const mediaLine = mediaLineFactory.build(); + + configService.get.mockReturnValueOnce(false); + + return { + user, + mediaLine, + }; + }; + + it('should throw an exception', async () => { + const { user, mediaLine } = setup(); + + await expect(uc.updateLineTitle(user.id, mediaLine.id, 'newTitle')).rejects.toThrow( + FeatureDisabledLoggableException + ); + }); + }); + }); + + describe('updateLineTitle', () => { + describe('when the user deletes a media line', () => { + const setup = () => { + const user = userEntityFactory.build(); + const mediaLine = mediaLineFactory.build(); + const boardDoAuthorizable = boardDoAuthorizableFactory.build(); + + configService.get.mockReturnValueOnce(true); + boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValueOnce(boardDoAuthorizable); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + mediaLineService.findById.mockResolvedValueOnce(mediaLine); + + return { + user, + mediaLine, + boardDoAuthorizable, + }; + }; + + it('should check the authorization', async () => { + const { user, mediaLine, boardDoAuthorizable } = setup(); + + await uc.deleteLine(user.id, mediaLine.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + boardDoAuthorizable, + AuthorizationContextBuilder.write([]) + ); + }); + + it('should delete the line', async () => { + const { user, mediaLine } = setup(); + + await uc.deleteLine(user.id, mediaLine.id); + + expect(mediaLineService.delete).toHaveBeenCalledWith(mediaLine); + }); + }); + + describe('when the feature is disabled', () => { + const setup = () => { + const user = userEntityFactory.build(); + const mediaLine = mediaLineFactory.build(); + + configService.get.mockReturnValueOnce(false); + + return { + user, + mediaLine, + }; + }; + + it('should throw an exception', async () => { + const { user, mediaLine } = setup(); + + await expect(uc.deleteLine(user.id, mediaLine.id)).rejects.toThrow(FeatureDisabledLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/uc/media-board/media-line.uc.ts b/apps/server/src/modules/board/uc/media-board/media-line.uc.ts new file mode 100644 index 00000000000..c0c235301f4 --- /dev/null +++ b/apps/server/src/modules/board/uc/media-board/media-line.uc.ts @@ -0,0 +1,71 @@ +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FeatureDisabledLoggableException } from '@shared/common/loggable-exception'; +import { BoardDoAuthorizable, type MediaBoard, type MediaLine } from '@shared/domain/domainobject'; +import type { User as UserEntity } from '@shared/domain/entity'; +import type { EntityId } from '@shared/domain/types'; +import type { MediaBoardConfig } from '../../media-board.config'; +import { BoardDoAuthorizableService, MediaBoardService, MediaLineService } from '../../service'; + +@Injectable() +export class MediaLineUc { + constructor( + private readonly authorizationService: AuthorizationService, + private readonly mediaBoardService: MediaBoardService, + private readonly mediaLineService: MediaLineService, + private readonly boardDoAuthorizableService: BoardDoAuthorizableService, + private readonly configService: ConfigService + ) {} + + public async moveLine( + userId: EntityId, + lineId: EntityId, + targetBoardId: EntityId, + targetPosition: number + ): Promise { + this.checkFeatureEnabled(); + + const targetBoard: MediaBoard = await this.mediaBoardService.findById(targetBoardId); + + const user: UserEntity = await this.authorizationService.getUserWithPermissions(userId); + const boardDoAuthorizable: BoardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable( + targetBoard + ); + this.authorizationService.checkPermission(user, boardDoAuthorizable, AuthorizationContextBuilder.write([])); + + const line: MediaLine = await this.mediaLineService.findById(lineId); + + await this.mediaLineService.move(line, targetBoard, targetPosition); + } + + public async updateLineTitle(userId: EntityId, lineId: EntityId, title: string): Promise { + this.checkFeatureEnabled(); + + const line: MediaLine = await this.mediaLineService.findById(lineId); + + const user: UserEntity = await this.authorizationService.getUserWithPermissions(userId); + const boardDoAuthorizable: BoardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(line); + this.authorizationService.checkPermission(user, boardDoAuthorizable, AuthorizationContextBuilder.write([])); + + await this.mediaLineService.updateTitle(line, title); + } + + public async deleteLine(userId: EntityId, lineId: EntityId): Promise { + this.checkFeatureEnabled(); + + const line: MediaLine = await this.mediaLineService.findById(lineId); + + const user: UserEntity = await this.authorizationService.getUserWithPermissions(userId); + const boardDoAuthorizable: BoardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(line); + this.authorizationService.checkPermission(user, boardDoAuthorizable, AuthorizationContextBuilder.write([])); + + await this.mediaLineService.delete(line); + } + + private checkFeatureEnabled() { + if (!this.configService.get('FEATURE_MEDIA_SHELF_ENABLED')) { + throw new FeatureDisabledLoggableException('FEATURE_MEDIA_SHELF_ENABLED'); + } + } +} diff --git a/apps/server/src/modules/board/uc/submission-item.uc.spec.ts b/apps/server/src/modules/board/uc/submission-item.uc.spec.ts index 5a9e66b7b1d..4fe5da487c5 100644 --- a/apps/server/src/modules/board/uc/submission-item.uc.spec.ts +++ b/apps/server/src/modules/board/uc/submission-item.uc.spec.ts @@ -7,8 +7,9 @@ import { UnprocessableEntityException, } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardDoAuthorizable, BoardRoles, ContentElementType, UserRoleEnum } from '@shared/domain/domainobject'; +import { BoardDoAuthorizable, BoardRoles, ContentElementType } from '@shared/domain/domainobject'; import { + columnBoardFactory, fileElementFactory, richTextElementFactory, setupEntities, @@ -90,10 +91,12 @@ describe(SubmissionItemUc.name, () => { boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( new BoardDoAuthorizable({ users: [ - { userId: user1.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }, - { userId: user2.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }, + { userId: user1.id, roles: [BoardRoles.READER] }, + { userId: user2.id, roles: [BoardRoles.READER] }, ], id: submissionContainerEl.id, + boardDo: submissionContainerEl, + rootDo: columnBoardFactory.build(), }) ); @@ -132,11 +135,13 @@ describe(SubmissionItemUc.name, () => { boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( new BoardDoAuthorizable({ users: [ - { userId: teacher.id, roles: [BoardRoles.EDITOR], userRoleEnum: UserRoleEnum.TEACHER }, - { userId: student1.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }, - { userId: student2.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }, + { userId: teacher.id, roles: [BoardRoles.EDITOR] }, + { userId: student1.id, roles: [BoardRoles.READER] }, + { userId: student2.id, roles: [BoardRoles.READER] }, ], id: submissionContainerEl.id, + boardDo: submissionContainerEl, + rootDo: columnBoardFactory.build(), }) ); @@ -158,36 +163,6 @@ describe(SubmissionItemUc.name, () => { expect(users.length).toBe(2); }); }); - describe('when user has not an authorized role', () => { - const setup = () => { - const user = userFactory.buildWithId(); - const submissionItem = submissionItemFactory.build({ - userId: user.id, - }); - const submissionContainerEl = submissionContainerElementFactory.build({ - children: [submissionItem], - }); - - boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( - new BoardDoAuthorizable({ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - users: [{ userId: user.id, roles: [BoardRoles.READER] }], - id: submissionContainerEl.id, - }) - ); - elementService.findById.mockResolvedValueOnce(submissionContainerEl); - - return { user, submissionContainerElement: submissionContainerEl }; - }; - it('should throw forbidden exception', async () => { - const { user, submissionContainerElement } = setup(); - - await expect(uc.findSubmissionItems(user.id, submissionContainerElement.id)).rejects.toThrow( - 'User not part of this board' - ); - }); - }); describe('when called with wrong board node', () => { const setup = () => { const teacher = userFactory.buildWithId(); @@ -211,13 +186,14 @@ describe(SubmissionItemUc.name, () => { const setup = () => { const user = userFactory.buildWithId(); + const columnBoard = columnBoardFactory.build(); const submissionItem = submissionItemFactory.build({ userId: user.id, }); submissionItemService.findById.mockResolvedValueOnce(submissionItem); - return { submissionItem, user, boardDoAuthorizableService }; + return { submissionItem, columnBoard, user, boardDoAuthorizableService }; }; it('should call service to find the submission item ', async () => { @@ -227,28 +203,22 @@ describe(SubmissionItemUc.name, () => { }); it('should authorize', async () => { - const { submissionItem, user } = setup(); + const { submissionItem, user, columnBoard } = setup(); boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }], + users: [{ userId: user.id, roles: [BoardRoles.READER] }], id: submissionItem.id, + boardDo: submissionItem, + rootDo: columnBoard, }) ); const boardDoAuthorizable = await boardDoAuthorizableService.getBoardAuthorizable(submissionItem); await uc.updateSubmissionItem(user.id, submissionItem.id, false); - const context = { action: Action.read, requiredPermissions: [] }; + const context = { action: Action.write, requiredPermissions: [] }; expect(authorizationService.checkPermission).toBeCalledWith(user, boardDoAuthorizable, context); }); - it('should throw if user is not creator of submission item', async () => { - const user2 = userFactory.buildWithId(); - const { submissionItem } = setup(); - - await expect(uc.updateSubmissionItem(user2.id, submissionItem.id, false)).rejects.toThrow( - new ForbiddenException() - ); - }); it('should call service to update submission item', async () => { const { submissionItem, user } = setup(); await uc.updateSubmissionItem(user.id, submissionItem.id, false); @@ -270,8 +240,10 @@ describe(SubmissionItemUc.name, () => { boardDoAuthorizableService.getBoardAuthorizable.mockResolvedValue( new BoardDoAuthorizable({ - users: [{ userId: user.id, roles: [BoardRoles.READER], userRoleEnum: UserRoleEnum.STUDENT }], + users: [{ userId: user.id, roles: [BoardRoles.READER] }], id: submissionItem.id, + boardDo: submissionItem, + rootDo: columnBoardFactory.build(), }) ); @@ -293,7 +265,7 @@ describe(SubmissionItemUc.name, () => { const boardDoAuthorizable = await boardDoAuthorizableService.getBoardAuthorizable(submissionItem); await uc.createElement(user.id, submissionItem.id, ContentElementType.RICH_TEXT); - const context = { action: Action.read, requiredPermissions: [] }; + const context = { action: Action.write, requiredPermissions: [] }; expect(authorizationService.checkPermission).toBeCalledWith(user, boardDoAuthorizable, context); }); diff --git a/apps/server/src/modules/board/uc/submission-item.uc.ts b/apps/server/src/modules/board/uc/submission-item.uc.ts index 2256376a5a6..44f0530a243 100644 --- a/apps/server/src/modules/board/uc/submission-item.uc.ts +++ b/apps/server/src/modules/board/uc/submission-item.uc.ts @@ -1,6 +1,7 @@ import { Action, AuthorizationService } from '@modules/authorization'; import { BadRequestException, + ForbiddenException, forwardRef, Inject, Injectable, @@ -8,6 +9,7 @@ import { UnprocessableEntityException, } from '@nestjs/common'; import { + BoardRoles, ContentElementType, FileElement, isFileElement, @@ -16,8 +18,7 @@ import { isSubmissionItem, RichTextElement, SubmissionItem, - UserBoardRoles, - UserRoleEnum, + UserWithBoardRoles, } from '@shared/domain/domainobject'; import { EntityId } from '@shared/domain/types'; import { BoardDoAuthorizableService, ContentElementService, SubmissionItemService } from '../service'; @@ -38,9 +39,8 @@ export class SubmissionItemUc extends BaseUc { async findSubmissionItems( userId: EntityId, submissionContainerId: EntityId - ): Promise<{ submissionItems: SubmissionItem[]; users: UserBoardRoles[] }> { + ): Promise<{ submissionItems: SubmissionItem[]; users: UserWithBoardRoles[] }> { const submissionContainerElement = await this.elementService.findById(submissionContainerId); - if (!isSubmissionContainerElement(submissionContainerElement)) { throw new NotFoundException('Could not find a submission container with this id'); } @@ -49,11 +49,13 @@ export class SubmissionItemUc extends BaseUc { let submissionItems = submissionContainerElement.children.filter(isSubmissionItem); - const boardAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(submissionContainerElement); - let users = boardAuthorizable.users.filter((user) => user.userRoleEnum === UserRoleEnum.STUDENT); + const boardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(submissionContainerElement); + + // only board readers can create submission items + let users = boardDoAuthorizable.users.filter((user) => user.roles.includes(BoardRoles.READER)); - const isAuthorizedStudent = await this.isAuthorizedStudent(userId, submissionContainerElement); - if (isAuthorizedStudent) { + // board readers can only see their own submission item + if (this.isUserBoardReader(userId, boardDoAuthorizable.users)) { submissionItems = submissionItems.filter((item) => item.userId === userId); users = []; } @@ -67,12 +69,21 @@ export class SubmissionItemUc extends BaseUc { completed: boolean ): Promise { const submissionItem = await this.submissionItemService.findById(submissionItemId); - await this.checkSubmissionItemWritePermission(userId, submissionItem); + + await this.checkPermission(userId, submissionItem, Action.write); + await this.submissionItemService.update(submissionItem, completed); return submissionItem; } + async deleteSubmissionItem(userId: EntityId, submissionItemId: EntityId): Promise { + const submissionItem = await this.submissionItemService.findById(submissionItemId); + await this.checkPermission(userId, submissionItem, Action.write); + + await this.submissionItemService.delete(submissionItem); + } + async createElement( userId: EntityId, submissionItemId: EntityId, @@ -84,7 +95,13 @@ export class SubmissionItemUc extends BaseUc { const submissionItem = await this.submissionItemService.findById(submissionItemId); - await this.checkSubmissionItemWritePermission(userId, submissionItem); + const boardDoAuthorizable = await this.boardDoAuthorizableService.getBoardAuthorizable(submissionItem); + + if (!this.isUserBoardReader(userId, boardDoAuthorizable.users)) { + throw new ForbiddenException(); + } + + await this.checkPermission(userId, submissionItem, Action.write); const element = await this.elementService.create(submissionItem, type); diff --git a/apps/server/src/modules/class/class.module.ts b/apps/server/src/modules/class/class.module.ts index b36f8637982..d7b7b12873c 100644 --- a/apps/server/src/modules/class/class.module.ts +++ b/apps/server/src/modules/class/class.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; +import { CqrsModule } from '@nestjs/cqrs'; import { ClassService } from './service'; import { ClassesRepo } from './repo'; @Module({ - imports: [LoggerModule], + imports: [CqrsModule, LoggerModule], providers: [ClassService, ClassesRepo], exports: [ClassService], }) diff --git a/apps/server/src/modules/class/entity/testing/factory/class.entity.factory.ts b/apps/server/src/modules/class/entity/testing/factory/class.entity.factory.ts index d122a5933d6..4089c70a9c7 100644 --- a/apps/server/src/modules/class/entity/testing/factory/class.entity.factory.ts +++ b/apps/server/src/modules/class/entity/testing/factory/class.entity.factory.ts @@ -1,6 +1,6 @@ import { ClassEntity, ClassEntityProps, ClassSourceOptionsEntity } from '@modules/class/entity'; import { BaseFactory } from '@shared/testing/factory/base.factory'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DeepPartial } from 'fishery'; class ClassEntityFactory extends BaseFactory { diff --git a/apps/server/src/modules/class/repo/classes.repo.spec.ts b/apps/server/src/modules/class/repo/classes.repo.spec.ts index df232d7878a..9904a627e6c 100644 --- a/apps/server/src/modules/class/repo/classes.repo.spec.ts +++ b/apps/server/src/modules/class/repo/classes.repo.spec.ts @@ -5,7 +5,7 @@ import { Test } from '@nestjs/testing'; import { TestingModule } from '@nestjs/testing/testing-module'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { SchoolEntity } from '@shared/domain/entity'; -import { cleanupCollections, schoolFactory } from '@shared/testing'; +import { cleanupCollections, schoolEntityFactory } from '@shared/testing'; import { Class } from '../domain'; import { ClassEntity } from '../entity'; import { ClassesRepo } from './classes.repo'; @@ -45,7 +45,7 @@ describe(ClassesRepo.name, () => { describe('when school has classes', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const classes: ClassEntity[] = classEntityFactory.buildListWithId(3, { schoolId: school.id }); await em.persistAndFlush(classes); diff --git a/apps/server/src/modules/class/service/class.service.spec.ts b/apps/server/src/modules/class/service/class.service.spec.ts index fca6bb9ee0d..25bbf05778a 100644 --- a/apps/server/src/modules/class/service/class.service.spec.ts +++ b/apps/server/src/modules/class/service/class.service.spec.ts @@ -5,6 +5,15 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityId } from '@shared/domain/types'; import { setupEntities } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { EventBus } from '@nestjs/cqrs'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DataDeletedEvent, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { Class } from '../domain'; import { classFactory } from '../domain/testing'; import { classEntityFactory } from '../entity/testing'; @@ -16,6 +25,7 @@ describe(ClassService.name, () => { let module: TestingModule; let service: ClassService; let classesRepo: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -29,11 +39,18 @@ describe(ClassService.name, () => { provide: Logger, useValue: createMock(), }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); service = module.get(ClassService); classesRepo = module.get(ClassesRepo); + eventBus = module.get(EventBus); await setupEntities(); }); @@ -117,7 +134,7 @@ describe(ClassService.name, () => { it('should throw and error', async () => { const { userId } = setup(); - await expect(service.deleteUserDataFromClasses(userId)).rejects.toThrowError(InternalServerErrorException); + await expect(service.deleteUserData(userId)).rejects.toThrowError(InternalServerErrorException); }); }); @@ -128,30 +145,76 @@ describe(ClassService.name, () => { const userId3 = new ObjectId(); const class1 = classEntityFactory.withUserIds([userId1, userId2]).build(); const class2 = classEntityFactory.withUserIds([userId1, userId3]).build(); - classEntityFactory.withUserIds([userId2, userId3]).build(); const mappedClasses = ClassMapper.mapToDOs([class1, class2]); classesRepo.findAllByUserId.mockResolvedValue(mappedClasses); + const expectedResult = DomainDeletionReportBuilder.build(DomainName.CLASS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [class1.id, class2.id]), + ]); + return { + expectedResult, userId1, }; }; it('should call classesRepo.findAllByUserId', async () => { const { userId1 } = setup(); - await service.deleteUserDataFromClasses(userId1.toHexString()); + await service.deleteUserData(userId1.toHexString()); expect(classesRepo.findAllByUserId).toBeCalledWith(userId1.toHexString()); }); it('should update classes without updated user', async () => { - const { userId1 } = setup(); + const { expectedResult, userId1 } = setup(); + + const result = await service.deleteUserData(userId1.toHexString()); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.CLASS; + const classId = new ObjectId().toHexString(); + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.CLASS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 1, [classId]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in classService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(service.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); - const result = await service.deleteUserDataFromClasses(userId1.toHexString()); + await service.handle({ deletionRequestId, targetRefId }); - expect(result).toEqual(2); + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); }); }); }); diff --git a/apps/server/src/modules/class/service/class.service.ts b/apps/server/src/modules/class/service/class.service.ts index 81d62253a79..1d9479d2c71 100644 --- a/apps/server/src/modules/class/service/class.service.ts +++ b/apps/server/src/modules/class/service/class.service.ts @@ -1,16 +1,38 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; -import { DomainModel, EntityId, StatusModel } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; -import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; +import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + UserDeletedEvent, + DataDeletedEvent, + DeletionService, + DomainDeletionReport, + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + StatusModel, + DataDeletionDomainOperationLoggable, +} from '@modules/deletion'; import { Class } from '../domain'; import { ClassesRepo } from '../repo'; @Injectable() -export class ClassService { - constructor(private readonly classesRepo: ClassesRepo, private readonly logger: Logger) { +@EventsHandler(UserDeletedEvent) +export class ClassService implements DeletionService, IEventHandler { + constructor( + private readonly classesRepo: ClassesRepo, + private readonly logger: Logger, + private readonly eventBus: EventBus + ) { this.logger.setContext(ClassService.name); } + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + public async findClassesForSchool(schoolId: EntityId): Promise { const classes: Class[] = await this.classesRepo.findAllBySchoolId(schoolId); @@ -23,11 +45,11 @@ export class ClassService { return classes; } - public async deleteUserDataFromClasses(userId: EntityId): Promise { + public async deleteUserData(userId: EntityId): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting data from Classes', - DomainModel.CLASS, + DomainName.CLASS, userId, StatusModel.PENDING ) @@ -49,10 +71,19 @@ export class ClassService { const numberOfUpdatedClasses = updatedClasses.length; await this.classesRepo.updateMany(updatedClasses); + + const result = DomainDeletionReportBuilder.build(DomainName.CLASS, [ + DomainOperationReportBuilder.build( + OperationType.UPDATE, + numberOfUpdatedClasses, + this.getClassesId(updatedClasses) + ), + ]); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully removed user data from Classes', - DomainModel.CLASS, + DomainName.CLASS, userId, StatusModel.FINISHED, numberOfUpdatedClasses, @@ -60,6 +91,10 @@ export class ClassService { ) ); - return numberOfUpdatedClasses; + return result; + } + + private getClassesId(classes: Class[]): EntityId[] { + return classes.map((item) => item.id); } } diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.spec.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.spec.ts new file mode 100644 index 00000000000..5c5db130cf5 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.spec.ts @@ -0,0 +1,110 @@ +import { faker } from '@faker-js/faker'; +import AdmZip from 'adm-zip'; +import { + CommonCartridgeElementType, + CommonCartridgeIntendedUseType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '../common-cartridge.enums'; +import { CommonCartridgeElementProps } from '../elements/common-cartridge-element-factory'; +import { CommonCartridgeResourceProps } from '../resources/common-cartridge-resource-factory'; +import { CommonCartridgeFileBuilder } from './common-cartridge-file-builder'; +import { CommonCartridgeOrganizationBuilderOptions } from './common-cartridge-organization-builder'; + +describe('CommonCartridgeFileBuilder', () => { + const getFileContentAsString = (zip: AdmZip, path: string): string | undefined => + zip.getEntry(path)?.getData().toString(); + + describe('build', () => { + describe('when a common cartridge archive has been created', () => { + const setup = async () => { + const metadataProps: CommonCartridgeElementProps = { + type: CommonCartridgeElementType.METADATA, + title: faker.lorem.words(), + creationDate: new Date(), + copyrightOwners: ['John Doe', 'Jane Doe'], + }; + const organizationOptions: CommonCartridgeOrganizationBuilderOptions = { + identifier: faker.string.uuid(), + title: faker.lorem.words(), + }; + const resourceProps: CommonCartridgeResourceProps = { + type: CommonCartridgeResourceType.WEB_CONTENT, + identifier: faker.string.uuid(), + title: faker.lorem.words(), + html: faker.lorem.paragraphs(), + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }; + const builder = new CommonCartridgeFileBuilder({ + version: CommonCartridgeVersion.V_1_1_0, + identifier: faker.string.uuid(), + }); + + builder + .addMetadata(metadataProps) + .addOrganization(organizationOptions) + .addResource(resourceProps) + .addSubOrganization(organizationOptions) + .addResource(resourceProps) + .addSubOrganization(organizationOptions) + .addResource(resourceProps); + + const archive = new AdmZip(await builder.build()); + + return { archive, metadataProps, organizationOptions, resourceProps }; + }; + + it('should have a imsmanifest.xml in archive root', async () => { + const { archive } = await setup(); + + const manifest = getFileContentAsString(archive, 'imsmanifest.xml'); + + expect(manifest).toBeDefined(); + }); + + it('should have included the resource in organization folder', async () => { + const { archive, organizationOptions, resourceProps } = await setup(); + + const resource = getFileContentAsString( + archive, + `${organizationOptions.identifier}/${resourceProps.identifier}.html` + ); + + expect(resource).toBeDefined(); + }); + + it('should have included the resource in sub-organization folder', async () => { + const { archive, organizationOptions, resourceProps } = await setup(); + + const resource = getFileContentAsString( + archive, + `${organizationOptions.identifier}/${organizationOptions.identifier}/${resourceProps.identifier}.html` + ); + + expect(resource).toBeDefined(); + }); + + it('should have included the resource in sub-sub-organization folder', async () => { + const { archive, organizationOptions, resourceProps } = await setup(); + + const resource = getFileContentAsString( + archive, + `${organizationOptions.identifier}/${organizationOptions.identifier}/${organizationOptions.identifier}/${resourceProps.identifier}.html` + ); + + expect(resource).toBeDefined(); + }); + }); + + describe('when metadata has not been provide', () => { + const sut = new CommonCartridgeFileBuilder({ + version: CommonCartridgeVersion.V_1_1_0, + identifier: faker.string.uuid(), + }); + + it('should throw an error', async () => { + await expect(sut.build()).rejects.toThrow('Metadata is not defined'); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.ts new file mode 100644 index 00000000000..0f605b5561d --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-file-builder.ts @@ -0,0 +1,80 @@ +import AdmZip from 'adm-zip'; +import { CommonCartridgeResourceType, CommonCartridgeVersion } from '../common-cartridge.enums'; +import { + CommonCartridgeElementFactory, + CommonCartridgeElementProps, +} from '../elements/common-cartridge-element-factory'; +import { CommonCartridgeElement, CommonCartridgeResource } from '../interfaces'; +import { CommonCartridgeResourceFactory } from '../resources/common-cartridge-resource-factory'; +import { OmitVersion } from '../utils'; +import { + CommonCartridgeOrganizationBuilder, + CommonCartridgeOrganizationBuilderOptions, +} from './common-cartridge-organization-builder'; + +export type CommonCartridgeFileBuilderProps = { + version: CommonCartridgeVersion; + identifier: string; +}; + +export class CommonCartridgeFileBuilder { + private readonly archive: AdmZip = new AdmZip(); + + private readonly organizationBuilders = new Array(); + + private readonly resources = new Array(); + + private metadata?: CommonCartridgeElement; + + constructor(private readonly props: CommonCartridgeFileBuilderProps) {} + + public addMetadata(props: CommonCartridgeElementProps): CommonCartridgeFileBuilder { + this.metadata = CommonCartridgeElementFactory.createElement({ + version: this.props.version, + ...props, + }); + + return this; + } + + public addOrganization( + props: OmitVersion + ): CommonCartridgeOrganizationBuilder { + const builder = new CommonCartridgeOrganizationBuilder( + { ...props, version: this.props.version }, + (resource: CommonCartridgeResource) => this.resources.push(resource) + ); + + this.organizationBuilders.push(builder); + + return builder; + } + + public async build(): Promise { + if (!this.metadata) { + throw new Error('Metadata is not defined'); + } + + const organizations = this.organizationBuilders.map((builder) => builder.build()); + const manifest = CommonCartridgeResourceFactory.createResource({ + type: CommonCartridgeResourceType.MANIFEST, + version: this.props.version, + identifier: this.props.identifier, + metadata: this.metadata, + organizations, + resources: this.resources, + }); + + for (const resources of this.resources) { + if (!resources.canInline()) { + this.archive.addFile(resources.getFilePath(), Buffer.from(resources.getFileContent())); + } + } + + this.archive.addFile(manifest.getFilePath(), Buffer.from(manifest.getFileContent())); + + const buffer = await this.archive.toBufferPromise(); + + return buffer; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-builder.spec.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-builder.spec.ts new file mode 100644 index 00000000000..ba9a36001ae --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-builder.spec.ts @@ -0,0 +1,87 @@ +import { faker } from '@faker-js/faker/locale/af_ZA'; +import { createCommonCartridgeWebContentResourcePropsV110 } from '../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeVersion } from '../common-cartridge.enums'; +import { CommonCartridgeElement, CommonCartridgeResource } from '../interfaces'; +import { + CommonCartridgeOrganizationBuilder, + CommonCartridgeOrganizationBuilderOptions, +} from './common-cartridge-organization-builder'; + +describe('CommonCartridgeOrganizationBuilder', () => { + describe('build', () => { + describe('when building a Common Cartridge organization with resources', () => { + const setup = () => { + const resources = new Array(); + + const organizationOptions: CommonCartridgeOrganizationBuilderOptions = { + identifier: faker.string.uuid(), + title: faker.lorem.words(), + }; + + const resourceProps = createCommonCartridgeWebContentResourcePropsV110(); + + const sut = new CommonCartridgeOrganizationBuilder( + { + ...organizationOptions, + version: CommonCartridgeVersion.V_1_1_0, + }, + (resource) => resources.push(resource) + ) + .addResource(resourceProps) + .addSubOrganization(organizationOptions) + .addResource(resourceProps) + .addSubOrganization(organizationOptions) + .addResource(resourceProps); + + return { sut, resources }; + }; + + it('should return a common cartridge element', () => { + const { sut, resources } = setup(); + + const element = sut.build(); + + expect(element).toBeInstanceOf(CommonCartridgeElement); + expect(resources.length).toBe(3); + }); + }); + + describe('when building a Common Cartridge organization with items', () => { + const setup = () => { + const resources = new Array(); + + const organizationOptions: CommonCartridgeOrganizationBuilderOptions = { + identifier: faker.string.uuid(), + title: faker.lorem.words(), + }; + + const resourceProps = createCommonCartridgeWebContentResourcePropsV110(); + + const sut = new CommonCartridgeOrganizationBuilder( + { + ...organizationOptions, + version: CommonCartridgeVersion.V_1_1_0, + }, + (resource) => resources.push(resource) + ) + .addResource(resourceProps) + .addSubOrganization(organizationOptions) + .addResource(resourceProps) + .addSubOrganization(organizationOptions) + .addResource(resourceProps) + .addResource(resourceProps); + + return { sut, resources }; + }; + + it('should return a common cartridge element', () => { + const { sut, resources } = setup(); + + const element = sut.build(); + + expect(element).toBeInstanceOf(CommonCartridgeElement); + expect(resources.length).toBe(4); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-builder.ts b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-builder.ts new file mode 100644 index 00000000000..20ff6d3e528 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/builders/common-cartridge-organization-builder.ts @@ -0,0 +1,82 @@ +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../common-cartridge.enums'; +import { CommonCartridgeElementFactory } from '../elements/common-cartridge-element-factory'; +import { CommonCartridgeElement } from '../interfaces/common-cartridge-element.interface'; +import { CommonCartridgeResource } from '../interfaces/common-cartridge-resource.interface'; +import { + CommonCartridgeResourceFactory, + CommonCartridgeResourceProps, +} from '../resources/common-cartridge-resource-factory'; +import { OmitVersionAndFolder } from '../utils'; + +export type CommonCartridgeOrganizationBuilderOptions = + OmitVersionAndFolder; + +type CommonCartridgeOrganizationBuilderOptionsInternal = { + version: CommonCartridgeVersion; + identifier: string; + title: string; + folder?: string; +}; + +export class CommonCartridgeOrganizationBuilder { + private readonly resources: CommonCartridgeResource[] = []; + + private readonly subOrganizations: CommonCartridgeOrganizationBuilder[] = []; + + constructor( + protected readonly options: CommonCartridgeOrganizationBuilderOptionsInternal, + private readonly addResourceToFileBuilder: (resource: CommonCartridgeResource) => void + ) {} + + private get folder(): string { + return this.options.folder ? `${this.options.folder}/${this.options.identifier}` : this.options.identifier; + } + + public addSubOrganization( + options: OmitVersionAndFolder + ): CommonCartridgeOrganizationBuilder { + const subOrganization = new CommonCartridgeOrganizationBuilder( + { ...options, version: this.options.version, folder: this.folder }, + (resource: CommonCartridgeResource) => this.addResourceToFileBuilder(resource) + ); + + this.subOrganizations.push(subOrganization); + + return subOrganization; + } + + public addResource(props: CommonCartridgeResourceProps): CommonCartridgeOrganizationBuilder { + const resource = CommonCartridgeResourceFactory.createResource({ + version: this.options.version, + folder: this.folder, + ...props, + }); + + this.resources.push(resource); + this.addResourceToFileBuilder(resource); + + return this; + } + + public build(): CommonCartridgeElement { + const organizationElement = CommonCartridgeElementFactory.createElement({ + type: CommonCartridgeElementType.ORGANIZATION, + version: this.options.version, + identifier: this.options.identifier, + title: this.options.title, + items: this.buildItems(), + }); + + return organizationElement; + } + + private buildItems(): (CommonCartridgeElement | CommonCartridgeResource)[] { + if (this.resources.length === 1 && this.subOrganizations.length === 0) { + return [...this.resources]; + } + + const items = [...this.resources, ...this.subOrganizations.map((subOrganization) => subOrganization.build())]; + + return items; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/common-cartridge.enums.ts b/apps/server/src/modules/common-cartridge/export/common-cartridge.enums.ts new file mode 100644 index 00000000000..8e474d7c3df --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/common-cartridge.enums.ts @@ -0,0 +1,28 @@ +export enum CommonCartridgeVersion { + V_1_0_0 = '1.0.0', + V_1_1_0 = '1.1.0', + V_1_2_0 = '1.2.0', + V_1_3_0 = '1.3.0', + V_1_4_0 = '1.4.0', +} + +export enum CommonCartridgeResourceType { + UNKNOWN = 'unknown', + MANIFEST = 'manifest', + WEB_CONTENT = 'webcontent', + WEB_LINK = 'weblink', +} + +export enum CommonCartridgeIntendedUseType { + ASSIGNMENT = 'assignment', + LESSON_PLAN = 'lessonplan', + SYLLABUS = 'syllabus', + UNSPECIFIED = 'unspecified', +} + +export enum CommonCartridgeElementType { + METADATA = 'metadata', + ORGANIZATION = 'organization', + RESOURCES_WRAPPER = 'resourceswrapper', + ORGANIZATIONS_WRAPPER = 'organizationswrapper', +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.spec.ts new file mode 100644 index 00000000000..b891d1aa49a --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.spec.ts @@ -0,0 +1,51 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { + createCommonCartridgeMetadataElementPropsV110, + createCommonCartridgeMetadataElementPropsV130, +} from '../../testing/common-cartridge-element-props.factory'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../common-cartridge.enums'; +import { CommonCartridgeElementFactory } from './common-cartridge-element-factory'; +import { CommonCartridgeMetadataElementPropsV110, CommonCartridgeMetadataElementV110 } from './v1.1.0'; +import { CommonCartridgeMetadataElementV130 } from './v1.3.0'; + +describe('CommonCartridgeElementFactory', () => { + describe('createElement', () => { + describe('when Common Cartridge versions is supported', () => { + const propsV110 = createCommonCartridgeMetadataElementPropsV110(); + const propsV130 = createCommonCartridgeMetadataElementPropsV130(); + + it('should return v1.1.0 element', () => { + const result = CommonCartridgeElementFactory.createElement(propsV110); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(CommonCartridgeMetadataElementV110); + }); + + it('should return v1.3.0 element', () => { + const result = CommonCartridgeElementFactory.createElement(propsV130); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(CommonCartridgeMetadataElementV130); + }); + }); + + describe('when versions is not supported', () => { + const notSupportedVersions = [ + CommonCartridgeVersion.V_1_0_0, + CommonCartridgeVersion.V_1_2_0, + CommonCartridgeVersion.V_1_4_0, + ]; + + it('should throw InternalServerErrorException', () => { + notSupportedVersions.forEach((version) => { + expect(() => + CommonCartridgeElementFactory.createElement({ + version, + type: CommonCartridgeElementType.METADATA, + } as CommonCartridgeMetadataElementPropsV110) + ).toThrow(InternalServerErrorException); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.ts b/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.ts new file mode 100644 index 00000000000..046f7033fde --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/common-cartridge-element-factory.ts @@ -0,0 +1,52 @@ +import { CommonCartridgeVersion } from '../common-cartridge.enums'; +import { CommonCartridgeElement } from '../interfaces'; +import { OmitVersionAndFolder, createVersionNotSupportedError } from '../utils'; +import { + CommonCartridgeElementFactoryV110, + CommonCartridgeMetadataElementPropsV110, + CommonCartridgeOrganizationElementPropsV110, + CommonCartridgeOrganizationsWrapperElementPropsV110, + CommonCartridgeResourcesWrapperElementPropsV110, +} from './v1.1.0'; +import { + CommonCartridgeElementFactoryV130, + CommonCartridgeMetadataElementPropsV130, + CommonCartridgeOrganizationElementPropsV130, + CommonCartridgeOrganizationsWrapperElementPropsV130, + CommonCartridgeResourcesWrapperElementPropsV130, +} from './v1.3.0'; + +export type CommonCartridgeElementProps = + | OmitVersionAndFolder + | OmitVersionAndFolder + | OmitVersionAndFolder + | OmitVersionAndFolder + | OmitVersionAndFolder + | OmitVersionAndFolder + | OmitVersionAndFolder + | OmitVersionAndFolder; + +type CommonCartridgeElementPropsInternal = + | CommonCartridgeMetadataElementPropsV110 + | CommonCartridgeOrganizationElementPropsV110 + | CommonCartridgeOrganizationsWrapperElementPropsV110 + | CommonCartridgeResourcesWrapperElementPropsV110 + | CommonCartridgeMetadataElementPropsV130 + | CommonCartridgeOrganizationElementPropsV130 + | CommonCartridgeOrganizationsWrapperElementPropsV130 + | CommonCartridgeResourcesWrapperElementPropsV130; + +export class CommonCartridgeElementFactory { + public static createElement(props: CommonCartridgeElementPropsInternal): CommonCartridgeElement { + const { version } = props; + + switch (version) { + case CommonCartridgeVersion.V_1_1_0: + return CommonCartridgeElementFactoryV110.createElement(props); + case CommonCartridgeVersion.V_1_3_0: + return CommonCartridgeElementFactoryV130.createElement(props); + default: + throw createVersionNotSupportedError(version); + } + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.spec.ts new file mode 100644 index 00000000000..3c43cf3a830 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.spec.ts @@ -0,0 +1,63 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { + createCommonCartridgeMetadataElementPropsV110, + createCommonCartridgeOrganizationElementPropsV110, + createCommonCartridgeOrganizationsWrapperElementPropsV110, + createCommonCartridgeResourcesWrapperElementPropsV110, +} from '../../../testing/common-cartridge-element-props.factory'; +import { CommonCartridgeElementType } from '../../common-cartridge.enums'; +import { CommonCartridgeElementFactoryV110 } from './common-cartridge-element-factory'; +import { CommonCartridgeMetadataElementV110 } from './common-cartridge-metadata-element'; +import { CommonCartridgeOrganizationElementV110 } from './common-cartridge-organization-element'; +import { CommonCartridgeOrganizationsWrapperElementV110 } from './common-cartridge-organizations-wrapper-element'; +import { CommonCartridgeResourcesWrapperElementV110 } from './common-cartridge-resources-wrapper-element'; + +describe('CommonCartridgeElementFactoryV110', () => { + describe('createElement', () => { + describe('when creating elements from props', () => { + it('should return metadata element', () => { + const props = createCommonCartridgeMetadataElementPropsV110(); + + const result = CommonCartridgeElementFactoryV110.createElement(props); + + expect(result).toBeInstanceOf(CommonCartridgeMetadataElementV110); + }); + + it('should return organization element', () => { + const props = createCommonCartridgeOrganizationElementPropsV110(); + + const result = CommonCartridgeElementFactoryV110.createElement(props); + + expect(result).toBeInstanceOf(CommonCartridgeOrganizationElementV110); + }); + + it('should return organization wrapper element', () => { + const props = createCommonCartridgeOrganizationsWrapperElementPropsV110(); + + const result = CommonCartridgeElementFactoryV110.createElement(props); + + expect(result).toBeInstanceOf(CommonCartridgeOrganizationsWrapperElementV110); + }); + + it('should return resources wrapper element', () => { + const props = createCommonCartridgeResourcesWrapperElementPropsV110(); + + const result = CommonCartridgeElementFactoryV110.createElement(props); + + expect(result).toBeInstanceOf(CommonCartridgeResourcesWrapperElementV110); + }); + }); + + describe('when element type is not supported', () => { + const notSupportedProps = createCommonCartridgeMetadataElementPropsV110(); + + notSupportedProps.type = 'not-supported' as CommonCartridgeElementType.METADATA; + + it('should throw error', () => { + expect(() => CommonCartridgeElementFactoryV110.createElement(notSupportedProps)).toThrow( + InternalServerErrorException + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.ts new file mode 100644 index 00000000000..88095cf218a --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-element-factory.ts @@ -0,0 +1,45 @@ +import { CommonCartridgeElementType } from '../../common-cartridge.enums'; +import { CommonCartridgeElement } from '../../interfaces'; +import { createElementTypeNotSupportedError } from '../../utils'; +import { + CommonCartridgeMetadataElementPropsV110, + CommonCartridgeMetadataElementV110, +} from './common-cartridge-metadata-element'; +import { + CommonCartridgeOrganizationElementPropsV110, + CommonCartridgeOrganizationElementV110, +} from './common-cartridge-organization-element'; +import { + CommonCartridgeOrganizationsWrapperElementPropsV110, + CommonCartridgeOrganizationsWrapperElementV110, +} from './common-cartridge-organizations-wrapper-element'; +import { + CommonCartridgeResourcesWrapperElementPropsV110, + CommonCartridgeResourcesWrapperElementV110, +} from './common-cartridge-resources-wrapper-element'; + +type CommonCartridgeElementPropsV110 = + | CommonCartridgeMetadataElementPropsV110 + | CommonCartridgeOrganizationElementPropsV110 + | CommonCartridgeOrganizationsWrapperElementPropsV110 + | CommonCartridgeResourcesWrapperElementPropsV110; + +export class CommonCartridgeElementFactoryV110 { + public static createElement(props: CommonCartridgeElementPropsV110): CommonCartridgeElement { + const { type } = props; + + switch (type) { + // AI next 8 lines + case CommonCartridgeElementType.METADATA: + return new CommonCartridgeMetadataElementV110(props); + case CommonCartridgeElementType.ORGANIZATION: + return new CommonCartridgeOrganizationElementV110(props); + case CommonCartridgeElementType.ORGANIZATIONS_WRAPPER: + return new CommonCartridgeOrganizationsWrapperElementV110(props); + case CommonCartridgeElementType.RESOURCES_WRAPPER: + return new CommonCartridgeResourcesWrapperElementV110(props); + default: + throw createElementTypeNotSupportedError(type); + } + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.spec.ts new file mode 100644 index 00000000000..cd12efa1590 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.spec.ts @@ -0,0 +1,71 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { createCommonCartridgeMetadataElementPropsV110 } from '../../../testing/common-cartridge-element-props.factory'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeMetadataElementV110 } from './common-cartridge-metadata-element'; + +describe('CommonCartridgeMetadataElementV110', () => { + describe('getSupportedVersion', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeMetadataElementPropsV110(); + const sut = new CommonCartridgeMetadataElementV110(props); + + return { sut, props }; + }; + + it('should return correct version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_1_0); + }); + }); + + describe('when using not supported Common Cartridge version', () => { + const notSupportedProps = createCommonCartridgeMetadataElementPropsV110(); + notSupportedProps.version = CommonCartridgeVersion.V_1_3_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeMetadataElementV110(notSupportedProps)).toThrow(InternalServerErrorException); + }); + }); + }); + + describe('getManifestXmlObject', () => { + describe('when using Common Cartridge version 1.1', () => { + const setup = () => { + const props = createCommonCartridgeMetadataElementPropsV110(); + const sut = new CommonCartridgeMetadataElementV110(props); + + return { sut, props }; + }; + + it('should return correct manifest xml object', () => { + const { sut, props } = setup(); + + const result = sut.getManifestXmlObject(); + + expect(result).toStrictEqual({ + schema: 'IMS Common Cartridge', + schemaversion: '1.1.0', + 'mnf:lom': { + 'mnf:general': { + 'mnf:title': { + 'mnf:string': props.title, + }, + }, + 'mnf:rights': { + 'mnf:copyrightAndOtherRestrictions': { + 'mnf:value': 'yes', + }, + 'mnf:description': { + 'mnf:string': `${props.creationDate.getFullYear()} ${props.copyrightOwners.join(', ')}`, + }, + }, + }, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.ts new file mode 100644 index 00000000000..c70cbd2c892 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-metadata-element.ts @@ -0,0 +1,42 @@ +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElement } from '../../interfaces'; + +export type CommonCartridgeMetadataElementPropsV110 = { + type: CommonCartridgeElementType.METADATA; + version: CommonCartridgeVersion; + title: string; + creationDate: Date; + copyrightOwners: string[]; +}; + +export class CommonCartridgeMetadataElementV110 extends CommonCartridgeElement { + constructor(private readonly props: CommonCartridgeMetadataElementPropsV110) { + super(props); + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_1_0; + } + + public getManifestXmlObject(): Record { + return { + schema: 'IMS Common Cartridge', + schemaversion: '1.1.0', + 'mnf:lom': { + 'mnf:general': { + 'mnf:title': { + 'mnf:string': this.props.title, + }, + }, + 'mnf:rights': { + 'mnf:copyrightAndOtherRestrictions': { + 'mnf:value': 'yes', + }, + 'mnf:description': { + 'mnf:string': `${this.props.creationDate.getFullYear()} ${this.props.copyrightOwners.join(', ')}`, + }, + }, + }, + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.spec.ts new file mode 100644 index 00000000000..8e1be0c920f --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.spec.ts @@ -0,0 +1,101 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { createCommonCartridgeOrganizationElementPropsV110 } from '../../../testing/common-cartridge-element-props.factory'; +import { createCommonCartridgeWeblinkResourcePropsV110 } from '../../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeResourceFactory } from '../../resources/common-cartridge-resource-factory'; +import { CommonCartridgeElementFactory } from '../common-cartridge-element-factory'; +import { CommonCartridgeOrganizationElementV110 } from './common-cartridge-organization-element'; + +describe('CommonCartridgeOrganizationElementV110', () => { + describe('getSupportedVersion', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeOrganizationElementPropsV110(); + const sut = new CommonCartridgeOrganizationElementV110(props); + + return { sut }; + }; + + it('should return correct version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_1_0); + }); + }); + + describe('when using not supported Common Cartridge version', () => { + const notSupportedProps = createCommonCartridgeOrganizationElementPropsV110(); + notSupportedProps.version = CommonCartridgeVersion.V_1_3_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeOrganizationElementV110(notSupportedProps)).toThrowError( + InternalServerErrorException + ); + }); + }); + }); + + describe('getManifestXmlObject', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const resourceProps = createCommonCartridgeWeblinkResourcePropsV110(); + + const subOrganization1Props = createCommonCartridgeOrganizationElementPropsV110( + CommonCartridgeResourceFactory.createResource(resourceProps) + ); + + const subOrganization2Props = createCommonCartridgeOrganizationElementPropsV110([ + CommonCartridgeResourceFactory.createResource(resourceProps), + ]); + + const organizationProps = createCommonCartridgeOrganizationElementPropsV110([ + CommonCartridgeElementFactory.createElement(subOrganization1Props), + CommonCartridgeElementFactory.createElement(subOrganization2Props), + ]); + + const sut = new CommonCartridgeOrganizationElementV110(organizationProps); + + return { sut, organizationProps, subOrganization1Props, subOrganization2Props, resourceProps }; + }; + + it('should return correct manifest xml object', () => { + const { sut, organizationProps, subOrganization1Props, subOrganization2Props, resourceProps } = setup(); + + const result = sut.getManifestXmlObject(); + + expect(result).toStrictEqual({ + $: { + identifier: organizationProps.identifier, + }, + title: organizationProps.title, + item: [ + { + $: { + identifier: subOrganization1Props.identifier, + identifierref: resourceProps.identifier, + }, + title: subOrganization1Props.title, + }, + { + $: { + identifier: subOrganization2Props.identifier, + }, + title: subOrganization2Props.title, + item: [ + { + $: { + identifier: expect.any(String), + identifierref: resourceProps.identifier, + }, + title: resourceProps.title, + }, + ], + }, + ], + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.ts new file mode 100644 index 00000000000..ff304d6ea08 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organization-element.ts @@ -0,0 +1,53 @@ +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElement, CommonCartridgeResource } from '../../interfaces'; +import { createIdentifier } from '../../utils'; + +export type CommonCartridgeOrganizationElementPropsV110 = { + type: CommonCartridgeElementType.ORGANIZATION; + version: CommonCartridgeVersion; + identifier: string; + title: string; + items: CommonCartridgeResource | Array; +}; + +export class CommonCartridgeOrganizationElementV110 extends CommonCartridgeElement { + constructor(protected readonly props: CommonCartridgeOrganizationElementPropsV110) { + super(props); + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_1_0; + } + + public getManifestXmlObject(): Record { + if (this.props.items instanceof CommonCartridgeResource) { + return { + $: { + identifier: this.identifier, + identifierref: this.props.items.identifier, + }, + title: this.title, + }; + } + + return { + $: { + identifier: this.identifier, + }, + title: this.title, + item: this.props.items.map((item) => { + if (item instanceof CommonCartridgeResource) { + return { + $: { + identifier: createIdentifier(), + identifierref: item.identifier, + }, + title: item.title, + }; + } + + return item.getManifestXmlObject(); + }), + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.spec.ts new file mode 100644 index 00000000000..fb5ae465a28 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.spec.ts @@ -0,0 +1,87 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { + createCommonCartridgeOrganizationElementPropsV110, + createCommonCartridgeOrganizationsWrapperElementPropsV110, +} from '../../../testing/common-cartridge-element-props.factory'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementFactory } from '../common-cartridge-element-factory'; +import { CommonCartridgeOrganizationsWrapperElementV110 } from './common-cartridge-organizations-wrapper-element'; + +describe('CommonCartridgeOrganizationsWrapperElementV110', () => { + describe('getSupportedVersion', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeOrganizationsWrapperElementPropsV110(); + const sut = new CommonCartridgeOrganizationsWrapperElementV110(props); + + return { sut, props }; + }; + + it('should return correct version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_1_0); + }); + }); + + describe('when using not supported Common Cartridge version', () => { + const notSupportedProps = createCommonCartridgeOrganizationsWrapperElementPropsV110(); + notSupportedProps.version = CommonCartridgeVersion.V_1_3_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeOrganizationsWrapperElementV110(notSupportedProps)).toThrowError( + InternalServerErrorException + ); + }); + }); + }); + + describe('getManifestXmlObject', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const organizationProps = createCommonCartridgeOrganizationElementPropsV110(); + const props = createCommonCartridgeOrganizationsWrapperElementPropsV110([ + CommonCartridgeElementFactory.createElement(organizationProps), + ]); + const sut = new CommonCartridgeOrganizationsWrapperElementV110(props); + + return { sut, organizationProps }; + }; + + it('should return correct manifest xml object', () => { + const { sut, organizationProps } = setup(); + + const result = sut.getManifestXmlObject(); + + expect(result).toStrictEqual({ + organization: [ + { + $: { + identifier: 'org-1', + structure: 'rooted-hierarchy', + }, + item: [ + { + $: { + identifier: 'LearningModules', + }, + item: [ + { + $: { + identifier: organizationProps.identifier, + }, + title: organizationProps.title, + item: [], + }, + ], + }, + ], + }, + ], + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.ts new file mode 100644 index 00000000000..9d1c44ec85d --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-organizations-wrapper-element.ts @@ -0,0 +1,39 @@ +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElement } from '../../interfaces'; + +export type CommonCartridgeOrganizationsWrapperElementPropsV110 = { + type: CommonCartridgeElementType.ORGANIZATIONS_WRAPPER; + version: CommonCartridgeVersion; + items: CommonCartridgeElement[]; +}; + +export class CommonCartridgeOrganizationsWrapperElementV110 extends CommonCartridgeElement { + constructor(private readonly props: CommonCartridgeOrganizationsWrapperElementPropsV110) { + super(props); + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_1_0; + } + + public getManifestXmlObject(): Record { + return { + organization: [ + { + $: { + identifier: 'org-1', + structure: 'rooted-hierarchy', + }, + item: [ + { + $: { + identifier: 'LearningModules', + }, + item: this.props.items.map((items) => items.getManifestXmlObject()), + }, + ], + }, + ], + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.spec.ts new file mode 100644 index 00000000000..0106eefbee5 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.spec.ts @@ -0,0 +1,78 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { createCommonCartridgeResourcesWrapperElementPropsV110 } from '../../../testing/common-cartridge-element-props.factory'; +import { createCommonCartridgeWeblinkResourcePropsV110 } from '../../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeResourceFactory } from '../../resources/common-cartridge-resource-factory'; +import { CommonCartridgeResourcesWrapperElementV110 } from './common-cartridge-resources-wrapper-element'; + +describe('CommonCartridgeResourcesWrapperElementV110', () => { + describe('getSupportedVersion', () => { + describe('when using common cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeResourcesWrapperElementPropsV110(); + const sut = new CommonCartridgeResourcesWrapperElementV110(props); + + return { sut }; + }; + + it('should return correct version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_1_0); + }); + }); + + describe('when using not supported common cartridge version', () => { + const notSupportedProps = createCommonCartridgeResourcesWrapperElementPropsV110(); + notSupportedProps.version = CommonCartridgeVersion.V_1_3_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeResourcesWrapperElementV110(notSupportedProps)).toThrow( + InternalServerErrorException + ); + }); + }); + }); + + describe('getManifestXmlObject', () => { + describe('when using common cartridge version 1.1.0', () => { + const setup = () => { + const resourceProps = createCommonCartridgeWeblinkResourcePropsV110(); + const props = createCommonCartridgeResourcesWrapperElementPropsV110([ + CommonCartridgeResourceFactory.createResource(resourceProps), + ]); + const sut = new CommonCartridgeResourcesWrapperElementV110(props); + + return { sut, resourceProps }; + }; + + it('should return correct manifest xml object', () => { + const { sut, resourceProps } = setup(); + + const result = sut.getManifestXmlObject(); + + expect(result).toStrictEqual({ + resources: [ + { + resource: [ + { + $: { + identifier: resourceProps.identifier, + type: expect.any(String), + }, + file: { + $: { + href: expect.any(String), + }, + }, + }, + ], + }, + ], + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.ts new file mode 100644 index 00000000000..4048787732a --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/common-cartridge-resources-wrapper-element.ts @@ -0,0 +1,28 @@ +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElement } from '../../interfaces'; + +export type CommonCartridgeResourcesWrapperElementPropsV110 = { + type: CommonCartridgeElementType.RESOURCES_WRAPPER; + version: CommonCartridgeVersion; + items: CommonCartridgeElement[]; +}; + +export class CommonCartridgeResourcesWrapperElementV110 extends CommonCartridgeElement { + constructor(private readonly props: CommonCartridgeResourcesWrapperElementPropsV110) { + super(props); + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_1_0; + } + + public getManifestXmlObject(): Record { + return { + resources: [ + { + resource: this.props.items.map((items) => items.getManifestXmlObject()), + }, + ], + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/index.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/index.ts new file mode 100644 index 00000000000..4fa057e5063 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.1.0/index.ts @@ -0,0 +1,8 @@ +export { CommonCartridgeElementFactoryV110 } from './common-cartridge-element-factory'; +export { + CommonCartridgeMetadataElementPropsV110, + CommonCartridgeMetadataElementV110, +} from './common-cartridge-metadata-element'; +export { CommonCartridgeOrganizationElementPropsV110 } from './common-cartridge-organization-element'; +export { CommonCartridgeOrganizationsWrapperElementPropsV110 } from './common-cartridge-organizations-wrapper-element'; +export { CommonCartridgeResourcesWrapperElementPropsV110 } from './common-cartridge-resources-wrapper-element'; diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.spec.ts new file mode 100644 index 00000000000..7fe5e1f0cf5 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.spec.ts @@ -0,0 +1,62 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { + createCommonCartridgeMetadataElementPropsV130, + createCommonCartridgeOrganizationElementPropsV130, + createCommonCartridgeOrganizationsWrapperElementPropsV130, + createCommonCartridgeResourcesWrapperElementPropsV130, +} from '../../../testing/common-cartridge-element-props.factory'; +import { CommonCartridgeElementType } from '../../common-cartridge.enums'; +import { CommonCartridgeElementFactoryV130 } from './common-cartridge-element-factory'; +import { CommonCartridgeMetadataElementV130 } from './common-cartridge-metadata-element'; +import { CommonCartridgeOrganizationElementV130 } from './common-cartridge-organization-element'; +import { CommonCartridgeOrganizationsWrapperElementV130 } from './common-cartridge-organizations-wrapper-element'; +import { CommonCartridgeResourcesWrapperElementV130 } from './common-cartridge-resources-wrapper-element'; + +describe('CommonCartridgeElementFactoryV130', () => { + describe('createElement', () => { + describe('when creating elements from props', () => { + it('should return metadata element', () => { + const props = createCommonCartridgeMetadataElementPropsV130(); + + const result = CommonCartridgeElementFactoryV130.createElement(props); + + expect(result).toBeInstanceOf(CommonCartridgeMetadataElementV130); + }); + + it('should return organization element', () => { + const props = createCommonCartridgeOrganizationElementPropsV130(); + + const result = CommonCartridgeElementFactoryV130.createElement(props); + + expect(result).toBeInstanceOf(CommonCartridgeOrganizationElementV130); + }); + + it('should return organization wrapper element', () => { + const props = createCommonCartridgeOrganizationsWrapperElementPropsV130(); + + const result = CommonCartridgeElementFactoryV130.createElement(props); + + expect(result).toBeInstanceOf(CommonCartridgeOrganizationsWrapperElementV130); + }); + + it('should return resources wrapper element', () => { + const props = createCommonCartridgeResourcesWrapperElementPropsV130(); + + const result = CommonCartridgeElementFactoryV130.createElement(props); + + expect(result).toBeInstanceOf(CommonCartridgeResourcesWrapperElementV130); + }); + }); + + describe('when element type is not supported', () => { + const notSupportedProps = createCommonCartridgeOrganizationsWrapperElementPropsV130(); + notSupportedProps.type = 'not-supported' as CommonCartridgeElementType.ORGANIZATIONS_WRAPPER; + + it('should throw error', () => { + expect(() => CommonCartridgeElementFactoryV130.createElement(notSupportedProps)).toThrow( + InternalServerErrorException + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.ts new file mode 100644 index 00000000000..6a1216e405a --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-element-factory.ts @@ -0,0 +1,45 @@ +import { CommonCartridgeElementType } from '../../common-cartridge.enums'; +import { CommonCartridgeElement } from '../../interfaces'; +import { createElementTypeNotSupportedError } from '../../utils'; +import { + CommonCartridgeMetadataElementPropsV130, + CommonCartridgeMetadataElementV130, +} from './common-cartridge-metadata-element'; +import { + CommonCartridgeOrganizationElementPropsV130, + CommonCartridgeOrganizationElementV130, +} from './common-cartridge-organization-element'; +import { + CommonCartridgeOrganizationsWrapperElementPropsV130, + CommonCartridgeOrganizationsWrapperElementV130, +} from './common-cartridge-organizations-wrapper-element'; +import { + CommonCartridgeResourcesWrapperElementPropsV130, + CommonCartridgeResourcesWrapperElementV130, +} from './common-cartridge-resources-wrapper-element'; + +type CommonCartridgeElementProps130 = + | CommonCartridgeMetadataElementPropsV130 + | CommonCartridgeOrganizationElementPropsV130 + | CommonCartridgeOrganizationsWrapperElementPropsV130 + | CommonCartridgeResourcesWrapperElementPropsV130; + +export class CommonCartridgeElementFactoryV130 { + public static createElement(props: CommonCartridgeElementProps130): CommonCartridgeElement { + const { type } = props; + + switch (type) { + // AI next 8 lines + case CommonCartridgeElementType.METADATA: + return new CommonCartridgeMetadataElementV130(props); + case CommonCartridgeElementType.ORGANIZATION: + return new CommonCartridgeOrganizationElementV130(props); + case CommonCartridgeElementType.ORGANIZATIONS_WRAPPER: + return new CommonCartridgeOrganizationsWrapperElementV130(props); + case CommonCartridgeElementType.RESOURCES_WRAPPER: + return new CommonCartridgeResourcesWrapperElementV130(props); + default: + throw createElementTypeNotSupportedError(type); + } + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.spec.ts new file mode 100644 index 00000000000..698b3efea04 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.spec.ts @@ -0,0 +1,71 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { createCommonCartridgeMetadataElementPropsV130 } from '../../../testing/common-cartridge-element-props.factory'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeMetadataElementV130 } from './common-cartridge-metadata-element'; + +describe('CommonCartridgeMetadataElementV130', () => { + describe('getSupportedVersion', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeMetadataElementPropsV130(); + const sut = new CommonCartridgeMetadataElementV130(props); + + return { sut, props }; + }; + + it('should return correct version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_3_0); + }); + }); + + describe('when using not supported Common Cartridge version', () => { + const notSupportedProps = createCommonCartridgeMetadataElementPropsV130(); + notSupportedProps.version = CommonCartridgeVersion.V_1_1_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeMetadataElementV130(notSupportedProps)).toThrow(InternalServerErrorException); + }); + }); + }); + + describe('getManifestXmlObject', () => { + describe('when using common cartridge version 1.3', () => { + const setup = () => { + const props = createCommonCartridgeMetadataElementPropsV130(); + const sut = new CommonCartridgeMetadataElementV130(props); + + return { sut, props }; + }; + + it('should return correct manifest xml object', () => { + const { sut, props } = setup(); + + const result = sut.getManifestXmlObject(); + + expect(result).toStrictEqual({ + schema: 'IMS Common Cartridge', + schemaversion: '1.3.0', + 'mnf:lom': { + 'mnf:general': { + 'mnf:title': { + 'mnf:string': props.title, + }, + }, + 'mnf:rights': { + 'mnf:copyrightAndOtherRestrictions': { + 'mnf:value': 'yes', + }, + 'mnf:description': { + 'mnf:string': `${props.creationDate.getFullYear()} ${props.copyrightOwners.join(', ')}`, + }, + }, + }, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.ts new file mode 100644 index 00000000000..a6477783e57 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-metadata-element.ts @@ -0,0 +1,42 @@ +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElement } from '../../interfaces'; + +export type CommonCartridgeMetadataElementPropsV130 = { + type: CommonCartridgeElementType.METADATA; + version: CommonCartridgeVersion; + title: string; + creationDate: Date; + copyrightOwners: string[]; +}; + +export class CommonCartridgeMetadataElementV130 extends CommonCartridgeElement { + constructor(private readonly props: CommonCartridgeMetadataElementPropsV130) { + super(props); + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_3_0; + } + + public getManifestXmlObject(): Record { + return { + schema: 'IMS Common Cartridge', + schemaversion: '1.3.0', + 'mnf:lom': { + 'mnf:general': { + 'mnf:title': { + 'mnf:string': this.props.title, + }, + }, + 'mnf:rights': { + 'mnf:copyrightAndOtherRestrictions': { + 'mnf:value': 'yes', + }, + 'mnf:description': { + 'mnf:string': `${this.props.creationDate.getFullYear()} ${this.props.copyrightOwners.join(', ')}`, + }, + }, + }, + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.spec.ts new file mode 100644 index 00000000000..73cb9b1af5a --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.spec.ts @@ -0,0 +1,101 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { createCommonCartridgeOrganizationElementPropsV130 } from '../../../testing/common-cartridge-element-props.factory'; +import { createCommonCartridgeWeblinkResourcePropsV130 } from '../../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeResourceFactory } from '../../resources/common-cartridge-resource-factory'; +import { CommonCartridgeElementFactory } from '../common-cartridge-element-factory'; +import { CommonCartridgeOrganizationElementV130 } from './common-cartridge-organization-element'; + +describe('CommonCartridgeOrganizationElementV130', () => { + describe('getSupportedVersion', () => { + describe('when using common cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeOrganizationElementPropsV130(); + const sut = new CommonCartridgeOrganizationElementV130(props); + + return { sut }; + }; + + it('should return correct version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_3_0); + }); + }); + + describe('when using not supported common cartridge version', () => { + const notSupportedProps = createCommonCartridgeOrganizationElementPropsV130(); + notSupportedProps.version = CommonCartridgeVersion.V_1_1_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeOrganizationElementV130(notSupportedProps)).toThrow( + InternalServerErrorException + ); + }); + }); + }); + + describe('getManifestXmlObject', () => { + describe('when using common cartridge version 1.3.0', () => { + const setup = () => { + const resourceProps = createCommonCartridgeWeblinkResourcePropsV130(); + + const subOrganization1Props = createCommonCartridgeOrganizationElementPropsV130( + CommonCartridgeResourceFactory.createResource(resourceProps) + ); + + const subOrganization2Props = createCommonCartridgeOrganizationElementPropsV130([ + CommonCartridgeResourceFactory.createResource(resourceProps), + ]); + + const organizationProps = createCommonCartridgeOrganizationElementPropsV130([ + CommonCartridgeElementFactory.createElement(subOrganization1Props), + CommonCartridgeElementFactory.createElement(subOrganization2Props), + ]); + + const sut = new CommonCartridgeOrganizationElementV130(organizationProps); + + return { sut, organizationProps, subOrganization1Props, subOrganization2Props, resourceProps }; + }; + + it('should return correct manifest xml object', () => { + const { sut, organizationProps, subOrganization1Props, subOrganization2Props, resourceProps } = setup(); + + const result = sut.getManifestXmlObject(); + + expect(result).toStrictEqual({ + $: { + identifier: organizationProps.identifier, + }, + title: organizationProps.title, + item: [ + { + $: { + identifier: subOrganization1Props.identifier, + identifierref: resourceProps.identifier, + }, + title: subOrganization1Props.title, + }, + { + $: { + identifier: subOrganization2Props.identifier, + }, + title: subOrganization2Props.title, + item: [ + { + $: { + identifier: expect.any(String), + identifierref: resourceProps.identifier, + }, + title: resourceProps.title, + }, + ], + }, + ], + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.ts new file mode 100644 index 00000000000..14696edfd95 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organization-element.ts @@ -0,0 +1,53 @@ +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElement, CommonCartridgeResource } from '../../interfaces'; +import { createIdentifier } from '../../utils'; + +export type CommonCartridgeOrganizationElementPropsV130 = { + type: CommonCartridgeElementType.ORGANIZATION; + version: CommonCartridgeVersion; + identifier: string; + title: string; + items: CommonCartridgeResource | Array; +}; + +export class CommonCartridgeOrganizationElementV130 extends CommonCartridgeElement { + constructor(private readonly props: CommonCartridgeOrganizationElementPropsV130) { + super(props); + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_3_0; + } + + public getManifestXmlObject(): Record { + if (this.props.items instanceof CommonCartridgeResource) { + return { + $: { + identifier: this.identifier, + identifierref: this.props.items.identifier, + }, + title: this.title, + }; + } + + return { + $: { + identifier: this.identifier, + }, + title: this.title, + item: this.props.items.map((item) => { + if (item instanceof CommonCartridgeResource) { + return { + $: { + identifier: createIdentifier(), + identifierref: item.identifier, + }, + title: item.title, + }; + } + + return item.getManifestXmlObject(); + }), + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.spec.ts new file mode 100644 index 00000000000..47c68046764 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.spec.ts @@ -0,0 +1,87 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { + createCommonCartridgeOrganizationElementPropsV130, + createCommonCartridgeOrganizationsWrapperElementPropsV130, +} from '../../../testing/common-cartridge-element-props.factory'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElementFactory } from '../common-cartridge-element-factory'; +import { CommonCartridgeOrganizationsWrapperElementV130 } from './common-cartridge-organizations-wrapper-element'; + +describe('CommonCartridgeOrganizationsWrapperElementV130', () => { + describe('getSupportedVersion', () => { + describe('when using common cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeOrganizationsWrapperElementPropsV130(); + const sut = new CommonCartridgeOrganizationsWrapperElementV130(props); + + return { sut }; + }; + + it('should return correct version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_3_0); + }); + }); + + describe('when using not supported common cartridge version', () => { + const notSupportedProps = createCommonCartridgeOrganizationsWrapperElementPropsV130(); + notSupportedProps.version = CommonCartridgeVersion.V_1_1_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeOrganizationsWrapperElementV130(notSupportedProps)).toThrow( + InternalServerErrorException + ); + }); + }); + }); + + describe('getManifestXmlObject', () => { + describe('when using common cartridge version 1.3.0', () => { + const setup = () => { + const organizationProps = createCommonCartridgeOrganizationElementPropsV130(); + const props = createCommonCartridgeOrganizationsWrapperElementPropsV130([ + CommonCartridgeElementFactory.createElement(organizationProps), + ]); + const sut = new CommonCartridgeOrganizationsWrapperElementV130(props); + + return { sut, organizationProps }; + }; + + it('should return correct manifest xml object', () => { + const { sut, organizationProps } = setup(); + + const result = sut.getManifestXmlObject(); + + expect(result).toStrictEqual({ + organization: [ + { + $: { + identifier: 'org-1', + structure: 'rooted-hierarchy', + }, + item: [ + { + $: { + identifier: 'LearningModules', + }, + item: [ + { + $: { + identifier: organizationProps.identifier, + }, + title: organizationProps.title, + item: [], + }, + ], + }, + ], + }, + ], + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.ts new file mode 100644 index 00000000000..3c00f4844d2 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-organizations-wrapper-element.ts @@ -0,0 +1,39 @@ +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElement } from '../../interfaces'; + +export type CommonCartridgeOrganizationsWrapperElementPropsV130 = { + type: CommonCartridgeElementType.ORGANIZATIONS_WRAPPER; + version: CommonCartridgeVersion; + items: CommonCartridgeElement[]; +}; + +export class CommonCartridgeOrganizationsWrapperElementV130 extends CommonCartridgeElement { + constructor(private readonly props: CommonCartridgeOrganizationsWrapperElementPropsV130) { + super(props); + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_3_0; + } + + public getManifestXmlObject(): Record { + return { + organization: [ + { + $: { + identifier: 'org-1', + structure: 'rooted-hierarchy', + }, + item: [ + { + $: { + identifier: 'LearningModules', + }, + item: this.props.items.map((items) => items.getManifestXmlObject()), + }, + ], + }, + ], + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.spec.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.spec.ts new file mode 100644 index 00000000000..2d71adcc144 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.spec.ts @@ -0,0 +1,78 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { createCommonCartridgeResourcesWrapperElementPropsV130 } from '../../../testing/common-cartridge-element-props.factory'; +import { createCommonCartridgeWeblinkResourcePropsV130 } from '../../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeResourceFactory } from '../../resources/common-cartridge-resource-factory'; +import { CommonCartridgeResourcesWrapperElementV130 } from './common-cartridge-resources-wrapper-element'; + +describe('CommonCartridgeResourcesWrapperElementV130', () => { + describe('getSupportedVersion', () => { + describe('when using common cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeResourcesWrapperElementPropsV130(); + const sut = new CommonCartridgeResourcesWrapperElementV130(props); + + return { sut }; + }; + + it('should return correct version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_3_0); + }); + }); + + describe('when using not supported common cartridge version', () => { + const notSupportedProps = createCommonCartridgeResourcesWrapperElementPropsV130(); + notSupportedProps.version = CommonCartridgeVersion.V_1_1_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeResourcesWrapperElementV130(notSupportedProps)).toThrowError( + InternalServerErrorException + ); + }); + }); + }); + + describe('getManifestXmlObject', () => { + describe('when using common cartridge version 1.3.0', () => { + const setup = () => { + const weblinkResourceProps = createCommonCartridgeWeblinkResourcePropsV130(); + const props = createCommonCartridgeResourcesWrapperElementPropsV130([ + CommonCartridgeResourceFactory.createResource(weblinkResourceProps), + ]); + const sut = new CommonCartridgeResourcesWrapperElementV130(props); + + return { sut, weblinkResourceProps }; + }; + + it('should return correct manifest xml object', () => { + const { sut, weblinkResourceProps } = setup(); + + const result = sut.getManifestXmlObject(); + + expect(result).toStrictEqual({ + resources: [ + { + resource: [ + { + $: { + identifier: weblinkResourceProps.identifier, + type: expect.any(String), + }, + file: { + $: { + href: expect.any(String), + }, + }, + }, + ], + }, + ], + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.ts new file mode 100644 index 00000000000..aa11d0f457a --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/common-cartridge-resources-wrapper-element.ts @@ -0,0 +1,28 @@ +import { CommonCartridgeElementType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeElement } from '../../interfaces'; + +export type CommonCartridgeResourcesWrapperElementPropsV130 = { + type: CommonCartridgeElementType.RESOURCES_WRAPPER; + version: CommonCartridgeVersion; + items: CommonCartridgeElement[]; +}; + +export class CommonCartridgeResourcesWrapperElementV130 extends CommonCartridgeElement { + constructor(private readonly props: CommonCartridgeResourcesWrapperElementPropsV130) { + super(props); + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_3_0; + } + + public getManifestXmlObject(): Record { + return { + resources: [ + { + resource: this.props.items.map((items) => items.getManifestXmlObject()), + }, + ], + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/index.ts b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/index.ts new file mode 100644 index 00000000000..1134c126384 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/elements/v1.3.0/index.ts @@ -0,0 +1,14 @@ +export { CommonCartridgeElementFactoryV130 } from './common-cartridge-element-factory'; +export { + CommonCartridgeMetadataElementPropsV130, + CommonCartridgeMetadataElementV130, +} from './common-cartridge-metadata-element'; +export { CommonCartridgeOrganizationElementPropsV130 } from './common-cartridge-organization-element'; +export { + CommonCartridgeOrganizationsWrapperElementPropsV130, + CommonCartridgeOrganizationsWrapperElementV130, +} from './common-cartridge-organizations-wrapper-element'; +export { + CommonCartridgeResourcesWrapperElementPropsV130, + CommonCartridgeResourcesWrapperElementV130, +} from './common-cartridge-resources-wrapper-element'; diff --git a/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-element.interface.ts b/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-element.interface.ts new file mode 100644 index 00000000000..869aa6f576f --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-element.interface.ts @@ -0,0 +1,43 @@ +import { CommonCartridgeVersion } from '../common-cartridge.enums'; +import { createVersionNotSupportedError } from '../utils'; + +type CommonCartridgeElementProps = { + version: CommonCartridgeVersion; + identifier?: string; + title?: string; +}; + +/** + * Every element which should be listed in the Common Cartridge manifest must implement this interface. + */ +export abstract class CommonCartridgeElement { + protected constructor(private readonly baseProps: CommonCartridgeElementProps) { + this.checkVersion(baseProps.version); + } + + public get identifier(): string | undefined { + return this.baseProps.identifier; + } + + public get title(): string | undefined { + return this.baseProps.title; + } + + /** + * Every element must know which versions it supports. + * @returns The supported versions for this element. + */ + abstract getSupportedVersion(): CommonCartridgeVersion; + + /** + * This method is used to build the imsmanifest.xml file. + * @returns The XML object representation for the imsmanifest.xml file. + */ + abstract getManifestXmlObject(): Record; + + private checkVersion(target: CommonCartridgeVersion): void { + if (this.getSupportedVersion() !== target) { + throw createVersionNotSupportedError(target); + } + } +} diff --git a/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-resource.interface.ts b/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-resource.interface.ts new file mode 100644 index 00000000000..dfa3adbc8b4 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/interfaces/common-cartridge-resource.interface.ts @@ -0,0 +1,24 @@ +import { CommonCartridgeElement } from './common-cartridge-element.interface'; + +/** + * Every resource which should be added to the Common Cartridge archive must implement this interface. + */ +export abstract class CommonCartridgeResource extends CommonCartridgeElement { + /** + * In later Common Cartridge versions, resources can be inlined in the imsmanifest.xml file. + * @returns true if the resource can be inlined, otherwise false. + */ + abstract canInline(): boolean; + + /** + * This method is used to determine the path of the resource in the Common Cartridge archive. + * @returns The path of the resource in the Common Cartridge archive. + */ + abstract getFilePath(): string; + + /** + * This method is used to get the content of the resource. + * @returns The content of the resource. + */ + abstract getFileContent(): string; +} diff --git a/apps/server/src/modules/common-cartridge/export/interfaces/index.ts b/apps/server/src/modules/common-cartridge/export/interfaces/index.ts new file mode 100644 index 00000000000..aaefb39018c --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/interfaces/index.ts @@ -0,0 +1,2 @@ +export { CommonCartridgeElement } from './common-cartridge-element.interface'; +export { CommonCartridgeResource } from './common-cartridge-resource.interface'; diff --git a/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.spec.ts new file mode 100644 index 00000000000..9bd274d4810 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.spec.ts @@ -0,0 +1,50 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { + createCommonCartridgeWebContentResourcePropsV110, + createCommonCartridgeWebContentResourcePropsV130, +} from '../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeResourceType, CommonCartridgeVersion } from '../common-cartridge.enums'; +import { CommonCartridgeResourceFactory } from './common-cartridge-resource-factory'; +import { CommonCartridgeWebContentResourcePropsV110, CommonCartridgeWebContentResourceV110 } from './v1.1.0'; +import { CommonCartridgeWebContentResourceV130 } from './v1.3.0'; + +describe('CommonCartridgeResourceVersion', () => { + describe('createResource', () => { + describe('when Common Cartridge version is supported', () => { + it('should return v1.1.0 resource', () => { + const props = createCommonCartridgeWebContentResourcePropsV110(); + + const result = CommonCartridgeResourceFactory.createResource(props); + + expect(result).toBeInstanceOf(CommonCartridgeWebContentResourceV110); + }); + + it('should return v1.3.0 resource', () => { + const props = createCommonCartridgeWebContentResourcePropsV130(); + + const result = CommonCartridgeResourceFactory.createResource(props); + + expect(result).toBeInstanceOf(CommonCartridgeWebContentResourceV130); + }); + }); + + describe('when versions is not supported', () => { + const notSupportedVersions = [ + CommonCartridgeVersion.V_1_0_0, + CommonCartridgeVersion.V_1_2_0, + CommonCartridgeVersion.V_1_4_0, + ]; + + it('should throw InternalServerErrorException', () => { + notSupportedVersions.forEach((version) => { + expect(() => + CommonCartridgeResourceFactory.createResource({ + version, + type: CommonCartridgeResourceType.WEB_CONTENT, + } as CommonCartridgeWebContentResourcePropsV110) + ).toThrow(InternalServerErrorException); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.ts b/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.ts new file mode 100644 index 00000000000..673672c129e --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/common-cartridge-resource-factory.ts @@ -0,0 +1,44 @@ +import { CommonCartridgeVersion } from '../common-cartridge.enums'; +import { CommonCartridgeResource } from '../interfaces'; +import { OmitVersionAndFolder, createVersionNotSupportedError } from '../utils'; +import { + CommonCartridgeManifestResourcePropsV110, + CommonCartridgeResourceFactoryV110, + CommonCartridgeWebContentResourcePropsV110, + CommonCartridgeWebLinkResourcePropsV110, +} from './v1.1.0'; +import { + CommonCartridgeManifestResourcePropsV130, + CommonCartridgeResourceFactoryV130, + CommonCartridgeWebContentResourcePropsV130, + CommonCartridgeWebLinkResourcePropsV130, +} from './v1.3.0'; + +export type CommonCartridgeResourceProps = + | OmitVersionAndFolder + | OmitVersionAndFolder + | OmitVersionAndFolder + | OmitVersionAndFolder; + +type CommonCartridgeResourcePropsInternal = + | CommonCartridgeManifestResourcePropsV110 + | CommonCartridgeWebContentResourcePropsV110 + | CommonCartridgeWebLinkResourcePropsV110 + | CommonCartridgeManifestResourcePropsV130 + | CommonCartridgeWebContentResourcePropsV130 + | CommonCartridgeWebLinkResourcePropsV130; + +export class CommonCartridgeResourceFactory { + public static createResource(props: CommonCartridgeResourcePropsInternal): CommonCartridgeResource { + const { version } = props; + + switch (version) { + case CommonCartridgeVersion.V_1_1_0: + return CommonCartridgeResourceFactoryV110.createResource(props); + case CommonCartridgeVersion.V_1_3_0: + return CommonCartridgeResourceFactoryV130.createResource(props); + default: + throw createVersionNotSupportedError(version); + } + } +} diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.spec.ts new file mode 100644 index 00000000000..aa63bb9237f --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.spec.ts @@ -0,0 +1,147 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { readFile } from 'fs/promises'; +import { createCommonCartridgeManifestResourcePropsV110 } from '../../../testing/common-cartridge-resource-props.factory'; +import { + CommonCartridgeElementType, + CommonCartridgeIntendedUseType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '../../common-cartridge.enums'; +import { CommonCartridgeElementFactory } from '../../elements/common-cartridge-element-factory'; +import { CommonCartridgeResourceFactory } from '../common-cartridge-resource-factory'; +import { CommonCartridgeManifestResourceV110 } from './common-cartridge-manifest-resource'; + +describe('CommonCartridgeManifestResourceV110', () => { + describe('canInline', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeManifestResourcePropsV110(); + const sut = new CommonCartridgeManifestResourceV110(props); + + return { sut }; + }; + + it('should return false', () => { + const { sut } = setup(); + + const result = sut.canInline(); + + expect(result).toBe(false); + }); + }); + }); + + describe('getFilePath', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeManifestResourcePropsV110(); + const sut = new CommonCartridgeManifestResourceV110(props); + + return { sut }; + }; + + it('should return constructed file path', () => { + const { sut } = setup(); + + const result = sut.getFilePath(); + + expect(result).toBe('imsmanifest.xml'); + }); + }); + }); + + describe('getFileContent', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const resource1 = CommonCartridgeResourceFactory.createResource({ + type: CommonCartridgeResourceType.WEB_CONTENT, + version: CommonCartridgeVersion.V_1_1_0, + title: 'Title 1', + identifier: 'r1', + folder: 'o1', + html: '

HTML

', + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }); + const resource2 = CommonCartridgeResourceFactory.createResource({ + type: CommonCartridgeResourceType.WEB_LINK, + version: CommonCartridgeVersion.V_1_1_0, + title: 'Title 2', + identifier: 'r2', + folder: 'o2', + url: 'https://www.example.tld', + }); + const organization1 = CommonCartridgeElementFactory.createElement({ + type: CommonCartridgeElementType.ORGANIZATION, + version: CommonCartridgeVersion.V_1_1_0, + title: 'Title 1', + identifier: 'o1', + items: resource1, + }); + const organization2 = CommonCartridgeElementFactory.createElement({ + type: CommonCartridgeElementType.ORGANIZATION, + version: CommonCartridgeVersion.V_1_1_0, + title: 'Title 2', + identifier: 'o2', + items: resource2, + }); + const metadata = CommonCartridgeElementFactory.createElement({ + type: CommonCartridgeElementType.METADATA, + version: CommonCartridgeVersion.V_1_1_0, + title: 'Common Cartridge Manifest', + copyrightOwners: ['John Doe', 'Jane Doe'], + creationDate: new Date('2023-01-01'), + }); + const sut = new CommonCartridgeManifestResourceV110({ + type: CommonCartridgeResourceType.MANIFEST, + version: CommonCartridgeVersion.V_1_1_0, + identifier: 'm1', + metadata, + organizations: [organization1, organization2], + resources: [resource1, resource2], + }); + + return { sut }; + }; + + it('should return constructed file content', async () => { + const { sut } = setup(); + + const expected = await readFile( + './apps/server/src/modules/common-cartridge/testing/assets/v1.1.0/manifest.xml', + 'utf-8' + ); + const result = sut.getFileContent(); + + expect(result).toEqual(expected); + }); + }); + }); + + describe('getSupportedVersion', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeManifestResourcePropsV110(); + const sut = new CommonCartridgeManifestResourceV110(props); + + return { sut }; + }; + + it('should return supported version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_1_0); + }); + }); + + describe('when using not supported Common Cartridge version', () => { + const notSupportedProps = createCommonCartridgeManifestResourcePropsV110(); + notSupportedProps.version = CommonCartridgeVersion.V_1_3_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeManifestResourceV110(notSupportedProps)).toThrow(InternalServerErrorException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.ts new file mode 100644 index 00000000000..ae578c34e4a --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-manifest-resource.ts @@ -0,0 +1,68 @@ +import { + CommonCartridgeElementType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '../../common-cartridge.enums'; +import { CommonCartridgeElementFactory } from '../../elements/common-cartridge-element-factory'; +import { CommonCartridgeElement, CommonCartridgeResource } from '../../interfaces'; +import { buildXmlString } from '../../utils'; + +export type CommonCartridgeManifestResourcePropsV110 = { + type: CommonCartridgeResourceType.MANIFEST; + version: CommonCartridgeVersion; + identifier: string; + metadata: CommonCartridgeElement; + organizations: CommonCartridgeElement[]; + resources: CommonCartridgeElement[]; +}; + +export class CommonCartridgeManifestResourceV110 extends CommonCartridgeResource { + constructor(private readonly props: CommonCartridgeManifestResourcePropsV110) { + super(props); + } + + public canInline(): boolean { + return false; + } + + public getFilePath(): string { + return 'imsmanifest.xml'; + } + + public getFileContent(): string { + return buildXmlString(this.getManifestXmlObject()); + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_1_0; + } + + public getManifestXmlObject(): Record { + return { + manifest: { + $: { + identifier: this.props.identifier, + xmlns: 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1', + 'xmlns:mnf': 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest', + 'xmlns:res': 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource', + 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + 'xsi:schemaLocation': + 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1 https://www.imsglobal.org/profile/cc/ccv1p1/ccv1p1_imscp_v1p2_v1p0.xsd ' + + 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest https://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lommanifest_v1p0.xsd ' + + 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource https://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lomresource_v1p0.xsd', + }, + metadata: this.props.metadata.getManifestXmlObject(), + organizations: CommonCartridgeElementFactory.createElement({ + type: CommonCartridgeElementType.ORGANIZATIONS_WRAPPER, + version: this.props.version, + items: this.props.organizations, + }).getManifestXmlObject(), + ...CommonCartridgeElementFactory.createElement({ + type: CommonCartridgeElementType.RESOURCES_WRAPPER, + version: this.props.version, + items: this.props.resources, + }).getManifestXmlObject(), + }, + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.spec.ts new file mode 100644 index 00000000000..fd93be44d2d --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.spec.ts @@ -0,0 +1,51 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { + createCommonCartridgeManifestResourcePropsV110, + createCommonCartridgeWebContentResourcePropsV110, + createCommonCartridgeWeblinkResourcePropsV110, +} from '../../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeManifestResourceV110 } from './common-cartridge-manifest-resource'; +import { CommonCartridgeResourceFactoryV110 } from './common-cartridge-resource-factory'; +import { CommonCartridgeWebContentResourceV110 } from './common-cartridge-web-content-resource'; +import { + CommonCartridgeWebLinkResourcePropsV110, + CommonCartridgeWebLinkResourceV110, +} from './common-cartridge-web-link-resource'; + +describe('CommonCartridgeResourceFactoryV110', () => { + describe('createResource', () => { + describe('when creating resources from props', () => { + it('should return manifest resource', () => { + const props = createCommonCartridgeManifestResourcePropsV110(); + + const result = CommonCartridgeResourceFactoryV110.createResource(props); + + expect(result).toBeInstanceOf(CommonCartridgeManifestResourceV110); + }); + + it('should return web content resource', () => { + const props = createCommonCartridgeWebContentResourcePropsV110(); + + const result = CommonCartridgeResourceFactoryV110.createResource(props); + + expect(result).toBeInstanceOf(CommonCartridgeWebContentResourceV110); + }); + + it('should return web link resource', () => { + const props = createCommonCartridgeWeblinkResourcePropsV110(); + + const result = CommonCartridgeResourceFactoryV110.createResource(props); + + expect(result).toBeInstanceOf(CommonCartridgeWebLinkResourceV110); + }); + }); + + describe('when resource type is not supported', () => { + it('should throw error', () => { + expect(() => + CommonCartridgeResourceFactoryV110.createResource({} as CommonCartridgeWebLinkResourcePropsV110) + ).toThrow(InternalServerErrorException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.ts new file mode 100644 index 00000000000..4e13ec77587 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-resource-factory.ts @@ -0,0 +1,37 @@ +import { CommonCartridgeResourceType } from '../../common-cartridge.enums'; +import { CommonCartridgeResource } from '../../interfaces'; +import { createResourceTypeNotSupportedError } from '../../utils'; +import { + CommonCartridgeManifestResourcePropsV110, + CommonCartridgeManifestResourceV110, +} from './common-cartridge-manifest-resource'; +import { + CommonCartridgeWebContentResourcePropsV110, + CommonCartridgeWebContentResourceV110, +} from './common-cartridge-web-content-resource'; +import { + CommonCartridgeWebLinkResourcePropsV110, + CommonCartridgeWebLinkResourceV110, +} from './common-cartridge-web-link-resource'; + +type CommonCartridgeResourcePropsV110 = + | CommonCartridgeManifestResourcePropsV110 + | CommonCartridgeWebContentResourcePropsV110 + | CommonCartridgeWebLinkResourcePropsV110; + +export class CommonCartridgeResourceFactoryV110 { + public static createResource(props: CommonCartridgeResourcePropsV110): CommonCartridgeResource { + const { type } = props; + + switch (type) { + case CommonCartridgeResourceType.MANIFEST: + return new CommonCartridgeManifestResourceV110(props); + case CommonCartridgeResourceType.WEB_CONTENT: + return new CommonCartridgeWebContentResourceV110(props); + case CommonCartridgeResourceType.WEB_LINK: + return new CommonCartridgeWebLinkResourceV110(props); + default: + throw createResourceTypeNotSupportedError(type); + } + } +} diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.spec.ts new file mode 100644 index 00000000000..169986b451e --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.spec.ts @@ -0,0 +1,123 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { createCommonCartridgeWebContentResourcePropsV110 } from '../../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeWebContentResourceV110 } from './common-cartridge-web-content-resource'; + +describe('CommonCartridgeWebContentResourceV110', () => { + describe('canInline', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeWebContentResourcePropsV110(); + const sut = new CommonCartridgeWebContentResourceV110(props); + + return { sut }; + }; + + it('should return false', () => { + const { sut } = setup(); + + const result = sut.canInline(); + + expect(result).toBe(false); + }); + }); + }); + + describe('getFilePath', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeWebContentResourcePropsV110(); + const sut = new CommonCartridgeWebContentResourceV110(props); + + return { sut, props }; + }; + + it('should return the constructed file path', () => { + const { sut, props } = setup(); + + const result = sut.getFilePath(); + + expect(result).toBe(`${props.folder}/${props.identifier}.html`); + }); + }); + }); + + describe('getFileContent', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeWebContentResourcePropsV110(); + const sut = new CommonCartridgeWebContentResourceV110(props); + + return { sut, props }; + }; + + it('should return the HTML', () => { + const { sut, props } = setup(); + + const result = sut.getFileContent(); + + expect(result).toBe(props.html); + }); + }); + }); + + describe('getSupportedVersion', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeWebContentResourcePropsV110(); + const sut = new CommonCartridgeWebContentResourceV110(props); + + return { sut }; + }; + + it('should return Common Cartridge version 1.1.0', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_1_0); + }); + }); + + describe('when using not supported Common Cartridge version', () => { + const notSupportedProps = createCommonCartridgeWebContentResourcePropsV110(); + notSupportedProps.version = CommonCartridgeVersion.V_1_3_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeWebContentResourceV110(notSupportedProps)).toThrow( + InternalServerErrorException + ); + }); + }); + }); + + describe('getManifestXmlObject', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeWebContentResourcePropsV110(); + const sut = new CommonCartridgeWebContentResourceV110(props); + + return { sut, props }; + }; + + it('should return the correct XML object', () => { + const { sut, props } = setup(); + + const result = sut.getManifestXmlObject(); + + expect(result).toEqual({ + $: { + identifier: props.identifier, + type: 'webcontent', + intendeduse: props.intendedUse, + }, + file: { + $: { + href: sut.getFilePath(), + }, + }, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.ts new file mode 100644 index 00000000000..f684709e246 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-content-resource.ts @@ -0,0 +1,61 @@ +import { + CommonCartridgeIntendedUseType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '../../common-cartridge.enums'; +import { CommonCartridgeResource } from '../../interfaces'; +import { checkIntendedUse } from '../../utils'; + +export type CommonCartridgeWebContentResourcePropsV110 = { + type: CommonCartridgeResourceType.WEB_CONTENT; + version: CommonCartridgeVersion; + identifier: string; + folder: string; + title: string; + html: string; + intendedUse: CommonCartridgeIntendedUseType; +}; + +export class CommonCartridgeWebContentResourceV110 extends CommonCartridgeResource { + private static readonly SUPPORTED_INTENDED_USES = [ + CommonCartridgeIntendedUseType.LESSON_PLAN, + CommonCartridgeIntendedUseType.SYLLABUS, + CommonCartridgeIntendedUseType.UNSPECIFIED, + ]; + + constructor(private readonly props: CommonCartridgeWebContentResourcePropsV110) { + super(props); + checkIntendedUse(props.intendedUse, CommonCartridgeWebContentResourceV110.SUPPORTED_INTENDED_USES); + } + + public canInline(): boolean { + return false; + } + + public getFilePath(): string { + return `${this.props.folder}/${this.props.identifier}.html`; + } + + public getFileContent(): string { + return this.props.html; + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_1_0; + } + + public getManifestXmlObject(): Record { + return { + $: { + identifier: this.props.identifier, + type: this.props.type, + intendeduse: this.props.intendedUse, + }, + file: { + $: { + href: this.getFilePath(), + }, + }, + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.spec.ts new file mode 100644 index 00000000000..79881d285f4 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.spec.ts @@ -0,0 +1,129 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { readFile } from 'fs/promises'; +import { createCommonCartridgeWeblinkResourcePropsV110 } from '../../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeWebLinkResourceV110 } from './common-cartridge-web-link-resource'; + +describe('CommonCartridgeWebLinkResourceV110', () => { + describe('canInline', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeWeblinkResourcePropsV110(); + const sut = new CommonCartridgeWebLinkResourceV110(props); + + return { sut }; + }; + + it('should return false', () => { + const { sut } = setup(); + + const result = sut.canInline(); + + expect(result).toBe(false); + }); + }); + }); + + describe('getFilePath', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeWeblinkResourcePropsV110(); + const sut = new CommonCartridgeWebLinkResourceV110(props); + + return { sut, props }; + }; + + it('should return the constructed file path', () => { + const { sut, props } = setup(); + + const result = sut.getFilePath(); + + expect(result).toBe(`${props.folder}/${props.identifier}.xml`); + }); + }); + }); + + describe('getFileContent', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeWeblinkResourcePropsV110(); + props.title = 'Title'; + props.url = 'http://www.example.tld'; + props.target = '_self'; + props.windowFeatures = 'width=100;height=100;'; + + const sut = new CommonCartridgeWebLinkResourceV110(props); + + return { sut }; + }; + it('should contain correct XML', async () => { + const { sut } = setup(); + + const expected = await readFile( + './apps/server/src/modules/common-cartridge/testing/assets/v1.1.0/weblink.xml', + 'utf8' + ); + const result = sut.getFileContent(); + + expect(result).toEqual(expected); + }); + }); + }); + + describe('getSupportedVersion', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeWeblinkResourcePropsV110(); + const sut = new CommonCartridgeWebLinkResourceV110(props); + + return { sut }; + }; + + it('should return the supported version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_1_0); + }); + }); + + describe('when using not supported Common Cartridge version', () => { + const notSupportedProps = createCommonCartridgeWeblinkResourcePropsV110(); + notSupportedProps.version = CommonCartridgeVersion.V_1_3_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeWebLinkResourceV110(notSupportedProps)).toThrow(InternalServerErrorException); + }); + }); + }); + + describe('getManifestXmlObject', () => { + describe('when using Common Cartridge version 1.1.0', () => { + const setup = () => { + const props = createCommonCartridgeWeblinkResourcePropsV110(); + const sut = new CommonCartridgeWebLinkResourceV110(props); + + return { sut, props }; + }; + + it('should return the manifest XML object', () => { + const { sut, props } = setup(); + + const result = sut.getManifestXmlObject(); + + expect(result).toEqual({ + $: { + identifier: props.identifier, + type: 'imswl_xmlv1p1', + }, + file: { + $: { + href: sut.getFilePath(), + }, + }, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.ts new file mode 100644 index 00000000000..4da6c641215 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/common-cartridge-web-link-resource.ts @@ -0,0 +1,67 @@ +import { CommonCartridgeResourceType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeResource } from '../../interfaces'; +import { buildXmlString } from '../../utils'; + +export type CommonCartridgeWebLinkResourcePropsV110 = { + type: CommonCartridgeResourceType.WEB_LINK; + version: CommonCartridgeVersion; + identifier: string; + folder: string; + title: string; + url: string; + target?: string; + windowFeatures?: string; +}; + +export class CommonCartridgeWebLinkResourceV110 extends CommonCartridgeResource { + constructor(private readonly props: CommonCartridgeWebLinkResourcePropsV110) { + super(props); + } + + public canInline(): boolean { + return false; + } + + public getFilePath(): string { + return `${this.props.folder}/${this.props.identifier}.xml`; + } + + public getFileContent(): string { + return buildXmlString({ + webLink: { + $: { + xmlns: 'http://www.imsglobal.org/xsd/imsccv1p1/imswl_v1p1', + 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + 'xsi:schemaLocation': + 'http://www.imsglobal.org/xsd/imsccv1p1/imswl_v1p1 https://www.imsglobal.org/profile/cc/ccv1p1/ccv1p1_imswl_v1p1.xsd', + }, + title: this.props.title, + url: { + $: { + href: this.props.url, + target: this.props.target, + windowFeatures: this.props.windowFeatures, + }, + }, + }, + }); + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_1_0; + } + + public getManifestXmlObject(): Record { + return { + $: { + identifier: this.props.identifier, + type: 'imswl_xmlv1p1', + }, + file: { + $: { + href: this.getFilePath(), + }, + }, + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/index.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/index.ts new file mode 100644 index 00000000000..62d0bde3846 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.1.0/index.ts @@ -0,0 +1,7 @@ +export { CommonCartridgeManifestResourcePropsV110 } from './common-cartridge-manifest-resource'; +export { CommonCartridgeResourceFactoryV110 } from './common-cartridge-resource-factory'; +export { + CommonCartridgeWebContentResourcePropsV110, + CommonCartridgeWebContentResourceV110, +} from './common-cartridge-web-content-resource'; +export { CommonCartridgeWebLinkResourcePropsV110 } from './common-cartridge-web-link-resource'; diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.spec.ts new file mode 100644 index 00000000000..f49caa016c9 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.spec.ts @@ -0,0 +1,148 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { readFile } from 'fs/promises'; +import { createCommonCartridgeManifestResourcePropsV130 } from '../../../testing/common-cartridge-resource-props.factory'; +import { + CommonCartridgeElementType, + CommonCartridgeIntendedUseType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '../../common-cartridge.enums'; +import { CommonCartridgeElementFactory } from '../../elements/common-cartridge-element-factory'; +import { CommonCartridgeElementFactoryV130 } from '../../elements/v1.3.0'; +import { CommonCartridgeResourceFactory } from '../common-cartridge-resource-factory'; +import { CommonCartridgeManifestResourceV130 } from './common-cartridge-manifest-resource'; + +describe('CommonCartridgeManifestResourceV130', () => { + describe('canInline', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeManifestResourcePropsV130(); + const sut = new CommonCartridgeManifestResourceV130(props); + + return { sut }; + }; + + it('should return false', () => { + const { sut } = setup(); + + const result = sut.canInline(); + + expect(result).toBe(false); + }); + }); + }); + + describe('getFilePath', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeManifestResourcePropsV130(); + const sut = new CommonCartridgeManifestResourceV130(props); + + return { sut }; + }; + + it('should return constructed file path', () => { + const { sut } = setup(); + + const result = sut.getFilePath(); + + expect(result).toBe('imsmanifest.xml'); + }); + }); + }); + + describe('getFileContent', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const resource1 = CommonCartridgeResourceFactory.createResource({ + type: CommonCartridgeResourceType.WEB_CONTENT, + version: CommonCartridgeVersion.V_1_3_0, + title: 'Title 1', + identifier: 'r1', + folder: 'o1', + html: '

HTML

', + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }); + const resource2 = CommonCartridgeResourceFactory.createResource({ + type: CommonCartridgeResourceType.WEB_LINK, + version: CommonCartridgeVersion.V_1_3_0, + title: 'Title 2', + identifier: 'r2', + folder: 'o2', + url: 'https://www.example.tld', + }); + const organization1 = CommonCartridgeElementFactoryV130.createElement({ + type: CommonCartridgeElementType.ORGANIZATION, + version: CommonCartridgeVersion.V_1_3_0, + title: 'Title 1', + identifier: 'o1', + items: resource1, + }); + const organization2 = CommonCartridgeElementFactory.createElement({ + type: CommonCartridgeElementType.ORGANIZATION, + version: CommonCartridgeVersion.V_1_3_0, + title: 'Title 2', + identifier: 'o2', + items: resource2, + }); + const metadata = CommonCartridgeElementFactoryV130.createElement({ + type: CommonCartridgeElementType.METADATA, + version: CommonCartridgeVersion.V_1_3_0, + title: 'Common Cartridge Manifest', + copyrightOwners: ['John Doe', 'Jane Doe'], + creationDate: new Date('2023-01-01'), + }); + const sut = new CommonCartridgeManifestResourceV130({ + type: CommonCartridgeResourceType.MANIFEST, + version: CommonCartridgeVersion.V_1_3_0, + identifier: 'm1', + metadata, + organizations: [organization1, organization2], + resources: [resource1, resource2], + }); + + return { sut }; + }; + + it('should return constructed file content', async () => { + const { sut } = setup(); + + const expected = await readFile( + './apps/server/src/modules/common-cartridge/testing/assets/v1.3.0/manifest.xml', + 'utf-8' + ); + const result = sut.getFileContent(); + + expect(result).toEqual(expected); + }); + }); + }); + + describe('getSupportedVersion', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeManifestResourcePropsV130(); + const sut = new CommonCartridgeManifestResourceV130(props); + + return { sut }; + }; + + it('should return supported version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_3_0); + }); + }); + + describe('when using not supported Common Cartridge version', () => { + const notSupportedProps = createCommonCartridgeManifestResourcePropsV130(); + notSupportedProps.version = CommonCartridgeVersion.V_1_1_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeManifestResourceV130(notSupportedProps)).toThrow(InternalServerErrorException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.ts new file mode 100644 index 00000000000..3da27cb30b8 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-manifest-resource.ts @@ -0,0 +1,71 @@ +import { + CommonCartridgeElementType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '../../common-cartridge.enums'; +import { + CommonCartridgeOrganizationsWrapperElementV130, + CommonCartridgeResourcesWrapperElementV130, +} from '../../elements/v1.3.0'; +import { CommonCartridgeElement, CommonCartridgeResource } from '../../interfaces'; +import { buildXmlString } from '../../utils'; + +export type CommonCartridgeManifestResourcePropsV130 = { + type: CommonCartridgeResourceType.MANIFEST; + version: CommonCartridgeVersion; + identifier: string; + metadata: CommonCartridgeElement; + organizations: CommonCartridgeElement[]; + resources: CommonCartridgeElement[]; +}; + +export class CommonCartridgeManifestResourceV130 extends CommonCartridgeResource { + constructor(private readonly props: CommonCartridgeManifestResourcePropsV130) { + super(props); + } + + public canInline(): boolean { + return false; + } + + public getFilePath(): string { + return 'imsmanifest.xml'; + } + + public getFileContent(): string { + return buildXmlString(this.getManifestXmlObject()); + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_3_0; + } + + public getManifestXmlObject(): Record { + return { + manifest: { + $: { + identifier: this.props.identifier, + xmlns: 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1', + 'xmlns:mnf': 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/manifest', + 'xmlns:res': 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/resource', + 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + 'xsi:schemaLocation': + 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1 https://www.imsglobal.org/profile/cc/ccv1p3/ccv1p3_imscp_v1p2_v1p0.xsd ' + + 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/manifest https://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lommanifest_v1p0.xsd ' + + 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/resource https://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lomresource_v1p0.xsd', + }, + metadata: this.props.metadata.getManifestXmlObject(), + organizations: new CommonCartridgeOrganizationsWrapperElementV130({ + type: CommonCartridgeElementType.ORGANIZATIONS_WRAPPER, + version: this.props.version, + items: this.props.organizations, + }).getManifestXmlObject(), + ...new CommonCartridgeResourcesWrapperElementV130({ + type: CommonCartridgeElementType.RESOURCES_WRAPPER, + version: this.props.version, + items: this.props.resources, + }).getManifestXmlObject(), + }, + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.spec.ts new file mode 100644 index 00000000000..ad630ea25d8 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.spec.ts @@ -0,0 +1,51 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { + createCommonCartridgeManifestResourcePropsV130, + createCommonCartridgeWebContentResourcePropsV130, + createCommonCartridgeWeblinkResourcePropsV130, +} from '../../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeManifestResourceV130 } from './common-cartridge-manifest-resource'; +import { CommonCartridgeResourceFactoryV130 } from './common-cartridge-resource-factory'; +import { CommonCartridgeWebContentResourceV130 } from './common-cartridge-web-content-resource'; +import { + CommonCartridgeWebLinkResourcePropsV130, + CommonCartridgeWebLinkResourceV130, +} from './common-cartridge-web-link-resource'; + +describe('CommonCartridgeResourceFactoryV130', () => { + describe('createResource', () => { + describe('when creating resources from props', () => { + it('should return manifest resource', () => { + const props = createCommonCartridgeManifestResourcePropsV130(); + + const result = CommonCartridgeResourceFactoryV130.createResource(props); + + expect(result).toBeInstanceOf(CommonCartridgeManifestResourceV130); + }); + + it('should return web content resource', () => { + const props = createCommonCartridgeWebContentResourcePropsV130(); + + const result = CommonCartridgeResourceFactoryV130.createResource(props); + + expect(result).toBeInstanceOf(CommonCartridgeWebContentResourceV130); + }); + + it('should return web link resource', () => { + const props = createCommonCartridgeWeblinkResourcePropsV130(); + + const result = CommonCartridgeResourceFactoryV130.createResource(props); + + expect(result).toBeInstanceOf(CommonCartridgeWebLinkResourceV130); + }); + }); + + describe('when resource type is not supported', () => { + it('should throw error', () => { + expect(() => + CommonCartridgeResourceFactoryV130.createResource({} as CommonCartridgeWebLinkResourcePropsV130) + ).toThrow(InternalServerErrorException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.ts new file mode 100644 index 00000000000..9be5fad11c1 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-resource-factory.ts @@ -0,0 +1,37 @@ +import { CommonCartridgeResourceType } from '../../common-cartridge.enums'; +import { CommonCartridgeResource } from '../../interfaces'; +import { createResourceTypeNotSupportedError } from '../../utils'; +import { + CommonCartridgeManifestResourcePropsV130, + CommonCartridgeManifestResourceV130, +} from './common-cartridge-manifest-resource'; +import { + CommonCartridgeWebContentResourcePropsV130, + CommonCartridgeWebContentResourceV130, +} from './common-cartridge-web-content-resource'; +import { + CommonCartridgeWebLinkResourcePropsV130, + CommonCartridgeWebLinkResourceV130, +} from './common-cartridge-web-link-resource'; + +type CommonCartridgeResourcePropsV130 = + | CommonCartridgeManifestResourcePropsV130 + | CommonCartridgeWebContentResourcePropsV130 + | CommonCartridgeWebLinkResourcePropsV130; + +export class CommonCartridgeResourceFactoryV130 { + public static createResource(props: CommonCartridgeResourcePropsV130): CommonCartridgeResource { + const { type } = props; + + switch (type) { + case CommonCartridgeResourceType.MANIFEST: + return new CommonCartridgeManifestResourceV130(props); + case CommonCartridgeResourceType.WEB_CONTENT: + return new CommonCartridgeWebContentResourceV130(props); + case CommonCartridgeResourceType.WEB_LINK: + return new CommonCartridgeWebLinkResourceV130(props); + default: + throw createResourceTypeNotSupportedError(type); + } + } +} diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.spec.ts new file mode 100644 index 00000000000..e1b3334fd7d --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.spec.ts @@ -0,0 +1,123 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { createCommonCartridgeWebContentResourcePropsV130 } from '../../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeWebContentResourceV130 } from './common-cartridge-web-content-resource'; + +describe('CommonCartridgeWebContentResourceV130', () => { + describe('canInline', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeWebContentResourcePropsV130(); + const sut = new CommonCartridgeWebContentResourceV130(props); + + return { sut }; + }; + + it('should return false', () => { + const { sut } = setup(); + + const result = sut.canInline(); + + expect(result).toBe(false); + }); + }); + }); + + describe('getFilePath', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeWebContentResourcePropsV130(); + const sut = new CommonCartridgeWebContentResourceV130(props); + + return { sut, props }; + }; + + it('should return the constructed file path', () => { + const { sut, props } = setup(); + + const result = sut.getFilePath(); + + expect(result).toBe(`${props.folder}/${props.identifier}.html`); + }); + }); + }); + + describe('getFileContent', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeWebContentResourcePropsV130(); + const sut = new CommonCartridgeWebContentResourceV130(props); + + return { sut, props }; + }; + + it('should return the HTML', () => { + const { sut, props } = setup(); + + const result = sut.getFileContent(); + + expect(result).toBe(props.html); + }); + }); + }); + + describe('getSupportedVersion', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeWebContentResourcePropsV130(); + const sut = new CommonCartridgeWebContentResourceV130(props); + + return { sut }; + }; + + it('should return the supported version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_3_0); + }); + }); + + describe('when using not supported Common Cartridge version', () => { + const notSupportedProps = createCommonCartridgeWebContentResourcePropsV130(); + notSupportedProps.version = CommonCartridgeVersion.V_1_1_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeWebContentResourceV130(notSupportedProps)).toThrow( + InternalServerErrorException + ); + }); + }); + }); + + describe('getManifestXmlObject', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeWebContentResourcePropsV130(); + const sut = new CommonCartridgeWebContentResourceV130(props); + + return { sut, props }; + }; + + it('should return the manifest XML object', () => { + const { sut, props } = setup(); + + const result = sut.getManifestXmlObject(); + + expect(result).toEqual({ + $: { + identifier: props.identifier, + type: props.type, + intendeduse: props.intendedUse, + }, + file: { + $: { + href: sut.getFilePath(), + }, + }, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.ts new file mode 100644 index 00000000000..eb168087a52 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-content-resource.ts @@ -0,0 +1,62 @@ +import { + CommonCartridgeIntendedUseType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '../../common-cartridge.enums'; +import { CommonCartridgeResource } from '../../interfaces'; +import { checkIntendedUse } from '../../utils'; + +export type CommonCartridgeWebContentResourcePropsV130 = { + type: CommonCartridgeResourceType.WEB_CONTENT; + version: CommonCartridgeVersion; + identifier: string; + folder: string; + title: string; + html: string; + intendedUse: CommonCartridgeIntendedUseType; +}; + +export class CommonCartridgeWebContentResourceV130 extends CommonCartridgeResource { + private static readonly SUPPORTED_INTENDED_USES = [ + CommonCartridgeIntendedUseType.ASSIGNMENT, + CommonCartridgeIntendedUseType.LESSON_PLAN, + CommonCartridgeIntendedUseType.SYLLABUS, + CommonCartridgeIntendedUseType.UNSPECIFIED, + ]; + + constructor(private readonly props: CommonCartridgeWebContentResourcePropsV130) { + super(props); + checkIntendedUse(props.intendedUse, CommonCartridgeWebContentResourceV130.SUPPORTED_INTENDED_USES); + } + + public canInline(): boolean { + return false; + } + + public getFilePath(): string { + return `${this.props.folder}/${this.props.identifier}.html`; + } + + public getFileContent(): string { + return this.props.html; + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_3_0; + } + + public getManifestXmlObject(): Record { + return { + $: { + identifier: this.props.identifier, + type: this.props.type, + intendeduse: this.props.intendedUse, + }, + file: { + $: { + href: this.getFilePath(), + }, + }, + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.spec.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.spec.ts new file mode 100644 index 00000000000..d6aee5e394f --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.spec.ts @@ -0,0 +1,130 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { readFile } from 'node:fs/promises'; +import { createCommonCartridgeWeblinkResourcePropsV130 } from '../../../testing/common-cartridge-resource-props.factory'; +import { CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeWebLinkResourceV130 } from './common-cartridge-web-link-resource'; + +describe('CommonCartridgeWebLinkResourceV130', () => { + describe('canInline', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeWeblinkResourcePropsV130(); + const sut = new CommonCartridgeWebLinkResourceV130(props); + + return { sut }; + }; + + it('should return false', () => { + const { sut } = setup(); + + const result = sut.canInline(); + + expect(result).toBe(false); + }); + }); + }); + + describe('getFilePath', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeWeblinkResourcePropsV130(); + const sut = new CommonCartridgeWebLinkResourceV130(props); + + return { sut, props }; + }; + + it('should return the constructed file path', () => { + const { sut, props } = setup(); + + const result = sut.getFilePath(); + + expect(result).toBe(`${props.folder}/${props.identifier}.xml`); + }); + }); + }); + + describe('getFileContent', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeWeblinkResourcePropsV130(); + props.title = 'Title'; + props.url = 'http://www.example.tld'; + props.target = '_self'; + props.windowFeatures = 'width=100;height=100;'; + + const sut = new CommonCartridgeWebLinkResourceV130(props); + + return { sut }; + }; + + it('should contain correct XML', async () => { + const { sut } = setup(); + + const expected = await readFile( + './apps/server/src/modules/common-cartridge/testing/assets/v1.3.0/weblink.xml', + 'utf8' + ); + const result = sut.getFileContent(); + + expect(result).toEqual(expected); + }); + }); + }); + + describe('getSupportedVersion', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeWeblinkResourcePropsV130(); + const sut = new CommonCartridgeWebLinkResourceV130(props); + + return { sut }; + }; + + it('should return the supported version', () => { + const { sut } = setup(); + + const result = sut.getSupportedVersion(); + + expect(result).toBe(CommonCartridgeVersion.V_1_3_0); + }); + }); + + describe('when using not supported Common Cartridge version', () => { + const notSupportedProps = createCommonCartridgeWeblinkResourcePropsV130(); + notSupportedProps.version = CommonCartridgeVersion.V_1_1_0; + + it('should throw error', () => { + expect(() => new CommonCartridgeWebLinkResourceV130(notSupportedProps)).toThrow(InternalServerErrorException); + }); + }); + }); + + describe('getManifestXmlObject', () => { + describe('when using Common Cartridge version 1.3.0', () => { + const setup = () => { + const props = createCommonCartridgeWeblinkResourcePropsV130(); + const sut = new CommonCartridgeWebLinkResourceV130(props); + + return { sut, props }; + }; + + it('should return the manifest XML object', () => { + const { sut, props } = setup(); + + const result = sut.getManifestXmlObject(); + + expect(result).toEqual({ + $: { + identifier: props.identifier, + type: 'imswl_xmlv1p3', + }, + file: { + $: { + href: sut.getFilePath(), + }, + }, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.ts new file mode 100644 index 00000000000..1cfd8a3df5b --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/common-cartridge-web-link-resource.ts @@ -0,0 +1,67 @@ +import { CommonCartridgeResourceType, CommonCartridgeVersion } from '../../common-cartridge.enums'; +import { CommonCartridgeResource } from '../../interfaces'; +import { buildXmlString } from '../../utils'; + +export type CommonCartridgeWebLinkResourcePropsV130 = { + type: CommonCartridgeResourceType.WEB_LINK; + version: CommonCartridgeVersion; + identifier: string; + folder: string; + title: string; + url: string; + target?: string; + windowFeatures?: string; +}; + +export class CommonCartridgeWebLinkResourceV130 extends CommonCartridgeResource { + constructor(private readonly props: CommonCartridgeWebLinkResourcePropsV130) { + super(props); + } + + public canInline(): boolean { + return false; + } + + public getFilePath(): string { + return `${this.props.folder}/${this.props.identifier}.xml`; + } + + public getFileContent(): string { + return buildXmlString({ + webLink: { + $: { + xmlns: 'http://www.imsglobal.org/xsd/imsccv1p3/imswl_v1p3', + 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + 'xsi:schemaLocation': + 'http://www.imsglobal.org/xsd/imsccv1p3/imswl_v1p3 https://www.imsglobal.org/profile/cc/ccv1p3/ccv1p3_imswl_v1p3.xsd', + }, + title: this.props.title, + url: { + $: { + href: this.props.url, + target: this.props.target, + windowFeatures: this.props.windowFeatures, + }, + }, + }, + }); + } + + public getSupportedVersion(): CommonCartridgeVersion { + return CommonCartridgeVersion.V_1_3_0; + } + + public getManifestXmlObject(): Record { + return { + $: { + identifier: this.props.identifier, + type: 'imswl_xmlv1p3', + }, + file: { + $: { + href: this.getFilePath(), + }, + }, + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/index.ts b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/index.ts new file mode 100644 index 00000000000..dca0baca323 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/resources/v1.3.0/index.ts @@ -0,0 +1,7 @@ +export { CommonCartridgeManifestResourcePropsV130 } from './common-cartridge-manifest-resource'; +export { CommonCartridgeResourceFactoryV130 } from './common-cartridge-resource-factory'; +export { + CommonCartridgeWebContentResourcePropsV130, + CommonCartridgeWebContentResourceV130, +} from './common-cartridge-web-content-resource'; +export { CommonCartridgeWebLinkResourcePropsV130 } from './common-cartridge-web-link-resource'; diff --git a/apps/server/src/modules/common-cartridge/export/utils.spec.ts b/apps/server/src/modules/common-cartridge/export/utils.spec.ts new file mode 100644 index 00000000000..3dae9ffa1c8 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/utils.spec.ts @@ -0,0 +1,84 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ObjectId } from 'bson'; +import { CommonCartridgeVersion } from './common-cartridge.enums'; +import { + buildXmlString, + checkIntendedUse, + createElementTypeNotSupportedError, + createIdentifier, + createResourceTypeNotSupportedError, + createVersionNotSupportedError, +} from './utils'; + +describe('CommonCartridgeUtils', () => { + describe('buildXmlString', () => { + it('should create xml string', () => { + const xml = buildXmlString({ root: { child: 'value' } }); + + expect(xml).toBe('\n\n value\n'); + }); + }); + + describe('createVersionNotSupportedError', () => { + describe('when creating error', () => { + it('should return error with message', () => { + const error = createVersionNotSupportedError(CommonCartridgeVersion.V_1_0_0); + + expect(error).toBeInstanceOf(InternalServerErrorException); + expect(error.message).toBe('Common Cartridge version 1.0.0 is not supported'); + }); + }); + }); + + describe('createIdentifier', () => { + describe('when creating identifier', () => { + it('should return identifier with prefix', () => { + const identifier = new ObjectId(); + + expect(createIdentifier(identifier)).toBe(`i${identifier.toHexString()}`); + }); + + it('should return identifier with prefix when identifier is undefined', () => { + expect(createIdentifier(undefined)).toMatch(/^i[0-9a-f]{24}$/); + }); + }); + }); + + describe('createResourceTypeNotSupportedError', () => { + describe('when creating error', () => { + it('should return error with message', () => { + const resourceType = 'unsupported'; + + const error = createResourceTypeNotSupportedError(resourceType); + + expect(error).toBeInstanceOf(InternalServerErrorException); + expect(error.message).toBe(`Common Cartridge resource type ${resourceType} is not supported`); + }); + }); + }); + + describe('createElementTypeNotSupportedError', () => { + describe('when creating error', () => { + it('should return error with message', () => { + const elementType = 'unsupported'; + + const error = createElementTypeNotSupportedError(elementType); + + expect(error).toBeInstanceOf(InternalServerErrorException); + expect(error.message).toBe(`Common Cartridge element type ${elementType} is not supported`); + }); + }); + }); + + describe('checkIntendedUse', () => { + describe('when intended use is not supported', () => { + it('should throw error', () => { + const supportedIntendedUses = ['use1', 'use2']; + + expect(() => checkIntendedUse('use3', supportedIntendedUses)).toThrowError( + 'Intended use use3 is not supported' + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/export/utils.ts b/apps/server/src/modules/common-cartridge/export/utils.ts new file mode 100644 index 00000000000..0b110f783c5 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/export/utils.ts @@ -0,0 +1,45 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ObjectId } from 'bson'; +import { Builder } from 'xml2js'; + +export type OmitVersion = Omit; + +export type OmitVersionAndFolder = Omit; + +export type OmitVersionAndType = Omit; + +const xmlBuilder = new Builder({ + xmldec: { version: '1.0', encoding: 'UTF-8' }, + renderOpts: { pretty: true, indent: ' ', newline: '\n' }, +}); + +export function buildXmlString(obj: unknown): string { + return xmlBuilder.buildObject(obj); +} + +export function createVersionNotSupportedError(version: string): Error { + return new InternalServerErrorException(`Common Cartridge version ${version} is not supported`); +} + +export function createResourceTypeNotSupportedError(type: string): Error { + return new InternalServerErrorException(`Common Cartridge resource type ${type} is not supported`); +} + +export function createElementTypeNotSupportedError(type: string): Error { + // AI next 1 line + return new InternalServerErrorException(`Common Cartridge element type ${type} is not supported`); +} + +export function createIdentifier(identifier?: string | ObjectId): string { + if (!identifier) { + return `i${new ObjectId().toString()}`; + } + + return `i${identifier.toString()}`; +} + +export function checkIntendedUse(intendedUse: string, supportedIntendedUses: string[]): void | never { + if (!supportedIntendedUses.includes(intendedUse)) { + throw new Error(`Intended use ${intendedUse} is not supported`); + } +} diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.spec.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.spec.ts new file mode 100644 index 00000000000..a8945c70187 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.spec.ts @@ -0,0 +1,47 @@ +import AdmZip from 'adm-zip'; +import { CommonCartridgeFileParser } from './common-cartridge-file-parser'; + +describe('CommonCartridgeFileParser', () => { + describe('constructor', () => { + describe('when manifest file is found', () => { + const setup = (manifestName: string) => { + const archive = new AdmZip(); + + archive.addFile(manifestName, Buffer.from('')); + + const file = archive.toBuffer(); + + return { file }; + }; + + it('should use imsmanifest.xml as manifest', () => { + const { file } = setup('imsmanifest.xml'); + const parser = new CommonCartridgeFileParser(file); + + expect(parser.manifest).toBeDefined(); + }); + + it('should use manifest.xml as manifest', () => { + const { file } = setup('manifest.xml'); + const parser = new CommonCartridgeFileParser(file); + + expect(parser.manifest).toBeDefined(); + }); + }); + + describe('when manifest file is not found', () => { + const setup = () => { + const archive = new AdmZip(); + const file = archive.toBuffer(); + + return { file }; + }; + + it('should throw', () => { + const { file } = setup(); + + expect(() => new CommonCartridgeFileParser(file)).toThrow('Manifest file not found'); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.ts new file mode 100644 index 00000000000..4ece64bea53 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-file-parser.ts @@ -0,0 +1,29 @@ +import AdmZip from 'adm-zip'; +import { DEFAULT_FILE_PARSER_OPTIONS } from './common-cartridge-import.types'; +import { CommonCartridgeManifestParser } from './common-cartridge-manifest-parser'; +import { CommonCartridgeManifestNotFoundException } from './utils/common-cartridge-manifest-not-found.exception'; + +export class CommonCartridgeFileParser { + private readonly manifestParser: CommonCartridgeManifestParser; + + public constructor(file: Buffer, private readonly options = DEFAULT_FILE_PARSER_OPTIONS) { + const archive = new AdmZip(file); + + this.manifestParser = new CommonCartridgeManifestParser(this.getManifestFileAsString(archive), this.options); + } + + public get manifest(): CommonCartridgeManifestParser { + return this.manifestParser; + } + + private getManifestFileAsString(archive: AdmZip): string | never { + // imsmanifest.xml is the standard name, but manifest.xml is also valid until v1.3 + const manifest = archive.getEntry('imsmanifest.xml') || archive.getEntry('manifest.xml'); + + if (manifest) { + return archive.readAsText(manifest); + } + + throw new CommonCartridgeManifestNotFoundException(); + } +} diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-import.types.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-import.types.ts new file mode 100644 index 00000000000..28150fedd72 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-import.types.ts @@ -0,0 +1,17 @@ +export type CommonCartridgeFileParserOptions = { + maxSearchDepth: number; + pathSeparator: string; +}; + +export const DEFAULT_FILE_PARSER_OPTIONS: CommonCartridgeFileParserOptions = { + maxSearchDepth: 3, + pathSeparator: '/', +}; + +export type OrganizationProps = { + path: string; + pathDepth: number; + identifier: string; + identifierRef?: string; + title: string; +}; diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-manifest-parser.spec.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-manifest-parser.spec.ts new file mode 100644 index 00000000000..8dd6b2c8541 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-manifest-parser.spec.ts @@ -0,0 +1,118 @@ +import AdmZip from 'adm-zip'; +import { readFile } from 'fs/promises'; +import { DEFAULT_FILE_PARSER_OPTIONS } from './common-cartridge-import.types'; +import { CommonCartridgeManifestParser } from './common-cartridge-manifest-parser'; + +describe('CommonCartridgeManifestParser', () => { + const setupFile = async (loadFile: boolean) => { + if (!loadFile) { + const sut = new CommonCartridgeManifestParser('', DEFAULT_FILE_PARSER_OPTIONS); + + return { sut }; + } + + const buffer = await readFile( + './apps/server/src/modules/common-cartridge/testing/assets/us_history_since_1877.imscc' + ); + const archive = new AdmZip(buffer); + const sut = new CommonCartridgeManifestParser(archive.readAsText('imsmanifest.xml'), DEFAULT_FILE_PARSER_OPTIONS); + + return { sut }; + }; + + describe('getSchema', () => { + describe('when schema is present', () => { + const setup = async () => setupFile(true); + + it('should return the schema', async () => { + const { sut } = await setup(); + const result = sut.getSchema(); + + expect(result).toBe('IMS Common Cartridge'); + }); + }); + + describe('when schema is not present', () => { + const setup = async () => setupFile(false); + + it('should return undefined', async () => { + const { sut } = await setup(); + const result = sut.getSchema(); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe('getVersion', () => { + describe('when version is present', () => { + const setup = async () => setupFile(true); + + it('should return the version', async () => { + const { sut } = await setup(); + const result = sut.getVersion(); + + expect(result).toBe('1.3.0'); + }); + }); + + describe('when version is not present', () => { + const setup = async () => setupFile(false); + + it('should return undefined', async () => { + const { sut } = await setup(); + const result = sut.getVersion(); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe('getTitle', () => { + describe('when title is present', () => { + const setup = async () => setupFile(true); + + it('should return the title', async () => { + const { sut } = await setup(); + const result = sut.getTitle(); + + expect(result).toBe('201510-AMH-2020-70C-12218-US History Since 1877'); + }); + }); + + describe('when title is not present', () => { + const setup = async () => setupFile(false); + + it('should return null', async () => { + const { sut } = await setup(); + const result = sut.getTitle(); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe('getOrganizations', () => { + describe('when organizations are present', () => { + const setup = async () => setupFile(true); + + it('should return the organization', async () => { + const { sut } = await setup(); + const result = sut.getOrganizations(); + + expect(result).toHaveLength(117); + }); + }); + + describe('when organizations are not present', () => { + const setup = async () => setupFile(false); + + it('should return empty list', async () => { + const { sut } = await setup(); + const result = sut.getOrganizations(); + + expect(result).toHaveLength(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/import/common-cartridge-manifest-parser.ts b/apps/server/src/modules/common-cartridge/import/common-cartridge-manifest-parser.ts new file mode 100644 index 00000000000..c3c485daa34 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/common-cartridge-manifest-parser.ts @@ -0,0 +1,36 @@ +import { JSDOM } from 'jsdom'; +import { CommonCartridgeFileParserOptions, OrganizationProps } from './common-cartridge-import.types'; +import { CommonCartridgeOrganizationVisitor } from './utils/common-cartridge-organization-visitor'; + +export class CommonCartridgeManifestParser { + private readonly doc: Document; + + constructor(manifest: string, private readonly options: CommonCartridgeFileParserOptions) { + this.doc = new JSDOM(manifest, { contentType: 'text/xml' }).window.document; + } + + public getSchema(): string | undefined { + const result = this.doc.querySelector('manifest > metadata > schema'); + + return result?.textContent ?? undefined; + } + + public getVersion(): string | undefined { + const result = this.doc.querySelector('manifest > metadata > schemaversion'); + + return result?.textContent ?? undefined; + } + + public getTitle(): string | undefined { + const result = this.doc.querySelector('manifest > metadata > lom > general > title > string'); + + return result?.textContent ?? undefined; + } + + public getOrganizations(): OrganizationProps[] { + const visitor = new CommonCartridgeOrganizationVisitor(this.doc, this.options); + const result = visitor.findAllOrganizations(); + + return result; + } +} diff --git a/apps/server/src/modules/common-cartridge/import/jsdom.d.ts b/apps/server/src/modules/common-cartridge/import/jsdom.d.ts new file mode 100644 index 00000000000..35b5ca6940c --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/jsdom.d.ts @@ -0,0 +1,11 @@ +// This is a workaround for the missing types for jsdom, because the types are not included in the package itself. +// This is a declaration file for the JSDOM class, which is used in the CommonCartridgeManifestParser. +// Currently the JSDOM types are bit buggy and don't work properly with our project setup. + +declare module 'jsdom' { + class JSDOM { + constructor(html: string, options?: Record); + + window: Window; + } +} diff --git a/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-manifest-not-found.exception.spec.ts b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-manifest-not-found.exception.spec.ts new file mode 100644 index 00000000000..5a27b06a960 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-manifest-not-found.exception.spec.ts @@ -0,0 +1,29 @@ +import { CommonCartridgeManifestNotFoundException } from './common-cartridge-manifest-not-found.exception'; + +describe('CommonCartridgeManifestNotFoundException', () => { + describe('getLogMessage', () => { + describe('when returning a message', () => { + const setup = () => { + const sut = new CommonCartridgeManifestNotFoundException(); + + return { sut }; + }; + + it('should contain the type', () => { + const { sut } = setup(); + + const result = sut.getLogMessage(); + + expect(result.type).toEqual('WRONG_FILE_FORMAT'); + }); + + it('should contain the stack', () => { + const { sut } = setup(); + + const result = sut.getLogMessage(); + + expect(result.stack).toEqual(sut.stack); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-manifest-not-found.exception.ts b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-manifest-not-found.exception.ts new file mode 100644 index 00000000000..473d56515c4 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-manifest-not-found.exception.ts @@ -0,0 +1,17 @@ +import { BadRequestException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; + +export class CommonCartridgeManifestNotFoundException extends BadRequestException implements Loggable { + constructor() { + super('Manifest file not found.'); + } + + public getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: 'WRONG_FILE_FORMAT', + stack: this.stack, + }; + + return message; + } +} diff --git a/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-organization-visitor.spec.ts b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-organization-visitor.spec.ts new file mode 100644 index 00000000000..2af6b1b48fb --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-organization-visitor.spec.ts @@ -0,0 +1,62 @@ +import AdmZip from 'adm-zip'; +import { readFile } from 'fs/promises'; +import { JSDOM } from 'jsdom'; +import { DEFAULT_FILE_PARSER_OPTIONS } from '../common-cartridge-import.types'; +import { CommonCartridgeOrganizationVisitor } from './common-cartridge-organization-visitor'; + +describe('CommonCartridgeOrganizationVisitor', () => { + const setupDocument = async (loadFile: boolean) => { + if (!loadFile) { + const { document } = new JSDOM('', { contentType: 'text/xml' }).window; + + return document; + } + + const buffer = await readFile( + './apps/server/src/modules/common-cartridge/testing/assets/us_history_since_1877.imscc' + ); + const archive = new AdmZip(buffer); + const { document } = new JSDOM(archive.readAsText('imsmanifest.xml'), { contentType: 'text/xml' }).window; + + return document; + }; + + describe('findAllOrganizations', () => { + describe('when organizations are present', () => { + const setup = async () => { + const document = await setupDocument(true); + const sut = new CommonCartridgeOrganizationVisitor(document, { + maxSearchDepth: 1, + pathSeparator: DEFAULT_FILE_PARSER_OPTIONS.pathSeparator, + }); + + return { sut }; + }; + + it('should return the organizations', async () => { + const { sut } = await setup(); + + const result = sut.findAllOrganizations(); + + expect(result).toHaveLength(117); + }); + }); + + describe('when organizations are not present', () => { + const setup = async () => { + const document = await setupDocument(false); + const sut = new CommonCartridgeOrganizationVisitor(document, DEFAULT_FILE_PARSER_OPTIONS); + + return { sut }; + }; + + it('should return an empty array', async () => { + const { sut } = await setup(); + + const result = sut.findAllOrganizations(); + + expect(result).toHaveLength(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-organization-visitor.ts b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-organization-visitor.ts new file mode 100644 index 00000000000..44210bd1ca0 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/import/utils/common-cartridge-organization-visitor.ts @@ -0,0 +1,107 @@ +import { randomUUID } from 'crypto'; +import { CommonCartridgeFileParserOptions, OrganizationProps } from '../common-cartridge-import.types'; + +type SearchElement = { depth: number; path: string; element: Element }; + +/* + * `CommonCartridgeOrganizationVisitor` is a class that is used to traverse and extract information from + * an XML document representing a Common Cartridge package. The class uses a breadth-first search algorithm + * to visit 'item' elements in the XML document, up to a specified maximum depth. + * + * The class is initialized with an XML document and an optional `ManifestParserOptions` object, which can + * specify the maximum search depth and the path separator to use when constructing paths to elements. + * + * The main public method of the class is `findAllOrganizations()`, which returns an array of `OrganizationProps` + * objects representing all the 'organization' elements in the XML document. Each `OrganizationProps` object + * includes the path to the element, the identifier of the element, the identifierref of the element, and the + * title of the element. + * + * The class also includes several private helper methods for initializing the search, determining whether to + * continue the search, visiting an element, and creating an `OrganizationProps` object for an element. + */ +export class CommonCartridgeOrganizationVisitor { + constructor(private readonly document: Document, private readonly options: CommonCartridgeFileParserOptions) {} + + public findAllOrganizations(): OrganizationProps[] { + return this.search().map((element) => this.createOrganizationProps(element.element, element.path, element.depth)); + } + + private search(): SearchElement[] { + const result = new Array(); + const queue = this.initSearch(); + + while (queue.length > 0) { + const current = queue.shift(); + + if (current && this.shouldContinueSearch(current.depth)) { + this.visit(current, queue); + result.push(current); + } + } + + return result; + } + + private initSearch(): SearchElement[] { + const result = new Array(); + const root = this.document.querySelectorAll('manifest > organizations > organization > item > item'); + + root.forEach((element) => { + result.push({ + depth: 0, + path: this.getElementIdentifier(element), + element, + }); + }); + + return result; + } + + private shouldContinueSearch(depth: number): boolean { + const shouldContinueSearch = depth <= this.options.maxSearchDepth; + + return shouldContinueSearch; + } + + private visit(element: SearchElement, queue: SearchElement[]): void { + element.element.querySelectorAll('item').forEach((child) => { + queue.push({ + depth: element.depth + 1, + path: `${element.path}${this.options.pathSeparator}${this.getElementIdentifier(child)}`, + element: child, + }); + }); + } + + private getElementIdentifier(element: Element): string { + const identifier = element.getAttribute('identifier') ?? `i${randomUUID()}`; + + return identifier; + } + + private getElementIdentifierRef(element: Element): string | undefined { + const identifierRef = element.getAttribute('identifierref') ?? undefined; + + return identifierRef; + } + + private getElementTitle(element: Element): string { + const title = element.querySelector('title')?.textContent ?? ''; + + return title; + } + + private createOrganizationProps(element: Element, path: string, pathDepth: number): OrganizationProps { + const title = this.getElementTitle(element); + const identifier = this.getElementIdentifier(element); + const identifierRef = this.getElementIdentifierRef(element); + + return { + path, + pathDepth, + identifier, + identifierRef, + title, + }; + } +} diff --git a/apps/server/src/modules/common-cartridge/index.ts b/apps/server/src/modules/common-cartridge/index.ts new file mode 100644 index 00000000000..4c6746ee1e2 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/index.ts @@ -0,0 +1,23 @@ +export { + CommonCartridgeFileBuilder, + CommonCartridgeFileBuilderProps, +} from './export/builders/common-cartridge-file-builder'; +export { + CommonCartridgeOrganizationBuilder, + CommonCartridgeOrganizationBuilderOptions, +} from './export/builders/common-cartridge-organization-builder'; +export { + CommonCartridgeElementType, + CommonCartridgeIntendedUseType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from './export/common-cartridge.enums'; +export { CommonCartridgeElementProps } from './export/elements/common-cartridge-element-factory'; +export { CommonCartridgeResourceProps } from './export/resources/common-cartridge-resource-factory'; +export { OmitVersion, createIdentifier } from './export/utils'; +export { CommonCartridgeFileParser } from './import/common-cartridge-file-parser'; +export { + CommonCartridgeFileParserOptions, + DEFAULT_FILE_PARSER_OPTIONS, + OrganizationProps, +} from './import/common-cartridge-import.types'; diff --git a/apps/server/src/modules/common-cartridge/testing/assets/README.md b/apps/server/src/modules/common-cartridge/testing/assets/README.md new file mode 100644 index 00000000000..7500ae9bee3 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/testing/assets/README.md @@ -0,0 +1,7 @@ +# Important Note + +When working with the XML files in the assets folder in the `common-cartridge` module, please ensure that they are saved with LF (Line Feed) line endings instead of CRLF (Carriage Return + Line Feed). Additionally, make sure that no new line is inserted at the end of the XML files. + +This is important, as the XML files are used for comparison in tests and the Common Cartridge standard does not add a line at the end of the XML files. + +Thank you for your attention to this matter. diff --git a/apps/server/src/modules/common-cartridge/testing/assets/us_history_since_1877.imscc b/apps/server/src/modules/common-cartridge/testing/assets/us_history_since_1877.imscc new file mode 100644 index 00000000000..6557fb2686a Binary files /dev/null and b/apps/server/src/modules/common-cartridge/testing/assets/us_history_since_1877.imscc differ diff --git a/apps/server/src/modules/common-cartridge/testing/assets/v1.1.0/manifest.xml b/apps/server/src/modules/common-cartridge/testing/assets/v1.1.0/manifest.xml new file mode 100644 index 00000000000..661d182508f --- /dev/null +++ b/apps/server/src/modules/common-cartridge/testing/assets/v1.1.0/manifest.xml @@ -0,0 +1,42 @@ + + + + IMS Common Cartridge + 1.1.0 + + + + Common Cartridge Manifest + + + + + yes + + + 2023 John Doe, Jane Doe + + + + + + + + + Title 1 + + + Title 2 + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/server/src/modules/common-cartridge/testing/assets/v1.1.0/weblink.xml b/apps/server/src/modules/common-cartridge/testing/assets/v1.1.0/weblink.xml new file mode 100644 index 00000000000..e202cc742ec --- /dev/null +++ b/apps/server/src/modules/common-cartridge/testing/assets/v1.1.0/weblink.xml @@ -0,0 +1,5 @@ + + + Title + + \ No newline at end of file diff --git a/apps/server/src/modules/common-cartridge/testing/assets/v1.3.0/manifest.xml b/apps/server/src/modules/common-cartridge/testing/assets/v1.3.0/manifest.xml new file mode 100644 index 00000000000..3b955fa1855 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/testing/assets/v1.3.0/manifest.xml @@ -0,0 +1,42 @@ + + + + IMS Common Cartridge + 1.3.0 + + + + Common Cartridge Manifest + + + + + yes + + + 2023 John Doe, Jane Doe + + + + + + + + + Title 1 + + + Title 2 + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/server/src/modules/common-cartridge/testing/assets/v1.3.0/weblink.xml b/apps/server/src/modules/common-cartridge/testing/assets/v1.3.0/weblink.xml new file mode 100644 index 00000000000..47f4a784c22 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/testing/assets/v1.3.0/weblink.xml @@ -0,0 +1,5 @@ + + + Title + + \ No newline at end of file diff --git a/apps/server/src/modules/common-cartridge/testing/common-cartridge-element-props.factory.ts b/apps/server/src/modules/common-cartridge/testing/common-cartridge-element-props.factory.ts new file mode 100644 index 00000000000..000ad586840 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/testing/common-cartridge-element-props.factory.ts @@ -0,0 +1,96 @@ +import { faker } from '@faker-js/faker'; +import { CommonCartridgeElementType, CommonCartridgeVersion } from '@modules/common-cartridge'; +import { CommonCartridgeMetadataElementPropsV110 } from '../export/elements/v1.1.0/common-cartridge-metadata-element'; +import { CommonCartridgeOrganizationElementPropsV110 } from '../export/elements/v1.1.0/common-cartridge-organization-element'; +import { CommonCartridgeOrganizationsWrapperElementPropsV110 } from '../export/elements/v1.1.0/common-cartridge-organizations-wrapper-element'; +import { CommonCartridgeResourcesWrapperElementPropsV110 } from '../export/elements/v1.1.0/common-cartridge-resources-wrapper-element'; +import { CommonCartridgeMetadataElementPropsV130 } from '../export/elements/v1.3.0/common-cartridge-metadata-element'; +import { CommonCartridgeOrganizationElementPropsV130 } from '../export/elements/v1.3.0/common-cartridge-organization-element'; +import { CommonCartridgeOrganizationsWrapperElementPropsV130 } from '../export/elements/v1.3.0/common-cartridge-organizations-wrapper-element'; +import { CommonCartridgeResourcesWrapperElementPropsV130 } from '../export/elements/v1.3.0/common-cartridge-resources-wrapper-element'; +import { CommonCartridgeElement } from '../export/interfaces/common-cartridge-element.interface'; +import { CommonCartridgeResource } from '../export/interfaces/common-cartridge-resource.interface'; + +export function createCommonCartridgeMetadataElementPropsV110(): CommonCartridgeMetadataElementPropsV110 { + return { + type: CommonCartridgeElementType.METADATA, + version: CommonCartridgeVersion.V_1_1_0, + title: faker.lorem.words(), + creationDate: faker.date.past(), + copyrightOwners: [faker.person.fullName(), faker.person.fullName()], + }; +} + +export function createCommonCartridgeMetadataElementPropsV130(): CommonCartridgeMetadataElementPropsV130 { + return { + type: CommonCartridgeElementType.METADATA, + version: CommonCartridgeVersion.V_1_3_0, + title: faker.lorem.words(), + creationDate: faker.date.past(), + copyrightOwners: [faker.person.fullName(), faker.person.fullName()], + }; +} + +export function createCommonCartridgeOrganizationElementPropsV110( + items?: CommonCartridgeResource | Array +): CommonCartridgeOrganizationElementPropsV110 { + return { + type: CommonCartridgeElementType.ORGANIZATION, + identifier: faker.string.uuid(), + title: faker.lorem.words(), + items: items || [], + version: CommonCartridgeVersion.V_1_1_0, + }; +} + +export function createCommonCartridgeOrganizationElementPropsV130( + items?: CommonCartridgeResource | Array +): CommonCartridgeOrganizationElementPropsV130 { + return { + type: CommonCartridgeElementType.ORGANIZATION, + identifier: faker.string.uuid(), + title: faker.lorem.words(), + items: items || [], + version: CommonCartridgeVersion.V_1_3_0, + }; +} + +export function createCommonCartridgeOrganizationsWrapperElementPropsV110( + items?: CommonCartridgeElement[] +): CommonCartridgeOrganizationsWrapperElementPropsV110 { + return { + type: CommonCartridgeElementType.ORGANIZATIONS_WRAPPER, + version: CommonCartridgeVersion.V_1_1_0, + items: items || [], + }; +} + +export function createCommonCartridgeOrganizationsWrapperElementPropsV130( + items?: CommonCartridgeElement[] +): CommonCartridgeOrganizationsWrapperElementPropsV130 { + return { + type: CommonCartridgeElementType.ORGANIZATIONS_WRAPPER, + version: CommonCartridgeVersion.V_1_3_0, + items: items || [], + }; +} + +export function createCommonCartridgeResourcesWrapperElementPropsV110( + items?: CommonCartridgeResource[] +): CommonCartridgeResourcesWrapperElementPropsV110 { + return { + type: CommonCartridgeElementType.RESOURCES_WRAPPER, + version: CommonCartridgeVersion.V_1_1_0, + items: items || [], + }; +} + +export function createCommonCartridgeResourcesWrapperElementPropsV130( + items?: CommonCartridgeResource[] +): CommonCartridgeResourcesWrapperElementPropsV130 { + return { + type: CommonCartridgeElementType.RESOURCES_WRAPPER, + version: CommonCartridgeVersion.V_1_3_0, + items: items || [], + }; +} diff --git a/apps/server/src/modules/common-cartridge/testing/common-cartridge-resource-props.factory.ts b/apps/server/src/modules/common-cartridge/testing/common-cartridge-resource-props.factory.ts new file mode 100644 index 00000000000..cbe4f31f0c6 --- /dev/null +++ b/apps/server/src/modules/common-cartridge/testing/common-cartridge-resource-props.factory.ts @@ -0,0 +1,81 @@ +import { faker } from '@faker-js/faker'; +import { + CommonCartridgeIntendedUseType, + CommonCartridgeResourceType, + CommonCartridgeVersion, +} from '@modules/common-cartridge'; +import { CommonCartridgeElement } from '../export/interfaces/common-cartridge-element.interface'; +import { CommonCartridgeManifestResourcePropsV110 } from '../export/resources/v1.1.0/common-cartridge-manifest-resource'; +import { CommonCartridgeWebContentResourcePropsV110 } from '../export/resources/v1.1.0/common-cartridge-web-content-resource'; +import { CommonCartridgeWebLinkResourcePropsV110 } from '../export/resources/v1.1.0/common-cartridge-web-link-resource'; +import { CommonCartridgeManifestResourcePropsV130 } from '../export/resources/v1.3.0/common-cartridge-manifest-resource'; +import { CommonCartridgeWebContentResourcePropsV130 } from '../export/resources/v1.3.0/common-cartridge-web-content-resource'; +import { CommonCartridgeWebLinkResourcePropsV130 } from '../export/resources/v1.3.0/common-cartridge-web-link-resource'; + +export function createCommonCartridgeWeblinkResourcePropsV110(): CommonCartridgeWebLinkResourcePropsV110 { + return { + type: CommonCartridgeResourceType.WEB_LINK, + identifier: faker.string.uuid(), + title: faker.lorem.words(), + url: faker.internet.url(), + version: CommonCartridgeVersion.V_1_1_0, + folder: faker.string.alphanumeric(10), + }; +} + +export function createCommonCartridgeWeblinkResourcePropsV130(): CommonCartridgeWebLinkResourcePropsV130 { + return { + type: CommonCartridgeResourceType.WEB_LINK, + identifier: faker.string.uuid(), + title: faker.lorem.words(), + url: faker.internet.url(), + version: CommonCartridgeVersion.V_1_3_0, + folder: faker.string.alphanumeric(10), + }; +} + +export function createCommonCartridgeWebContentResourcePropsV110(): CommonCartridgeWebContentResourcePropsV110 { + return { + type: CommonCartridgeResourceType.WEB_CONTENT, + version: CommonCartridgeVersion.V_1_1_0, + identifier: faker.string.uuid(), + folder: faker.string.alphanumeric(10), + title: faker.lorem.words(), + html: faker.lorem.paragraphs(), + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }; +} + +export function createCommonCartridgeWebContentResourcePropsV130(): CommonCartridgeWebContentResourcePropsV130 { + return { + type: CommonCartridgeResourceType.WEB_CONTENT, + version: CommonCartridgeVersion.V_1_3_0, + identifier: faker.string.uuid(), + folder: faker.string.alphanumeric(10), + title: faker.lorem.words(), + html: faker.lorem.paragraphs(), + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }; +} + +export function createCommonCartridgeManifestResourcePropsV110(): CommonCartridgeManifestResourcePropsV110 { + return { + type: CommonCartridgeResourceType.MANIFEST, + version: CommonCartridgeVersion.V_1_1_0, + identifier: faker.string.uuid(), + metadata: {} as CommonCartridgeElement, + organizations: [], + resources: [], + }; +} + +export function createCommonCartridgeManifestResourcePropsV130(): CommonCartridgeManifestResourcePropsV130 { + return { + type: CommonCartridgeResourceType.MANIFEST, + version: CommonCartridgeVersion.V_1_3_0, + identifier: faker.string.uuid(), + metadata: {} as CommonCartridgeElement, + organizations: [], + resources: [], + }; +} diff --git a/apps/server/src/modules/copy-helper/dto/copy.response.ts b/apps/server/src/modules/copy-helper/dto/copy.response.ts index 549dcac7014..d3fbb33c8da 100644 --- a/apps/server/src/modules/copy-helper/dto/copy.response.ts +++ b/apps/server/src/modules/copy-helper/dto/copy.response.ts @@ -45,4 +45,11 @@ export class CopyApiResponse { description: 'List of included sub elements with recursive type structure', }) elements?: CopyApiResponse[]; + + @ApiPropertyOptional({ + isArray: true, + enum: CopyElementType, + description: 'Array with listed types of all sub elements', + }) + elementsTypes?: CopyElementType[]; } diff --git a/apps/server/src/modules/copy-helper/mapper/copy.mapper.ts b/apps/server/src/modules/copy-helper/mapper/copy.mapper.ts index 581e33161bd..0e65613fa8f 100644 --- a/apps/server/src/modules/copy-helper/mapper/copy.mapper.ts +++ b/apps/server/src/modules/copy-helper/mapper/copy.mapper.ts @@ -5,7 +5,7 @@ import { TaskCopyParentParams } from '@modules/task/types'; import { LessonEntity, Task } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; import { CopyApiResponse } from '../dto/copy.response'; -import { CopyStatus, CopyStatusEnum } from '../types/copy.types'; +import { CopyElementType, CopyStatus, CopyStatusEnum } from '../types/copy.types'; export class CopyMapper { static mapToResponse(copyStatus: CopyStatus): CopyApiResponse { @@ -46,4 +46,19 @@ export class CopyMapper { return dto; } + + static mapElementsToTypes(element: CopyStatus, types: CopyElementType[] = []): CopyElementType[] { + const indexOfFound = types.indexOf(element.type); + if (indexOfFound === -1) { + types.push(element.type); + } + + if (element.elements) { + element.elements.forEach((child) => { + this.mapElementsToTypes(child, types); + }); + } + + return types; + } } diff --git a/apps/server/src/modules/copy-helper/types/copy.types.ts b/apps/server/src/modules/copy-helper/types/copy.types.ts index e1c4269bbc9..c2c80f61fdb 100644 --- a/apps/server/src/modules/copy-helper/types/copy.types.ts +++ b/apps/server/src/modules/copy-helper/types/copy.types.ts @@ -38,6 +38,9 @@ export enum CopyElementType { LERNSTORE_MATERIAL_GROUP = 'LERNSTORE_MATERIAL_GROUP', LINK_ELEMENT = 'LINK_ELEMENT', LTITOOL_GROUP = 'LTITOOL_GROUP', + MEDIA_BOARD = 'MEDIA_BOARD', + MEDIA_LINE = 'MEDIA_LINE', + MEDIA_EXTERNAL_TOOL_ELEMENT = 'MEDIA_EXTERNAL_TOOL_ELEMENT', METADATA = 'METADATA', RICHTEXT_ELEMENT = 'RICHTEXT_ELEMENT', SUBMISSION_CONTAINER_ELEMENT = 'SUBMISSION_CONTAINER_ELEMENT', diff --git a/apps/server/src/modules/deletion-console/deletion-client/builder/deletion-request-input.builder.spec.ts b/apps/server/src/modules/deletion-console/deletion-client/builder/deletion-request-input.builder.spec.ts index bd49bb841e6..ca719fb316f 100644 --- a/apps/server/src/modules/deletion-console/deletion-client/builder/deletion-request-input.builder.spec.ts +++ b/apps/server/src/modules/deletion-console/deletion-client/builder/deletion-request-input.builder.spec.ts @@ -1,4 +1,4 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DeletionRequestInput } from '../interface'; import { DeletionRequestInputBuilder } from './deletion-request-input.builder'; diff --git a/apps/server/src/modules/deletion-console/deletion-client/builder/deletion-request-output.builder.spec.ts b/apps/server/src/modules/deletion-console/deletion-client/builder/deletion-request-output.builder.spec.ts index 399821f33ff..631f7335b13 100644 --- a/apps/server/src/modules/deletion-console/deletion-client/builder/deletion-request-output.builder.spec.ts +++ b/apps/server/src/modules/deletion-console/deletion-client/builder/deletion-request-output.builder.spec.ts @@ -1,4 +1,4 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DeletionRequestOutput } from '../interface'; import { DeletionRequestOutputBuilder } from './deletion-request-output.builder'; diff --git a/apps/server/src/modules/deletion-console/deletion-client/builder/deletion-request-target-ref-input.builder.spec.ts b/apps/server/src/modules/deletion-console/deletion-client/builder/deletion-request-target-ref-input.builder.spec.ts index 74b0631e49d..d25aa5c7295 100644 --- a/apps/server/src/modules/deletion-console/deletion-client/builder/deletion-request-target-ref-input.builder.spec.ts +++ b/apps/server/src/modules/deletion-console/deletion-client/builder/deletion-request-target-ref-input.builder.spec.ts @@ -1,4 +1,4 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DeletionRequestTargetRefInput } from '../interface'; import { DeletionRequestTargetRefInputBuilder } from './deletion-request-target-ref-input.builder'; diff --git a/apps/server/src/modules/deletion-console/deletion-client/deletion.client.ts b/apps/server/src/modules/deletion-console/deletion-client/deletion.client.ts index a3c47844656..598b6cf271f 100644 --- a/apps/server/src/modules/deletion-console/deletion-client/deletion.client.ts +++ b/apps/server/src/modules/deletion-console/deletion-client/deletion.client.ts @@ -1,5 +1,5 @@ import { HttpService } from '@nestjs/axios'; -import { BadGatewayException, Injectable } from '@nestjs/common'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ErrorUtils } from '@src/core/error/utils'; import { firstValueFrom } from 'rxjs'; @@ -57,7 +57,10 @@ export class DeletionClient { return resp.data; } catch (err) { // Throw an error if sending deletion request has failed. - throw new BadGatewayException('DeletionClient:queueDeletionRequest', ErrorUtils.createHttpExceptionOptions(err)); + throw new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(err, 'DeletionClient:queueDeletionRequest') + ); } } @@ -81,7 +84,10 @@ export class DeletionClient { } } catch (err) { // Throw an error if sending deletion request(s) execution trigger has failed. - throw new BadGatewayException('DeletionClient:executeDeletions', ErrorUtils.createHttpExceptionOptions(err)); + throw new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(err, 'DeletionClient:executeDeletions') + ); } } diff --git a/apps/server/src/modules/deletion-console/deletion-console.module.ts b/apps/server/src/modules/deletion-console/deletion-console.module.ts index 39a2784dd90..cf3a1dee2e7 100644 --- a/apps/server/src/modules/deletion-console/deletion-console.module.ts +++ b/apps/server/src/modules/deletion-console/deletion-console.module.ts @@ -4,22 +4,27 @@ import { ConfigModule } from '@nestjs/config'; import { ConsoleModule } from 'nestjs-console'; import { ConsoleWriterModule } from '@infra/console'; import { createConfigModuleOptions } from '@src/config'; -import { DeletionModule } from '@modules/deletion'; import { DeletionClient } from './deletion-client'; import { getDeletionClientConfig } from './deletion-client/deletion-client.config'; import { DeletionQueueConsole } from './deletion-queue.console'; import { DeletionExecutionConsole } from './deletion-execution.console'; import { BatchDeletionService } from './services'; -import { BatchDeletionUc } from './uc'; +import { BatchDeletionUc, DeletionExecutionUc } from './uc'; @Module({ imports: [ ConsoleModule, ConsoleWriterModule, - DeletionModule, HttpModule, ConfigModule.forRoot(createConfigModuleOptions(getDeletionClientConfig)), ], - providers: [DeletionClient, BatchDeletionService, BatchDeletionUc, DeletionQueueConsole, DeletionExecutionConsole], + providers: [ + DeletionClient, + BatchDeletionService, + BatchDeletionUc, + DeletionExecutionUc, + DeletionQueueConsole, + DeletionExecutionConsole, + ], }) export class DeletionConsoleModule {} diff --git a/apps/server/src/modules/deletion-console/services/batch-deletion.service.spec.ts b/apps/server/src/modules/deletion-console/services/batch-deletion.service.spec.ts index 1dd257786fc..6c09cc6c8d9 100644 --- a/apps/server/src/modules/deletion-console/services/batch-deletion.service.spec.ts +++ b/apps/server/src/modules/deletion-console/services/batch-deletion.service.spec.ts @@ -1,4 +1,4 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { DeletionClient, DeletionRequestOutput, DeletionRequestOutputBuilder } from '../deletion-client'; diff --git a/apps/server/src/modules/deletion-console/services/builder/queue-deletion-request-input.builder.spec.ts b/apps/server/src/modules/deletion-console/services/builder/queue-deletion-request-input.builder.spec.ts index e5d87858156..2ec9a4f05c8 100644 --- a/apps/server/src/modules/deletion-console/services/builder/queue-deletion-request-input.builder.spec.ts +++ b/apps/server/src/modules/deletion-console/services/builder/queue-deletion-request-input.builder.spec.ts @@ -1,4 +1,4 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { QueueDeletionRequestInput } from '../interface'; import { QueueDeletionRequestInputBuilder } from './queue-deletion-request-input.builder'; diff --git a/apps/server/src/modules/deletion-console/services/builder/queue-deletion-request-output.builder.spec.ts b/apps/server/src/modules/deletion-console/services/builder/queue-deletion-request-output.builder.spec.ts index cd835a9cf4a..877dcf87f0b 100644 --- a/apps/server/src/modules/deletion-console/services/builder/queue-deletion-request-output.builder.spec.ts +++ b/apps/server/src/modules/deletion-console/services/builder/queue-deletion-request-output.builder.spec.ts @@ -1,4 +1,4 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { QueueDeletionRequestOutput } from '../interface'; import { QueueDeletionRequestOutputBuilder } from './queue-deletion-request-output.builder'; diff --git a/apps/server/src/modules/deletion-console/uc/batch-deletion.uc.spec.ts b/apps/server/src/modules/deletion-console/uc/batch-deletion.uc.spec.ts index 3238ca32ffc..558f9a73276 100644 --- a/apps/server/src/modules/deletion-console/uc/batch-deletion.uc.spec.ts +++ b/apps/server/src/modules/deletion-console/uc/batch-deletion.uc.spec.ts @@ -1,4 +1,4 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { BatchDeletionUc } from './batch-deletion.uc'; diff --git a/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.spec.ts b/apps/server/src/modules/deletion/api/builder/deletion-request-body-props.builder.spec.ts similarity index 80% rename from apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.spec.ts rename to apps/server/src/modules/deletion/api/builder/deletion-request-body-props.builder.spec.ts index dcde2f6adb3..b86277aca15 100644 --- a/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.spec.ts +++ b/apps/server/src/modules/deletion/api/builder/deletion-request-body-props.builder.spec.ts @@ -1,6 +1,6 @@ import { ObjectId } from 'bson'; -import { DomainModel } from '@shared/domain/types'; -import { DeletionRequestBodyPropsBuilder } from './deletion-request-body-props.builder'; +import { DeletionRequestBodyPropsBuilder } from '.'; +import { DomainName } from '../../domain/types'; describe(DeletionRequestBodyPropsBuilder.name, () => { afterAll(() => { @@ -8,7 +8,7 @@ describe(DeletionRequestBodyPropsBuilder.name, () => { }); describe('when create deletionRequestBodyParams', () => { const setup = () => { - const domain = DomainModel.PSEUDONYMS; + const domain = DomainName.PSEUDONYMS; const refId = new ObjectId().toHexString(); const deleteInMinutes = 1000; return { domain, refId, deleteInMinutes }; diff --git a/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.ts b/apps/server/src/modules/deletion/api/builder/deletion-request-body-props.builder.ts similarity index 53% rename from apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.ts rename to apps/server/src/modules/deletion/api/builder/deletion-request-body-props.builder.ts index 21e00fb7ab0..7295d8c657f 100644 --- a/apps/server/src/modules/deletion/builder/deletion-request-body-props.builder.ts +++ b/apps/server/src/modules/deletion/api/builder/deletion-request-body-props.builder.ts @@ -1,8 +1,9 @@ -import { DomainModel, EntityId } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; import { DeletionRequestBodyProps } from '../controller/dto'; +import { DomainName } from '../../domain/types'; export class DeletionRequestBodyPropsBuilder { - static build(domain: DomainModel, id: EntityId, deleteInMinutes?: number): DeletionRequestBodyProps { + static build(domain: DomainName, id: EntityId, deleteInMinutes?: number): DeletionRequestBodyProps { const deletionRequestItem = { targetRef: { domain, id }, deleteInMinutes, diff --git a/apps/server/src/modules/deletion/api/builder/deletion-request-log-response.builder.spec.ts b/apps/server/src/modules/deletion/api/builder/deletion-request-log-response.builder.spec.ts new file mode 100644 index 00000000000..caa0998dea8 --- /dev/null +++ b/apps/server/src/modules/deletion/api/builder/deletion-request-log-response.builder.spec.ts @@ -0,0 +1,37 @@ +import { ObjectId } from 'bson'; +import { DomainName, StatusModel, DomainOperationReportBuilder, OperationType } from '../..'; +import { DeletionTargetRefBuilder, DeletionLogStatisticBuilder } from '../controller/dto/builder'; +import { DeletionRequestLogResponseBuilder } from './deletion-request-log-response.builder'; + +describe(DeletionRequestLogResponseBuilder, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + const setup = () => { + const targetRefDomain = DomainName.PSEUDONYMS; + const targetRefId = '653e4833cc39e5907a1e18d2'; + const targetRef = DeletionTargetRefBuilder.build(targetRefDomain, targetRefId); + const deletionPlannedAt = new Date(); + const status = StatusModel.SUCCESS; + const operations = [ + DomainOperationReportBuilder.build(OperationType.DELETE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]; + const statistics = [DeletionLogStatisticBuilder.build(targetRefDomain, operations)]; + + return { deletionPlannedAt, statistics, status, targetRef }; + }; + + it('should build generic deletionRequestLog with all attributes', () => { + const { deletionPlannedAt, statistics, status, targetRef } = setup(); + + const result = DeletionRequestLogResponseBuilder.build(targetRef, deletionPlannedAt, status, statistics); + + expect(result.targetRef).toEqual(targetRef); + expect(result.deletionPlannedAt).toEqual(deletionPlannedAt); + expect(result.status).toEqual(status); + expect(result.statistics).toEqual(statistics); + }); +}); diff --git a/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.ts b/apps/server/src/modules/deletion/api/builder/deletion-request-log-response.builder.ts similarity index 60% rename from apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.ts rename to apps/server/src/modules/deletion/api/builder/deletion-request-log-response.builder.ts index 04dccb52162..df67a209efa 100644 --- a/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.ts +++ b/apps/server/src/modules/deletion/api/builder/deletion-request-log-response.builder.ts @@ -1,14 +1,15 @@ -import { DomainOperation } from '@shared/domain/interface'; +import { DeletionTargetRef, DomainDeletionReport } from '../../domain/interface'; +import { StatusModel } from '../../domain/types'; import { DeletionRequestLogResponse } from '../controller/dto'; -import { DeletionTargetRef } from '../interface'; export class DeletionRequestLogResponseBuilder { static build( targetRef: DeletionTargetRef, deletionPlannedAt: Date, - statistics?: DomainOperation[] + status: StatusModel, + statistics?: DomainDeletionReport[] ): DeletionRequestLogResponse { - const deletionRequestLog = { targetRef, deletionPlannedAt, statistics }; + const deletionRequestLog = { targetRef, deletionPlannedAt, status, statistics }; return deletionRequestLog; } diff --git a/apps/server/src/modules/deletion/builder/index.ts b/apps/server/src/modules/deletion/api/builder/index.ts similarity index 53% rename from apps/server/src/modules/deletion/builder/index.ts rename to apps/server/src/modules/deletion/api/builder/index.ts index 7c40649196a..8375c95c989 100644 --- a/apps/server/src/modules/deletion/builder/index.ts +++ b/apps/server/src/modules/deletion/api/builder/index.ts @@ -1,4 +1,2 @@ -export * from './deletion-log-statistic.builder'; -export * from './deletion-target-ref.builder'; export * from './deletion-request-body-props.builder'; export * from './deletion-request-log-response.builder'; diff --git a/apps/server/src/modules/deletion/controller/api-test/deletion-executions.api.spec.ts b/apps/server/src/modules/deletion/api/controller/api-test/deletion-executions.api.spec.ts similarity index 90% rename from apps/server/src/modules/deletion/controller/api-test/deletion-executions.api.spec.ts rename to apps/server/src/modules/deletion/api/controller/api-test/deletion-executions.api.spec.ts index fdf1a30bf7a..ae95f07234c 100644 --- a/apps/server/src/modules/deletion/controller/api-test/deletion-executions.api.spec.ts +++ b/apps/server/src/modules/deletion/api/controller/api-test/deletion-executions.api.spec.ts @@ -1,16 +1,16 @@ +import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; import { ExecutionContext, INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { Request } from 'express'; import { AuthGuard } from '@nestjs/passport'; +import { Test, TestingModule } from '@nestjs/testing'; import { TestXApiKeyClient } from '@shared/testing'; -import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; +import { Request } from 'express'; const baseRouteName = '/deletionExecutions'; describe(`deletionExecution (api)`, () => { let app: INestApplication; let testXApiKeyClient: TestXApiKeyClient; - const API_KEY = '1ab2c3d4e5f61ab2c3d4e5f6'; + const API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -28,7 +28,7 @@ describe(`deletionExecution (api)`, () => { app = module.createNestApplication(); await app.init(); - testXApiKeyClient = new TestXApiKeyClient(app, baseRouteName); + testXApiKeyClient = new TestXApiKeyClient(app, baseRouteName, API_KEY); }); afterAll(async () => { diff --git a/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts b/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-create.api.spec.ts similarity index 96% rename from apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts rename to apps/server/src/modules/deletion/api/controller/api-test/deletion-request-create.api.spec.ts index 22814bf8590..40f1794c393 100644 --- a/apps/server/src/modules/deletion/controller/api-test/deletion-request-create.api.spec.ts +++ b/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-create.api.spec.ts @@ -5,9 +5,9 @@ import { AuthGuard } from '@nestjs/passport'; import { EntityManager } from '@mikro-orm/mongodb'; import { TestXApiKeyClient } from '@shared/testing'; import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; -import { DomainModel } from '@shared/domain/types'; import { DeletionRequestBodyProps, DeletionRequestResponse } from '../dto'; -import { DeletionRequestEntity } from '../../entity'; +import { DeletionRequestEntity } from '../../../repo/entity'; +import { DomainName } from '../../../domain/types'; const baseRouteName = '/deletionRequests'; @@ -55,7 +55,7 @@ describe(`deletionRequest create (api)`, () => { let app: INestApplication; let em: EntityManager; let testXApiKeyClient: TestXApiKeyClient; - const API_KEY = '1ab2c3d4e5f61ab2c3d4e5f6'; + const API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -86,14 +86,14 @@ describe(`deletionRequest create (api)`, () => { const setup = () => { const deletionRequestToCreate: DeletionRequestBodyProps = { targetRef: { - domain: DomainModel.USER, + domain: DomainName.USER, id: '653e4833cc39e5907a1e18d2', }, }; const deletionRequestToImmediateRemoval: DeletionRequestBodyProps = { targetRef: { - domain: DomainModel.USER, + domain: DomainName.USER, id: '653e4833cc39e5907a1e18d2', }, deleteInMinutes: 0, diff --git a/apps/server/src/modules/deletion/controller/api-test/deletion-request-delete.api.spec.ts b/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-delete.api.spec.ts similarity index 91% rename from apps/server/src/modules/deletion/controller/api-test/deletion-request-delete.api.spec.ts rename to apps/server/src/modules/deletion/api/controller/api-test/deletion-request-delete.api.spec.ts index 1c049cba0bc..7e3e7e48011 100644 --- a/apps/server/src/modules/deletion/controller/api-test/deletion-request-delete.api.spec.ts +++ b/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-delete.api.spec.ts @@ -5,8 +5,8 @@ import { AuthGuard } from '@nestjs/passport'; import { TestXApiKeyClient, cleanupCollections } from '@shared/testing'; import { EntityManager } from '@mikro-orm/mongodb'; import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; -import { deletionRequestEntityFactory } from '../../entity/testing'; -import { DeletionRequestEntity } from '../../entity'; +import { deletionRequestEntityFactory } from '../../../repo/entity/testing'; +import { DeletionRequestEntity } from '../../../repo/entity'; const baseRouteName = '/deletionRequests'; @@ -14,7 +14,7 @@ describe(`deletionRequest delete (api)`, () => { let app: INestApplication; let em: EntityManager; let testXApiKeyClient: TestXApiKeyClient; - const API_KEY = '1ab2c3d4e5f61ab2c3d4e5f6'; + const API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ diff --git a/apps/server/src/modules/deletion/controller/api-test/deletion-request-find.api.spec.ts b/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-find.api.spec.ts similarity index 91% rename from apps/server/src/modules/deletion/controller/api-test/deletion-request-find.api.spec.ts rename to apps/server/src/modules/deletion/api/controller/api-test/deletion-request-find.api.spec.ts index 437063e9651..4adb7781cbc 100644 --- a/apps/server/src/modules/deletion/controller/api-test/deletion-request-find.api.spec.ts +++ b/apps/server/src/modules/deletion/api/controller/api-test/deletion-request-find.api.spec.ts @@ -5,7 +5,7 @@ import { AuthGuard } from '@nestjs/passport'; import { EntityManager } from '@mikro-orm/mongodb'; import { TestXApiKeyClient, cleanupCollections } from '@shared/testing'; import { AdminApiServerTestModule } from '@modules/server/admin-api.server.module'; -import { deletionRequestEntityFactory } from '../../entity/testing'; +import { deletionRequestEntityFactory } from '../../../repo/entity/testing'; import { DeletionRequestLogResponse } from '../dto'; const baseRouteName = '/deletionRequests'; @@ -14,7 +14,7 @@ describe(`deletionRequest find (api)`, () => { let app: INestApplication; let em: EntityManager; let testXApiKeyClient: TestXApiKeyClient; - const API_KEY = '1ab2c3d4e5f61ab2c3d4e5f6'; + const API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -65,6 +65,7 @@ describe(`deletionRequest find (api)`, () => { const response = await testXApiKeyClient.get(`${deletionRequest.id}`); const result = response.body as DeletionRequestLogResponse; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expect(result.targetRef.id).toEqual(deletionRequest.targetRefId); }); }); diff --git a/apps/server/src/modules/deletion/controller/deletion-executions.controller.ts b/apps/server/src/modules/deletion/api/controller/deletion-executions.controller.ts similarity index 100% rename from apps/server/src/modules/deletion/controller/deletion-executions.controller.ts rename to apps/server/src/modules/deletion/api/controller/deletion-executions.controller.ts diff --git a/apps/server/src/modules/deletion/controller/deletion-requests.controller.ts b/apps/server/src/modules/deletion/api/controller/deletion-requests.controller.ts similarity index 100% rename from apps/server/src/modules/deletion/controller/deletion-requests.controller.ts rename to apps/server/src/modules/deletion/api/controller/deletion-requests.controller.ts diff --git a/apps/server/src/modules/deletion/api/controller/dto/builder/deletion-log-statistic.builder.spec.ts b/apps/server/src/modules/deletion/api/controller/dto/builder/deletion-log-statistic.builder.spec.ts new file mode 100644 index 00000000000..3cd7ee18865 --- /dev/null +++ b/apps/server/src/modules/deletion/api/controller/dto/builder/deletion-log-statistic.builder.spec.ts @@ -0,0 +1,30 @@ +import { ObjectId } from 'bson'; +import { DomainName, OperationType } from '../../../../domain/types'; +import { DomainOperationReportBuilder } from '../../../../domain/builder'; +import { DeletionLogStatisticBuilder } from '.'; + +describe(DeletionLogStatisticBuilder.name, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + const setup = () => { + const domain = DomainName.PSEUDONYMS; + const operations = [ + DomainOperationReportBuilder.build(OperationType.DELETE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]; + + return { domain, operations }; + }; + + it('should build generic deletionLogStatistic with all attributes', () => { + const { domain, operations } = setup(); + + const result = DeletionLogStatisticBuilder.build(domain, operations); + + expect(result.domain).toEqual(domain); + expect(result.operations).toEqual(operations); + }); +}); diff --git a/apps/server/src/modules/deletion/api/controller/dto/builder/deletion-log-statistic.builder.ts b/apps/server/src/modules/deletion/api/controller/dto/builder/deletion-log-statistic.builder.ts new file mode 100644 index 00000000000..e4f8ec8212c --- /dev/null +++ b/apps/server/src/modules/deletion/api/controller/dto/builder/deletion-log-statistic.builder.ts @@ -0,0 +1,18 @@ +import { DomainOperationReport, DomainDeletionReport } from '../../../../domain/interface'; +import { DomainName } from '../../../../domain/types'; + +export class DeletionLogStatisticBuilder { + static build( + domain: DomainName, + operations: DomainOperationReport[], + subdomainOperations?: DomainDeletionReport[] | null + ): DomainDeletionReport { + const deletionLogStatistic: DomainDeletionReport = { + domain, + operations, + subdomainOperations: subdomainOperations || null, + }; + + return deletionLogStatistic; + } +} diff --git a/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.spec.ts b/apps/server/src/modules/deletion/api/controller/dto/builder/deletion-target-ref.builder.spec.ts similarity index 72% rename from apps/server/src/modules/deletion/builder/deletion-target-ref.builder.spec.ts rename to apps/server/src/modules/deletion/api/controller/dto/builder/deletion-target-ref.builder.spec.ts index 762518d17bd..27dcaee8a58 100644 --- a/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.spec.ts +++ b/apps/server/src/modules/deletion/api/controller/dto/builder/deletion-target-ref.builder.spec.ts @@ -1,5 +1,5 @@ -import { DomainModel } from '@shared/domain/types'; -import { DeletionTargetRefBuilder } from './index'; +import { DomainName } from '../../../../domain/types'; +import { DeletionTargetRefBuilder } from '.'; describe(DeletionTargetRefBuilder.name, () => { afterAll(() => { @@ -8,7 +8,7 @@ describe(DeletionTargetRefBuilder.name, () => { it('should build generic deletionTargetRef with all attributes', () => { // Arrange - const domain = DomainModel.PSEUDONYMS; + const domain = DomainName.PSEUDONYMS; const refId = '653e4833cc39e5907a1e18d2'; const result = DeletionTargetRefBuilder.build(domain, refId); diff --git a/apps/server/src/modules/deletion/api/controller/dto/builder/deletion-target-ref.builder.ts b/apps/server/src/modules/deletion/api/controller/dto/builder/deletion-target-ref.builder.ts new file mode 100644 index 00000000000..555770ef84f --- /dev/null +++ b/apps/server/src/modules/deletion/api/controller/dto/builder/deletion-target-ref.builder.ts @@ -0,0 +1,11 @@ +import { EntityId } from '@shared/domain/types'; +import { DeletionTargetRef } from '../../../../domain/interface'; +import { DomainName } from '../../../../domain/types'; + +export class DeletionTargetRefBuilder { + static build(domain: DomainName, id: EntityId): DeletionTargetRef { + const deletionTargetRef = { domain, id }; + + return deletionTargetRef; + } +} diff --git a/apps/server/src/modules/deletion/api/controller/dto/builder/index.ts b/apps/server/src/modules/deletion/api/controller/dto/builder/index.ts new file mode 100644 index 00000000000..ef8e1f02fb7 --- /dev/null +++ b/apps/server/src/modules/deletion/api/controller/dto/builder/index.ts @@ -0,0 +1,2 @@ +export * from './deletion-log-statistic.builder'; +export * from './deletion-target-ref.builder'; diff --git a/apps/server/src/modules/deletion/controller/dto/deletion-execution.params.spec.ts b/apps/server/src/modules/deletion/api/controller/dto/deletion-execution.params.spec.ts similarity index 100% rename from apps/server/src/modules/deletion/controller/dto/deletion-execution.params.spec.ts rename to apps/server/src/modules/deletion/api/controller/dto/deletion-execution.params.spec.ts diff --git a/apps/server/src/modules/deletion/controller/dto/deletion-execution.params.ts b/apps/server/src/modules/deletion/api/controller/dto/deletion-execution.params.ts similarity index 100% rename from apps/server/src/modules/deletion/controller/dto/deletion-execution.params.ts rename to apps/server/src/modules/deletion/api/controller/dto/deletion-execution.params.ts diff --git a/apps/server/src/modules/deletion/api/controller/dto/deletion-request-log.response.spec.ts b/apps/server/src/modules/deletion/api/controller/dto/deletion-request-log.response.spec.ts new file mode 100644 index 00000000000..7e987c1841d --- /dev/null +++ b/apps/server/src/modules/deletion/api/controller/dto/deletion-request-log.response.spec.ts @@ -0,0 +1,39 @@ +import { ObjectId } from 'bson'; +import { DomainName, StatusModel, OperationType } from '../../../domain/types'; +import { DomainOperationReportBuilder } from '../../../domain/builder'; +import { DeletionTargetRefBuilder, DeletionLogStatisticBuilder } from './builder'; +import { DeletionRequestLogResponse } from './deletion-request-log.response'; + +describe(DeletionRequestLogResponse.name, () => { + describe('constructor', () => { + describe('when passed properties', () => { + const setup = () => { + const targetRefDomain = DomainName.PSEUDONYMS; + const targetRefId = new ObjectId().toHexString(); + const targetRef = DeletionTargetRefBuilder.build(targetRefDomain, targetRefId); + const status = StatusModel.SUCCESS; + const deletionPlannedAt = new Date(); + const operations = [ + DomainOperationReportBuilder.build(OperationType.DELETE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]; + const statistics = [DeletionLogStatisticBuilder.build(targetRefDomain, operations)]; + + return { targetRef, deletionPlannedAt, status, statistics }; + }; + + it('should set the id', () => { + const { targetRef, deletionPlannedAt, status, statistics } = setup(); + + const deletionRequestLog = new DeletionRequestLogResponse({ targetRef, deletionPlannedAt, status, statistics }); + + expect(deletionRequestLog.targetRef).toEqual(targetRef); + expect(deletionRequestLog.deletionPlannedAt).toEqual(deletionPlannedAt); + expect(deletionRequestLog.status).toEqual(status); + expect(deletionRequestLog.statistics).toEqual(statistics); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.ts b/apps/server/src/modules/deletion/api/controller/dto/deletion-request-log.response.ts similarity index 64% rename from apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.ts rename to apps/server/src/modules/deletion/api/controller/dto/deletion-request-log.response.ts index e0b5d1546fe..bbc252bd5b8 100644 --- a/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.ts +++ b/apps/server/src/modules/deletion/api/controller/dto/deletion-request-log.response.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsOptional } from 'class-validator'; -import { DomainOperation } from '@shared/domain/interface'; -import { DeletionTargetRef } from '../../interface'; +import { DeletionTargetRef, DomainDeletionReport } from '../../../domain/interface'; +import { StatusModel } from '../../../domain/types'; export class DeletionRequestLogResponse { @ApiProperty() @@ -10,13 +10,17 @@ export class DeletionRequestLogResponse { @ApiProperty() deletionPlannedAt: Date; + @ApiProperty() + status: StatusModel; + @ApiProperty() @IsOptional() - statistics?: DomainOperation[]; + statistics?: DomainDeletionReport[]; constructor(response: DeletionRequestLogResponse) { this.targetRef = response.targetRef; this.deletionPlannedAt = response.deletionPlannedAt; + this.status = response.status; this.statistics = response.statistics; } } diff --git a/apps/server/src/modules/deletion/controller/dto/deletion-request.body.params.spec.ts b/apps/server/src/modules/deletion/api/controller/dto/deletion-request.body.props.spec.ts similarity index 99% rename from apps/server/src/modules/deletion/controller/dto/deletion-request.body.params.spec.ts rename to apps/server/src/modules/deletion/api/controller/dto/deletion-request.body.props.spec.ts index 787e44ec22a..1c4b5b80460 100644 --- a/apps/server/src/modules/deletion/controller/dto/deletion-request.body.params.spec.ts +++ b/apps/server/src/modules/deletion/api/controller/dto/deletion-request.body.props.spec.ts @@ -1,4 +1,4 @@ -import { DeletionRequestBodyProps } from './deletion-request.body.params'; +import { DeletionRequestBodyProps } from './deletion-request.body.props'; describe(DeletionRequestBodyProps.name, () => { describe('constructor', () => { diff --git a/apps/server/src/modules/deletion/controller/dto/deletion-request.body.params.ts b/apps/server/src/modules/deletion/api/controller/dto/deletion-request.body.props.ts similarity index 87% rename from apps/server/src/modules/deletion/controller/dto/deletion-request.body.params.ts rename to apps/server/src/modules/deletion/api/controller/dto/deletion-request.body.props.ts index d772ba7bbb6..5dc07f31d9b 100644 --- a/apps/server/src/modules/deletion/controller/dto/deletion-request.body.params.ts +++ b/apps/server/src/modules/deletion/api/controller/dto/deletion-request.body.props.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsNumber, IsOptional, Min } from 'class-validator'; -import { DeletionTargetRef } from '../../interface'; +import { DeletionTargetRef } from '../../../domain/interface'; const MINUTES_OF_30_DAYS = 30 * 24 * 60; export class DeletionRequestBodyProps { diff --git a/apps/server/src/modules/deletion/controller/dto/deletion-request.response.spec.ts b/apps/server/src/modules/deletion/api/controller/dto/deletion-request.response.spec.ts similarity index 92% rename from apps/server/src/modules/deletion/controller/dto/deletion-request.response.spec.ts rename to apps/server/src/modules/deletion/api/controller/dto/deletion-request.response.spec.ts index d99dbf07c13..cfa1c855446 100644 --- a/apps/server/src/modules/deletion/controller/dto/deletion-request.response.spec.ts +++ b/apps/server/src/modules/deletion/api/controller/dto/deletion-request.response.spec.ts @@ -1,4 +1,4 @@ -import { deletionRequestFactory } from '../../domain/testing'; +import { deletionRequestFactory } from '../../../domain/testing'; import { DeletionRequestResponse } from './deletion-request.response'; describe(DeletionRequestResponse.name, () => { diff --git a/apps/server/src/modules/deletion/controller/dto/deletion-request.response.ts b/apps/server/src/modules/deletion/api/controller/dto/deletion-request.response.ts similarity index 100% rename from apps/server/src/modules/deletion/controller/dto/deletion-request.response.ts rename to apps/server/src/modules/deletion/api/controller/dto/deletion-request.response.ts diff --git a/apps/server/src/modules/deletion/controller/dto/index.ts b/apps/server/src/modules/deletion/api/controller/dto/index.ts similarity index 74% rename from apps/server/src/modules/deletion/controller/dto/index.ts rename to apps/server/src/modules/deletion/api/controller/dto/index.ts index 5f8cd514d35..9e620f67f2f 100644 --- a/apps/server/src/modules/deletion/controller/dto/index.ts +++ b/apps/server/src/modules/deletion/api/controller/dto/index.ts @@ -1,4 +1,4 @@ -export * from './deletion-request.response'; -export * from './deletion-request.body.params'; -export * from './deletion-request-log.response'; +export * from './deletion-request.body.props'; export * from './deletion-execution.params'; +export * from './deletion-request-log.response'; +export * from './deletion-request.response'; diff --git a/apps/server/src/modules/deletion/api/uc/deletion-request.uc.spec.ts b/apps/server/src/modules/deletion/api/uc/deletion-request.uc.spec.ts new file mode 100644 index 00000000000..78941a9e818 --- /dev/null +++ b/apps/server/src/modules/deletion/api/uc/deletion-request.uc.spec.ts @@ -0,0 +1,366 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { setupEntities } from '@shared/testing'; +import { ObjectId } from 'bson'; +import { EventBus } from '@nestjs/cqrs'; +import { LegacyLogger } from '@src/core/logger'; +import { DomainDeletionReportBuilder, DomainOperationReportBuilder } from '../../domain/builder'; +import { UserDeletedEvent } from '../../domain/event'; +import { DomainDeletionReport } from '../../domain/interface'; +import { DeletionRequestService, DeletionLogService } from '../../domain/service'; +import { deletionRequestFactory, deletionLogFactory } from '../../domain/testing'; +import { DomainName, OperationType, StatusModel } from '../../domain/types'; +import { DeletionRequestLogResponseBuilder } from '../builder'; +import { DeletionRequestBodyProps } from '../controller/dto'; +import { DeletionTargetRefBuilder, DeletionLogStatisticBuilder } from '../controller/dto/builder'; +import { DeletionRequestUc } from './deletion-request.uc'; + +describe(DeletionRequestUc.name, () => { + let module: TestingModule; + let uc: DeletionRequestUc; + let deletionRequestService: DeepMocked; + let deletionLogService: DeepMocked; + let eventBus: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + DeletionRequestUc, + { + provide: DeletionRequestService, + useValue: createMock(), + }, + { + provide: DeletionLogService, + useValue: createMock(), + }, + { + provide: LegacyLogger, + useValue: createMock(), + }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, + ], + }).compile(); + + uc = module.get(DeletionRequestUc); + deletionRequestService = module.get(DeletionRequestService); + deletionLogService = module.get(DeletionLogService); + eventBus = module.get(EventBus); + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createDeletionRequest', () => { + describe('when creating a deletionRequest', () => { + const setup = () => { + const deletionRequestToCreate: DeletionRequestBodyProps = { + targetRef: { + domain: DomainName.USER, + id: new ObjectId().toHexString(), + }, + deleteInMinutes: 1440, + }; + const deletionRequest = deletionRequestFactory.build(); + + return { + deletionRequestToCreate, + deletionRequest, + }; + }; + + it('should call the service to create the deletionRequest', async () => { + const { deletionRequestToCreate } = setup(); + + await uc.createDeletionRequest(deletionRequestToCreate); + + expect(deletionRequestService.createDeletionRequest).toHaveBeenCalledWith( + deletionRequestToCreate.targetRef.id, + deletionRequestToCreate.targetRef.domain, + deletionRequestToCreate.deleteInMinutes + ); + }); + + it('should return the deletionRequestID and deletionPlannedAt', async () => { + const { deletionRequestToCreate, deletionRequest } = setup(); + + deletionRequestService.createDeletionRequest.mockResolvedValueOnce({ + requestId: deletionRequest.id, + deletionPlannedAt: deletionRequest.deleteAfter, + }); + + const result = await uc.createDeletionRequest(deletionRequestToCreate); + + expect(result).toEqual({ + requestId: deletionRequest.id, + deletionPlannedAt: deletionRequest.deleteAfter, + }); + }); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.ACCOUNT; + const accountId = new ObjectId().toHexString(); + const deletionRequest = deletionRequestFactory.buildWithId({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const domainDeletionReport = DomainDeletionReportBuilder.build(DomainName.ACCOUNT, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [accountId]), + ]); + + return { + deletionRequestId, + domainDeletionReport, + }; + }; + + describe('when DataDeletedEvent is received', () => { + it('should call logDeletion', async () => { + const { deletionRequestId, domainDeletionReport } = setup(); + + await uc.handle({ deletionRequestId, domainDeletionReport }); + + expect(deletionLogService.createDeletionLog).toHaveBeenCalledWith(deletionRequestId, domainDeletionReport); + }); + }); + }); + + describe('executeDeletionRequests', () => { + describe('when executing deletionRequests', () => { + const setup = () => { + const deletionRequest = deletionRequestFactory.build({ deleteAfter: new Date('2023-01-01') }); + + return { + deletionRequest, + }; + }; + + it('should call deletionRequestService.findAllItemsToExecute', async () => { + await uc.executeDeletionRequests(); + + expect(deletionRequestService.findAllItemsToExecute).toHaveBeenCalled(); + }); + + it('should call deletionRequestService.markDeletionRequestAsPending to update status of deletionRequests', async () => { + const { deletionRequest } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequest]); + + await uc.executeDeletionRequests(); + + expect(deletionRequestService.markDeletionRequestAsPending).toHaveBeenCalledWith(deletionRequest.id); + }); + + it('should call eventBus.publish', async () => { + const { deletionRequest } = setup(); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequest]); + + await uc.executeDeletionRequests(); + + expect(eventBus.publish).toHaveBeenCalledWith( + new UserDeletedEvent(deletionRequest.id, deletionRequest.targetRefId) + ); + }); + }); + + describe('when an error occurred', () => { + const setup = () => { + const deletionRequestToExecute = deletionRequestFactory.build({ deleteAfter: new Date('2023-01-01') }); + + deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); + eventBus.publish.mockRejectedValueOnce(new Error()); + + return { + deletionRequestToExecute, + }; + }; + + it('should throw an arror', async () => { + const { deletionRequestToExecute } = setup(); + + await uc.executeDeletionRequests(); + + expect(deletionRequestService.markDeletionRequestAsFailed).toHaveBeenCalledWith(deletionRequestToExecute.id); + }); + }); + }); + + describe('findById', () => { + describe('when searching for logs for deletionRequest which was executed with success status', () => { + const setup = () => { + const deletionRequestExecuted = deletionRequestFactory.build({ status: StatusModel.SUCCESS }); + const deletionLogExecuted = deletionLogFactory.build({ deletionRequestId: deletionRequestExecuted.id }); + + const targetRef = DeletionTargetRefBuilder.build( + deletionRequestExecuted.targetRefDomain, + deletionRequestExecuted.targetRefId + ); + const statistics: DomainDeletionReport = DomainDeletionReportBuilder.build( + deletionLogExecuted.domain, + deletionLogExecuted.operations, + deletionLogExecuted.subdomainOperations + ); + + const executedDeletionRequestSummary = DeletionRequestLogResponseBuilder.build( + targetRef, + deletionRequestExecuted.deleteAfter, + StatusModel.SUCCESS, + [statistics] + ); + + return { + deletionRequestExecuted, + executedDeletionRequestSummary, + deletionLogExecuted, + }; + }; + + it('should call to deletionRequestService and deletionLogService', async () => { + const { deletionRequestExecuted } = setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequestExecuted); + + await uc.findById(deletionRequestExecuted.id); + + expect(deletionRequestService.findById).toHaveBeenCalledWith(deletionRequestExecuted.id); + expect(deletionLogService.findByDeletionRequestId).toHaveBeenCalledWith(deletionRequestExecuted.id); + }); + + it('should return object with summary of deletionRequest', async () => { + const { deletionRequestExecuted, deletionLogExecuted, executedDeletionRequestSummary } = setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequestExecuted); + deletionLogService.findByDeletionRequestId.mockResolvedValueOnce([deletionLogExecuted]); + + const result = await uc.findById(deletionRequestExecuted.id); + + expect(result).toEqual(executedDeletionRequestSummary); + expect(result.status).toEqual(StatusModel.SUCCESS); + }); + }); + + describe('when searching for logs for deletionRequest which was executed with failed status', () => { + const setup = () => { + const deletionRequestExecuted = deletionRequestFactory.build({ status: StatusModel.FAILED }); + const deletionLogExecuted = deletionLogFactory.build({ deletionRequestId: deletionRequestExecuted.id }); + + const targetRef = DeletionTargetRefBuilder.build( + deletionRequestExecuted.targetRefDomain, + deletionRequestExecuted.targetRefId + ); + const statistics = DeletionLogStatisticBuilder.build( + deletionLogExecuted.domain, + deletionLogExecuted.operations, + deletionLogExecuted.subdomainOperations + ); + + const executedDeletionRequestSummary = DeletionRequestLogResponseBuilder.build( + targetRef, + deletionRequestExecuted.deleteAfter, + StatusModel.FAILED, + [statistics] + ); + + return { + deletionRequestExecuted, + executedDeletionRequestSummary, + deletionLogExecuted, + }; + }; + + it('should call to deletionRequestService and deletionLogService', async () => { + const { deletionRequestExecuted } = setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequestExecuted); + + await uc.findById(deletionRequestExecuted.id); + + expect(deletionRequestService.findById).toHaveBeenCalledWith(deletionRequestExecuted.id); + expect(deletionLogService.findByDeletionRequestId).toHaveBeenCalledWith(deletionRequestExecuted.id); + }); + + it('should return object with summary of deletionRequest', async () => { + const { deletionRequestExecuted, deletionLogExecuted, executedDeletionRequestSummary } = setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequestExecuted); + deletionLogService.findByDeletionRequestId.mockResolvedValueOnce([deletionLogExecuted]); + + const result = await uc.findById(deletionRequestExecuted.id); + + expect(result).toEqual(executedDeletionRequestSummary); + expect(result.status).toEqual(StatusModel.FAILED); + }); + }); + + describe('when searching for logs for deletionRequest which was not executed', () => { + const setup = () => { + const deletionRequest = deletionRequestFactory.build(); + const targetRef = DeletionTargetRefBuilder.build(deletionRequest.targetRefDomain, deletionRequest.targetRefId); + const notExecutedDeletionRequestSummary = DeletionRequestLogResponseBuilder.build( + targetRef, + deletionRequest.deleteAfter, + StatusModel.REGISTERED, + [] + ); + + return { + deletionRequest, + notExecutedDeletionRequestSummary, + }; + }; + + it('should call to deletionRequestService and deletionLogService', async () => { + const { deletionRequest } = setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequest); + + await uc.findById(deletionRequest.id); + + expect(deletionRequestService.findById).toHaveBeenCalledWith(deletionRequest.id); + expect(deletionLogService.findByDeletionRequestId).toHaveBeenCalledWith(deletionRequest.id); + }); + + it('should return object with summary of deletionRequest', async () => { + const { deletionRequest, notExecutedDeletionRequestSummary } = setup(); + + deletionRequestService.findById.mockResolvedValueOnce(deletionRequest); + + const result = await uc.findById(deletionRequest.id); + + expect(result).toEqual(notExecutedDeletionRequestSummary); + expect(result.status).toEqual(StatusModel.REGISTERED); + }); + }); + }); + + describe('deleteDeletionRequestById', () => { + describe('when deleting a deletionRequestId', () => { + const setup = () => { + const deletionRequest = deletionRequestFactory.build(); + + return { + deletionRequest, + }; + }; + + it('should call the service deletionRequestService.deleteById', async () => { + const { deletionRequest } = setup(); + + await uc.deleteDeletionRequestById(deletionRequest.id); + + expect(deletionRequestService.deleteById).toHaveBeenCalledWith(deletionRequest.id); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/api/uc/deletion-request.uc.ts b/apps/server/src/modules/deletion/api/uc/deletion-request.uc.ts new file mode 100644 index 00000000000..4c4730c5c83 --- /dev/null +++ b/apps/server/src/modules/deletion/api/uc/deletion-request.uc.ts @@ -0,0 +1,118 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { LegacyLogger } from '@src/core/logger'; +import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { DomainDeletionReportBuilder } from '../../domain/builder'; +import { DeletionLog, DeletionRequest } from '../../domain/do'; +import { DataDeletedEvent, UserDeletedEvent } from '../../domain/event'; +import { DomainDeletionReport } from '../../domain/interface'; +import { DeletionRequestService, DeletionLogService } from '../../domain/service'; +import { DeletionRequestLogResponseBuilder } from '../builder'; +import { DeletionRequestBodyProps, DeletionRequestResponse, DeletionRequestLogResponse } from '../controller/dto'; +import { DeletionTargetRefBuilder } from '../controller/dto/builder'; + +@Injectable() +@EventsHandler(DataDeletedEvent) +export class DeletionRequestUc implements IEventHandler { + config: string[]; + + constructor( + private readonly deletionRequestService: DeletionRequestService, + private readonly deletionLogService: DeletionLogService, + private readonly logger: LegacyLogger, + private readonly eventBus: EventBus + ) { + this.logger.setContext(DeletionRequestUc.name); + this.config = [ + 'account', + 'class', + 'courseGroup', + 'course', + 'dashboard', + 'file', + 'fileRecords', + 'lessons', + 'pseudonyms', + 'rocketChatUser', + 'task', + 'teams', + 'user', + 'submissions', + 'news', + ]; + } + + async handle({ deletionRequestId, domainDeletionReport }: DataDeletedEvent) { + await this.deletionLogService.createDeletionLog(deletionRequestId, domainDeletionReport); + + const deletionLogs: DeletionLog[] = await this.deletionLogService.findByDeletionRequestId(deletionRequestId); + + if (this.checkLogsPerDomain(deletionLogs)) { + await this.deletionRequestService.markDeletionRequestAsExecuted(deletionRequestId); + } + } + + private checkLogsPerDomain(deletionLogs: DeletionLog[]): boolean { + return this.config.every((domain) => deletionLogs.some((log) => log.domain === domain)); + } + + async createDeletionRequest(deletionRequest: DeletionRequestBodyProps): Promise { + this.logger.debug({ action: 'createDeletionRequest', deletionRequest }); + const result = await this.deletionRequestService.createDeletionRequest( + deletionRequest.targetRef.id, + deletionRequest.targetRef.domain, + deletionRequest.deleteInMinutes + ); + + return result; + } + + async executeDeletionRequests(limit?: number): Promise { + this.logger.debug({ action: 'executeDeletionRequests', limit }); + + const deletionRequestToExecution: DeletionRequest[] = await this.deletionRequestService.findAllItemsToExecute( + limit + ); + + await Promise.all( + deletionRequestToExecution.map(async (req) => { + await this.executeDeletionRequest(req); + }) + ); + } + + async findById(deletionRequestId: EntityId): Promise { + this.logger.debug({ action: 'findById', deletionRequestId }); + + const deletionRequest: DeletionRequest = await this.deletionRequestService.findById(deletionRequestId); + let response: DeletionRequestLogResponse = DeletionRequestLogResponseBuilder.build( + DeletionTargetRefBuilder.build(deletionRequest.targetRefDomain, deletionRequest.targetRefId), + deletionRequest.deleteAfter, + deletionRequest.status + ); + + const deletionLog: DeletionLog[] = await this.deletionLogService.findByDeletionRequestId(deletionRequestId); + const domainOperation: DomainDeletionReport[] = deletionLog.map((log) => + DomainDeletionReportBuilder.build(log.domain, log.operations, log.subdomainOperations) + ); + response = { ...response, statistics: domainOperation }; + + return response; + } + + async deleteDeletionRequestById(deletionRequestId: EntityId): Promise { + this.logger.debug({ action: 'deleteDeletionRequestById', deletionRequestId }); + + await this.deletionRequestService.deleteById(deletionRequestId); + } + + private async executeDeletionRequest(deletionRequest: DeletionRequest): Promise { + try { + await this.eventBus.publish(new UserDeletedEvent(deletionRequest.id, deletionRequest.targetRefId)); + await this.deletionRequestService.markDeletionRequestAsPending(deletionRequest.id); + } catch (error) { + this.logger.error(`execution of deletionRequest ${deletionRequest.id} has failed`, error); + await this.deletionRequestService.markDeletionRequestAsFailed(deletionRequest.id); + } + } +} diff --git a/apps/server/src/modules/deletion/uc/index.ts b/apps/server/src/modules/deletion/api/uc/index.ts similarity index 58% rename from apps/server/src/modules/deletion/uc/index.ts rename to apps/server/src/modules/deletion/api/uc/index.ts index 7023c48ff6b..d9f93f4f8ad 100644 --- a/apps/server/src/modules/deletion/uc/index.ts +++ b/apps/server/src/modules/deletion/api/uc/index.ts @@ -1,2 +1 @@ -export * from '../builder'; export * from './deletion-request.uc'; diff --git a/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.spec.ts b/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.spec.ts deleted file mode 100644 index a8a998e7a4e..00000000000 --- a/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { DomainModel } from '@shared/domain/types'; -import { DeletionLogStatisticBuilder } from '.'; - -describe(DeletionLogStatisticBuilder.name, () => { - afterAll(() => { - jest.clearAllMocks(); - }); - - it('should build generic deletionLogStatistic with all attributes', () => { - // Arrange - const domain = DomainModel.PSEUDONYMS; - const modifiedCount = 0; - const deletedCount = 2; - - const result = DeletionLogStatisticBuilder.build(domain, modifiedCount, deletedCount); - - // Assert - expect(result.domain).toEqual(domain); - expect(result.modifiedCount).toEqual(modifiedCount); - expect(result.deletedCount).toEqual(deletedCount); - }); -}); diff --git a/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.ts b/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.ts deleted file mode 100644 index fa0680b8500..00000000000 --- a/apps/server/src/modules/deletion/builder/deletion-log-statistic.builder.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DomainOperation } from '@shared/domain/interface'; -import { DomainModel } from '@shared/domain/types'; - -export class DeletionLogStatisticBuilder { - static build( - domain: DomainModel, - modifiedCount: number, - deletedCount: number, - modifiedRef?: string[], - deletedRef?: string[] - ): DomainOperation { - const deletionLogStatistic = { domain, modifiedCount, deletedCount, modifiedRef, deletedRef }; - - return deletionLogStatistic; - } -} diff --git a/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.spec.ts b/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.spec.ts deleted file mode 100644 index 718af3faf2d..00000000000 --- a/apps/server/src/modules/deletion/builder/deletion-request-log-response.builder.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DomainModel } from '@shared/domain/types'; -import { DeletionLogStatisticBuilder, DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder } from '.'; - -describe(DeletionRequestLogResponseBuilder, () => { - afterAll(() => { - jest.clearAllMocks(); - }); - - it('should build generic deletionRequestLog with all attributes', () => { - // Arrange - const targetRefDomain = DomainModel.PSEUDONYMS; - const targetRefId = '653e4833cc39e5907a1e18d2'; - const targetRef = DeletionTargetRefBuilder.build(targetRefDomain, targetRefId); - const deletionPlannedAt = new Date(); - const modifiedCount = 0; - const deletedCount = 2; - const statistics = [DeletionLogStatisticBuilder.build(targetRefDomain, modifiedCount, deletedCount)]; - - const result = DeletionRequestLogResponseBuilder.build(targetRef, deletionPlannedAt, statistics); - - // Assert - expect(result.targetRef).toEqual(targetRef); - expect(result.deletionPlannedAt).toEqual(deletionPlannedAt); - expect(result.statistics).toEqual(statistics); - }); -}); diff --git a/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.ts b/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.ts deleted file mode 100644 index 1d1cee14a04..00000000000 --- a/apps/server/src/modules/deletion/builder/deletion-target-ref.builder.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DomainModel, EntityId } from '@shared/domain/types'; -import { DeletionTargetRef } from '../interface'; - -export class DeletionTargetRefBuilder { - static build(domain: DomainModel, id: EntityId): DeletionTargetRef { - const deletionTargetRef = { domain, id }; - - return deletionTargetRef; - } -} diff --git a/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.spec.ts b/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.spec.ts deleted file mode 100644 index be91c23b06c..00000000000 --- a/apps/server/src/modules/deletion/controller/dto/deletion-request-log.response.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ObjectId } from 'bson'; -import { DomainModel } from '@shared/domain/types'; -import { DeletionLogStatisticBuilder, DeletionTargetRefBuilder } from '../../builder'; -import { DeletionRequestLogResponse } from './index'; - -describe(DeletionRequestLogResponse.name, () => { - describe('constructor', () => { - describe('when passed properties', () => { - const setup = () => { - const targetRefDomain = DomainModel.PSEUDONYMS; - const targetRefId = new ObjectId().toHexString(); - const targetRef = DeletionTargetRefBuilder.build(targetRefDomain, targetRefId); - const deletionPlannedAt = new Date(); - const modifiedCount = 0; - const deletedCount = 2; - const statistics = [DeletionLogStatisticBuilder.build(targetRefDomain, modifiedCount, deletedCount)]; - - return { targetRef, deletionPlannedAt, statistics }; - }; - - it('should set the id', () => { - const { targetRef, deletionPlannedAt, statistics } = setup(); - - const deletionRequestLog = new DeletionRequestLogResponse({ targetRef, deletionPlannedAt, statistics }); - - expect(deletionRequestLog.targetRef).toEqual(targetRef); - expect(deletionRequestLog.deletionPlannedAt).toEqual(deletionPlannedAt); - expect(deletionRequestLog.statistics).toEqual(statistics); - }); - }); - }); -}); diff --git a/apps/server/src/modules/deletion/deletion-api.module.ts b/apps/server/src/modules/deletion/deletion-api.module.ts index 5e4a6cf427d..31d39f78d3f 100644 --- a/apps/server/src/modules/deletion/deletion-api.module.ts +++ b/apps/server/src/modules/deletion/deletion-api.module.ts @@ -1,49 +1,32 @@ import { Module } from '@nestjs/common'; -import { DeletionModule } from '@modules/deletion'; -import { AccountModule } from '@modules/account'; -import { ClassModule } from '@modules/class'; -import { LearnroomModule } from '@modules/learnroom'; -import { FilesModule } from '@modules/files'; -import { PseudonymModule } from '@modules/pseudonym'; -import { LessonModule } from '@modules/lesson'; -import { TeamsModule } from '@modules/teams'; -import { UserModule } from '@modules/user'; import { LoggerModule } from '@src/core/logger'; +import { CqrsModule } from '@nestjs/cqrs'; import { AuthenticationModule } from '@modules/authentication'; import { RocketChatUserModule } from '@modules/rocketchat-user'; -import { Configuration } from '@hpi-schul-cloud/commons'; -import { RocketChatModule } from '@modules/rocketchat'; -import { RegistrationPinModule } from '@modules/registration-pin'; -import { TaskModule } from '@modules/task'; -import { FilesStorageClientModule } from '@modules/files-storage-client'; -import { DeletionRequestsController } from './controller/deletion-requests.controller'; -import { DeletionExecutionsController } from './controller/deletion-executions.controller'; -import { DeletionRequestUc } from './uc'; +import { ClassModule } from '@modules/class'; +import { NewsModule } from '@modules/news'; +import { TeamsModule } from '@modules/teams'; +import { PseudonymModule } from '@modules/pseudonym'; +import { FilesModule } from '@modules/files'; +import { CalendarModule } from '@src/infra/calendar'; +import { DeletionModule } from '.'; +import { DeletionRequestUc } from './api/uc'; +import { DeletionExecutionsController } from './api/controller/deletion-executions.controller'; +import { DeletionRequestsController } from './api/controller/deletion-requests.controller'; @Module({ imports: [ + CalendarModule, + CqrsModule, DeletionModule, - AccountModule, - ClassModule, - LearnroomModule, - FilesModule, - LessonModule, - PseudonymModule, - TeamsModule, - UserModule, LoggerModule, AuthenticationModule, + ClassModule, + NewsModule, + TeamsModule, + PseudonymModule, + FilesModule, RocketChatUserModule, - RegistrationPinModule, - FilesStorageClientModule, - TaskModule, - RocketChatModule.forRoot({ - uri: Configuration.get('ROCKET_CHAT_URI') as string, - adminId: Configuration.get('ROCKET_CHAT_ADMIN_ID') as string, - adminToken: Configuration.get('ROCKET_CHAT_ADMIN_TOKEN') as string, - adminUser: Configuration.get('ROCKET_CHAT_ADMIN_USER') as string, - adminPassword: Configuration.get('ROCKET_CHAT_ADMIN_PASSWORD') as string, - }), ], controllers: [DeletionRequestsController, DeletionExecutionsController], providers: [DeletionRequestUc], diff --git a/apps/server/src/modules/deletion/deletion.module.ts b/apps/server/src/modules/deletion/deletion.module.ts index 3be026b827c..7769f86ddf4 100644 --- a/apps/server/src/modules/deletion/deletion.module.ts +++ b/apps/server/src/modules/deletion/deletion.module.ts @@ -1,10 +1,8 @@ import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { DeletionRequestService } from './services/deletion-request.service'; -import { DeletionRequestRepo } from './repo/deletion-request.repo'; +import { DeletionRequestService, DeletionLogService } from './domain/service'; +import { DeletionRequestRepo, DeletionLogRepo } from './repo'; import { XApiKeyConfig } from '../authentication/config/x-api-key.config'; -import { DeletionLogService } from './services/deletion-log.service'; -import { DeletionLogRepo } from './repo'; @Module({ providers: [ diff --git a/apps/server/src/modules/deletion/domain/builder/domain-deletion-report.builder.spec.ts b/apps/server/src/modules/deletion/domain/builder/domain-deletion-report.builder.spec.ts new file mode 100644 index 00000000000..e1f44f5218d --- /dev/null +++ b/apps/server/src/modules/deletion/domain/builder/domain-deletion-report.builder.spec.ts @@ -0,0 +1,30 @@ +import { ObjectId } from 'bson'; +import { DomainName, OperationType } from '../types'; +import { DomainDeletionReportBuilder } from './domain-deletion-report.builder'; +import { DomainOperationReportBuilder } from './domain-operation-report.builder'; + +describe(DomainDeletionReportBuilder.name, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const domain = DomainName.ACCOUNT; + const operation = OperationType.DELETE; + const refs = [new ObjectId().toHexString(), new ObjectId().toHexString()]; + const count = 2; + + const operationReport = DomainOperationReportBuilder.build(operation, count, refs); + + return { domain, operationReport }; + }; + + it('should build generic domainDeletionReport with all attributes', () => { + const { domain, operationReport } = setup(); + + const result = DomainDeletionReportBuilder.build(domain, [operationReport]); + + expect(result.domain).toEqual(domain); + expect(result.operations).toEqual([operationReport]); + }); +}); diff --git a/apps/server/src/modules/deletion/domain/builder/domain-deletion-report.builder.ts b/apps/server/src/modules/deletion/domain/builder/domain-deletion-report.builder.ts new file mode 100644 index 00000000000..453753c7710 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/builder/domain-deletion-report.builder.ts @@ -0,0 +1,15 @@ +import { DomainDeletionReport } from '../interface/domain-deletion-report'; +import { DomainOperationReport } from '../interface/domain-operation-report'; +import { DomainName } from '../types/domain-name.enum'; + +export class DomainDeletionReportBuilder { + static build( + domain: DomainName, + operations: DomainOperationReport[], + subdomainOperations?: DomainDeletionReport[] + ): DomainDeletionReport { + const domainDeletionReport = { domain, operations, subdomainOperations: subdomainOperations || null }; + + return domainDeletionReport; + } +} diff --git a/apps/server/src/modules/deletion/domain/builder/domain-operation-report.builder.spec.ts b/apps/server/src/modules/deletion/domain/builder/domain-operation-report.builder.spec.ts new file mode 100644 index 00000000000..17647a1e74f --- /dev/null +++ b/apps/server/src/modules/deletion/domain/builder/domain-operation-report.builder.spec.ts @@ -0,0 +1,27 @@ +import { ObjectId } from 'bson'; +import { OperationType } from '../types'; +import { DomainOperationReportBuilder } from './domain-operation-report.builder'; + +describe(DomainOperationReportBuilder.name, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const operation = OperationType.DELETE; + const refs = [new ObjectId().toHexString(), new ObjectId().toHexString()]; + const count = 2; + + return { count, operation, refs }; + }; + + it('should build generic domainOperationReport with all attributes', () => { + const { count, operation, refs } = setup(); + + const result = DomainOperationReportBuilder.build(operation, count, refs); + + expect(result.operation).toEqual(operation); + expect(result.count).toEqual(count); + expect(result.refs).toEqual(refs); + }); +}); diff --git a/apps/server/src/modules/deletion/domain/builder/domain-operation-report.builder.ts b/apps/server/src/modules/deletion/domain/builder/domain-operation-report.builder.ts new file mode 100644 index 00000000000..bd62a81acff --- /dev/null +++ b/apps/server/src/modules/deletion/domain/builder/domain-operation-report.builder.ts @@ -0,0 +1,11 @@ +import { EntityId } from '@shared/domain/types'; +import { OperationType } from '../types/operation-type.enum'; +import { DomainOperationReport } from '../interface/domain-operation-report'; + +export class DomainOperationReportBuilder { + static build(operation: OperationType, count: number, refs: EntityId[]): DomainOperationReport { + const domainOperationReport = { operation, count, refs }; + + return domainOperationReport; + } +} diff --git a/apps/server/src/modules/deletion/domain/builder/index.ts b/apps/server/src/modules/deletion/domain/builder/index.ts new file mode 100644 index 00000000000..178e63924f7 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/builder/index.ts @@ -0,0 +1,2 @@ +export * from './domain-deletion-report.builder'; +export * from './domain-operation-report.builder'; diff --git a/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts b/apps/server/src/modules/deletion/domain/do/deletion-log.do.spec.ts similarity index 67% rename from apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts rename to apps/server/src/modules/deletion/domain/do/deletion-log.do.spec.ts index 10040956627..f739595af38 100644 --- a/apps/server/src/modules/deletion/domain/deletion-log.do.spec.ts +++ b/apps/server/src/modules/deletion/domain/do/deletion-log.do.spec.ts @@ -1,8 +1,8 @@ import { ObjectId } from '@mikro-orm/mongodb'; -import { DomainModel } from '@shared/domain/types'; -import { deletionLogFactory } from './testing/factory/deletion-log.factory'; +import { DomainOperationReportBuilder, DomainDeletionReportBuilder } from '../builder'; +import { deletionLogFactory } from '../testing'; +import { DomainName, OperationType } from '../types'; import { DeletionLog } from './deletion-log.do'; -import { DeletionOperationModel } from './types'; describe(DeletionLog.name, () => { describe('constructor', () => { @@ -34,13 +34,20 @@ describe(DeletionLog.name, () => { describe('getters', () => { describe('When getters are used', () => { const setup = () => { + const deletionRequestId = new ObjectId().toHexString(); const props = { id: new ObjectId().toHexString(), - domain: DomainModel.USER, - operation: DeletionOperationModel.DELETE, - modifiedCount: 0, - deletedCount: 1, - deletionRequestId: new ObjectId().toHexString(), + domain: DomainName.USER, + operations: [DomainOperationReportBuilder.build(OperationType.DELETE, 1, [new ObjectId().toHexString()])], + subdomainOperations: [ + DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]), + ], + deletionRequestId, performedAt: new Date(), createdAt: new Date(), updatedAt: new Date(), @@ -56,9 +63,8 @@ describe(DeletionLog.name, () => { const gettersValues = { id: deletionLogDo.id, domain: deletionLogDo.domain, - operation: deletionLogDo.operation, - modifiedCount: deletionLogDo.modifiedCount, - deletedCount: deletionLogDo.deletedCount, + operations: deletionLogDo.operations, + subdomainOperations: deletionLogDo.subdomainOperations, deletionRequestId: deletionLogDo.deletionRequestId, performedAt: deletionLogDo.performedAt, createdAt: deletionLogDo.createdAt, diff --git a/apps/server/src/modules/deletion/domain/deletion-log.do.ts b/apps/server/src/modules/deletion/domain/do/deletion-log.do.ts similarity index 51% rename from apps/server/src/modules/deletion/domain/deletion-log.do.ts rename to apps/server/src/modules/deletion/domain/do/deletion-log.do.ts index 81a9c7f41cb..34c58632ad3 100644 --- a/apps/server/src/modules/deletion/domain/deletion-log.do.ts +++ b/apps/server/src/modules/deletion/domain/do/deletion-log.do.ts @@ -1,15 +1,15 @@ -import { DomainModel, EntityId } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; -import { DeletionOperationModel } from './types'; +import { DomainOperationReport, DomainDeletionReport } from '../interface'; +import { DomainName } from '../types'; export interface DeletionLogProps extends AuthorizableObject { createdAt?: Date; updatedAt?: Date; - domain: DomainModel; - operation?: DeletionOperationModel; - modifiedCount: number; - deletedCount: number; - deletionRequestId?: EntityId; + domain: DomainName; + operations: DomainOperationReport[]; + subdomainOperations?: DomainDeletionReport[]; + deletionRequestId: EntityId; performedAt?: Date; } @@ -22,23 +22,19 @@ export class DeletionLog extends DomainObject { return this.props.updatedAt; } - get domain(): DomainModel { + get domain(): DomainName { return this.props.domain; } - get operation(): DeletionOperationModel | undefined { - return this.props.operation; + get operations(): DomainOperationReport[] { + return this.props.operations; } - get modifiedCount(): number { - return this.props.modifiedCount; + get subdomainOperations(): DomainDeletionReport[] | undefined { + return this.props.subdomainOperations; } - get deletedCount(): number { - return this.props.deletedCount; - } - - get deletionRequestId(): EntityId | undefined { + get deletionRequestId(): EntityId { return this.props.deletionRequestId; } diff --git a/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts b/apps/server/src/modules/deletion/domain/do/deletion-request.do.spec.ts similarity index 87% rename from apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts rename to apps/server/src/modules/deletion/domain/do/deletion-request.do.spec.ts index 2650e894fdc..ff6f7f13a9e 100644 --- a/apps/server/src/modules/deletion/domain/deletion-request.do.spec.ts +++ b/apps/server/src/modules/deletion/domain/do/deletion-request.do.spec.ts @@ -1,8 +1,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; -import { DomainModel } from '@shared/domain/types'; +import { deletionRequestFactory } from '../testing'; +import { DomainName, StatusModel } from '../types'; import { DeletionRequest } from './deletion-request.do'; -import { DeletionStatusModel } from './types'; -import { deletionRequestFactory } from './testing/factory/deletion-request.factory'; describe(DeletionRequest.name, () => { describe('constructor', () => { @@ -36,10 +35,10 @@ describe(DeletionRequest.name, () => { const setup = () => { const props = { id: new ObjectId().toHexString(), - targetRefDomain: DomainModel.USER, + targetRefDomain: DomainName.USER, deleteAfter: new Date(), targetRefId: new ObjectId().toHexString(), - status: DeletionStatusModel.REGISTERED, + status: StatusModel.REGISTERED, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/apps/server/src/modules/deletion/domain/deletion-request.do.ts b/apps/server/src/modules/deletion/domain/do/deletion-request.do.ts similarity index 72% rename from apps/server/src/modules/deletion/domain/deletion-request.do.ts rename to apps/server/src/modules/deletion/domain/do/deletion-request.do.ts index 92010ef2570..14c5e55775e 100644 --- a/apps/server/src/modules/deletion/domain/deletion-request.do.ts +++ b/apps/server/src/modules/deletion/domain/do/deletion-request.do.ts @@ -1,14 +1,14 @@ -import { DomainModel, EntityId } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; -import { DeletionStatusModel } from './types'; +import { DomainName, StatusModel } from '../types'; export interface DeletionRequestProps extends AuthorizableObject { createdAt?: Date; updatedAt?: Date; - targetRefDomain: DomainModel; + targetRefDomain: DomainName; deleteAfter: Date; targetRefId: EntityId; - status: DeletionStatusModel; + status: StatusModel; } export class DeletionRequest extends DomainObject { @@ -20,7 +20,7 @@ export class DeletionRequest extends DomainObject { return this.props.updatedAt; } - get targetRefDomain(): DomainModel { + get targetRefDomain(): DomainName { return this.props.targetRefDomain; } @@ -32,7 +32,7 @@ export class DeletionRequest extends DomainObject { return this.props.targetRefId; } - get status(): DeletionStatusModel { + get status(): StatusModel { return this.props.status; } } diff --git a/apps/server/src/modules/deletion/domain/index.ts b/apps/server/src/modules/deletion/domain/do/index.ts similarity index 100% rename from apps/server/src/modules/deletion/domain/index.ts rename to apps/server/src/modules/deletion/domain/do/index.ts diff --git a/apps/server/src/modules/deletion/domain/event/data-deleted.event.ts b/apps/server/src/modules/deletion/domain/event/data-deleted.event.ts new file mode 100644 index 00000000000..1611ae8bcec --- /dev/null +++ b/apps/server/src/modules/deletion/domain/event/data-deleted.event.ts @@ -0,0 +1,13 @@ +import { EntityId } from '@shared/domain/types'; +import { DomainDeletionReport } from '../interface'; + +export class DataDeletedEvent { + deletionRequestId: EntityId; + + domainDeletionReport: DomainDeletionReport; + + constructor(deletionRequestId: EntityId, domainDeletionReport: DomainDeletionReport) { + this.deletionRequestId = deletionRequestId; + this.domainDeletionReport = domainDeletionReport; + } +} diff --git a/apps/server/src/modules/deletion/domain/event/index.ts b/apps/server/src/modules/deletion/domain/event/index.ts new file mode 100644 index 00000000000..1b7571754d6 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/event/index.ts @@ -0,0 +1,2 @@ +export * from './user-deleted.event'; +export * from './data-deleted.event'; diff --git a/apps/server/src/modules/deletion/domain/event/user-deleted.event.ts b/apps/server/src/modules/deletion/domain/event/user-deleted.event.ts new file mode 100644 index 00000000000..e7ba6c9e339 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/event/user-deleted.event.ts @@ -0,0 +1,12 @@ +import { EntityId } from '@shared/domain/types'; + +export class UserDeletedEvent { + deletionRequestId: EntityId; + + targetRefId: EntityId; + + constructor(deletionRequestId: EntityId, targetRefId: EntityId) { + this.deletionRequestId = deletionRequestId; + this.targetRefId = targetRefId; + } +} diff --git a/apps/server/src/modules/deletion/domain/helper/index.ts b/apps/server/src/modules/deletion/domain/helper/index.ts new file mode 100644 index 00000000000..c5300a10ca9 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/helper/index.ts @@ -0,0 +1 @@ +export * from './operation-report.helper'; diff --git a/apps/server/src/modules/deletion/domain/helper/operation-report.helper.spec.ts b/apps/server/src/modules/deletion/domain/helper/operation-report.helper.spec.ts new file mode 100644 index 00000000000..a87770515cb --- /dev/null +++ b/apps/server/src/modules/deletion/domain/helper/operation-report.helper.spec.ts @@ -0,0 +1,41 @@ +import { ObjectId } from 'bson'; +import { DomainName, OperationType } from '../types'; +import { OperationReportHelper } from '.'; +import { DomainDeletionReportBuilder, DomainOperationReportBuilder } from '../builder'; + +describe(OperationReportHelper.name, () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + const setup = () => { + const userRegistrationPinId = new ObjectId().toHexString(); + const parentRegistrationPinId = new ObjectId().toHexString(); + + const domainReport1 = DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [userRegistrationPinId]), + ]); + const domainReport2 = DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [parentRegistrationPinId]), + ]); + + const expectedResult = DomainOperationReportBuilder.build(OperationType.DELETE, 2, [ + userRegistrationPinId, + parentRegistrationPinId, + ]); + + return { + domainReport1, + domainReport2, + expectedResult, + }; + }; + + it('should transform domainDeletionReports into one domainDeletionReport with proper values', () => { + const { domainReport1, domainReport2, expectedResult } = setup(); + + const result = OperationReportHelper.extractOperationReports([domainReport1, domainReport2]); + + expect(result).toEqual([expectedResult]); + }); +}); diff --git a/apps/server/src/modules/deletion/domain/helper/operation-report.helper.ts b/apps/server/src/modules/deletion/domain/helper/operation-report.helper.ts new file mode 100644 index 00000000000..329e9426fd0 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/helper/operation-report.helper.ts @@ -0,0 +1,18 @@ +import { DomainDeletionReport, DomainOperationReport } from '../interface'; +import { OperationType } from '../types'; + +export class OperationReportHelper { + public static extractOperationReports(reports: DomainDeletionReport[]): DomainOperationReport[] { + const operationReports: { [key in OperationType]?: DomainOperationReport } = {}; + + for (const { operations } of reports) { + for (const { operation, count, refs } of operations) { + operationReports[operation] = operationReports[operation] || { operation, count: 0, refs: [] }; + operationReports[operation]!.count += count; + operationReports[operation]!.refs.push(...refs); + } + } + + return Object.values(operationReports).filter((report) => report !== undefined); + } +} diff --git a/apps/server/src/modules/deletion/domain/interface/deletion-config.ts b/apps/server/src/modules/deletion/domain/interface/deletion-config.ts new file mode 100644 index 00000000000..23ae3dea329 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/interface/deletion-config.ts @@ -0,0 +1,3 @@ +export interface DeletionConfig { + ADMIN_API__MODIFICATION_THRESHOLD_MS: number; +} diff --git a/apps/server/src/modules/deletion/domain/interface/deletion-service.ts b/apps/server/src/modules/deletion/domain/interface/deletion-service.ts new file mode 100644 index 00000000000..1b93eb6286c --- /dev/null +++ b/apps/server/src/modules/deletion/domain/interface/deletion-service.ts @@ -0,0 +1,6 @@ +import { EntityId } from '@shared/domain/types'; +import { DomainDeletionReport } from './domain-deletion-report'; + +export interface DeletionService { + deleteUserData(id: EntityId): Promise; +} diff --git a/apps/server/src/modules/deletion/domain/interface/deletion-target-ref.ts b/apps/server/src/modules/deletion/domain/interface/deletion-target-ref.ts new file mode 100644 index 00000000000..c1ba6d6e9bd --- /dev/null +++ b/apps/server/src/modules/deletion/domain/interface/deletion-target-ref.ts @@ -0,0 +1,7 @@ +import { EntityId } from '@shared/domain/types'; +import { DomainName } from '../types'; + +export interface DeletionTargetRef { + domain: DomainName; + id: EntityId; +} diff --git a/apps/server/src/modules/deletion/domain/interface/domain-deletion-report.ts b/apps/server/src/modules/deletion/domain/interface/domain-deletion-report.ts new file mode 100644 index 00000000000..f7775096fe2 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/interface/domain-deletion-report.ts @@ -0,0 +1,8 @@ +import { DomainName } from '../types'; +import { DomainOperationReport } from './domain-operation-report'; + +export interface DomainDeletionReport { + domain: DomainName; + operations: DomainOperationReport[]; + subdomainOperations?: DomainDeletionReport[] | null; +} diff --git a/apps/server/src/modules/deletion/domain/interface/domain-operation-report.ts b/apps/server/src/modules/deletion/domain/interface/domain-operation-report.ts new file mode 100644 index 00000000000..90e9462b39c --- /dev/null +++ b/apps/server/src/modules/deletion/domain/interface/domain-operation-report.ts @@ -0,0 +1,7 @@ +import { OperationType } from '../types'; + +export interface DomainOperationReport { + operation: OperationType; + count: number; + refs: string[]; +} diff --git a/apps/server/src/modules/deletion/domain/interface/index.ts b/apps/server/src/modules/deletion/domain/interface/index.ts new file mode 100644 index 00000000000..5812dc4bce6 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/interface/index.ts @@ -0,0 +1,5 @@ +export * from './deletion-service'; +export * from './domain-deletion-report'; +export * from './domain-operation-report'; +export * from './deletion-target-ref'; +export * from './deletion-config'; diff --git a/apps/server/src/modules/deletion/domain/loggable-exception/deletion-error.loggable-exception.spec.ts b/apps/server/src/modules/deletion/domain/loggable-exception/deletion-error.loggable-exception.spec.ts new file mode 100644 index 00000000000..3d0902bfac0 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/loggable-exception/deletion-error.loggable-exception.spec.ts @@ -0,0 +1,30 @@ +import { DeletionErrorLoggableException } from './deletion-error.loggable-exception'; + +describe(DeletionErrorLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const message = 'Error during deletion process'; + + const exception = new DeletionErrorLoggableException(message); + + return { + exception, + message, + }; + }; + + it('should log the correct message', () => { + const { exception, message } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'DELETION_ERROR', + stack: expect.any(String), + data: { + errorMessage: message, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/domain/loggable-exception/deletion-error.loggable-exception.ts b/apps/server/src/modules/deletion/domain/loggable-exception/deletion-error.loggable-exception.ts new file mode 100644 index 00000000000..8805ceb93d4 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/loggable-exception/deletion-error.loggable-exception.ts @@ -0,0 +1,21 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage } from '@src/core/logger/types'; + +export class DeletionErrorLoggableException extends InternalServerErrorException implements Loggable { + constructor(private readonly errorMessage: string) { + super(); + } + + getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: 'DELETION_ERROR', + stack: this.stack, + data: { + errorMessage: this.errorMessage, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/deletion/domain/loggable-exception/index.ts b/apps/server/src/modules/deletion/domain/loggable-exception/index.ts new file mode 100644 index 00000000000..706eca9d77e --- /dev/null +++ b/apps/server/src/modules/deletion/domain/loggable-exception/index.ts @@ -0,0 +1 @@ +export * from './deletion-error.loggable-exception'; diff --git a/apps/server/src/shared/common/loggable/data-deletion-domain-operation-loggable.spec.ts b/apps/server/src/modules/deletion/domain/loggable/data-deletion-domain-operation-loggable.spec.ts similarity index 88% rename from apps/server/src/shared/common/loggable/data-deletion-domain-operation-loggable.spec.ts rename to apps/server/src/modules/deletion/domain/loggable/data-deletion-domain-operation-loggable.spec.ts index b9ed8b111d8..00339ac7dc8 100644 --- a/apps/server/src/shared/common/loggable/data-deletion-domain-operation-loggable.spec.ts +++ b/apps/server/src/modules/deletion/domain/loggable/data-deletion-domain-operation-loggable.spec.ts @@ -1,13 +1,14 @@ import { ObjectId } from 'bson'; -import { DomainModel, EntityId, StatusModel } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; import { DataDeletionDomainOperationLoggable } from './data-deletion-domain-operation-loggable'; +import { DomainName, StatusModel } from '../types'; describe(DataDeletionDomainOperationLoggable.name, () => { describe('getLogMessage', () => { const setup = () => { const user: EntityId = new ObjectId().toHexString(); const message = 'Test message.'; - const domain = DomainModel.USER; + const domain = DomainName.USER; const status = StatusModel.FINISHED; const modifiedCount = 0; const deletedCount = 1; diff --git a/apps/server/src/shared/common/loggable/data-deletion-domain-operation-loggable.ts b/apps/server/src/modules/deletion/domain/loggable/data-deletion-domain-operation-loggable.ts similarity index 78% rename from apps/server/src/shared/common/loggable/data-deletion-domain-operation-loggable.ts rename to apps/server/src/modules/deletion/domain/loggable/data-deletion-domain-operation-loggable.ts index 1be7727a6cd..851737b51cd 100644 --- a/apps/server/src/shared/common/loggable/data-deletion-domain-operation-loggable.ts +++ b/apps/server/src/modules/deletion/domain/loggable/data-deletion-domain-operation-loggable.ts @@ -1,11 +1,12 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { DomainModel, EntityId, StatusModel } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; import { ErrorLogMessage, LogMessage, Loggable, ValidationErrorLogMessage } from '@src/core/logger'; +import { DomainName } from '../types/domain-name.enum'; +import { StatusModel } from '../types'; export class DataDeletionDomainOperationLoggable implements Loggable { constructor( private readonly message: string, - private readonly domain: DomainModel, + private readonly domain: DomainName, private readonly user: EntityId, private readonly status: StatusModel, private readonly modifiedCount?: number, diff --git a/apps/server/src/modules/deletion/domain/loggable/index.ts b/apps/server/src/modules/deletion/domain/loggable/index.ts new file mode 100644 index 00000000000..f7cc50de722 --- /dev/null +++ b/apps/server/src/modules/deletion/domain/loggable/index.ts @@ -0,0 +1 @@ +export * from './data-deletion-domain-operation-loggable'; diff --git a/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts b/apps/server/src/modules/deletion/domain/service/deletion-log.service.spec.ts similarity index 77% rename from apps/server/src/modules/deletion/services/deletion-log.service.spec.ts rename to apps/server/src/modules/deletion/domain/service/deletion-log.service.spec.ts index e453ab24419..695012e295f 100644 --- a/apps/server/src/modules/deletion/services/deletion-log.service.spec.ts +++ b/apps/server/src/modules/deletion/domain/service/deletion-log.service.spec.ts @@ -2,11 +2,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { setupEntities } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; -import { DomainModel } from '@shared/domain/types'; -import { DeletionLogRepo } from '../repo'; -import { DeletionOperationModel } from '../domain/types'; +import { DeletionLogRepo } from '../../repo'; +import { DomainOperationReportBuilder, DomainDeletionReportBuilder } from '../builder'; +import { deletionLogFactory } from '../testing'; +import { DomainName, OperationType } from '../types'; import { DeletionLogService } from './deletion-log.service'; -import { deletionLogFactory } from '../domain/testing/factory/deletion-log.factory'; describe(DeletionLogService.name, () => { let module: TestingModule; @@ -48,28 +48,27 @@ describe(DeletionLogService.name, () => { describe('when creating a deletionRequest', () => { const setup = () => { const deletionRequestId = '653e4833cc39e5907a1e18d2'; - const domain = DomainModel.USER; - const operation = DeletionOperationModel.DELETE; - const modifiedCount = 0; - const deletedCount = 1; + const domain = DomainName.USER; + const operations = [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [new ObjectId().toHexString()]), + ]; - return { deletionRequestId, domain, operation, modifiedCount, deletedCount }; + const domainDeletionReport = DomainDeletionReportBuilder.build(domain, operations); + + return { deletionRequestId, domainDeletionReport, operations }; }; it('should call deletionRequestRepo.create', async () => { - const { deletionRequestId, domain, operation, modifiedCount, deletedCount } = setup(); + const { deletionRequestId, domainDeletionReport, operations } = setup(); - await service.createDeletionLog(deletionRequestId, domain, operation, modifiedCount, deletedCount); + await service.createDeletionLog(deletionRequestId, domainDeletionReport); expect(deletionLogRepo.create).toHaveBeenCalledWith( expect.objectContaining({ id: expect.any(String), performedAt: expect.any(Date), deletionRequestId, - domain, - operation, - modifiedCount, - deletedCount, + operations, }) ); }); @@ -83,7 +82,7 @@ describe(DeletionLogService.name, () => { const deletionLog1 = deletionLogFactory.build({ deletionRequestId }); const deletionLog2 = deletionLogFactory.build({ deletionRequestId, - domain: DomainModel.PSEUDONYMS, + domain: DomainName.PSEUDONYMS, }); const deletionLogs = [deletionLog1, deletionLog2]; diff --git a/apps/server/src/modules/deletion/services/deletion-log.service.ts b/apps/server/src/modules/deletion/domain/service/deletion-log.service.ts similarity index 54% rename from apps/server/src/modules/deletion/services/deletion-log.service.ts rename to apps/server/src/modules/deletion/domain/service/deletion-log.service.ts index 577e76a864b..c92afcdfcf8 100644 --- a/apps/server/src/modules/deletion/services/deletion-log.service.ts +++ b/apps/server/src/modules/deletion/domain/service/deletion-log.service.ts @@ -1,29 +1,24 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { DomainModel, EntityId } from '@shared/domain/types'; -import { DeletionLog } from '../domain/deletion-log.do'; -import { DeletionOperationModel } from '../domain/types'; -import { DeletionLogRepo } from '../repo'; +import { EntityId } from '@shared/domain/types'; +import { DomainDeletionReport } from '../interface'; +import { DeletionLogRepo } from '../../repo'; +import { DeletionLog } from '../do'; @Injectable() export class DeletionLogService { constructor(private readonly deletionLogRepo: DeletionLogRepo) {} - async createDeletionLog( - deletionRequestId: EntityId, - domain: DomainModel, - operation: DeletionOperationModel, - modifiedCount: number, - deletedCount: number - ): Promise { + async createDeletionLog(deletionRequestId: EntityId, domainDeletionReport: DomainDeletionReport): Promise { const newDeletionLog = new DeletionLog({ id: new ObjectId().toHexString(), - performedAt: new Date(), - domain, deletionRequestId, - operation, - modifiedCount, - deletedCount, + performedAt: new Date(), + domain: domainDeletionReport.domain, + operations: domainDeletionReport.operations, + subdomainOperations: domainDeletionReport.subdomainOperations + ? domainDeletionReport.subdomainOperations + : undefined, }); await this.deletionLogRepo.create(newDeletionLog); diff --git a/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts b/apps/server/src/modules/deletion/domain/service/deletion-request.service.spec.ts similarity index 81% rename from apps/server/src/modules/deletion/services/deletion-request.service.spec.ts rename to apps/server/src/modules/deletion/domain/service/deletion-request.service.spec.ts index d4675d62861..01b82d33bd8 100644 --- a/apps/server/src/modules/deletion/services/deletion-request.service.spec.ts +++ b/apps/server/src/modules/deletion/domain/service/deletion-request.service.spec.ts @@ -1,12 +1,13 @@ -import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { setupEntities } from '@shared/testing'; -import { DomainModel } from '@shared/domain/types'; +import { ObjectId } from 'bson'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { DeletionRequestRepo } from '../../repo'; +import { deletionRequestFactory, deletionTestConfig } from '../testing'; +import { DomainName, StatusModel } from '../types'; import { DeletionRequestService } from './deletion-request.service'; -import { DeletionRequestRepo } from '../repo'; -import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; -import { DeletionStatusModel } from '../domain/types'; describe(DeletionRequestService.name, () => { let module: TestingModule; @@ -15,6 +16,7 @@ describe(DeletionRequestService.name, () => { beforeAll(async () => { module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(createConfigModuleOptions(deletionTestConfig))], providers: [ DeletionRequestService, { @@ -48,7 +50,7 @@ describe(DeletionRequestService.name, () => { describe('when creating a deletionRequest', () => { const setup = () => { const targetRefId = '653e4833cc39e5907a1e18d2'; - const targetRefDomain = DomainModel.USER; + const targetRefDomain = DomainName.USER; return { targetRefId, targetRefDomain }; }; @@ -64,7 +66,7 @@ describe(DeletionRequestService.name, () => { targetRefDomain, deleteAfter: expect.any(Date), targetRefId, - status: DeletionStatusModel.REGISTERED, + status: StatusModel.REGISTERED, }) ); }); @@ -104,6 +106,8 @@ describe(DeletionRequestService.name, () => { describe('when finding all deletionRequests for execution', () => { const setup = () => { const dateInPast = new Date(); + const threshold = 1000; + const limit = undefined; dateInPast.setDate(dateInPast.getDate() - 1); const deletionRequest1 = deletionRequestFactory.build({ deleteAfter: dateInPast }); const deletionRequest2 = deletionRequestFactory.build({ deleteAfter: dateInPast }); @@ -111,13 +115,15 @@ describe(DeletionRequestService.name, () => { deletionRequestRepo.findAllItemsToExecution.mockResolvedValue([deletionRequest1, deletionRequest2]); const deletionRequests = [deletionRequest1, deletionRequest2]; - return { deletionRequests }; + return { deletionRequests, limit, threshold }; }; - it('should call deletionRequestRepo.findAllItemsByDeletionDate', async () => { + it('should call deletionRequestRepo.findAllItemsByDeletionDate with required parameter', async () => { + const { limit, threshold } = setup(); + await service.findAllItemsToExecute(); - expect(deletionRequestRepo.findAllItemsToExecution).toBeCalled(); + expect(deletionRequestRepo.findAllItemsToExecution).toBeCalledWith(threshold, limit); }); it('should return array of two deletionRequests to execute', async () => { @@ -181,6 +187,23 @@ describe(DeletionRequestService.name, () => { }); }); + describe('markDeletionRequestAsPending', () => { + describe('when mark deletionRequest as pending', () => { + const setup = () => { + const deletionRequestId = new ObjectId().toHexString(); + + return { deletionRequestId }; + }; + + it('should call deletionRequestRepo.markDeletionRequestAsPending', async () => { + const { deletionRequestId } = setup(); + await service.markDeletionRequestAsPending(deletionRequestId); + + expect(deletionRequestRepo.markDeletionRequestAsPending).toBeCalledWith(deletionRequestId); + }); + }); + }); + describe('deleteById', () => { describe('when deleting deletionRequest', () => { const setup = () => { diff --git a/apps/server/src/modules/deletion/services/deletion-request.service.ts b/apps/server/src/modules/deletion/domain/service/deletion-request.service.ts similarity index 66% rename from apps/server/src/modules/deletion/services/deletion-request.service.ts rename to apps/server/src/modules/deletion/domain/service/deletion-request.service.ts index 08c282a8693..fe3af2fd5e4 100644 --- a/apps/server/src/modules/deletion/services/deletion-request.service.ts +++ b/apps/server/src/modules/deletion/domain/service/deletion-request.service.ts @@ -1,17 +1,22 @@ -import { ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; -import { DomainModel, EntityId } from '@shared/domain/types'; -import { DeletionRequest } from '../domain/deletion-request.do'; -import { DeletionStatusModel } from '../domain/types'; -import { DeletionRequestRepo } from '../repo/deletion-request.repo'; +import { EntityId } from '@shared/domain/types'; +import { ObjectId } from 'bson'; +import { ConfigService } from '@nestjs/config'; +import { DeletionRequestRepo } from '../../repo'; +import { DeletionRequest } from '../do'; +import { DomainName, StatusModel } from '../types'; +import { DeletionConfig } from '../interface'; @Injectable() export class DeletionRequestService { - constructor(private readonly deletionRequestRepo: DeletionRequestRepo) {} + constructor( + private readonly deletionRequestRepo: DeletionRequestRepo, + private readonly configService: ConfigService + ) {} async createDeletionRequest( targetRefId: EntityId, - targetRefDomain: DomainModel, + targetRefDomain: DomainName, deleteInMinutes = 43200 ): Promise<{ requestId: EntityId; deletionPlannedAt: Date }> { const dateOfDeletion = new Date(); @@ -22,7 +27,7 @@ export class DeletionRequestService { targetRefDomain, deleteAfter: dateOfDeletion, targetRefId, - status: DeletionStatusModel.REGISTERED, + status: StatusModel.REGISTERED, }); await this.deletionRequestRepo.create(newDeletionRequest); @@ -37,7 +42,8 @@ export class DeletionRequestService { } async findAllItemsToExecute(limit?: number): Promise { - const itemsToDelete: DeletionRequest[] = await this.deletionRequestRepo.findAllItemsToExecution(limit); + const threshold = this.configService.get('ADMIN_API__MODIFICATION_THRESHOLD_MS'); + const itemsToDelete: DeletionRequest[] = await this.deletionRequestRepo.findAllItemsToExecution(threshold, limit); return itemsToDelete; } @@ -54,6 +60,10 @@ export class DeletionRequestService { return this.deletionRequestRepo.markDeletionRequestAsFailed(deletionRequestId); } + async markDeletionRequestAsPending(deletionRequestId: EntityId): Promise { + return this.deletionRequestRepo.markDeletionRequestAsPending(deletionRequestId); + } + async deleteById(deletionRequestId: EntityId): Promise { await this.deletionRequestRepo.deleteById(deletionRequestId); } diff --git a/apps/server/src/modules/deletion/services/index.ts b/apps/server/src/modules/deletion/domain/service/index.ts similarity index 100% rename from apps/server/src/modules/deletion/services/index.ts rename to apps/server/src/modules/deletion/domain/service/index.ts diff --git a/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts b/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts index 2593ba5c242..802f8189714 100644 --- a/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts +++ b/apps/server/src/modules/deletion/domain/testing/factory/deletion-log.factory.ts @@ -1,16 +1,22 @@ import { DoBaseFactory } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; -import { DomainModel } from '@shared/domain/types'; -import { DeletionLog, DeletionLogProps } from '../../deletion-log.do'; -import { DeletionOperationModel } from '../../types'; +import { DomainOperationReportBuilder, DomainDeletionReportBuilder } from '../../builder'; +import { DeletionLog, DeletionLogProps } from '../../do'; +import { DomainName, OperationType } from '../../types'; export const deletionLogFactory = DoBaseFactory.define(DeletionLog, () => { return { id: new ObjectId().toHexString(), - domain: DomainModel.USER, - operation: DeletionOperationModel.DELETE, - modifiedCount: 0, - deletedCount: 1, + domain: DomainName.USER, + operations: [DomainOperationReportBuilder.build(OperationType.DELETE, 1, [new ObjectId().toHexString()])], + subdomainOperations: [ + DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]), + ], deletionRequestId: new ObjectId().toHexString(), performedAt: new Date(), createdAt: new Date(), diff --git a/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts b/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts index e0ae6e7e41b..d10fbd9defd 100644 --- a/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts +++ b/apps/server/src/modules/deletion/domain/testing/factory/deletion-request.factory.ts @@ -1,9 +1,8 @@ import { DoBaseFactory } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; import { DeepPartial } from 'fishery'; -import { DomainModel } from '@shared/domain/types'; -import { DeletionRequest, DeletionRequestProps } from '../../deletion-request.do'; -import { DeletionStatusModel } from '../../types'; +import { DeletionRequest, DeletionRequestProps } from '../../do'; +import { DomainName, StatusModel } from '../../types'; class DeletionRequestFactory extends DoBaseFactory { withUserIds(id: string): this { @@ -18,10 +17,10 @@ class DeletionRequestFactory extends DoBaseFactory { return { id: new ObjectId().toHexString(), - targetRefDomain: DomainModel.USER, + targetRefDomain: DomainName.USER, deleteAfter: new Date(), targetRefId: new ObjectId().toHexString(), - status: DeletionStatusModel.REGISTERED, + status: StatusModel.REGISTERED, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/apps/server/src/modules/deletion/domain/testing/index.ts b/apps/server/src/modules/deletion/domain/testing/index.ts index d847d7abce6..f1f12489964 100644 --- a/apps/server/src/modules/deletion/domain/testing/index.ts +++ b/apps/server/src/modules/deletion/domain/testing/index.ts @@ -1 +1,2 @@ export * from './factory'; +export * from './test-config'; diff --git a/apps/server/src/modules/deletion/domain/testing/test-config.ts b/apps/server/src/modules/deletion/domain/testing/test-config.ts new file mode 100644 index 00000000000..723f0a0ad7d --- /dev/null +++ b/apps/server/src/modules/deletion/domain/testing/test-config.ts @@ -0,0 +1,13 @@ +import { Configuration } from '@hpi-schul-cloud/commons'; + +const deletionConfig = { + ADMIN_API__MODIFICATION_THRESHOLD_MS: Configuration.get('ADMIN_API__MODIFICATION_THRESHOLD_MS') as number, +}; + +const config = () => deletionConfig; + +export const deletionTestConfig = () => { + const conf = config(); + conf.ADMIN_API__MODIFICATION_THRESHOLD_MS = 1000; + return conf; +}; diff --git a/apps/server/src/modules/deletion/domain/types/deletion-operation-model.enum.ts b/apps/server/src/modules/deletion/domain/types/deletion-operation-model.enum.ts deleted file mode 100644 index 675189e634b..00000000000 --- a/apps/server/src/modules/deletion/domain/types/deletion-operation-model.enum.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const enum DeletionOperationModel { - DELETE = 'delete', - UPDATE = 'update', -} diff --git a/apps/server/src/shared/domain/types/domain.ts b/apps/server/src/modules/deletion/domain/types/domain-name.enum.ts similarity index 67% rename from apps/server/src/shared/domain/types/domain.ts rename to apps/server/src/modules/deletion/domain/types/domain-name.enum.ts index babc23631d1..6aef66974dc 100644 --- a/apps/server/src/shared/domain/types/domain.ts +++ b/apps/server/src/modules/deletion/domain/types/domain-name.enum.ts @@ -1,5 +1,6 @@ -export const enum DomainModel { +export const enum DomainName { ACCOUNT = 'account', + BOARD = 'board', CLASS = 'class', COURSEGROUP = 'courseGroup', COURSE = 'course', @@ -10,7 +11,11 @@ export const enum DomainModel { PSEUDONYMS = 'pseudonyms', REGISTRATIONPIN = 'registrationPin', ROCKETCHATUSER = 'rocketChatUser', + ROCKETCHATSERVICE = 'rocketChatService', TASK = 'task', TEAMS = 'teams', USER = 'user', + SUBMISSIONS = 'submissions', + NEWS = 'news', + CALENDAR = 'calendar', } diff --git a/apps/server/src/modules/deletion/domain/types/index.ts b/apps/server/src/modules/deletion/domain/types/index.ts index 607e7fbe5e5..31d25bce86b 100644 --- a/apps/server/src/modules/deletion/domain/types/index.ts +++ b/apps/server/src/modules/deletion/domain/types/index.ts @@ -1,2 +1,3 @@ -export * from './deletion-operation-model.enum'; -export * from './deletion-status-model.enum'; +export * from './domain-name.enum'; +export * from './operation-type.enum'; +export * from './status-model.enum'; diff --git a/apps/server/src/modules/deletion/domain/types/operation-type.enum.ts b/apps/server/src/modules/deletion/domain/types/operation-type.enum.ts new file mode 100644 index 00000000000..754ee6add0b --- /dev/null +++ b/apps/server/src/modules/deletion/domain/types/operation-type.enum.ts @@ -0,0 +1,4 @@ +export const enum OperationType { + DELETE = 'delete', + UPDATE = 'update', +} diff --git a/apps/server/src/shared/domain/types/status-model.enum.ts b/apps/server/src/modules/deletion/domain/types/status-model.enum.ts similarity index 53% rename from apps/server/src/shared/domain/types/status-model.enum.ts rename to apps/server/src/modules/deletion/domain/types/status-model.enum.ts index d4550d60bcd..2a918859c9f 100644 --- a/apps/server/src/shared/domain/types/status-model.enum.ts +++ b/apps/server/src/modules/deletion/domain/types/status-model.enum.ts @@ -1,4 +1,7 @@ export const enum StatusModel { + FAILED = 'failed', FINISHED = 'finished', PENDING = 'pending', + SUCCESS = 'success', + REGISTERED = 'registered', } diff --git a/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts b/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts deleted file mode 100644 index 6090f14402d..00000000000 --- a/apps/server/src/modules/deletion/entity/testing/factory/deletion-log.entity.factory.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ObjectId } from '@mikro-orm/mongodb'; -import { BaseFactory } from '@shared/testing'; -import { DomainModel } from '@shared/domain/types'; -import { DeletionLogEntity, DeletionLogEntityProps } from '../../deletion-log.entity'; -import { DeletionOperationModel } from '../../../domain/types'; - -export const deletionLogEntityFactory = BaseFactory.define( - DeletionLogEntity, - () => { - return { - id: new ObjectId().toHexString(), - domain: DomainModel.USER, - operation: DeletionOperationModel.DELETE, - modifiedCount: 0, - deletedCount: 1, - deletionRequestId: new ObjectId(), - createdAt: new Date(), - updatedAt: new Date(), - }; - } -); diff --git a/apps/server/src/modules/deletion/index.ts b/apps/server/src/modules/deletion/index.ts index 58cba219e0e..704a0d16cbf 100644 --- a/apps/server/src/modules/deletion/index.ts +++ b/apps/server/src/modules/deletion/index.ts @@ -1,3 +1,9 @@ -export * from './deletion.module'; -export * from './services'; -export * from './uc'; +export { DeletionModule } from './deletion.module'; +export { DataDeletedEvent, UserDeletedEvent } from './domain/event'; +export { DomainDeletionReportBuilder, DomainOperationReportBuilder } from './domain/builder'; +export { DomainName, OperationType, StatusModel } from './domain/types'; +export { DeletionService, DomainDeletionReport, DomainOperationReport } from './domain/interface'; +export { DataDeletionDomainOperationLoggable } from './domain/loggable'; +export { DeletionErrorLoggableException } from './domain/loggable-exception'; +export { OperationReportHelper } from './domain/helper'; +export { DeletionConfig } from './domain/interface'; diff --git a/apps/server/src/modules/deletion/interface/index.ts b/apps/server/src/modules/deletion/interface/index.ts deleted file mode 100644 index 95786098275..00000000000 --- a/apps/server/src/modules/deletion/interface/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './interfaces'; diff --git a/apps/server/src/modules/deletion/interface/interfaces.ts b/apps/server/src/modules/deletion/interface/interfaces.ts deleted file mode 100644 index 7a2621d87b7..00000000000 --- a/apps/server/src/modules/deletion/interface/interfaces.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { DomainModel, EntityId } from '@shared/domain/types'; - -export interface DeletionTargetRef { - domain: DomainModel; - id: EntityId; -} diff --git a/apps/server/src/modules/deletion/repo/deletion-log.repo.spec.ts b/apps/server/src/modules/deletion/repo/deletion-log.repo.spec.ts index 1ab1e515acc..38039f50d6f 100644 --- a/apps/server/src/modules/deletion/repo/deletion-log.repo.spec.ts +++ b/apps/server/src/modules/deletion/repo/deletion-log.repo.spec.ts @@ -3,12 +3,12 @@ import { Test } from '@nestjs/testing'; import { TestingModule } from '@nestjs/testing/testing-module'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections } from '@shared/testing'; -import { DeletionLogMapper } from './mapper'; -import { DeletionLogEntity } from '../entity'; +import { DeletionLog } from '../domain/do'; +import { deletionLogFactory } from '../domain/testing'; import { DeletionLogRepo } from './deletion-log.repo'; -import { deletionLogFactory } from '../domain/testing/factory/deletion-log.factory'; -import { DeletionLog } from '../domain/deletion-log.do'; -import { deletionLogEntityFactory } from '../entity/testing/factory/deletion-log.entity.factory'; +import { DeletionLogEntity } from './entity'; +import { deletionLogEntityFactory } from './entity/testing'; +import { DeletionLogMapper } from './mapper'; describe(DeletionLogRepo.name, () => { let module: TestingModule; @@ -60,9 +60,8 @@ describe(DeletionLogRepo.name, () => { const expectedDomainObject = { id: domainObject.id, domain: domainObject.domain, - operation: domainObject.operation, - modifiedCount: domainObject.modifiedCount, - deletedCount: domainObject.deletedCount, + operations: domainObject.operations, + subdomainOperations: domainObject.subdomainOperations, deletionRequestId: domainObject.deletionRequestId, performedAt: domainObject.performedAt, createdAt: domainObject.createdAt, @@ -71,6 +70,7 @@ describe(DeletionLogRepo.name, () => { return { domainObject, deletionLogId, expectedDomainObject }; }; + it('should create a new deletionLog', async () => { const { domainObject, deletionLogId, expectedDomainObject } = setup(); await repo.create(domainObject); @@ -86,20 +86,19 @@ describe(DeletionLogRepo.name, () => { describe('when searching by Id', () => { const setup = async () => { // Test deletionLog entity - const entity: DeletionLogEntity = deletionLogEntityFactory.build(); + const entity: DeletionLogEntity = deletionLogEntityFactory.buildWithId(); await em.persistAndFlush(entity); - const expectedDeletionLog = { + const expectedDeletionLog = new DeletionLog({ id: entity.id, domain: entity.domain, - operation: entity.operation, - modifiedCount: entity.modifiedCount, - deletedCount: entity.deletedCount, + operations: entity.operations, + subdomainOperations: entity.subdomainOperations, deletionRequestId: entity.deletionRequestId?.toHexString(), performedAt: entity.performedAt, createdAt: entity.createdAt, updatedAt: entity.updatedAt, - }; + }); return { entity, @@ -146,28 +145,26 @@ describe(DeletionLogRepo.name, () => { em.clear(); const expectedArray = [ - { + new DeletionLog({ id: deletionLogEntity1.id, domain: deletionLogEntity1.domain, - operation: deletionLogEntity1.operation, + operations: deletionLogEntity1.operations, + subdomainOperations: deletionLogEntity1.subdomainOperations, deletionRequestId: deletionLogEntity1.deletionRequestId?.toHexString(), performedAt: deletionLogEntity1.performedAt, - modifiedCount: deletionLogEntity1.modifiedCount, - deletedCount: deletionLogEntity1.deletedCount, createdAt: deletionLogEntity1.createdAt, updatedAt: deletionLogEntity1.updatedAt, - }, - { + }), + new DeletionLog({ id: deletionLogEntity2.id, domain: deletionLogEntity2.domain, - operation: deletionLogEntity2.operation, + operations: deletionLogEntity2.operations, + subdomainOperations: deletionLogEntity2.subdomainOperations, deletionRequestId: deletionLogEntity2.deletionRequestId?.toHexString(), performedAt: deletionLogEntity2.performedAt, - modifiedCount: deletionLogEntity2.modifiedCount, - deletedCount: deletionLogEntity2.deletedCount, createdAt: deletionLogEntity2.createdAt, updatedAt: deletionLogEntity2.updatedAt, - }, + }), ]; return { deletionLogEntity3, deletionRequest1Id, expectedArray }; @@ -176,7 +173,7 @@ describe(DeletionLogRepo.name, () => { it('should find deletionRequests with deleteAfter smaller then today', async () => { const { deletionLogEntity3, deletionRequest1Id, expectedArray } = await setup(); - const results = await repo.findAllByDeletionRequestId(deletionRequest1Id.toHexString()); + const results = await repo.findAllByDeletionRequestId(deletionRequest1Id?.toHexString()); expect(results.length).toEqual(2); diff --git a/apps/server/src/modules/deletion/repo/deletion-log.repo.ts b/apps/server/src/modules/deletion/repo/deletion-log.repo.ts index ad7b17436f3..edabf759926 100644 --- a/apps/server/src/modules/deletion/repo/deletion-log.repo.ts +++ b/apps/server/src/modules/deletion/repo/deletion-log.repo.ts @@ -1,9 +1,10 @@ -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { ObjectId } from 'bson'; import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; -import { DeletionLog } from '../domain/deletion-log.do'; -import { DeletionLogEntity } from '../entity/deletion-log.entity'; -import { DeletionLogMapper } from './mapper/deletion-log.mapper'; +import { DeletionLog } from '../domain/do'; +import { DeletionLogEntity } from './entity'; +import { DeletionLogMapper } from './mapper'; @Injectable() export class DeletionLogRepo { diff --git a/apps/server/src/modules/deletion/repo/deletion-request-scope.ts b/apps/server/src/modules/deletion/repo/deletion-request-scope.ts deleted file mode 100644 index 6ac8351ce13..00000000000 --- a/apps/server/src/modules/deletion/repo/deletion-request-scope.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Scope } from '@shared/repo'; -import { DeletionRequestEntity } from '../entity'; -import { DeletionStatusModel } from '../domain/types'; - -export class DeletionRequestScope extends Scope { - byDeleteAfter(currentDate: Date): DeletionRequestScope { - this.addQuery({ deleteAfter: { $lt: currentDate } }); - - return this; - } - - byStatus(): DeletionRequestScope { - this.addQuery({ status: [DeletionStatusModel.REGISTERED, DeletionStatusModel.FAILED] }); - - return this; - } -} diff --git a/apps/server/src/modules/deletion/repo/deletion-request.repo.spec.ts b/apps/server/src/modules/deletion/repo/deletion-request.repo.spec.ts index 4bd0c86c5cc..387786e51e3 100644 --- a/apps/server/src/modules/deletion/repo/deletion-request.repo.spec.ts +++ b/apps/server/src/modules/deletion/repo/deletion-request.repo.spec.ts @@ -1,15 +1,16 @@ -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { ObjectId } from 'bson'; import { Test } from '@nestjs/testing'; import { TestingModule } from '@nestjs/testing/testing-module'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections } from '@shared/testing'; -import { DeletionRequestMapper } from './mapper'; +import { DeletionRequest } from '../domain/do'; +import { deletionRequestFactory } from '../domain/testing'; +import { StatusModel } from '../domain/types'; import { DeletionRequestRepo } from './deletion-request.repo'; -import { DeletionRequestEntity } from '../entity'; -import { DeletionRequest } from '../domain/deletion-request.do'; -import { deletionRequestEntityFactory } from '../entity/testing/factory/deletion-request.entity.factory'; -import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; -import { DeletionStatusModel } from '../domain/types'; +import { DeletionRequestEntity } from './entity'; +import { deletionRequestEntityFactory } from './entity/testing'; +import { DeletionRequestMapper } from './mapper'; describe(DeletionRequestRepo.name, () => { let module: TestingModule; @@ -103,8 +104,16 @@ describe(DeletionRequestRepo.name, () => { describe('findAllItemsToExecution', () => { describe('when there is no deletionRequest for execution', () => { + const setup = () => { + const threshold = 1000; + + return { + threshold, + }; + }; it('should return empty array', async () => { - const result = await repo.findAllItemsToExecution(); + const { threshold } = setup(); + const result = await repo.findAllItemsToExecution(threshold); expect(result).toEqual([]); }); @@ -112,24 +121,29 @@ describe(DeletionRequestRepo.name, () => { describe('when there are deletionRequests for execution', () => { const setup = async () => { + const threshold = 1000; const dateInFuture = new Date(); dateInFuture.setDate(dateInFuture.getDate() + 30); const deletionRequestEntity1: DeletionRequestEntity = deletionRequestEntityFactory.build({ createdAt: new Date(2023, 7, 1), + updatedAt: new Date(2023, 8, 2), deleteAfter: new Date(2023, 8, 1), - status: DeletionStatusModel.SUCCESS, + status: StatusModel.SUCCESS, }); const deletionRequestEntity2: DeletionRequestEntity = deletionRequestEntityFactory.build({ createdAt: new Date(2023, 7, 1), + updatedAt: new Date(2023, 8, 2), deleteAfter: new Date(2023, 8, 1), - status: DeletionStatusModel.FAILED, + status: StatusModel.FAILED, }); const deletionRequestEntity3: DeletionRequestEntity = deletionRequestEntityFactory.build({ createdAt: new Date(2023, 8, 1), + updatedAt: new Date(2023, 8, 1), deleteAfter: new Date(2023, 9, 1), }); const deletionRequestEntity4: DeletionRequestEntity = deletionRequestEntityFactory.build({ createdAt: new Date(2023, 9, 1), + updatedAt: new Date(2023, 9, 1), deleteAfter: new Date(2023, 10, 1), }); const deletionRequestEntity5: DeletionRequestEntity = deletionRequestEntityFactory.build({ @@ -175,13 +189,13 @@ describe(DeletionRequestRepo.name, () => { }, ]; - return { deletionRequestEntity1, deletionRequestEntity5, expectedArray }; + return { deletionRequestEntity1, deletionRequestEntity5, expectedArray, threshold }; }; it('should find deletionRequests with deleteAfter smaller then today and status with value registered or failed', async () => { - const { deletionRequestEntity1, deletionRequestEntity5, expectedArray } = await setup(); + const { deletionRequestEntity1, deletionRequestEntity5, expectedArray, threshold } = await setup(); - const results = await repo.findAllItemsToExecution(); + const results = await repo.findAllItemsToExecution(threshold); expect(results.length).toEqual(3); @@ -204,9 +218,9 @@ describe(DeletionRequestRepo.name, () => { }); it('should find deletionRequests to execute with limit = 2', async () => { - const { expectedArray } = await setup(); + const { expectedArray, threshold } = await setup(); - const results = await repo.findAllItemsToExecution(2); + const results = await repo.findAllItemsToExecution(threshold, 2); expect(results.length).toEqual(2); @@ -227,7 +241,7 @@ describe(DeletionRequestRepo.name, () => { await em.persistAndFlush(entity); // Arrange expected DeletionRequestEntity after changing status - entity.status = DeletionStatusModel.SUCCESS; + entity.status = StatusModel.SUCCESS; const deletionRequestToUpdate = DeletionRequestMapper.mapToDO(entity); return { @@ -274,7 +288,7 @@ describe(DeletionRequestRepo.name, () => { const result: DeletionRequest = await repo.findById(entity.id); - expect(result.status).toEqual(DeletionStatusModel.FAILED); + expect(result.status).toEqual(StatusModel.FAILED); }); }); }); @@ -305,7 +319,38 @@ describe(DeletionRequestRepo.name, () => { const result: DeletionRequest = await repo.findById(entity.id); - expect(result.status).toEqual(DeletionStatusModel.SUCCESS); + expect(result.status).toEqual(StatusModel.SUCCESS); + }); + }); + }); + + describe('markDeletionRequestAsPending', () => { + describe('when mark deletionRequest as pending', () => { + const setup = async () => { + const userId = new ObjectId().toHexString(); + + const entity: DeletionRequestEntity = deletionRequestEntityFactory.build({ targetRefId: userId }); + await em.persistAndFlush(entity); + + return { entity }; + }; + + it('should update the deletionRequest', async () => { + const { entity } = await setup(); + + const result = await repo.markDeletionRequestAsPending(entity.id); + + expect(result).toBe(true); + }); + + it('should update the deletionRequest', async () => { + const { entity } = await setup(); + + await repo.markDeletionRequestAsPending(entity.id); + + const result: DeletionRequest = await repo.findById(entity.id); + + expect(result.status).toEqual(StatusModel.PENDING); }); }); }); diff --git a/apps/server/src/modules/deletion/repo/deletion-request.repo.ts b/apps/server/src/modules/deletion/repo/deletion-request.repo.ts index 9bd7c303d1d..d681d7e7be0 100644 --- a/apps/server/src/modules/deletion/repo/deletion-request.repo.ts +++ b/apps/server/src/modules/deletion/repo/deletion-request.repo.ts @@ -2,10 +2,10 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; import { SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { DeletionRequest } from '../domain/deletion-request.do'; -import { DeletionRequestEntity } from '../entity'; -import { DeletionRequestScope } from './deletion-request-scope'; -import { DeletionRequestMapper } from './mapper/deletion-request.mapper'; +import { DeletionRequest } from '../domain/do'; +import { DeletionRequestEntity } from './entity'; +import { DeletionRequestMapper } from './mapper'; +import { DeletionRequestScope } from './scope'; @Injectable() export class DeletionRequestRepo { @@ -31,9 +31,10 @@ export class DeletionRequestRepo { await this.em.flush(); } - async findAllItemsToExecution(limit?: number): Promise { + async findAllItemsToExecution(threshold: number, limit?: number): Promise { const currentDate = new Date(); - const scope = new DeletionRequestScope().byDeleteAfter(currentDate).byStatus(); + const modificationThreshold = new Date(Date.now() - threshold); + const scope = new DeletionRequestScope().byDeleteAfter(currentDate).byStatus(modificationThreshold); const order = { createdAt: SortOrder.desc }; const [deletionRequestEntities] = await this.em.findAndCount(DeletionRequestEntity, scope.query, { @@ -75,6 +76,17 @@ export class DeletionRequestRepo { return true; } + async markDeletionRequestAsPending(deletionRequestId: EntityId): Promise { + const deletionRequest: DeletionRequestEntity = await this.em.findOneOrFail(DeletionRequestEntity, { + id: deletionRequestId, + }); + + deletionRequest.pending(); + await this.em.persistAndFlush(deletionRequest); + + return true; + } + async deleteById(deletionRequestId: EntityId): Promise { const entity: DeletionRequestEntity | null = await this.em.findOneOrFail(DeletionRequestEntity, { id: deletionRequestId, diff --git a/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts b/apps/server/src/modules/deletion/repo/entity/deletion-log.entity.spec.ts similarity index 65% rename from apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts rename to apps/server/src/modules/deletion/repo/entity/deletion-log.entity.spec.ts index a0524a30a52..26aec1fd133 100644 --- a/apps/server/src/modules/deletion/entity/deletion-log.entity.spec.ts +++ b/apps/server/src/modules/deletion/repo/entity/deletion-log.entity.spec.ts @@ -1,8 +1,8 @@ import { setupEntities } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; -import { DomainModel } from '@shared/domain/types'; +import { DomainOperationReport, DomainDeletionReport } from '../../domain/interface'; +import { DomainName, OperationType } from '../../domain/types'; import { DeletionLogEntity } from './deletion-log.entity'; -import { DeletionOperationModel } from '../domain/types'; describe(DeletionLogEntity.name, () => { beforeAll(async () => { @@ -14,12 +14,27 @@ describe(DeletionLogEntity.name, () => { const setup = () => { const props = { id: new ObjectId().toHexString(), - domain: DomainModel.USER, - operation: DeletionOperationModel.DELETE, - modifiedCount: 0, - deletedCount: 1, + domain: DomainName.USER, + operations: [ + { + operation: OperationType.DELETE, + count: 1, + refs: [new ObjectId().toHexString()], + } as DomainOperationReport, + ], + subdomainOperations: [ + { + domain: DomainName.REGISTRATIONPIN, + operations: [ + { + operation: OperationType.DELETE, + count: 2, + refs: [new ObjectId().toHexString(), new ObjectId().toHexString()], + } as DomainOperationReport, + ], + } as unknown as DomainDeletionReport, + ], deletionRequestId: new ObjectId(), - performedAt: new Date(), createdAt: new Date(), updatedAt: new Date(), }; @@ -46,9 +61,8 @@ describe(DeletionLogEntity.name, () => { const entityProps = { id: entity.id, domain: entity.domain, - operation: entity.operation, - modifiedCount: entity.modifiedCount, - deletedCount: entity.deletedCount, + operations: entity.operations, + subdomainOperations: entity.subdomainOperations, deletionRequestId: entity.deletionRequestId, performedAt: entity.performedAt, createdAt: entity.createdAt, diff --git a/apps/server/src/modules/deletion/entity/deletion-log.entity.ts b/apps/server/src/modules/deletion/repo/entity/deletion-log.entity.ts similarity index 55% rename from apps/server/src/modules/deletion/entity/deletion-log.entity.ts rename to apps/server/src/modules/deletion/repo/entity/deletion-log.entity.ts index 03dfaf5123e..971b712cab7 100644 --- a/apps/server/src/modules/deletion/entity/deletion-log.entity.ts +++ b/apps/server/src/modules/deletion/repo/entity/deletion-log.entity.ts @@ -1,16 +1,16 @@ -import { Entity, Index, Property } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; -import { DomainModel, EntityId } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; import { ObjectId } from 'bson'; -import { DeletionOperationModel } from '../domain/types'; +import { Entity, Property, Index } from '@mikro-orm/core'; +import { DomainOperationReport, DomainDeletionReport } from '../../domain/interface'; +import { DomainName } from '../../domain/types'; export interface DeletionLogEntityProps { id?: EntityId; - domain: DomainModel; - operation?: DeletionOperationModel; - modifiedCount: number; - deletedCount: number; - deletionRequestId?: ObjectId; + domain: DomainName; + operations: DomainOperationReport[]; + subdomainOperations?: DomainDeletionReport[]; + deletionRequestId: ObjectId; performedAt?: Date; createdAt?: Date; updatedAt?: Date; @@ -19,19 +19,18 @@ export interface DeletionLogEntityProps { @Entity({ tableName: 'deletionlogs' }) export class DeletionLogEntity extends BaseEntityWithTimestamps { @Property() - domain: DomainModel; - - @Property({ nullable: true }) - operation?: DeletionOperationModel; - - @Property() - modifiedCount: number; + @Index() + domain: DomainName; @Property() - deletedCount: number; + operations: DomainOperationReport[]; @Property({ nullable: true }) - deletionRequestId?: ObjectId; + subdomainOperations?: DomainDeletionReport[]; + + @Property() + @Index() + deletionRequestId: ObjectId; @Property({ nullable: true }) @Index({ options: { expireAfterSeconds: 7776000 } }) @@ -44,15 +43,11 @@ export class DeletionLogEntity extends BaseEntityWithTimestamps { } this.domain = props.domain; + this.operations = props.operations; + this.deletionRequestId = props.deletionRequestId; - if (props.operation !== undefined) { - this.operation = props.operation; - } - this.modifiedCount = props.modifiedCount; - this.deletedCount = props.deletedCount; - - if (props.deletionRequestId !== undefined) { - this.deletionRequestId = props.deletionRequestId; + if (props.subdomainOperations !== undefined) { + this.subdomainOperations = props.subdomainOperations; } if (props.createdAt !== undefined) { diff --git a/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts b/apps/server/src/modules/deletion/repo/entity/deletion-request.entity.spec.ts similarity index 77% rename from apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts rename to apps/server/src/modules/deletion/repo/entity/deletion-request.entity.spec.ts index 273679ef671..c939de71304 100644 --- a/apps/server/src/modules/deletion/entity/deletion-request.entity.spec.ts +++ b/apps/server/src/modules/deletion/repo/entity/deletion-request.entity.spec.ts @@ -1,8 +1,7 @@ import { setupEntities } from '@shared/testing'; import { ObjectId } from '@mikro-orm/mongodb'; -import { DomainModel } from '@shared/domain/types'; -import { DeletionStatusModel } from '../domain/types'; -import { DeletionRequestEntity } from '.'; +import { DomainName, StatusModel } from '../../domain/types'; +import { DeletionRequestEntity } from './deletion-request.entity'; describe(DeletionRequestEntity.name, () => { beforeAll(async () => { @@ -16,10 +15,10 @@ describe(DeletionRequestEntity.name, () => { const setup = () => { const props = { id: new ObjectId().toHexString(), - targetRefDomain: DomainModel.USER, + targetRefDomain: DomainName.USER, deleteAfter: new Date(), targetRefId: new ObjectId().toHexString(), - status: DeletionStatusModel.REGISTERED, + status: StatusModel.REGISTERED, createdAt: new Date(), updatedAt: new Date(), }; @@ -68,7 +67,7 @@ describe(DeletionRequestEntity.name, () => { entity.executed(); - expect(entity.status).toEqual(DeletionStatusModel.SUCCESS); + expect(entity.status).toEqual(StatusModel.SUCCESS); }); }); @@ -79,7 +78,18 @@ describe(DeletionRequestEntity.name, () => { entity.failed(); - expect(entity.status).toEqual(DeletionStatusModel.FAILED); + expect(entity.status).toEqual(StatusModel.FAILED); + }); + }); + + describe('pending', () => { + it('should update status with value pending', () => { + const { props } = setup(); + const entity: DeletionRequestEntity = new DeletionRequestEntity(props); + + entity.pending(); + + expect(entity.status).toEqual(StatusModel.PENDING); }); }); }); diff --git a/apps/server/src/modules/deletion/entity/deletion-request.entity.ts b/apps/server/src/modules/deletion/repo/entity/deletion-request.entity.ts similarity index 75% rename from apps/server/src/modules/deletion/entity/deletion-request.entity.ts rename to apps/server/src/modules/deletion/repo/entity/deletion-request.entity.ts index b81835641a9..3b856e6fb17 100644 --- a/apps/server/src/modules/deletion/entity/deletion-request.entity.ts +++ b/apps/server/src/modules/deletion/repo/entity/deletion-request.entity.ts @@ -1,15 +1,15 @@ import { Entity, Index, Property, Unique } from '@mikro-orm/core'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; -import { DomainModel, EntityId } from '@shared/domain/types'; -import { DeletionStatusModel } from '../domain/types'; +import { EntityId } from '@shared/domain/types'; +import { DomainName, StatusModel } from '../../domain/types'; const SECONDS_OF_90_DAYS = 90 * 24 * 60 * 60; export interface DeletionRequestEntityProps { id?: EntityId; - targetRefDomain: DomainModel; + targetRefDomain: DomainName; deleteAfter: Date; targetRefId: EntityId; - status: DeletionStatusModel; + status: StatusModel; createdAt?: Date; updatedAt?: Date; } @@ -22,13 +22,16 @@ export class DeletionRequestEntity extends BaseEntityWithTimestamps { deleteAfter: Date; @Property() + @Index() targetRefId!: EntityId; @Property() - targetRefDomain: DomainModel; + @Index() + targetRefDomain: DomainName; @Property() - status: DeletionStatusModel; + @Index() + status: StatusModel; constructor(props: DeletionRequestEntityProps) { super(); @@ -51,10 +54,14 @@ export class DeletionRequestEntity extends BaseEntityWithTimestamps { } public executed(): void { - this.status = DeletionStatusModel.SUCCESS; + this.status = StatusModel.SUCCESS; } public failed(): void { - this.status = DeletionStatusModel.FAILED; + this.status = StatusModel.FAILED; + } + + public pending(): void { + this.status = StatusModel.PENDING; } } diff --git a/apps/server/src/modules/deletion/entity/index.ts b/apps/server/src/modules/deletion/repo/entity/index.ts similarity index 100% rename from apps/server/src/modules/deletion/entity/index.ts rename to apps/server/src/modules/deletion/repo/entity/index.ts index 7e3e31dcd19..a09d7286f83 100644 --- a/apps/server/src/modules/deletion/entity/index.ts +++ b/apps/server/src/modules/deletion/repo/entity/index.ts @@ -1,2 +1,2 @@ -export * from './deletion-request.entity'; export * from './deletion-log.entity'; +export * from './deletion-request.entity'; diff --git a/apps/server/src/modules/deletion/repo/entity/testing/factory/deletion-log.entity.factory.ts b/apps/server/src/modules/deletion/repo/entity/testing/factory/deletion-log.entity.factory.ts new file mode 100644 index 00000000000..faf0ade5bf8 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/entity/testing/factory/deletion-log.entity.factory.ts @@ -0,0 +1,28 @@ +import { BaseFactory } from '@shared/testing'; +import { DomainOperationReportBuilder, DomainDeletionReportBuilder } from '@src/modules/deletion/domain/builder'; +import { DomainName, OperationType } from '@src/modules/deletion/domain/types'; +import { ObjectId } from 'bson'; +import { DeletionLogEntity, DeletionLogEntityProps } from '../../deletion-log.entity'; + +export const deletionLogEntityFactory = BaseFactory.define( + DeletionLogEntity, + () => { + return { + id: new ObjectId().toHexString(), + domain: DomainName.USER, + operations: [DomainOperationReportBuilder.build(OperationType.DELETE, 1, [new ObjectId().toHexString()])], + subdomainOperations: [ + DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]), + ], + performedAt: new Date(), + deletionRequestId: new ObjectId(), + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts b/apps/server/src/modules/deletion/repo/entity/testing/factory/deletion-request.entity.factory.ts similarity index 66% rename from apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts rename to apps/server/src/modules/deletion/repo/entity/testing/factory/deletion-request.entity.factory.ts index 8f33e0d66d6..4629a3fbdb8 100644 --- a/apps/server/src/modules/deletion/entity/testing/factory/deletion-request.entity.factory.ts +++ b/apps/server/src/modules/deletion/repo/entity/testing/factory/deletion-request.entity.factory.ts @@ -1,7 +1,6 @@ -import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '@shared/testing'; -import { DomainModel } from '@shared/domain/types'; -import { DeletionStatusModel } from '../../../domain/types'; +import { DomainName, StatusModel } from '@src/modules/deletion/domain/types'; +import { ObjectId } from 'bson'; import { DeletionRequestEntity, DeletionRequestEntityProps } from '../../deletion-request.entity'; export const deletionRequestEntityFactory = BaseFactory.define( @@ -9,10 +8,10 @@ export const deletionRequestEntityFactory = BaseFactory.define { return { id: new ObjectId().toHexString(), - targetRefDomain: DomainModel.USER, + targetRefDomain: DomainName.USER, deleteAfter: new Date(), targetRefId: new ObjectId().toHexString(), - status: DeletionStatusModel.REGISTERED, + status: StatusModel.REGISTERED, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/apps/server/src/modules/deletion/entity/testing/factory/index.ts b/apps/server/src/modules/deletion/repo/entity/testing/factory/index.ts similarity index 100% rename from apps/server/src/modules/deletion/entity/testing/factory/index.ts rename to apps/server/src/modules/deletion/repo/entity/testing/factory/index.ts diff --git a/apps/server/src/modules/deletion/entity/testing/index.ts b/apps/server/src/modules/deletion/repo/entity/testing/index.ts similarity index 100% rename from apps/server/src/modules/deletion/entity/testing/index.ts rename to apps/server/src/modules/deletion/repo/entity/testing/index.ts diff --git a/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.spec.ts b/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.spec.ts index 3937710336a..bb77ad02998 100644 --- a/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.spec.ts +++ b/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.spec.ts @@ -1,9 +1,9 @@ -import { ObjectId } from '@mikro-orm/mongodb'; -import { deletionLogEntityFactory } from '../../entity/testing/factory/deletion-log.entity.factory'; +import { ObjectId } from 'bson'; +import { DeletionLog } from '../../domain/do'; +import { deletionLogFactory } from '../../domain/testing'; +import { DeletionLogEntity } from '../entity'; +import { deletionLogEntityFactory } from '../entity/testing'; import { DeletionLogMapper } from './deletion-log.mapper'; -import { DeletionLog } from '../../domain/deletion-log.do'; -import { deletionLogFactory } from '../../domain/testing/factory/deletion-log.factory'; -import { DeletionLogEntity } from '../../entity'; describe(DeletionLogMapper.name, () => { describe('mapToDO', () => { @@ -14,11 +14,10 @@ describe(DeletionLogMapper.name, () => { const expectedDomainObject = new DeletionLog({ id: entity.id, domain: entity.domain, - operation: entity.operation, + operations: entity.operations, + subdomainOperations: entity.subdomainOperations, deletionRequestId: entity.deletionRequestId?.toHexString(), performedAt: entity.performedAt, - modifiedCount: entity.modifiedCount, - deletedCount: entity.deletedCount, createdAt: entity.createdAt, updatedAt: entity.updatedAt, }); @@ -53,11 +52,10 @@ describe(DeletionLogMapper.name, () => { new DeletionLog({ id: entity.id, domain: entity.domain, - operation: entity.operation, + operations: entity.operations, + subdomainOperations: entity.subdomainOperations, deletionRequestId: entity.deletionRequestId?.toHexString(), performedAt: entity.performedAt, - modifiedCount: entity.modifiedCount, - deletedCount: entity.deletedCount, createdAt: entity.createdAt, updatedAt: entity.updatedAt, }) @@ -92,11 +90,10 @@ describe(DeletionLogMapper.name, () => { const expectedEntities = new DeletionLogEntity({ id: domainObject.id, domain: domainObject.domain, - operation: domainObject.operation, + operations: domainObject.operations, + subdomainOperations: domainObject.subdomainOperations, deletionRequestId: new ObjectId(domainObject.deletionRequestId), performedAt: domainObject.performedAt, - modifiedCount: domainObject.modifiedCount, - deletedCount: domainObject.deletedCount, createdAt: domainObject.createdAt, updatedAt: domainObject.updatedAt, }); @@ -141,11 +138,10 @@ describe(DeletionLogMapper.name, () => { new DeletionLogEntity({ id: domainObject.id, domain: domainObject.domain, - operation: domainObject.operation, + operations: domainObject.operations, + subdomainOperations: domainObject.subdomainOperations, deletionRequestId: new ObjectId(domainObject.deletionRequestId), performedAt: domainObject.performedAt, - modifiedCount: domainObject.modifiedCount, - deletedCount: domainObject.deletedCount, createdAt: domainObject.createdAt, updatedAt: domainObject.updatedAt, }) diff --git a/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.ts b/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.ts index bb10fe0ac93..68361093fcd 100644 --- a/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.ts +++ b/apps/server/src/modules/deletion/repo/mapper/deletion-log.mapper.ts @@ -1,6 +1,6 @@ -import { ObjectId } from '@mikro-orm/mongodb'; -import { DeletionLogEntity } from '../../entity/deletion-log.entity'; -import { DeletionLog } from '../../domain/deletion-log.do'; +import { ObjectId } from 'bson'; +import { DeletionLog } from '../../domain/do'; +import { DeletionLogEntity } from '../entity'; export class DeletionLogMapper { static mapToDO(entity: DeletionLogEntity): DeletionLog { @@ -9,10 +9,9 @@ export class DeletionLogMapper { createdAt: entity.createdAt, updatedAt: entity.updatedAt, domain: entity.domain, - operation: entity.operation, - modifiedCount: entity.modifiedCount, - deletedCount: entity.deletedCount, - deletionRequestId: entity.deletionRequestId?.toHexString(), + operations: entity.operations, + subdomainOperations: entity.subdomainOperations, + deletionRequestId: entity.deletionRequestId.toHexString(), performedAt: entity.performedAt, }); } @@ -23,9 +22,8 @@ export class DeletionLogMapper { createdAt: domainObject.createdAt, updatedAt: domainObject.updatedAt, domain: domainObject.domain, - operation: domainObject.operation, - modifiedCount: domainObject.modifiedCount, - deletedCount: domainObject.deletedCount, + operations: domainObject.operations, + subdomainOperations: domainObject.subdomainOperations, deletionRequestId: new ObjectId(domainObject.deletionRequestId), performedAt: domainObject.performedAt, }); diff --git a/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.spec.ts b/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.spec.ts index 4e880aab54e..3bc03dca37e 100644 --- a/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.spec.ts +++ b/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.spec.ts @@ -1,7 +1,7 @@ -import { DeletionRequest } from '../../domain/deletion-request.do'; -import { deletionRequestFactory } from '../../domain/testing/factory/deletion-request.factory'; -import { DeletionRequestEntity } from '../../entity'; -import { deletionRequestEntityFactory } from '../../entity/testing/factory/deletion-request.entity.factory'; +import { DeletionRequest } from '../../domain/do'; +import { deletionRequestFactory } from '../../domain/testing'; +import { DeletionRequestEntity } from '../entity'; +import { deletionRequestEntityFactory } from '../entity/testing'; import { DeletionRequestMapper } from './deletion-request.mapper'; describe(DeletionRequestMapper.name, () => { diff --git a/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.ts b/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.ts index fd6c273011f..4796cbfa2bc 100644 --- a/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.ts +++ b/apps/server/src/modules/deletion/repo/mapper/deletion-request.mapper.ts @@ -1,5 +1,5 @@ -import { DeletionRequest } from '../../domain/deletion-request.do'; -import { DeletionRequestEntity } from '../../entity'; +import { DeletionRequest } from '../../domain/do'; +import { DeletionRequestEntity } from '../entity'; export class DeletionRequestMapper { static mapToDO(entity: DeletionRequestEntity): DeletionRequest { diff --git a/apps/server/src/modules/deletion/repo/scope/deletion-request-scope.spec.ts b/apps/server/src/modules/deletion/repo/scope/deletion-request-scope.spec.ts new file mode 100644 index 00000000000..f183e52386b --- /dev/null +++ b/apps/server/src/modules/deletion/repo/scope/deletion-request-scope.spec.ts @@ -0,0 +1,65 @@ +import { StatusModel } from '../../domain/types'; +import { DeletionRequestScope } from './deletion-request-scope'; + +describe(DeletionRequestScope.name, () => { + let scope: DeletionRequestScope; + + beforeEach(() => { + scope = new DeletionRequestScope(); + scope.allowEmptyQuery(true); + }); + + describe('byDeleteAfter', () => { + const setup = () => { + const currentDate = new Date(); + const expectedQuery = { deleteAfter: { $lt: currentDate } }; + + return { + currentDate, + expectedQuery, + }; + }; + + describe('when currentDate is set', () => { + it('should add query', () => { + const { currentDate, expectedQuery } = setup(); + + const result = scope.byDeleteAfter(currentDate); + + expect(result).toBeInstanceOf(DeletionRequestScope); + expect(scope.query).toEqual(expectedQuery); + }); + }); + }); + + describe('byStatus', () => { + const setup = () => { + const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000); // 15 minutes ago + const expectedQuery = { + $or: [ + { status: StatusModel.FAILED }, + { + $and: [ + { status: [StatusModel.REGISTERED, StatusModel.PENDING] }, + { updatedAt: { $lt: fifteenMinutesAgo } }, + ], + }, + ], + }; + return { + expectedQuery, + fifteenMinutesAgo, + }; + }; + describe('when fifteenMinutesAgo is set', () => { + it('should add query', () => { + const { expectedQuery, fifteenMinutesAgo } = setup(); + + const result = scope.byStatus(fifteenMinutesAgo); + + expect(result).toBeInstanceOf(DeletionRequestScope); + expect(scope.query).toEqual(expectedQuery); + }); + }); + }); +}); diff --git a/apps/server/src/modules/deletion/repo/scope/deletion-request-scope.ts b/apps/server/src/modules/deletion/repo/scope/deletion-request-scope.ts new file mode 100644 index 00000000000..896e581ea25 --- /dev/null +++ b/apps/server/src/modules/deletion/repo/scope/deletion-request-scope.ts @@ -0,0 +1,24 @@ +import { Scope } from '@shared/repo'; +import { DeletionRequestEntity } from '../entity'; +import { StatusModel } from '../../domain/types'; + +export class DeletionRequestScope extends Scope { + byDeleteAfter(currentDate: Date): this { + this.addQuery({ deleteAfter: { $lt: currentDate } }); + + return this; + } + + byStatus(fifteenMinutesAgo: Date): this { + this.addQuery({ + $or: [ + { status: StatusModel.FAILED }, + { + $and: [{ status: [StatusModel.REGISTERED, StatusModel.PENDING] }, { updatedAt: { $lt: fifteenMinutesAgo } }], + }, + ], + }); + + return this; + } +} diff --git a/apps/server/src/modules/deletion/repo/scope/index.ts b/apps/server/src/modules/deletion/repo/scope/index.ts new file mode 100644 index 00000000000..9db98db127e --- /dev/null +++ b/apps/server/src/modules/deletion/repo/scope/index.ts @@ -0,0 +1 @@ +export * from './deletion-request-scope'; diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts deleted file mode 100644 index 9ebee1e77ef..00000000000 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.spec.ts +++ /dev/null @@ -1,619 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { setupEntities, userDoFactory } from '@shared/testing'; -import { AccountService } from '@modules/account'; -import { ClassService } from '@modules/class'; -import { CourseGroupService, CourseService, DashboardService } from '@modules/learnroom'; -import { FilesService } from '@modules/files'; -import { LessonService } from '@modules/lesson'; -import { PseudonymService } from '@modules/pseudonym'; -import { TeamService } from '@modules/teams'; -import { UserService } from '@modules/user'; -import { RocketChatService } from '@modules/rocketchat'; -import { RocketChatUser, RocketChatUserService, rocketChatUserFactory } from '@modules/rocketchat-user'; -import { LegacyLogger } from '@src/core/logger'; -import { ObjectId } from 'bson'; -import { RegistrationPinService } from '@modules/registration-pin'; -import { FilesStorageClientAdapterService } from '@src/modules/files-storage-client'; -import { DomainModel } from '@shared/domain/types'; -import { TaskService } from '@modules/task'; -import { DomainOperationBuilder } from '@shared/domain/builder'; -import { DeletionStatusModel } from '../domain/types'; -import { DeletionLogService } from '../services/deletion-log.service'; -import { DeletionRequestService } from '../services'; -import { DeletionRequestUc } from './deletion-request.uc'; -import { deletionRequestFactory } from '../domain/testing/factory/deletion-request.factory'; -import { deletionLogFactory } from '../domain/testing'; -import { DeletionRequestBodyProps } from '../controller/dto'; -import { DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder } from '../builder'; - -describe(DeletionRequestUc.name, () => { - let module: TestingModule; - let uc: DeletionRequestUc; - let deletionRequestService: DeepMocked; - let deletionLogService: DeepMocked; - let accountService: DeepMocked; - let classService: DeepMocked; - let courseGroupService: DeepMocked; - let courseService: DeepMocked; - let filesService: DeepMocked; - let lessonService: DeepMocked; - let pseudonymService: DeepMocked; - let teamService: DeepMocked; - let userService: DeepMocked; - let rocketChatUserService: DeepMocked; - let rocketChatService: DeepMocked; - let registrationPinService: DeepMocked; - let filesStorageClientAdapterService: DeepMocked; - let dashboardService: DeepMocked; - let taskService: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - DeletionRequestUc, - { - provide: DeletionRequestService, - useValue: createMock(), - }, - { - provide: DeletionLogService, - useValue: createMock(), - }, - { - provide: AccountService, - useValue: createMock(), - }, - { - provide: ClassService, - useValue: createMock(), - }, - { - provide: CourseGroupService, - useValue: createMock(), - }, - { - provide: CourseService, - useValue: createMock(), - }, - { - provide: FilesService, - useValue: createMock(), - }, - { - provide: LessonService, - useValue: createMock(), - }, - { - provide: PseudonymService, - useValue: createMock(), - }, - { - provide: TeamService, - useValue: createMock(), - }, - { - provide: UserService, - useValue: createMock(), - }, - { - provide: RocketChatUserService, - useValue: createMock(), - }, - { - provide: RocketChatService, - useValue: createMock(), - }, - { - provide: LegacyLogger, - useValue: createMock(), - }, - { - provide: RegistrationPinService, - useValue: createMock(), - }, - { - provide: FilesStorageClientAdapterService, - useValue: createMock(), - }, - { - provide: DashboardService, - useValue: createMock(), - }, - { - provide: TaskService, - useValue: createMock(), - }, - ], - }).compile(); - - uc = module.get(DeletionRequestUc); - deletionRequestService = module.get(DeletionRequestService); - deletionLogService = module.get(DeletionLogService); - accountService = module.get(AccountService); - classService = module.get(ClassService); - courseGroupService = module.get(CourseGroupService); - courseService = module.get(CourseService); - filesService = module.get(FilesService); - lessonService = module.get(LessonService); - pseudonymService = module.get(PseudonymService); - teamService = module.get(TeamService); - userService = module.get(UserService); - rocketChatUserService = module.get(RocketChatUserService); - rocketChatService = module.get(RocketChatService); - registrationPinService = module.get(RegistrationPinService); - filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); - dashboardService = module.get(DashboardService); - taskService = module.get(TaskService); - await setupEntities(); - }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('createDeletionRequest', () => { - describe('when creating a deletionRequest', () => { - const setup = () => { - const deletionRequestToCreate: DeletionRequestBodyProps = { - targetRef: { - domain: DomainModel.USER, - id: new ObjectId().toHexString(), - }, - deleteInMinutes: 1440, - }; - const deletionRequest = deletionRequestFactory.build(); - - return { - deletionRequestToCreate, - deletionRequest, - }; - }; - - it('should call the service to create the deletionRequest', async () => { - const { deletionRequestToCreate } = setup(); - - await uc.createDeletionRequest(deletionRequestToCreate); - - expect(deletionRequestService.createDeletionRequest).toHaveBeenCalledWith( - deletionRequestToCreate.targetRef.id, - deletionRequestToCreate.targetRef.domain, - deletionRequestToCreate.deleteInMinutes - ); - }); - - it('should return the deletionRequestID and deletionPlannedAt', async () => { - const { deletionRequestToCreate, deletionRequest } = setup(); - - deletionRequestService.createDeletionRequest.mockResolvedValueOnce({ - requestId: deletionRequest.id, - deletionPlannedAt: deletionRequest.deleteAfter, - }); - - const result = await uc.createDeletionRequest(deletionRequestToCreate); - - expect(result).toEqual({ - requestId: deletionRequest.id, - deletionPlannedAt: deletionRequest.deleteAfter, - }); - }); - }); - }); - - describe('executeDeletionRequests', () => { - describe('when executing deletionRequests', () => { - const setup = () => { - const deletionRequestToExecute = deletionRequestFactory.build({ deleteAfter: new Date('2023-01-01') }); - const user = userDoFactory.buildWithId(); - const rocketChatUser: RocketChatUser = rocketChatUserFactory.build({ - userId: deletionRequestToExecute.targetRefId, - }); - const parentEmail = 'parent@parent.eu'; - const tasksModifiedByRemoveCreatorId = DomainOperationBuilder.build(DomainModel.TASK, 1, 0); - const tasksModifiedByRemoveUserFromFinished = DomainOperationBuilder.build(DomainModel.TASK, 1, 0); - const tasksDeleted = DomainOperationBuilder.build(DomainModel.TASK, 0, 1); - - registrationPinService.deleteRegistrationPinByEmail.mockResolvedValueOnce(2); - classService.deleteUserDataFromClasses.mockResolvedValueOnce(1); - courseGroupService.deleteUserDataFromCourseGroup.mockResolvedValueOnce(2); - courseService.deleteUserDataFromCourse.mockResolvedValueOnce(2); - filesService.markFilesOwnedByUserForDeletion.mockResolvedValueOnce(2); - filesService.removeUserPermissionsOrCreatorReferenceToAnyFiles.mockResolvedValueOnce(2); - lessonService.deleteUserDataFromLessons.mockResolvedValueOnce(2); - pseudonymService.deleteByUserId.mockResolvedValueOnce(2); - teamService.deleteUserDataFromTeams.mockResolvedValueOnce(2); - userService.deleteUser.mockResolvedValueOnce(1); - rocketChatUserService.deleteByUserId.mockResolvedValueOnce(1); - filesStorageClientAdapterService.removeCreatorIdFromFileRecords.mockResolvedValueOnce(5); - dashboardService.deleteDashboardByUserId.mockResolvedValueOnce(1); - taskService.removeCreatorIdFromTasks.mockResolvedValueOnce(tasksModifiedByRemoveCreatorId); - taskService.removeCreatorIdFromTasks.mockResolvedValueOnce(tasksModifiedByRemoveUserFromFinished); - taskService.deleteTasksByOnlyCreator.mockResolvedValueOnce(tasksDeleted); - - return { - deletionRequestToExecute, - rocketChatUser, - user, - parentEmail, - }; - }; - - it('should call deletionRequestService.findAllItemsToExecute', async () => { - await uc.executeDeletionRequests(); - - expect(deletionRequestService.findAllItemsToExecute).toHaveBeenCalled(); - }); - - it('should call deletionRequestService.markDeletionRequestAsExecuted to update status of deletionRequests', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(deletionRequestService.markDeletionRequestAsExecuted).toHaveBeenCalledWith(deletionRequestToExecute.id); - }); - - it('should call accountService.deleteByUserId to delete user data in account module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(accountService.deleteByUserId).toHaveBeenCalled(); - }); - - it('should call registrationPinService.deleteRegistrationPinByEmail to delete user data in registrationPin module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(registrationPinService.deleteRegistrationPinByEmail).toHaveBeenCalled(); - }); - - it('should call userService.getParentEmailsFromUser to get parentEmails', async () => { - const { deletionRequestToExecute, user, parentEmail } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - userService.findById.mockResolvedValueOnce(user); - userService.getParentEmailsFromUser.mockRejectedValue([parentEmail]); - registrationPinService.deleteRegistrationPinByEmail.mockRejectedValueOnce(2); - - await uc.executeDeletionRequests(); - - expect(userService.getParentEmailsFromUser).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call classService.deleteUserDataFromClasses to delete user data in class module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(classService.deleteUserDataFromClasses).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call courseGroupService.deleteUserDataFromCourseGroup to delete user data in courseGroup module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(courseGroupService.deleteUserDataFromCourseGroup).toHaveBeenCalledWith( - deletionRequestToExecute.targetRefId - ); - }); - - it('should call courseService.deleteUserDataFromCourse to delete user data in course module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(courseService.deleteUserDataFromCourse).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call filesService.markFilesOwnedByUserForDeletion to mark users files to delete in file module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(filesService.markFilesOwnedByUserForDeletion).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call filesService.removeUserPermissionsToAnyFiles to remove users permissions to any files in file module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(filesService.removeUserPermissionsOrCreatorReferenceToAnyFiles).toHaveBeenCalledWith( - deletionRequestToExecute.targetRefId - ); - }); - - it('should call filesStorageClientAdapterService.removeCreatorIdFromFileRecords to remove cratorId to any files in fileRecords module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(filesStorageClientAdapterService.removeCreatorIdFromFileRecords).toHaveBeenCalledWith( - deletionRequestToExecute.targetRefId - ); - }); - - it('should call lessonService.deleteUserDataFromLessons to delete users data in lesson module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(lessonService.deleteUserDataFromLessons).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call pseudonymService.deleteByUserId to delete users data in pseudonym module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(pseudonymService.deleteByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call teamService.deleteUserDataFromTeams to delete users data in teams module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(teamService.deleteUserDataFromTeams).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call userService.deleteUsers to delete user in user module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(userService.deleteUser).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call rocketChatUserService.findByUserId to find rocketChatUser in rocketChatUser module', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(rocketChatUserService.findByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call rocketChatUserService.deleteByUserId to delete rocketChatUser in rocketChatUser module', async () => { - const { deletionRequestToExecute, rocketChatUser } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - rocketChatUserService.findByUserId.mockResolvedValueOnce(rocketChatUser); - - await uc.executeDeletionRequests(); - - expect(rocketChatUserService.deleteByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call rocketChatService.deleteUser to delete rocketChatUser in rocketChat external module', async () => { - const { deletionRequestToExecute, rocketChatUser } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - rocketChatUserService.findByUserId.mockResolvedValueOnce(rocketChatUser); - - await uc.executeDeletionRequests(); - - expect(rocketChatService.deleteUser).toHaveBeenCalledWith(rocketChatUser.username); - }); - - it('should call dashboardService.deleteDashboardByUserId to delete USERS DASHBOARD', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(dashboardService.deleteDashboardByUserId).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call taskService.deleteTasksByOnlyCreator to delete Tasks only with creator', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(taskService.deleteTasksByOnlyCreator).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call taskService.removeCreatorIdFromTasks to update Tasks without creatorId', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(taskService.removeCreatorIdFromTasks).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call taskService.removeUserFromFinished to update Tasks without creatorId in Finished collection', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(taskService.removeUserFromFinished).toHaveBeenCalledWith(deletionRequestToExecute.targetRefId); - }); - - it('should call deletionLogService.createDeletionLog to create logs for deletionRequest', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(deletionLogService.createDeletionLog).toHaveBeenCalledTimes(13); - }); - }); - - describe('when an error occurred', () => { - const setup = () => { - const deletionRequestToExecute = deletionRequestFactory.build({ deleteAfter: new Date('2023-01-01') }); - - classService.deleteUserDataFromClasses.mockResolvedValueOnce(1); - courseGroupService.deleteUserDataFromCourseGroup.mockResolvedValueOnce(2); - courseService.deleteUserDataFromCourse.mockResolvedValueOnce(2); - filesService.markFilesOwnedByUserForDeletion.mockResolvedValueOnce(2); - filesService.removeUserPermissionsOrCreatorReferenceToAnyFiles.mockResolvedValueOnce(2); - lessonService.deleteUserDataFromLessons.mockResolvedValueOnce(2); - pseudonymService.deleteByUserId.mockResolvedValueOnce(2); - teamService.deleteUserDataFromTeams.mockResolvedValueOnce(2); - userService.deleteUser.mockRejectedValueOnce(new Error()); - - return { - deletionRequestToExecute, - }; - }; - - it('should throw an arror', async () => { - const { deletionRequestToExecute } = setup(); - - deletionRequestService.findAllItemsToExecute.mockResolvedValueOnce([deletionRequestToExecute]); - - await uc.executeDeletionRequests(); - - expect(deletionRequestService.markDeletionRequestAsFailed).toHaveBeenCalledWith(deletionRequestToExecute.id); - }); - }); - }); - - describe('findById', () => { - describe('when searching for logs for deletionRequest which was executed', () => { - const setup = () => { - const deletionRequestExecuted = deletionRequestFactory.build({ status: DeletionStatusModel.SUCCESS }); - const deletionLogExecuted = deletionLogFactory.build({ deletionRequestId: deletionRequestExecuted.id }); - - const targetRef = DeletionTargetRefBuilder.build( - deletionRequestExecuted.targetRefDomain, - deletionRequestExecuted.targetRefId - ); - const statistics = DomainOperationBuilder.build( - deletionLogExecuted.domain, - deletionLogExecuted.modifiedCount, - deletionLogExecuted.deletedCount - ); - - const executedDeletionRequestSummary = DeletionRequestLogResponseBuilder.build( - targetRef, - deletionRequestExecuted.deleteAfter, - [statistics] - ); - - return { - deletionRequestExecuted, - executedDeletionRequestSummary, - deletionLogExecuted, - }; - }; - - it('should call to deletionRequestService and deletionLogService', async () => { - const { deletionRequestExecuted } = setup(); - - deletionRequestService.findById.mockResolvedValueOnce(deletionRequestExecuted); - - await uc.findById(deletionRequestExecuted.id); - - expect(deletionRequestService.findById).toHaveBeenCalledWith(deletionRequestExecuted.id); - expect(deletionLogService.findByDeletionRequestId).toHaveBeenCalledWith(deletionRequestExecuted.id); - }); - - it('should return object with summary of deletionRequest', async () => { - const { deletionRequestExecuted, deletionLogExecuted, executedDeletionRequestSummary } = setup(); - - deletionRequestService.findById.mockResolvedValueOnce(deletionRequestExecuted); - deletionLogService.findByDeletionRequestId.mockResolvedValueOnce([deletionLogExecuted]); - - const result = await uc.findById(deletionRequestExecuted.id); - - expect(result).toEqual(executedDeletionRequestSummary); - }); - }); - - describe('when searching for logs for deletionRequest which was not executed', () => { - const setup = () => { - const deletionRequest = deletionRequestFactory.build(); - const targetRef = DeletionTargetRefBuilder.build(deletionRequest.targetRefDomain, deletionRequest.targetRefId); - const notExecutedDeletionRequestSummary = DeletionRequestLogResponseBuilder.build( - targetRef, - deletionRequest.deleteAfter - ); - - return { - deletionRequest, - notExecutedDeletionRequestSummary, - }; - }; - - it('should call to deletionRequestService', async () => { - const { deletionRequest } = setup(); - - deletionRequestService.findById.mockResolvedValueOnce(deletionRequest); - - await uc.findById(deletionRequest.id); - - expect(deletionRequestService.findById).toHaveBeenCalledWith(deletionRequest.id); - expect(deletionLogService.findByDeletionRequestId).not.toHaveBeenCalled(); - }); - - it('should return object with summary of deletionRequest', async () => { - const { deletionRequest, notExecutedDeletionRequestSummary } = setup(); - - deletionRequestService.findById.mockResolvedValueOnce(deletionRequest); - - const result = await uc.findById(deletionRequest.id); - - expect(result).toEqual(notExecutedDeletionRequestSummary); - }); - }); - }); - - describe('deleteDeletionRequestById', () => { - describe('when deleting a deletionRequestId', () => { - const setup = () => { - const deletionRequest = deletionRequestFactory.build(); - - return { - deletionRequest, - }; - }; - - it('should call the service deletionRequestService.deleteById', async () => { - const { deletionRequest } = setup(); - - await uc.deleteDeletionRequestById(deletionRequest.id); - - expect(deletionRequestService.deleteById).toHaveBeenCalledWith(deletionRequest.id); - }); - }); - }); -}); diff --git a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts b/apps/server/src/modules/deletion/uc/deletion-request.uc.ts deleted file mode 100644 index 16973af5a2c..00000000000 --- a/apps/server/src/modules/deletion/uc/deletion-request.uc.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { AccountService } from '@modules/account/services'; -import { ClassService } from '@modules/class'; -import { FilesService } from '@modules/files/service'; -import { CourseGroupService, CourseService, DashboardService } from '@modules/learnroom'; -import { LessonService } from '@modules/lesson/service'; -import { PseudonymService } from '@modules/pseudonym'; -import { RegistrationPinService } from '@modules/registration-pin'; -import { RocketChatService } from '@modules/rocketchat'; -import { RocketChatUserService } from '@modules/rocketchat-user'; -import { TeamService } from '@modules/teams'; -import { UserService } from '@modules/user'; -import { Injectable } from '@nestjs/common'; -import { DomainModel, EntityId } from '@shared/domain/types'; -import { LegacyLogger } from '@src/core/logger'; -import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; -import { TaskService } from '@modules/task'; -import { DomainOperation } from '@shared/domain/interface'; -import { DomainOperationBuilder } from '@shared/domain/builder/domain-operation.builder'; -import { DeletionRequestLogResponseBuilder, DeletionTargetRefBuilder } from '../builder'; -import { DeletionRequestBodyProps, DeletionRequestLogResponse, DeletionRequestResponse } from '../controller/dto'; -import { DeletionRequest, DeletionLog } from '../domain'; -import { DeletionOperationModel, DeletionStatusModel } from '../domain/types'; -import { DeletionRequestService, DeletionLogService } from '../services'; - -@Injectable() -export class DeletionRequestUc { - constructor( - private readonly deletionRequestService: DeletionRequestService, - private readonly deletionLogService: DeletionLogService, - private readonly accountService: AccountService, - private readonly classService: ClassService, - private readonly courseGroupService: CourseGroupService, - private readonly courseService: CourseService, - private readonly filesService: FilesService, - private readonly lessonService: LessonService, - private readonly pseudonymService: PseudonymService, - private readonly teamService: TeamService, - private readonly userService: UserService, - private readonly rocketChatUserService: RocketChatUserService, - private readonly rocketChatService: RocketChatService, - private readonly logger: LegacyLogger, - private readonly registrationPinService: RegistrationPinService, - private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, - private readonly dashboardService: DashboardService, - private readonly taskService: TaskService - ) { - this.logger.setContext(DeletionRequestUc.name); - } - - async createDeletionRequest(deletionRequest: DeletionRequestBodyProps): Promise { - this.logger.debug({ action: 'createDeletionRequest', deletionRequest }); - const result = await this.deletionRequestService.createDeletionRequest( - deletionRequest.targetRef.id, - deletionRequest.targetRef.domain, - deletionRequest.deleteInMinutes - ); - - return result; - } - - async executeDeletionRequests(limit?: number): Promise { - this.logger.debug({ action: 'executeDeletionRequests', limit }); - - const deletionRequestToExecution: DeletionRequest[] = await this.deletionRequestService.findAllItemsToExecute( - limit - ); - - for (const req of deletionRequestToExecution) { - // eslint-disable-next-line no-await-in-loop - await this.executeDeletionRequest(req); - } - } - - async findById(deletionRequestId: EntityId): Promise { - this.logger.debug({ action: 'deletionRequestId', deletionRequestId }); - - const deletionRequest: DeletionRequest = await this.deletionRequestService.findById(deletionRequestId); - let response: DeletionRequestLogResponse = DeletionRequestLogResponseBuilder.build( - DeletionTargetRefBuilder.build(deletionRequest.targetRefDomain, deletionRequest.targetRefId), - deletionRequest.deleteAfter - ); - - if (deletionRequest.status === DeletionStatusModel.SUCCESS) { - const deletionLog: DeletionLog[] = await this.deletionLogService.findByDeletionRequestId(deletionRequestId); - const domainOperation: DomainOperation[] = deletionLog.map((log) => - DomainOperationBuilder.build(log.domain, log.modifiedCount, log.deletedCount) - ); - response = { ...response, statistics: domainOperation }; - } - - return response; - } - - async deleteDeletionRequestById(deletionRequestId: EntityId): Promise { - this.logger.debug({ action: 'deleteDeletionRequestById', deletionRequestId }); - - await this.deletionRequestService.deleteById(deletionRequestId); - } - - private async executeDeletionRequest(deletionRequest: DeletionRequest): Promise { - try { - await Promise.all([ - this.removeAccount(deletionRequest), - this.removeUserFromClasses(deletionRequest), - this.removeUserFromCourseGroup(deletionRequest), - this.removeUserFromCourse(deletionRequest), - this.removeUsersFilesAndPermissions(deletionRequest), - this.removeUsersDataFromFileRecords(deletionRequest), - this.removeUserFromLessons(deletionRequest), - this.removeUsersPseudonyms(deletionRequest), - this.removeUserFromTeams(deletionRequest), - this.removeUser(deletionRequest), - this.removeUserFromRocketChat(deletionRequest), - this.removeUserRegistrationPin(deletionRequest), - this.removeUsersDashboard(deletionRequest), - this.removeUserFromTasks(deletionRequest), - ]); - await this.deletionRequestService.markDeletionRequestAsExecuted(deletionRequest.id); - } catch (error) { - this.logger.error(`execution of deletionRequest ${deletionRequest.id} was failed`, error); - await this.deletionRequestService.markDeletionRequestAsFailed(deletionRequest.id); - } - } - - private async logDeletion( - deletionRequest: DeletionRequest, - domainModel: DomainModel, - operationModel: DeletionOperationModel, - updatedCount: number, - deletedCount: number - ): Promise { - await this.deletionLogService.createDeletionLog( - deletionRequest.id, - domainModel, - operationModel, - updatedCount, - deletedCount - ); - } - - private async removeAccount(deletionRequest: DeletionRequest) { - this.logger.debug({ action: 'removeAccount', deletionRequest }); - - await this.accountService.deleteByUserId(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DomainModel.ACCOUNT, DeletionOperationModel.DELETE, 0, 1); - } - - private async removeUserRegistrationPin(deletionRequest: DeletionRequest) { - const userToDeletion = await this.userService.findById(deletionRequest.targetRefId); - const parentEmails = await this.userService.getParentEmailsFromUser(deletionRequest.targetRefId); - const emailsToDeletion: string[] = [userToDeletion.email, ...parentEmails]; - - const result = await Promise.all( - emailsToDeletion.map((email) => this.registrationPinService.deleteRegistrationPinByEmail(email)) - ); - const deletedRegistrationPin = result.filter((res) => res !== 0).length; - - await this.logDeletion( - deletionRequest, - DomainModel.REGISTRATIONPIN, - DeletionOperationModel.DELETE, - 0, - deletedRegistrationPin - ); - } - - private async removeUserFromClasses(deletionRequest: DeletionRequest) { - this.logger.debug({ action: 'removeUserFromClasses', deletionRequest }); - - const classesUpdated: number = await this.classService.deleteUserDataFromClasses(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DomainModel.CLASS, DeletionOperationModel.UPDATE, classesUpdated, 0); - } - - private async removeUserFromCourseGroup(deletionRequest: DeletionRequest) { - this.logger.debug({ action: 'removeUserFromCourseGroup', deletionRequest }); - - const courseGroupUpdated: number = await this.courseGroupService.deleteUserDataFromCourseGroup( - deletionRequest.targetRefId - ); - await this.logDeletion( - deletionRequest, - DomainModel.COURSEGROUP, - DeletionOperationModel.UPDATE, - courseGroupUpdated, - 0 - ); - } - - private async removeUserFromCourse(deletionRequest: DeletionRequest) { - this.logger.debug({ action: 'removeUserFromCourse', deletionRequest }); - - const courseUpdated: number = await this.courseService.deleteUserDataFromCourse(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DomainModel.COURSE, DeletionOperationModel.UPDATE, courseUpdated, 0); - } - - private async removeUsersDashboard(deletionRequest: DeletionRequest) { - this.logger.debug({ action: 'removeUsersDashboard', deletionRequest }); - - const dashboardDeleted: number = await this.dashboardService.deleteDashboardByUserId(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DomainModel.DASHBOARD, DeletionOperationModel.DELETE, 0, dashboardDeleted); - } - - private async removeUsersFilesAndPermissions(deletionRequest: DeletionRequest) { - this.logger.debug({ action: 'removeUsersFilesAndPermissions', deletionRequest }); - - const filesDeleted: number = await this.filesService.markFilesOwnedByUserForDeletion(deletionRequest.targetRefId); - const filesUpdated: number = await this.filesService.removeUserPermissionsOrCreatorReferenceToAnyFiles( - deletionRequest.targetRefId - ); - await this.logDeletion( - deletionRequest, - DomainModel.FILE, - DeletionOperationModel.UPDATE, - filesDeleted + filesUpdated, - 0 - ); - } - - private async removeUsersDataFromFileRecords(deletionRequest: DeletionRequest) { - this.logger.debug({ action: 'removeUsersDataFromFileRecords', deletionRequest }); - - const fileRecordsUpdated = await this.filesStorageClientAdapterService.removeCreatorIdFromFileRecords( - deletionRequest.targetRefId - ); - - await this.logDeletion( - deletionRequest, - DomainModel.FILERECORDS, - DeletionOperationModel.UPDATE, - fileRecordsUpdated, - 0 - ); - } - - private async removeUserFromLessons(deletionRequest: DeletionRequest) { - this.logger.debug({ action: 'removeUserFromLessons', deletionRequest }); - - const lessonsUpdated: number = await this.lessonService.deleteUserDataFromLessons(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DomainModel.LESSONS, DeletionOperationModel.UPDATE, lessonsUpdated, 0); - } - - private async removeUsersPseudonyms(deletionRequest: DeletionRequest) { - this.logger.debug({ action: 'removeUsersPseudonyms', deletionRequest }); - - const pseudonymDeleted: number = await this.pseudonymService.deleteByUserId(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DomainModel.PSEUDONYMS, DeletionOperationModel.DELETE, 0, pseudonymDeleted); - } - - private async removeUserFromTeams(deletionRequest: DeletionRequest) { - this.logger.debug({ action: ' removeUserFromTeams', deletionRequest }); - - const teamsUpdated: number = await this.teamService.deleteUserDataFromTeams(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DomainModel.TEAMS, DeletionOperationModel.UPDATE, teamsUpdated, 0); - } - - private async removeUser(deletionRequest: DeletionRequest) { - this.logger.debug({ action: 'removeUser', deletionRequest }); - - const userDeleted: number = await this.userService.deleteUser(deletionRequest.targetRefId); - await this.logDeletion(deletionRequest, DomainModel.USER, DeletionOperationModel.DELETE, 0, userDeleted); - } - - private async removeUserFromRocketChat(deletionRequest: DeletionRequest) { - this.logger.debug({ action: 'removeUserFromRocketChat', deletionRequest }); - - const rocketChatUser = await this.rocketChatUserService.findByUserId(deletionRequest.targetRefId); - - const [, rocketChatUserDeleted] = await Promise.all([ - this.rocketChatService.deleteUser(rocketChatUser.username), - this.rocketChatUserService.deleteByUserId(rocketChatUser.userId), - ]); - await this.logDeletion( - deletionRequest, - DomainModel.ROCKETCHATUSER, - DeletionOperationModel.DELETE, - 0, - rocketChatUserDeleted - ); - } - - private async removeUserFromTasks(deletionRequest: DeletionRequest) { - this.logger.debug({ action: 'removeUserFromTasks', deletionRequest }); - - const tasksDeleted = await this.taskService.deleteTasksByOnlyCreator(deletionRequest.targetRefId); - const tasksModifiedByRemoveCreator = await this.taskService.removeCreatorIdFromTasks(deletionRequest.targetRefId); - const tasksModifiedByRemoveUserFromFinished = await this.taskService.removeUserFromFinished( - deletionRequest.targetRefId - ); - - await this.logDeletion( - deletionRequest, - DomainModel.TASK, - DeletionOperationModel.UPDATE, - tasksModifiedByRemoveCreator.modifiedCount + tasksModifiedByRemoveUserFromFinished.modifiedCount, - tasksDeleted.deletedCount - ); - } -} diff --git a/apps/server/src/modules/deletion/uc/interface/interfaces.ts b/apps/server/src/modules/deletion/uc/interface/interfaces.ts deleted file mode 100644 index 004484c20b2..00000000000 --- a/apps/server/src/modules/deletion/uc/interface/interfaces.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { EntityId } from '@shared/domain/types'; -import { DomainModel } from '@shared/domain/types/domain'; - -export interface DeletionTargetRef { - targetRefDomain: DomainModel; - targetRefId: EntityId; -} - -export interface DeletionRequestLog { - targetRef: DeletionTargetRef; - deletionPlannedAt: Date; - statistics?: DeletionLogStatistic[]; -} - -export interface DeletionLogStatistic { - domain: DomainModel; - modifiedCount?: number; - deletedCount?: number; -} - -export interface DeletionRequestProps { - targetRef: { targetRefDoamin: DomainModel; targetRefId: EntityId }; - deleteInMinutes?: number; -} - -export interface DeletionRequestCreateAnswer { - requestId: EntityId; - deletionPlannedAt: Date; -} diff --git a/apps/server/src/modules/files-storage-client/dto/file.dto.ts b/apps/server/src/modules/files-storage-client/dto/file.dto.ts index 3770d610df5..f14f55ad68d 100644 --- a/apps/server/src/modules/files-storage-client/dto/file.dto.ts +++ b/apps/server/src/modules/files-storage-client/dto/file.dto.ts @@ -11,10 +11,16 @@ export class FileDto { parentId: EntityId; + createdAt?: Date; + + updatedAt?: Date; + constructor(props: FileDomainObjectProps) { this.id = props.id; this.name = props.name; this.parentType = props.parentType; this.parentId = props.parentId; + this.createdAt = props.createdAt; + this.updatedAt = props.updatedAt; } } diff --git a/apps/server/src/modules/files-storage-client/interfaces/files-storage-client-config.ts b/apps/server/src/modules/files-storage-client/files-storage-client-config.ts similarity index 100% rename from apps/server/src/modules/files-storage-client/interfaces/files-storage-client-config.ts rename to apps/server/src/modules/files-storage-client/files-storage-client-config.ts diff --git a/apps/server/src/modules/files-storage-client/files-storage-client.module.ts b/apps/server/src/modules/files-storage-client/files-storage-client.module.ts index 11c8eccdd3b..0c6095fc206 100644 --- a/apps/server/src/modules/files-storage-client/files-storage-client.module.ts +++ b/apps/server/src/modules/files-storage-client/files-storage-client.module.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; +// The files-storage-client should not know the copy-helper import { CopyHelperModule } from '@modules/copy-helper'; -import { CopyFilesService } from './service/copy-files.service'; -import { FilesStorageClientAdapterService } from './service/files-storage-client.service'; -import { FilesStorageProducer } from './service/files-storage.producer'; +import { CqrsModule } from '@nestjs/cqrs'; +import { CopyFilesService, FilesStorageClientAdapterService, FilesStorageProducer } from './service'; @Module({ - imports: [LoggerModule, CopyHelperModule], + imports: [LoggerModule, CopyHelperModule, CqrsModule], providers: [FilesStorageClientAdapterService, CopyFilesService, FilesStorageProducer], exports: [FilesStorageClientAdapterService, CopyFilesService], }) diff --git a/apps/server/src/modules/files-storage-client/index.ts b/apps/server/src/modules/files-storage-client/index.ts index ae847364123..b7f971c7195 100644 --- a/apps/server/src/modules/files-storage-client/index.ts +++ b/apps/server/src/modules/files-storage-client/index.ts @@ -1,6 +1,6 @@ export { FileDto } from './dto'; -export * from './files-storage-client.module'; -export { FilesStorageClientConfig } from './interfaces'; -export { FileParamBuilder } from './mapper/files-storage-param.builder'; -export * from './service/copy-files.service'; -export { FilesStorageClientAdapterService } from './service/files-storage-client.service'; +export { FilesStorageClientModule } from './files-storage-client.module'; +export { FilesStorageClientConfig } from './files-storage-client-config'; +export { FileParamBuilder } from './mapper'; +export { FileUrlReplacement } from './interfaces'; +export { FilesStorageClientAdapterService, CopyFilesService } from './service'; diff --git a/apps/server/src/modules/files-storage-client/interfaces/copy-file-domain-object-props.ts b/apps/server/src/modules/files-storage-client/interfaces/copy-file-domain-object-props.ts index 6b8ef88cc1f..183ac7f8f69 100644 --- a/apps/server/src/modules/files-storage-client/interfaces/copy-file-domain-object-props.ts +++ b/apps/server/src/modules/files-storage-client/interfaces/copy-file-domain-object-props.ts @@ -1,5 +1,6 @@ import { EntityId } from '@shared/domain/types'; +// This interface is invalid and should be removed export interface CopyFileDomainObjectProps { id?: EntityId | undefined; sourceId: EntityId; diff --git a/apps/server/src/modules/files-storage-client/interfaces/file-domain-object-props.ts b/apps/server/src/modules/files-storage-client/interfaces/file-domain-object-props.ts index 9b4b1bc749c..7053f5ed7de 100644 --- a/apps/server/src/modules/files-storage-client/interfaces/file-domain-object-props.ts +++ b/apps/server/src/modules/files-storage-client/interfaces/file-domain-object-props.ts @@ -6,4 +6,6 @@ export interface FileDomainObjectProps { name: string; parentType: FileRecordParentType; parentId: EntityId; + createdAt?: Date; + updatedAt?: Date; } diff --git a/apps/server/src/modules/files-storage-client/interfaces/index.ts b/apps/server/src/modules/files-storage-client/interfaces/index.ts index 3e7eb774e4d..8fd0a6d468a 100644 --- a/apps/server/src/modules/files-storage-client/interfaces/index.ts +++ b/apps/server/src/modules/files-storage-client/interfaces/index.ts @@ -1,5 +1,5 @@ export * from './copy-file-domain-object-props'; +export * from './copy-file-request-info'; export * from './file-domain-object-props'; export * from './file-request-info'; -export * from './files-storage-client-config'; export * from './types'; diff --git a/apps/server/src/modules/files-storage-client/interfaces/types.ts b/apps/server/src/modules/files-storage-client/interfaces/types.ts index 29185da451a..cb998786145 100644 --- a/apps/server/src/modules/files-storage-client/interfaces/types.ts +++ b/apps/server/src/modules/files-storage-client/interfaces/types.ts @@ -2,3 +2,7 @@ import { LessonEntity, Submission, Task } from '@shared/domain/entity'; export type EntitiesWithFiles = Task | LessonEntity | Submission; export type EntityWithEmbeddedFiles = Task | LessonEntity; +export type FileUrlReplacement = { + regex: RegExp; + replacement: string; +}; diff --git a/apps/server/src/modules/files-storage-client/mapper/copy-files-of-parent-param.builder.ts b/apps/server/src/modules/files-storage-client/mapper/copy-files-of-parent-param.builder.ts index d41195dda11..91259ceb206 100644 --- a/apps/server/src/modules/files-storage-client/mapper/copy-files-of-parent-param.builder.ts +++ b/apps/server/src/modules/files-storage-client/mapper/copy-files-of-parent-param.builder.ts @@ -1,6 +1,5 @@ import { EntityId } from '@shared/domain/types'; -import { FileRequestInfo } from '../interfaces'; -import { CopyFilesRequestInfo } from '../interfaces/copy-file-request-info'; +import { FileRequestInfo, CopyFilesRequestInfo } from '../interfaces'; export class CopyFilesOfParentParamBuilder { static build(userId: EntityId, source: FileRequestInfo, target: FileRequestInfo): CopyFilesRequestInfo { diff --git a/apps/server/src/modules/files-storage-client/mapper/files-storage-client.mapper.ts b/apps/server/src/modules/files-storage-client/mapper/files-storage-client.mapper.ts index d60977aa62e..b724654a6b4 100644 --- a/apps/server/src/modules/files-storage-client/mapper/files-storage-client.mapper.ts +++ b/apps/server/src/modules/files-storage-client/mapper/files-storage-client.mapper.ts @@ -31,6 +31,8 @@ export class FilesStorageClientMapper { name: fileRecordResponse.name, parentType, parentId: fileRecordResponse.parentId, + createdAt: fileRecordResponse.createdAt, + updatedAt: fileRecordResponse.updatedAt, }); return fileDto; diff --git a/apps/server/src/modules/files-storage-client/service/copy-files.service.spec.ts b/apps/server/src/modules/files-storage-client/service/copy-files.service.spec.ts index fbc54e3cf0f..99eb7ddf3f2 100644 --- a/apps/server/src/modules/files-storage-client/service/copy-files.service.spec.ts +++ b/apps/server/src/modules/files-storage-client/service/copy-files.service.spec.ts @@ -6,7 +6,7 @@ import { courseFactory, legacyFileEntityMockFactory, lessonFactory, - schoolFactory, + schoolEntityFactory, setupEntities, } from '@shared/testing'; import { CopyFilesService } from './copy-files.service'; @@ -57,7 +57,7 @@ describe('copy files service', () => { describe('copy files of entity', () => { const setup = () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const file1 = legacyFileEntityMockFactory.build(); const file2 = legacyFileEntityMockFactory.build(); const imageHTML1 = getImageHTML(file1.id, file1.name); diff --git a/apps/server/src/modules/files-storage-client/service/copy-files.service.ts b/apps/server/src/modules/files-storage-client/service/copy-files.service.ts index 5283088ae84..75f46b26fd5 100644 --- a/apps/server/src/modules/files-storage-client/service/copy-files.service.ts +++ b/apps/server/src/modules/files-storage-client/service/copy-files.service.ts @@ -2,17 +2,12 @@ import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from ' import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { CopyFileDto } from '../dto'; -import { EntityWithEmbeddedFiles } from '../interfaces'; +import { EntityWithEmbeddedFiles, FileUrlReplacement } from '../interfaces'; import { CopyFilesOfParentParamBuilder, FileParamBuilder } from '../mapper'; import { FilesStorageClientAdapterService } from './files-storage-client.service'; const FILE_COULD_NOT_BE_COPIED_HINT = 'fileCouldNotBeCopied'; -export type FileUrlReplacement = { - regex: RegExp; - replacement: string; -}; - @Injectable() export class CopyFilesService { constructor( @@ -70,6 +65,7 @@ export class CopyFilesService { status: this.copyHelperService.deriveStatusFromElements(fileStatuses), elements: fileStatuses, }; + return fileGroupStatus; } } diff --git a/apps/server/src/modules/files-storage-client/service/files-storage-client.service.spec.ts b/apps/server/src/modules/files-storage-client/service/files-storage-client.service.spec.ts index e1a7096bc1c..be2c14b78ca 100644 --- a/apps/server/src/modules/files-storage-client/service/files-storage-client.service.spec.ts +++ b/apps/server/src/modules/files-storage-client/service/files-storage-client.service.spec.ts @@ -1,8 +1,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { schoolFactory, setupEntities, taskFactory } from '@shared/testing'; +import { schoolEntityFactory, setupEntities, taskFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; +import { FileRecordParentType } from '@infra/rabbitmq'; +import { EventBus } from '@nestjs/cqrs'; +import { + DomainName, + DomainDeletionReportBuilder, + DomainOperationReportBuilder, + OperationType, + DataDeletedEvent, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { FileParamBuilder, FilesStorageClientMapper } from '../mapper'; import { CopyFilesOfParentParamBuilder } from '../mapper/copy-files-of-parent-param.builder'; import { FilesStorageClientAdapterService } from './files-storage-client.service'; @@ -12,6 +22,7 @@ describe('FilesStorageClientAdapterService', () => { let module: TestingModule; let service: FilesStorageClientAdapterService; let client: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -27,11 +38,18 @@ describe('FilesStorageClientAdapterService', () => { provide: FilesStorageProducer, useValue: createMock(), }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); service = module.get(FilesStorageClientAdapterService); client = module.get(FilesStorageProducer); + eventBus = module.get(EventBus); }); afterAll(async () => { @@ -45,7 +63,7 @@ describe('FilesStorageClientAdapterService', () => { describe('copyFilesOfParent', () => { it('Should call all steps.', async () => { const userId = new ObjectId().toHexString(); - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const sourceEntity = taskFactory.buildWithId({ school }); const targetEntity = taskFactory.buildWithId({ school }); @@ -69,7 +87,7 @@ describe('FilesStorageClientAdapterService', () => { it('Should call error mapper if throw an error.', async () => { const userId = new ObjectId().toHexString(); - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const sourceEntity = taskFactory.buildWithId({ school }); const targetEntity = taskFactory.buildWithId({ school }); @@ -86,32 +104,26 @@ describe('FilesStorageClientAdapterService', () => { describe('listFilesOfParent', () => { it('Should call all steps.', async () => { - const schoolId = 'school123'; const task = taskFactory.buildWithId(); - const param = FileParamBuilder.build(schoolId, task); - const spy = jest .spyOn(FilesStorageClientMapper, 'mapfileRecordListResponseToDomainFilesDto') .mockImplementation(() => []); - await service.listFilesOfParent(param); + await service.listFilesOfParent(task.id); - expect(client.listFilesOfParent).toHaveBeenCalledWith(param); + expect(client.listFilesOfParent).toHaveBeenCalledWith(task.id); expect(spy).toBeCalled(); spy.mockRestore(); }); it('Should call error mapper if throw an error.', async () => { - const schoolId = 'school123'; const task = taskFactory.buildWithId(); - const param = FileParamBuilder.build(schoolId, task); - client.listFilesOfParent.mockRejectedValue(new Error()); - await expect(service.listFilesOfParent(param)).rejects.toThrowError(); + await expect(service.listFilesOfParent(task.id)).rejects.toThrowError(); }); }); @@ -156,6 +168,54 @@ describe('FilesStorageClientAdapterService', () => { }); }); + describe('deleteFiles', () => { + describe('when files are deleted successfully', () => { + const setup = () => { + const recordId = new ObjectId().toHexString(); + + const spy = jest + .spyOn(FilesStorageClientMapper, 'mapfileRecordListResponseToDomainFilesDto') + .mockImplementation(() => [ + { + id: recordId, + name: 'file', + parentId: 'parentId', + parentType: FileRecordParentType.BoardNode, + }, + ]); + + return { recordId, spy }; + }; + + it('Should call all steps.', async () => { + const { recordId, spy } = setup(); + + await service.deleteFiles([recordId]); + + expect(client.deleteFiles).toHaveBeenCalledWith([recordId]); + expect(spy).toBeCalled(); + + spy.mockRestore(); + }); + }); + + describe('when error is thrown', () => { + const setup = () => { + const recordId = new ObjectId().toHexString(); + + client.deleteFiles.mockRejectedValue(new Error()); + + return { recordId }; + }; + + it('Should call error mapper if throw an error.', async () => { + const { recordId } = setup(); + + await expect(service.deleteFiles([recordId])).rejects.toThrowError(); + }); + }); + }); + describe('removeCreatorIdFromFileRecords', () => { describe('when creatorId is deleted successfully', () => { const setup = () => { @@ -167,7 +227,7 @@ describe('FilesStorageClientAdapterService', () => { it('Should call client.removeCreatorIdFromFileRecords', async () => { const { creatorId } = setup(); - await service.removeCreatorIdFromFileRecords(creatorId); + await service.deleteUserData(creatorId); expect(client.removeCreatorIdFromFileRecords).toHaveBeenCalledWith(creatorId); }); @@ -185,7 +245,51 @@ describe('FilesStorageClientAdapterService', () => { it('Should call error mapper if throw an error.', async () => { const { creatorId } = setup(); - await expect(service.removeCreatorIdFromFileRecords(creatorId)).rejects.toThrowError(); + await expect(service.deleteUserData(creatorId)).rejects.toThrowError(); + }); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILERECORDS; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in classService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(service.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); }); }); }); diff --git a/apps/server/src/modules/files-storage-client/service/files-storage-client.service.ts b/apps/server/src/modules/files-storage-client/service/files-storage-client.service.ts index a729f878587..3b888298e19 100644 --- a/apps/server/src/modules/files-storage-client/service/files-storage-client.service.ts +++ b/apps/server/src/modules/files-storage-client/service/files-storage-client.service.ts @@ -1,18 +1,39 @@ import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { LegacyLogger } from '@src/core/logger'; -import { CopyFileDto, FileDto } from '../dto'; -import { FileRequestInfo } from '../interfaces'; -import { CopyFilesRequestInfo } from '../interfaces/copy-file-request-info'; -import { FilesStorageClientMapper } from '../mapper'; +import { FileDO } from '@src/infra/rabbitmq'; +import { IEventHandler, EventBus, EventsHandler } from '@nestjs/cqrs'; +import { + UserDeletedEvent, + DeletionService, + DataDeletedEvent, + DomainDeletionReport, + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, +} from '@modules/deletion'; import { FilesStorageProducer } from './files-storage.producer'; +import { FilesStorageClientMapper } from '../mapper'; +import { CopyFilesRequestInfo } from '../interfaces/copy-file-request-info'; +import { CopyFileDto, FileDto } from '../dto'; @Injectable() -export class FilesStorageClientAdapterService { - constructor(private logger: LegacyLogger, private readonly fileStorageMQProducer: FilesStorageProducer) { +@EventsHandler(UserDeletedEvent) +export class FilesStorageClientAdapterService implements DeletionService, IEventHandler { + constructor( + private logger: LegacyLogger, + private readonly fileStorageMQProducer: FilesStorageProducer, + private readonly eventBus: EventBus + ) { this.logger.setContext(FilesStorageClientAdapterService.name); } + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + async copyFilesOfParent(param: CopyFilesRequestInfo): Promise { const response = await this.fileStorageMQProducer.copyFilesOfParent(param); const fileInfos = FilesStorageClientMapper.mapCopyFileListResponseToCopyFilesDto(response); @@ -20,8 +41,8 @@ export class FilesStorageClientAdapterService { return fileInfos; } - async listFilesOfParent(param: FileRequestInfo): Promise { - const response = await this.fileStorageMQProducer.listFilesOfParent(param); + async listFilesOfParent(parentId: EntityId): Promise { + const response = await this.fileStorageMQProducer.listFilesOfParent(parentId); const fileInfos = FilesStorageClientMapper.mapfileRecordListResponseToDomainFilesDto(response); @@ -36,9 +57,25 @@ export class FilesStorageClientAdapterService { return fileInfos; } - async removeCreatorIdFromFileRecords(creatorId: EntityId): Promise { + async deleteFiles(fileRecordIds: EntityId[]): Promise { + const response = await this.fileStorageMQProducer.deleteFiles(fileRecordIds); + + const fileInfos = FilesStorageClientMapper.mapfileRecordListResponseToDomainFilesDto(response); + + return fileInfos; + } + + async deleteUserData(creatorId: EntityId): Promise { const response = await this.fileStorageMQProducer.removeCreatorIdFromFileRecords(creatorId); - return response.length; + const result = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, response.length, this.getFileRecordsId(response)), + ]); + + return result; + } + + private getFileRecordsId(files: FileDO[]): EntityId[] { + return files.map((file) => file.id); } } diff --git a/apps/server/src/modules/files-storage-client/service/files-storage.producer.spec.ts b/apps/server/src/modules/files-storage-client/service/files-storage.producer.spec.ts index 2a472a9edea..3910585ecab 100644 --- a/apps/server/src/modules/files-storage-client/service/files-storage.producer.spec.ts +++ b/apps/server/src/modules/files-storage-client/service/files-storage.producer.spec.ts @@ -132,45 +132,31 @@ describe('FilesStorageProducer', () => { describe('listFilesOfParent', () => { describe('when valid parameter passed and amqpConnection return with error in response', () => { const setup = () => { - const schoolId = new ObjectId().toHexString(); const parentId = new ObjectId().toHexString(); - const param = { - parentType: FileRecordParentType.Task, - schoolId, - parentId, - }; - amqpConnection.request.mockResolvedValue({ error: new Error() }); const spy = jest.spyOn(ErrorMapper, 'mapRpcErrorResponseToDomainError'); - return { param, spy }; + return { parentId, spy }; }; it('should call error mapper and throw with error', async () => { - const { param, spy } = setup(); + const { parentId, spy } = setup(); - await expect(service.listFilesOfParent(param)).rejects.toThrowError(); + await expect(service.listFilesOfParent(parentId)).rejects.toThrowError(); expect(spy).toBeCalled(); }); }); describe('when valid params are passed and ampq do return with message', () => { const setup = () => { - const schoolId = new ObjectId().toHexString(); const parentId = new ObjectId().toHexString(); - const param = { - parentType: FileRecordParentType.Task, - schoolId, - parentId, - }; - const expectedParams = { exchange: FilesStorageExchange, routingKey: FilesStorageEvents.LIST_FILES_OF_PARENT, - payload: param, + payload: parentId, timeout, }; @@ -178,21 +164,21 @@ describe('FilesStorageProducer', () => { amqpConnection.request.mockResolvedValue({ message }); - return { param, expectedParams, message }; + return { parentId, expectedParams, message }; }; it('should call the ampqConnection.', async () => { - const { param, expectedParams } = setup(); + const { parentId, expectedParams } = setup(); - await service.listFilesOfParent(param); + await service.listFilesOfParent(parentId); expect(amqpConnection.request).toHaveBeenCalledWith(expectedParams); }); it('should return the response message.', async () => { - const { param, message } = setup(); + const { parentId, message } = setup(); - const res = await service.listFilesOfParent(param); + const res = await service.listFilesOfParent(parentId); expect(res).toEqual(message); }); @@ -253,6 +239,59 @@ describe('FilesStorageProducer', () => { }); }); + describe('deleteFiles', () => { + describe('when valid parameter passed and amqpConnection return with error in response', () => { + const setup = () => { + const recordId = new ObjectId().toHexString(); + + amqpConnection.request.mockResolvedValue({ error: new Error() }); + const spy = jest.spyOn(ErrorMapper, 'mapRpcErrorResponseToDomainError'); + + return { recordId, spy }; + }; + + it('should call error mapper and throw with error', async () => { + const { recordId, spy } = setup(); + + await expect(service.deleteFiles([recordId])).rejects.toThrowError(); + expect(spy).toBeCalled(); + }); + }); + + describe('when valid parameter passed and amqpConnection return with message', () => { + const setup = () => { + const recordId = new ObjectId().toHexString(); + + const message = []; + amqpConnection.request.mockResolvedValue({ message }); + + const expectedParams = { + exchange: FilesStorageExchange, + routingKey: FilesStorageEvents.DELETE_FILES, + payload: [recordId], + timeout, + }; + return { recordId, message, expectedParams }; + }; + + it('should call the ampqConnection.', async () => { + const { recordId, expectedParams } = setup(); + + await service.deleteFiles([recordId]); + + expect(amqpConnection.request).toHaveBeenCalledWith(expectedParams); + }); + + it('should return the response message.', async () => { + const { recordId, message } = setup(); + + const res = await service.deleteFiles([recordId]); + + expect(res).toEqual(message); + }); + }); + }); + describe('removeCreatorIdFromFileRecords', () => { describe('when valid parameter passed and amqpConnection return with error in response', () => { const setup = () => { diff --git a/apps/server/src/modules/files-storage-client/service/files-storage.producer.ts b/apps/server/src/modules/files-storage-client/service/files-storage.producer.ts index b6c36731bdc..1bb0ca52a49 100644 --- a/apps/server/src/modules/files-storage-client/service/files-storage.producer.ts +++ b/apps/server/src/modules/files-storage-client/service/files-storage.producer.ts @@ -3,7 +3,6 @@ import { CopyFileDO, CopyFilesOfParentParams, FileDO, - FileRecordParams, FilesStorageEvents, FilesStorageExchange, RpcMessageProducer, @@ -12,7 +11,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { EntityId } from '@shared/domain/types'; import { LegacyLogger } from '@src/core/logger'; -import { FilesStorageClientConfig } from '../interfaces'; +import { FilesStorageClientConfig } from '../files-storage-client-config'; @Injectable() export class FilesStorageProducer extends RpcMessageProducer { @@ -34,7 +33,7 @@ export class FilesStorageProducer extends RpcMessageProducer { return response; } - async listFilesOfParent(payload: FileRecordParams): Promise { + async listFilesOfParent(payload: EntityId): Promise { this.logger.debug({ action: 'listFilesOfParent:started', payload }); const response = await this.request(FilesStorageEvents.LIST_FILES_OF_PARENT, payload); @@ -52,6 +51,15 @@ export class FilesStorageProducer extends RpcMessageProducer { return response; } + async deleteFiles(payload: EntityId[]): Promise { + this.logger.debug({ action: 'deleteFiles:started', payload }); + const response = await this.request(FilesStorageEvents.DELETE_FILES, payload); + + this.logger.debug({ action: 'deleteFiles:finished', payload }); + + return response; + } + async removeCreatorIdFromFileRecords(payload: EntityId): Promise { this.logger.debug({ action: 'removeCreatorIdFromFileRecords:started', payload }); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument diff --git a/apps/server/src/modules/files-storage-client/service/index.ts b/apps/server/src/modules/files-storage-client/service/index.ts new file mode 100644 index 00000000000..2d408c2dab9 --- /dev/null +++ b/apps/server/src/modules/files-storage-client/service/index.ts @@ -0,0 +1,3 @@ +export * from './copy-files.service'; +export * from './files-storage-client.service'; +export * from './files-storage.producer'; diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-security.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-security.api.spec.ts index 38d33bb4168..3de38711ad4 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-security.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-security.api.spec.ts @@ -11,7 +11,7 @@ import { fileRecordFactory, mapUserToCurrentUser, roleFactory, - schoolFactory, + schoolEntityFactory, userFactory, } from '@shared/testing'; import NodeClam from 'clamscan'; @@ -80,7 +80,7 @@ describe(`${baseRouteName} (api)`, () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts index 6f57f89d27e..810765c99d7 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-copy-files.api.spec.ts @@ -15,7 +15,7 @@ import { fileRecordFactory, mapUserToCurrentUser, roleFactory, - schoolFactory, + schoolEntityFactory, userFactory, } from '@shared/testing'; import NodeClam from 'clamscan'; @@ -127,7 +127,7 @@ describe(`${baseRouteName} (api)`, () => { describe('with bad request data', () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); @@ -199,7 +199,7 @@ describe(`${baseRouteName} (api)`, () => { describe(`with valid request data`, () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); @@ -254,7 +254,7 @@ describe(`${baseRouteName} (api)`, () => { describe('with bad request data', () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); @@ -295,7 +295,7 @@ describe(`${baseRouteName} (api)`, () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts index 97fed8c660e..295ac8c6ac3 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-delete-files.api.spec.ts @@ -15,7 +15,7 @@ import { fileRecordFactory, mapUserToCurrentUser, roleFactory, - schoolFactory, + schoolEntityFactory, userFactory, } from '@shared/testing'; import NodeClam from 'clamscan'; @@ -125,7 +125,7 @@ describe(`${baseRouteName} (api)`, () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW, Permission.FILESTORAGE_REMOVE], }); @@ -177,7 +177,7 @@ describe(`${baseRouteName} (api)`, () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW, Permission.FILESTORAGE_REMOVE], }); @@ -213,6 +213,8 @@ describe(`${baseRouteName} (api)`, () => { name: expect.any(String), url: expect.any(String), parentId: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), parentType: 'schools', mimeType: 'text/plain', deletedSince: expect.any(String), @@ -250,7 +252,7 @@ describe(`${baseRouteName} (api)`, () => { describe('with bad request data', () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW, Permission.FILESTORAGE_REMOVE], }); @@ -277,7 +279,7 @@ describe(`${baseRouteName} (api)`, () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW, Permission.FILESTORAGE_REMOVE], }); @@ -308,6 +310,8 @@ describe(`${baseRouteName} (api)`, () => { name: expect.any(String), url: expect.any(String), parentId: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), parentType: 'schools', mimeType: 'text/plain', deletedSince: expect.any(String), diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts index 14843ad75e0..a3027ee5d34 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-download-upload.api.spec.ts @@ -9,7 +9,13 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; +import { + cleanupCollections, + mapUserToCurrentUser, + roleFactory, + schoolEntityFactory, + userFactory, +} from '@shared/testing'; import NodeClam from 'clamscan'; import { Request } from 'express'; import FileType from 'file-type-cjs/file-type-cjs-index'; @@ -138,7 +144,7 @@ describe('files-storage controller (API)', () => { beforeEach(async () => { jest.resetAllMocks(); await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-list-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-list-files.api.spec.ts index df220828567..9efc1e4351f 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-list-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-list-files.api.spec.ts @@ -13,7 +13,7 @@ import { fileRecordFactory, mapUserToCurrentUser, roleFactory, - schoolFactory, + schoolEntityFactory, userFactory, } from '@shared/testing'; import NodeClam from 'clamscan'; @@ -83,7 +83,7 @@ describe(`${baseRouteName} (api)`, () => { describe('with bad request data', () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); @@ -133,7 +133,7 @@ describe(`${baseRouteName} (api)`, () => { describe(`with valid request data`, () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); @@ -190,6 +190,8 @@ describe(`${baseRouteName} (api)`, () => { name: expect.any(String), url: expect.any(String), parentId: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), parentType: 'schools', mimeType: 'application/octet-stream', securityCheckStatus: 'pending', diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts index f218cf3e6e8..1274bd99f35 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-preview.api.spec.ts @@ -10,7 +10,13 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; +import { + cleanupCollections, + mapUserToCurrentUser, + roleFactory, + schoolEntityFactory, + userFactory, +} from '@shared/testing'; import NodeClam from 'clamscan'; import { Request } from 'express'; import FileType from 'file-type-cjs/file-type-cjs-index'; @@ -153,7 +159,7 @@ describe('File Controller (API) - preview', () => { beforeEach(async () => { jest.resetAllMocks(); await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-rename-file.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-rename-file.api.spec.ts index d1d814437cf..2933a53822e 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-rename-file.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-rename-file.api.spec.ts @@ -11,7 +11,7 @@ import { fileRecordFactory, mapUserToCurrentUser, roleFactory, - schoolFactory, + schoolEntityFactory, userFactory, } from '@shared/testing'; import NodeClam from 'clamscan'; @@ -80,7 +80,7 @@ describe(`${baseRouteName} (api)`, () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW, Permission.FILESTORAGE_EDIT], }); diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts index 04241fdb4a5..a92691fef5c 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage-restore-files.api.spec.ts @@ -15,7 +15,7 @@ import { fileRecordFactory, mapUserToCurrentUser, roleFactory, - schoolFactory, + schoolEntityFactory, userFactory, } from '@shared/testing'; import NodeClam from 'clamscan'; @@ -151,7 +151,7 @@ describe(`${baseRouteName} (api)`, () => { beforeEach(async () => { await cleanupCollections(em); const roles = roleFactory.buildList(1, { permissions: [] }); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const user = userFactory.build({ roles, school }); await em.persistAndFlush([user]); @@ -200,7 +200,7 @@ describe(`${baseRouteName} (api)`, () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW, Permission.FILESTORAGE_REMOVE], }); @@ -238,6 +238,8 @@ describe(`${baseRouteName} (api)`, () => { name: expect.any(String), url: expect.any(String), parentId: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), parentType: 'schools', mimeType: 'text/plain', securityCheckStatus: 'pending', @@ -276,7 +278,7 @@ describe(`${baseRouteName} (api)`, () => { beforeEach(async () => { await cleanupCollections(em); const roles = roleFactory.buildList(1, { permissions: [] }); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const user = userFactory.build({ roles, school }); await em.persistAndFlush([user]); @@ -300,7 +302,7 @@ describe(`${baseRouteName} (api)`, () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW, Permission.FILESTORAGE_REMOVE], }); @@ -335,6 +337,8 @@ describe(`${baseRouteName} (api)`, () => { name: expect.any(String), url: expect.any(String), parentId: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), parentType: 'schools', mimeType: 'text/plain', securityCheckStatus: 'pending', diff --git a/apps/server/src/modules/files-storage/controller/api-test/files-storage.config.api.spec.ts b/apps/server/src/modules/files-storage/controller/api-test/files-storage.config.api.spec.ts new file mode 100644 index 00000000000..d84d50e520f --- /dev/null +++ b/apps/server/src/modules/files-storage/controller/api-test/files-storage.config.api.spec.ts @@ -0,0 +1,42 @@ +import { createMock } from '@golevelup/ts-jest'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import NodeClam from 'clamscan'; +import { TestApiClient } from '@shared/testing'; +import { FilesStorageTestModule } from '../../files-storage-test.module'; +import { FILES_STORAGE_S3_CONNECTION } from '../../files-storage.config'; + +describe(`files-storage (api)`, () => { + let app: INestApplication; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [FilesStorageTestModule], + }) + .overrideProvider(AntivirusService) + .useValue(createMock()) + .overrideProvider(FILES_STORAGE_S3_CONNECTION) + .useValue(createMock()) + .overrideProvider(NodeClam) + .useValue(createMock()) + .compile(); + + app = module.createNestApplication(); + await app.init(); + testApiClient = new TestApiClient(app, 'file'); + }); + + describe('config/public', () => { + describe('when configuration is set', () => { + it('should be return the public configuration as json', async () => { + const response = await testApiClient.get('config/public'); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ MAX_FILE_SIZE: 2684354560 }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/files-storage/controller/api-test/mocks.ts b/apps/server/src/modules/files-storage/controller/api-test/mocks.ts index bb914703b53..4290f5ee620 100644 --- a/apps/server/src/modules/files-storage/controller/api-test/mocks.ts +++ b/apps/server/src/modules/files-storage/controller/api-test/mocks.ts @@ -1 +1 @@ -export const availableParentTypes = 'users, schools, courses, tasks, lessons, submissions, boardnodes'; +export const availableParentTypes = 'users, schools, courses, tasks, lessons, submissions, gradings, boardnodes'; diff --git a/apps/server/src/modules/files-storage/controller/dto/file-storage.response.ts b/apps/server/src/modules/files-storage/controller/dto/file-storage.response.ts index dc595d62d9a..463d177ab37 100644 --- a/apps/server/src/modules/files-storage/controller/dto/file-storage.response.ts +++ b/apps/server/src/modules/files-storage/controller/dto/file-storage.response.ts @@ -14,8 +14,11 @@ export class FileRecordResponse { this.creatorId = fileRecord.creatorId; this.mimeType = fileRecord.mimeType; this.parentType = fileRecord.parentType; + this.isUploading = fileRecord.isUploading; this.deletedSince = fileRecord.deletedSince; this.previewStatus = fileRecord.getPreviewStatus(); + this.createdAt = fileRecord.createdAt; + this.updatedAt = fileRecord.updatedAt; } @ApiProperty() @@ -46,11 +49,20 @@ export class FileRecordResponse { @ApiProperty({ enum: FileRecordParentType, enumName: 'FileRecordParentType' }) parentType: FileRecordParentType; + @ApiPropertyOptional() + isUploading?: boolean; + @ApiProperty({ enum: PreviewStatus, enumName: 'PreviewStatus' }) previewStatus: PreviewStatus; @ApiPropertyOptional() deletedSince?: Date; + + @ApiPropertyOptional() + createdAt?: Date; + + @ApiPropertyOptional() + updatedAt?: Date; } export class FileRecordListResponse extends PaginationResponse { diff --git a/apps/server/src/modules/files-storage/controller/files-storage-config.controller.ts b/apps/server/src/modules/files-storage/controller/files-storage-config.controller.ts new file mode 100644 index 00000000000..4e71f566d29 --- /dev/null +++ b/apps/server/src/modules/files-storage/controller/files-storage-config.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { FilesStorageUC } from '../uc'; +import { FilesStorageConfigResponse } from '../dto/files-storage-config.response'; + +@ApiTags('file/config') +@Controller('file/config') +export class FilesStorageConfigController { + constructor(private readonly filesStorageUC: FilesStorageUC) {} + + @ApiOperation({ summary: 'Useable configuration for clients' }) + @ApiResponse({ status: 200, type: FilesStorageConfigResponse }) + @Get('/public') + publicConfig(): FilesStorageConfigResponse { + const response = this.filesStorageUC.getPublicConfig(); + + return response; + } +} diff --git a/apps/server/src/modules/files-storage/controller/files-storage.consumer.spec.ts b/apps/server/src/modules/files-storage/controller/files-storage.consumer.spec.ts index f44de01c551..c8291131252 100644 --- a/apps/server/src/modules/files-storage/controller/files-storage.consumer.spec.ts +++ b/apps/server/src/modules/files-storage/controller/files-storage.consumer.spec.ts @@ -123,43 +123,34 @@ describe('FilesStorageConsumer', () => { }); describe('fileRecordsOfParent()', () => { - const schoolId: EntityId = new ObjectId().toHexString(); describe('WHEN valid file exists', () => { it('should call filesStorageService.fileRecordsOfParent with params', async () => { - const payload = { - parentId: new ObjectId().toHexString(), - parentType: FileRecordParentType.Course, - schoolId, - }; + const parentId = new ObjectId().toHexString(); + filesStorageService.getFileRecordsOfParent.mockResolvedValue([[], 0]); - await service.getFilesOfParent(payload); - expect(filesStorageService.getFileRecordsOfParent).toBeCalledWith(payload.parentId); + await service.getFilesOfParent(parentId); + expect(filesStorageService.getFileRecordsOfParent).toBeCalledWith(parentId); }); it('should return array instances of FileRecordResponse', async () => { - const payload = { - parentId: new ObjectId().toHexString(), - parentType: FileRecordParentType.Course, - schoolId: new ObjectId().toHexString(), - }; + const parentId = new ObjectId().toHexString(); - const fileRecords = fileRecordFactory.buildList(3, payload); + const fileRecords = fileRecordFactory.buildList(3, { + parentId, + }); filesStorageService.getFileRecordsOfParent.mockResolvedValue([fileRecords, fileRecords.length]); - const response = await service.getFilesOfParent(payload); + const response = await service.getFilesOfParent(parentId); expect(response.message[0]).toBeInstanceOf(FileRecordResponse); }); }); describe('WHEN file not exists', () => { it('should return RpcMessage with empty array', async () => { - const payload = { - parentId: new ObjectId().toHexString(), - parentType: FileRecordParentType.Course, - schoolId, - }; + const parentId = new ObjectId().toHexString(); + filesStorageService.getFileRecordsOfParent.mockResolvedValue([[], 0]); - const response = await service.getFilesOfParent(payload); + const response = await service.getFilesOfParent(parentId); expect(response).toStrictEqual({ message: [] }); }); }); @@ -220,6 +211,53 @@ describe('FilesStorageConsumer', () => { }); }); + describe('deleteFiles()', () => { + describe('WHEN valid file exists', () => { + const setup = () => { + const recordId = new ObjectId().toHexString(); + + const fileRecord = fileRecordFactory.build(); + filesStorageService.getFileRecord.mockResolvedValue(fileRecord); + + return { recordId, fileRecord }; + }; + + it('should call filesStorageService.deleteFiles with params', async () => { + const { recordId, fileRecord } = setup(); + + await service.deleteFiles([recordId]); + + const result = [fileRecord]; + expect(filesStorageService.getFileRecord).toBeCalledWith({ fileRecordId: recordId }); + expect(filesStorageService.delete).toBeCalledWith(result); + }); + + it('should return array instances of FileRecordResponse', async () => { + const { recordId } = setup(); + + const response = await service.deleteFiles([recordId]); + + expect(response.message[0]).toBeInstanceOf(FileRecordResponse); + }); + }); + + describe('WHEN no file exists', () => { + const setup = () => { + const recordId = new ObjectId().toHexString(); + + filesStorageService.getFileRecord.mockRejectedValue(new Error('not found')); + + return { recordId }; + }; + + it('should throw', async () => { + const { recordId } = setup(); + + await expect(service.deleteFiles([recordId])).rejects.toThrow('not found'); + }); + }); + }); + describe('removeCreatorIdFromFileRecords()', () => { describe('WHEN valid file exists', () => { const setup = () => { diff --git a/apps/server/src/modules/files-storage/controller/files-storage.consumer.ts b/apps/server/src/modules/files-storage/controller/files-storage.consumer.ts index 66addb576d3..eb795f4de81 100644 --- a/apps/server/src/modules/files-storage/controller/files-storage.consumer.ts +++ b/apps/server/src/modules/files-storage/controller/files-storage.consumer.ts @@ -1,6 +1,5 @@ import { RabbitPayload, RabbitRPC } from '@golevelup/nestjs-rabbitmq'; -import { CopyFileDO, FileDO, FilesStorageEvents, FilesStorageExchange } from '@infra/rabbitmq'; -import { RpcMessage } from '@infra/rabbitmq/rpc-message'; +import { CopyFileDO, FileDO, FilesStorageEvents, FilesStorageExchange, RpcMessage } from '@infra/rabbitmq'; import { MikroORM, UseRequestContext } from '@mikro-orm/core'; import { Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; @@ -8,7 +7,7 @@ import { LegacyLogger } from '@src/core/logger'; import { FilesStorageMapper } from '../mapper'; import { FilesStorageService } from '../service/files-storage.service'; import { PreviewService } from '../service/preview.service'; -import { CopyFilesOfParentPayload, FileRecordParams } from './dto'; +import { CopyFilesOfParentPayload } from './dto'; @Injectable() export class FilesStorageConsumer { @@ -45,10 +44,10 @@ export class FilesStorageConsumer { queue: FilesStorageEvents.LIST_FILES_OF_PARENT, }) @UseRequestContext() - public async getFilesOfParent(@RabbitPayload() payload: FileRecordParams): Promise> { + public async getFilesOfParent(@RabbitPayload() payload: EntityId): Promise> { this.logger.debug({ action: 'getFilesOfParent', payload }); - const [fileRecords, total] = await this.filesStorageService.getFileRecordsOfParent(payload.parentId); + const [fileRecords, total] = await this.filesStorageService.getFileRecordsOfParent(payload); const response = FilesStorageMapper.mapToFileRecordListResponse(fileRecords, total); return { message: response.data }; @@ -73,6 +72,26 @@ export class FilesStorageConsumer { return { message: response.data }; } + @RabbitRPC({ + exchange: FilesStorageExchange, + routingKey: FilesStorageEvents.DELETE_FILES, + queue: FilesStorageEvents.DELETE_FILES, + }) + @UseRequestContext() + public async deleteFiles(@RabbitPayload() payload: EntityId[]): Promise> { + this.logger.debug({ action: 'deleteFiles', payload }); + + const promise = payload.map((fileRecordId) => this.filesStorageService.getFileRecord({ fileRecordId })); + const fileRecords = await Promise.all(promise); + + await this.previewService.deletePreviews(fileRecords); + await this.filesStorageService.delete(fileRecords); + + const response = FilesStorageMapper.mapToFileRecordListResponse(fileRecords, fileRecords.length); + + return { message: response.data }; + } + @RabbitRPC({ exchange: FilesStorageExchange, routingKey: FilesStorageEvents.REMOVE_CREATORID_OF_FILES, diff --git a/apps/server/src/modules/files-storage/controller/files-storage.controller.ts b/apps/server/src/modules/files-storage/controller/files-storage.controller.ts index 7269336c44c..dd1605b42f1 100644 --- a/apps/server/src/modules/files-storage/controller/files-storage.controller.ts +++ b/apps/server/src/modules/files-storage/controller/files-storage.controller.ts @@ -23,13 +23,11 @@ import { UseInterceptors, } from '@nestjs/common'; import { ApiConsumes, ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { ApiValidationError, RequestLoggingInterceptor, RequestTimeout } from '@shared/common'; +import { ApiValidationError, RequestLoggingInterceptor } from '@shared/common'; import { PaginationParams } from '@shared/controller'; import { Request, Response } from 'express'; -import { config } from '../files-storage.config'; import { GetFileResponse } from '../interface'; -import { FilesStorageMapper } from '../mapper'; -import { FileRecordMapper } from '../mapper/file-record.mapper'; +import { FilesStorageMapper, FileRecordMapper } from '../mapper'; import { FilesStorageUC } from '../uc'; import { CopyFileListResponse, @@ -127,8 +125,8 @@ export class FilesStorageController { @ApiResponse({ status: 422, type: UnprocessableEntityException }) @ApiResponse({ status: 500, type: InternalServerErrorException }) @ApiHeader({ name: 'Range', required: false }) + @ApiHeader({ name: 'If-None-Match', required: false }) @Get('/preview/:fileRecordId/:fileName') - @RequestTimeout(config().INCOMING_REQUEST_TIMEOUT) async downloadPreview( @Param() params: DownloadFileParams, @CurrentUser() currentUser: ICurrentUser, diff --git a/apps/server/src/modules/files-storage/controller/index.ts b/apps/server/src/modules/files-storage/controller/index.ts index 7aa61d2c93b..830944f4e7c 100644 --- a/apps/server/src/modules/files-storage/controller/index.ts +++ b/apps/server/src/modules/files-storage/controller/index.ts @@ -1,3 +1,4 @@ export * from './file-security.controller'; export * from './files-storage.consumer'; export * from './files-storage.controller'; +export * from './files-storage-config.controller'; diff --git a/apps/server/src/modules/files-storage/dto/files-storage-config.response.ts b/apps/server/src/modules/files-storage/dto/files-storage-config.response.ts new file mode 100644 index 00000000000..1ef26bf69bb --- /dev/null +++ b/apps/server/src/modules/files-storage/dto/files-storage-config.response.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class FilesStorageConfigResponse { + @ApiProperty() + MAX_FILE_SIZE: number; + + constructor(config: FilesStorageConfigResponse) { + this.MAX_FILE_SIZE = config.MAX_FILE_SIZE; + } +} diff --git a/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts b/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts index 13c17ddcad8..4caac8fafc6 100644 --- a/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts +++ b/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts @@ -32,7 +32,7 @@ describe('FileRecord Entity', () => { }; }); - it('should provide the target id as entity id', () => { + it('should provide target id', () => { const parentId = new ObjectId().toHexString(); const fileRecord = new FileRecord({ ...props, @@ -41,7 +41,7 @@ describe('FileRecord Entity', () => { expect(fileRecord.parentId).toEqual(parentId); }); - it('should provide the creator id as entity id', () => { + it('should provide creator id', () => { const creatorId = new ObjectId().toHexString(); const fileRecord = new FileRecord({ ...props, @@ -50,7 +50,7 @@ describe('FileRecord Entity', () => { expect(fileRecord.creatorId).toEqual(creatorId); }); - it('should provide the school id as entity id', () => { + it('should provide school id', () => { const schoolId = new ObjectId().toHexString(); const fileRecord = new FileRecord({ ...props, @@ -59,7 +59,7 @@ describe('FileRecord Entity', () => { expect(fileRecord.schoolId).toEqual(schoolId); }); - it('should provide the isCopyFrom as entity id', () => { + it('should provide isCopyFrom', () => { const isCopyFrom = new ObjectId().toHexString(); const fileRecord = new FileRecord({ ...props, @@ -67,6 +67,15 @@ describe('FileRecord Entity', () => { }); expect(fileRecord.isCopyFrom).toEqual(isCopyFrom); }); + + it('should provide isUploading', () => { + const isUploading = true; + const fileRecord = new FileRecord({ + ...props, + isUploading, + }); + expect(fileRecord.isUploading).toEqual(isUploading); + }); }); describe('when embedding the security status', () => { @@ -852,4 +861,23 @@ describe('FileRecord Entity', () => { }); }); }); + + describe('markAsLoaded is called', () => { + describe('WHEN isUploading is true', () => { + const setup = () => { + const isUploading = true; + const fileRecord = fileRecordFactory.build({ isUploading }); + + return { fileRecord, isUploading }; + }; + + it('should set it to undefined', () => { + const { fileRecord } = setup(); + expect(fileRecord.isUploading).toBe(true); + const result = fileRecord.markAsUploaded(); + + expect(result).toBe(undefined); + }); + }); + }); }); diff --git a/apps/server/src/modules/files-storage/entity/filerecord.entity.ts b/apps/server/src/modules/files-storage/entity/filerecord.entity.ts index 9278d93a4fa..58195356de6 100644 --- a/apps/server/src/modules/files-storage/entity/filerecord.entity.ts +++ b/apps/server/src/modules/files-storage/entity/filerecord.entity.ts @@ -23,6 +23,7 @@ export enum FileRecordParentType { 'Task' = 'tasks', 'Lesson' = 'lessons', 'Submission' = 'submissions', + 'Grading' = 'gradings', 'BoardNode' = 'boardnodes', } @@ -80,6 +81,7 @@ export interface FileRecordProperties { schoolId: EntityId; deletedSince?: Date; isCopyFrom?: EntityId; + isUploading?: boolean; } interface ParentInfo { @@ -120,6 +122,9 @@ export class FileRecord extends BaseEntityWithTimestamps { @Enum() parentType: FileRecordParentType; + @Property({ nullable: true }) + isUploading?: boolean; + @Index() @Property({ fieldName: 'parent' }) _parentId: ObjectId; @@ -128,6 +133,7 @@ export class FileRecord extends BaseEntityWithTimestamps { return this._parentId.toHexString(); } + @Index() @Property({ fieldName: 'creator', nullable: true }) _creatorId?: ObjectId; @@ -161,6 +167,7 @@ export class FileRecord extends BaseEntityWithTimestamps { this.name = props.name; this.mimeType = props.mimeType; this.parentType = props.parentType; + this.isUploading = props.isUploading; this._parentId = new ObjectId(props.parentId); if (props.creatorId !== undefined) { this._creatorId = new ObjectId(props.creatorId); @@ -311,4 +318,8 @@ export class FileRecord extends BaseEntityWithTimestamps { public removeCreatorId(): void { this.creatorId = undefined; } + + public markAsUploaded(): void { + this.isUploading = undefined; + } } diff --git a/apps/server/src/modules/files-storage/files-preview-amqp.module.ts b/apps/server/src/modules/files-storage/files-preview-amqp.module.ts index c59dc720729..d671993acfa 100644 --- a/apps/server/src/modules/files-storage/files-preview-amqp.module.ts +++ b/apps/server/src/modules/files-storage/files-preview-amqp.module.ts @@ -1,10 +1,13 @@ import { PreviewGeneratorConsumerModule } from '@infra/preview-generator'; import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; -import { defaultConfig, s3Config } from './files-storage.config'; +import { config, defaultConfig, s3Config } from './files-storage.config'; @Module({ imports: [ + ConfigModule.forRoot(createConfigModuleOptions(config)), PreviewGeneratorConsumerModule.register({ storageConfig: s3Config, serverConfig: defaultConfig }), CoreModule, ], diff --git a/apps/server/src/modules/files-storage/files-storage-amqp.module.ts b/apps/server/src/modules/files-storage/files-storage-amqp.module.ts index 2a63c9ba244..351a6ef6d86 100644 --- a/apps/server/src/modules/files-storage/files-storage-amqp.module.ts +++ b/apps/server/src/modules/files-storage/files-storage-amqp.module.ts @@ -1,11 +1,14 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; import { FilesStorageConsumer } from './controller'; +import { config } from './files-storage.config'; import { FilesStorageModule } from './files-storage.module'; @Module({ - imports: [FilesStorageModule, CoreModule, LoggerModule], + imports: [FilesStorageModule, CoreModule, LoggerModule, ConfigModule.forRoot(createConfigModuleOptions(config))], providers: [FilesStorageConsumer], }) export class FilesStorageAMQPModule {} diff --git a/apps/server/src/modules/files-storage/files-storage-api.module.ts b/apps/server/src/modules/files-storage/files-storage-api.module.ts index 3bf18aa4047..b6675ece9b1 100644 --- a/apps/server/src/modules/files-storage/files-storage-api.module.ts +++ b/apps/server/src/modules/files-storage/files-storage-api.module.ts @@ -1,15 +1,25 @@ +import { AuthenticationModule } from '@modules/authentication'; +import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; -import { AuthenticationModule } from '@modules/authentication/authentication.module'; -import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; -import { FileSecurityController, FilesStorageController } from './controller'; +import { FileSecurityController, FilesStorageConfigController, FilesStorageController } from './controller'; +import { config } from './files-storage.config'; import { FilesStorageModule } from './files-storage.module'; import { FilesStorageUC } from './uc'; @Module({ - imports: [AuthorizationReferenceModule, FilesStorageModule, AuthenticationModule, CoreModule, HttpModule], - controllers: [FilesStorageController, FileSecurityController], + imports: [ + AuthorizationReferenceModule, + FilesStorageModule, + AuthenticationModule, + CoreModule, + HttpModule, + ConfigModule.forRoot(createConfigModuleOptions(config)), + ], + controllers: [FilesStorageController, FilesStorageConfigController, FileSecurityController], providers: [FilesStorageUC], }) export class FilesStorageApiModule {} diff --git a/apps/server/src/modules/files-storage/files-storage.config.ts b/apps/server/src/modules/files-storage/files-storage.config.ts index 5c02be598e6..985aa728ff4 100644 --- a/apps/server/src/modules/files-storage/files-storage.config.ts +++ b/apps/server/src/modules/files-storage/files-storage.config.ts @@ -15,7 +15,6 @@ export const defaultConfig = { }; const fileStorageConfig: FileStorageConfig = { - INCOMING_REQUEST_TIMEOUT_COPY_API: Configuration.get('INCOMING_REQUEST_TIMEOUT_COPY_API') as number, MAX_FILE_SIZE: Configuration.get('FILES_STORAGE__MAX_FILE_SIZE') as number, MAX_SECURITY_CHECK_FILE_SIZE: Configuration.get('FILES_STORAGE__MAX_FILE_SIZE') as number, USE_STREAM_TO_ANTIVIRUS: Configuration.get('FILES_STORAGE__USE_STREAM_TO_ANTIVIRUS') as boolean, diff --git a/apps/server/src/modules/files-storage/files-storage.module.ts b/apps/server/src/modules/files-storage/files-storage.module.ts index 0b049951767..f6d440130e0 100644 --- a/apps/server/src/modules/files-storage/files-storage.module.ts +++ b/apps/server/src/modules/files-storage/files-storage.module.ts @@ -6,18 +6,16 @@ import { S3ClientModule } from '@infra/s3-client'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { Module, NotFoundException } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; import { ALL_ENTITIES } from '@shared/domain/entity'; -import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; +import { DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { LoggerModule } from '@src/core/logger'; import { FileRecord, FileRecordSecurityCheck } from './entity'; -import { config, s3Config } from './files-storage.config'; +import { s3Config } from './files-storage.config'; import { FileRecordRepo } from './repo'; import { FilesStorageService, PreviewService } from './service'; const imports = [ LoggerModule, - ConfigModule.forRoot(createConfigModuleOptions(config)), AntivirusModule.forRoot({ enabled: Configuration.get('ENABLE_FILE_SECURITY_CHECK') as boolean, filesServiceBaseUrl: Configuration.get('FILES_STORAGE__SERVICE_BASE_URL') as string, diff --git a/apps/server/src/modules/files-storage/helper/file-name.spec.ts b/apps/server/src/modules/files-storage/helper/file-name.spec.ts index 427a5bdefd3..43227c3fc1a 100644 --- a/apps/server/src/modules/files-storage/helper/file-name.spec.ts +++ b/apps/server/src/modules/files-storage/helper/file-name.spec.ts @@ -1,6 +1,6 @@ import { EntityId } from '@shared/domain/types'; import { fileRecordFactory, setupEntities } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import crypto from 'crypto'; import { createPreviewNameHash, hasDuplicateName, resolveFileNameDuplicates } from '.'; import { FileRecord } from '../entity'; diff --git a/apps/server/src/modules/files-storage/helper/file-record.spec.ts b/apps/server/src/modules/files-storage/helper/file-record.spec.ts index 41eab6b6be0..f50f7edd409 100644 --- a/apps/server/src/modules/files-storage/helper/file-record.spec.ts +++ b/apps/server/src/modules/files-storage/helper/file-record.spec.ts @@ -1,6 +1,6 @@ import { EntityId } from '@shared/domain/types'; import { fileRecordFactory, setupEntities } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { createFileRecord, getFormat, getPreviewName, markForDelete, unmarkForDelete } from '.'; import { FileRecord } from '../entity'; import { PreviewOutputMimeTypes } from '../interface'; diff --git a/apps/server/src/modules/files-storage/helper/file-record.ts b/apps/server/src/modules/files-storage/helper/file-record.ts index ed291661735..80a73249441 100644 --- a/apps/server/src/modules/files-storage/helper/file-record.ts +++ b/apps/server/src/modules/files-storage/helper/file-record.ts @@ -36,6 +36,7 @@ export function createFileRecord( parentId: params.parentId, creatorId: userId, schoolId: params.schoolId, + isUploading: true, }); return entity; diff --git a/apps/server/src/modules/files-storage/helper/path.spec.ts b/apps/server/src/modules/files-storage/helper/path.spec.ts index fe12ccf3e8d..3b5fab49de4 100644 --- a/apps/server/src/modules/files-storage/helper/path.spec.ts +++ b/apps/server/src/modules/files-storage/helper/path.spec.ts @@ -1,6 +1,6 @@ import { EntityId } from '@shared/domain/types'; import { fileRecordFactory, setupEntities } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { createCopyFiles, createPath, createPreviewDirectoryPath, createPreviewFilePath, getPaths } from '.'; import { FileRecord } from '../entity'; import { ErrorType } from '../error'; diff --git a/apps/server/src/modules/files-storage/mapper/config.response.mapper.ts b/apps/server/src/modules/files-storage/mapper/config.response.mapper.ts new file mode 100644 index 00000000000..06d5750b1d5 --- /dev/null +++ b/apps/server/src/modules/files-storage/mapper/config.response.mapper.ts @@ -0,0 +1,12 @@ +import { FilesStorageConfigResponse } from '../dto/files-storage-config.response'; + +export class ConfigResponseMapper { + public static mapToResponse(maxFileSize: number): FilesStorageConfigResponse { + const mappedConfig = { + MAX_FILE_SIZE: maxFileSize, + }; + const configResponse = new FilesStorageConfigResponse(mappedConfig); + + return configResponse; + } +} diff --git a/apps/server/src/modules/files-storage/mapper/files-storage.mapper.spec.ts b/apps/server/src/modules/files-storage/mapper/files-storage.mapper.spec.ts index 1f681c371d1..d6b62add4f0 100644 --- a/apps/server/src/modules/files-storage/mapper/files-storage.mapper.spec.ts +++ b/apps/server/src/modules/files-storage/mapper/files-storage.mapper.spec.ts @@ -116,6 +116,8 @@ describe('FilesStorageMapper', () => { parentType: fileRecord.parentType, deletedSince: fileRecord.deletedSince, previewStatus: PreviewStatus.PREVIEW_NOT_POSSIBLE_WRONG_MIME_TYPE, + createdAt: fileRecord.createdAt, + updatedAt: fileRecord.updatedAt, }; expect(result).toEqual(expectedFileRecordResponse); diff --git a/apps/server/src/modules/files-storage/mapper/files-storage.mapper.ts b/apps/server/src/modules/files-storage/mapper/files-storage.mapper.ts index 5b786aff96e..e3d7a674d3e 100644 --- a/apps/server/src/modules/files-storage/mapper/files-storage.mapper.ts +++ b/apps/server/src/modules/files-storage/mapper/files-storage.mapper.ts @@ -20,6 +20,7 @@ export class FilesStorageMapper { types.set(FileRecordParentType.School, AuthorizableReferenceType.School); types.set(FileRecordParentType.Lesson, AuthorizableReferenceType.Lesson); types.set(FileRecordParentType.Submission, AuthorizableReferenceType.Submission); + types.set(FileRecordParentType.Grading, AuthorizableReferenceType.Submission); types.set(FileRecordParentType.BoardNode, AuthorizableReferenceType.BoardNode); const res = types.get(type); diff --git a/apps/server/src/modules/files-storage/mapper/index.ts b/apps/server/src/modules/files-storage/mapper/index.ts index bc8af7f7f05..2e52020270f 100644 --- a/apps/server/src/modules/files-storage/mapper/index.ts +++ b/apps/server/src/modules/files-storage/mapper/index.ts @@ -4,3 +4,4 @@ export * from './file-record.mapper'; export * from './file-response.builder'; export * from './files-storage.mapper'; export * from './preview.builder'; +export * from './config.response.mapper'; diff --git a/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts b/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts index 765de1077bd..63049e0c587 100644 --- a/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts +++ b/apps/server/src/modules/files-storage/service/files-storage-upload.service.spec.ts @@ -1,10 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; import { ObjectId } from '@mikro-orm/mongodb'; import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@infra/antivirus'; -import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { readableStreamWithFileTypeFactory } from '@shared/testing/factory/readable-stream-with-file-type.factory'; import { LegacyLogger } from '@src/core/logger'; @@ -157,6 +157,8 @@ describe('FilesStorageService upload methods', () => { if (fr instanceof FileRecord && !fr._id) { fr._id = new ObjectId(); } + + return Promise.resolve(); }); return { @@ -187,6 +189,37 @@ describe('FilesStorageService upload methods', () => { expect(getFileRecordsOfParentSpy).toHaveBeenCalledWith(params.parentId); }); + it('should call fileRecordRepo.save in first call with isUploading: true', async () => { + const { params, file, userId } = setup(); + + // haveBeenCalledWith can't be use here because fileRecord is a reference and + // it will always compare the final state of the object + let param: FileRecord | undefined; + + fileRecordRepo.save.mockReset(); + fileRecordRepo.save.mockImplementationOnce(async (fr) => { + if (fr instanceof FileRecord && !fr._id) { + fr._id = new ObjectId(); + } + + param = JSON.parse(JSON.stringify(fr)) as FileRecord; + + return Promise.resolve(); + }); + + fileRecordRepo.save.mockImplementationOnce(async (fr) => { + if (fr instanceof FileRecord && !fr._id) { + fr._id = new ObjectId(); + } + + return Promise.resolve(); + }); + + await service.uploadFile(userId, params, file); + + expect(param).toMatchObject({ isUploading: true }); + }); + it('should call fileRecordRepo.save twice with correct params', async () => { const { params, file, fileSize, userId, readableStreamWithFileType, expectedFileRecord } = setup(); @@ -201,6 +234,7 @@ describe('FilesStorageService upload methods', () => { size: fileSize, createdAt: expect.any(Date), updatedAt: expect.any(Date), + isUploading: undefined, }) ); }); diff --git a/apps/server/src/modules/files-storage/service/files-storage.service.ts b/apps/server/src/modules/files-storage/service/files-storage.service.ts index 449971b5542..656c6362baf 100644 --- a/apps/server/src/modules/files-storage/service/files-storage.service.ts +++ b/apps/server/src/modules/files-storage/service/files-storage.service.ts @@ -180,6 +180,9 @@ export class FilesStorageService { // The actual file size is set here because it is known only after the whole file is streamed. fileRecord.size = await fileSizePromise; this.throwErrorIfFileIsTooBig(fileRecord.size); + + fileRecord.markAsUploaded(); + await this.fileRecordRepo.save(fileRecord); if (!useStreamToAntivirus || !fileRecord.isPreviewPossible()) { @@ -217,8 +220,14 @@ export class FilesStorageService { } } + public getMaxFileSize(): number { + const maxFileSize = this.configService.get('MAX_FILE_SIZE'); + + return maxFileSize; + } + private throwErrorIfFileIsTooBig(fileSize: number): void { - if (fileSize > this.configService.get('MAX_FILE_SIZE')) { + if (fileSize > this.getMaxFileSize()) { throw new BadRequestException(ErrorType.FILE_TOO_BIG); } } diff --git a/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts index 0f72cd326e6..9dda2df44da 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-copy.uc.spec.ts @@ -1,6 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; import { S3ClientAdapter } from '@infra/s3-client'; +import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; import { Action } from '@modules/authorization'; import { AuthorizationReferenceService } from '@modules/authorization/domain'; @@ -110,6 +111,10 @@ describe('FilesStorageUC', () => { provide: PreviewService, useValue: createMock(), }, + { + provide: EntityManager, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts index b4ea26c20b1..8606fcda798 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-delete.uc.spec.ts @@ -1,6 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; import { S3ClientAdapter } from '@infra/s3-client'; +import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; @@ -93,6 +94,10 @@ describe('FilesStorageUC delete methods', () => { provide: PreviewService, useValue: createMock(), }, + { + provide: EntityManager, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts index d34f004d73b..2e2f89ee224 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-download-preview.uc.spec.ts @@ -1,13 +1,14 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; +import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@infra/antivirus'; -import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { SingleFileParams } from '../controller/dto'; import { FileRecord } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; @@ -79,6 +80,10 @@ describe('FilesStorageUC', () => { provide: HttpService, useValue: createMock(), }, + { + provide: EntityManager, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts index bcb5b2ec827..3654fdabe8e 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-download.uc.spec.ts @@ -1,13 +1,14 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; +import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@infra/antivirus'; -import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { SingleFileParams } from '../controller/dto'; import { FileRecord } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; @@ -70,6 +71,10 @@ describe('FilesStorageUC', () => { provide: PreviewService, useValue: createMock(), }, + { + provide: EntityManager, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts index a4f3e0f8b2f..708bd1bce2f 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-get.uc.spec.ts @@ -1,12 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; +import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@infra/antivirus'; -import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { FileRecordParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; @@ -73,6 +74,10 @@ describe('FilesStorageUC', () => { provide: PreviewService, useValue: createMock(), }, + { + provide: EntityManager, + useValue: createMock(), + }, ], }).compile(); @@ -174,4 +179,44 @@ describe('FilesStorageUC', () => { }); }); }); + + describe('getPublicConfig', () => { + describe('when service is aviable', () => { + const setup = () => { + const fileSize = 500; + filesStorageService.getMaxFileSize.mockReturnValueOnce(fileSize); + + const expectedResult = { + MAX_FILE_SIZE: fileSize, + }; + + return { expectedResult }; + }; + + it('should be create a config response dto', () => { + const { expectedResult } = setup(); + + const result = filesStorageUC.getPublicConfig(); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when service throw an error', () => { + const setup = () => { + const error = new Error('Service throw error'); + filesStorageService.getMaxFileSize.mockImplementationOnce(() => { + throw error; + }); + + return { error }; + }; + + it('should be create a config response dto', () => { + const { error } = setup(); + + expect(() => filesStorageUC.getPublicConfig()).toThrowError(error); + }); + }); + }); }); diff --git a/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts index dc811c566a4..6ee67c75cb6 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-restore.uc.spec.ts @@ -1,13 +1,14 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; +import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@infra/antivirus'; -import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { FileRecordParams, SingleFileParams } from '../controller/dto'; import { FileRecord, FileRecordParentType } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; @@ -88,6 +89,10 @@ describe('FilesStorageUC', () => { provide: PreviewService, useValue: createMock(), }, + { + provide: EntityManager, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts index 5b126c8ea2a..f07914a8915 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-update.uc.spec.ts @@ -1,12 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AntivirusService } from '@infra/antivirus'; +import { S3ClientAdapter } from '@infra/s3-client'; +import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; -import { AntivirusService } from '@infra/antivirus'; -import { S3ClientAdapter } from '@infra/s3-client'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { RenameFileParams, ScanResultParams, SingleFileParams } from '../controller/dto'; import { FileRecord } from '../entity'; import { FileStorageAuthorizationContext } from '../files-storage.const'; @@ -67,6 +68,10 @@ describe('FilesStorageUC', () => { provide: PreviewService, useValue: createMock(), }, + { + provide: EntityManager, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts b/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts index 5b9e9b410f3..336745140af 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage-upload.uc.spec.ts @@ -1,6 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AntivirusService } from '@infra/antivirus'; import { S3ClientAdapter } from '@infra/s3-client'; +import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; import { Action } from '@modules/authorization'; import { AuthorizationReferenceService } from '@modules/authorization/domain'; @@ -110,6 +111,10 @@ describe('FilesStorageUC upload methods', () => { provide: PreviewService, useValue: createMock(), }, + { + provide: EntityManager, + useValue: createMock(), + }, ], }).compile(); @@ -285,13 +290,21 @@ describe('FilesStorageUC upload methods', () => { mimeType: fileRecord.mimeType, }; + let resolveUploadFile: (value: FileRecord | PromiseLike) => void; + const fileRecordPromise = new Promise((resolve) => { + resolveUploadFile = resolve; + }); + filesStorageService.uploadFile.mockImplementationOnce(() => fileRecordPromise); + request.pipe.mockImplementation((requestStream) => { requestStream.emit('file', 'file', readable, fileInfo); + + requestStream.emit('finish'); + resolveUploadFile(fileRecord); + return requestStream; }); - filesStorageService.uploadFile.mockResolvedValueOnce(fileRecord); - return { params, userId, request, fileRecord, readable, fileInfo }; }; @@ -314,7 +327,6 @@ describe('FilesStorageUC upload methods', () => { const file = FileDtoBuilder.buildFromRequest(fileInfo, readable); await filesStorageUC.upload(userId, params, request); - expect(filesStorageService.uploadFile).toHaveBeenCalledWith(userId, params, file); }); @@ -333,9 +345,16 @@ describe('FilesStorageUC upload methods', () => { const fileRecord = fileRecords[0]; const request = createRequest(); const readable = Readable.from('abc'); + const error = new Error('test'); const size = request.headers['content-length']; + let rejectUploadFile: (value: Error) => void; + const fileRecordPromise = new Promise((resolve, reject) => { + rejectUploadFile = reject; + }); + filesStorageService.uploadFile.mockImplementationOnce(() => fileRecordPromise); + request.get.mockReturnValue(size); request.pipe.mockImplementation((requestStream) => { requestStream.emit('file', 'file', readable, { @@ -344,12 +363,12 @@ describe('FilesStorageUC upload methods', () => { mimeType: fileRecord.mimeType, }); + requestStream.emit('finish'); + rejectUploadFile(error); + return requestStream; }); - const error = new Error('test'); - filesStorageService.uploadFile.mockRejectedValueOnce(error); - return { params, userId, request, error }; }; diff --git a/apps/server/src/modules/files-storage/uc/files-storage.uc.ts b/apps/server/src/modules/files-storage/uc/files-storage.uc.ts index a9fae9db118..94d2afc02e4 100644 --- a/apps/server/src/modules/files-storage/uc/files-storage.uc.ts +++ b/apps/server/src/modules/files-storage/uc/files-storage.uc.ts @@ -1,3 +1,4 @@ +import { EntityManager, RequestContext } from '@mikro-orm/core'; import { AuthorizationContext } from '@modules/authorization'; import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { HttpService } from '@nestjs/axios'; @@ -25,18 +26,21 @@ import { FileRecord, FileRecordParentType } from '../entity'; import { ErrorType } from '../error'; import { FileStorageAuthorizationContext } from '../files-storage.const'; import { GetFileResponse } from '../interface'; -import { FileDtoBuilder, FilesStorageMapper } from '../mapper'; +import { ConfigResponseMapper, FileDtoBuilder, FilesStorageMapper } from '../mapper'; import { FilesStorageService } from '../service/files-storage.service'; import { PreviewService } from '../service/preview.service'; +import { FilesStorageConfigResponse } from '../dto/files-storage-config.response'; @Injectable() export class FilesStorageUC { constructor( - private logger: LegacyLogger, + private readonly logger: LegacyLogger, private readonly authorizationReferenceService: AuthorizationReferenceService, private readonly httpService: HttpService, private readonly filesStorageService: FilesStorageService, - private readonly previewService: PreviewService + private readonly previewService: PreviewService, + // maybe better to pass the request context from controller and avoid em at this place + private readonly em: EntityManager ) { this.logger.setContext(FilesStorageUC.name); } @@ -51,6 +55,14 @@ export class FilesStorageUC { await this.authorizationReferenceService.checkPermissionByReferences(userId, allowedType, parentId, context); } + public getPublicConfig(): FilesStorageConfigResponse { + const maxFileSize = this.filesStorageService.getMaxFileSize(); + + const configResponse = ConfigResponseMapper.mapToResponse(maxFileSize); + + return configResponse; + } + // upload public async upload(userId: EntityId, params: FileRecordParams, req: Request): Promise { await this.checkPermission(userId, params.parentType, params.parentId, FileStorageAuthorizationContext.create); @@ -63,18 +75,25 @@ export class FilesStorageUC { private async uploadFileWithBusboy(userId: EntityId, params: FileRecordParams, req: Request): Promise { const promise = new Promise((resolve, reject) => { const bb = busboy({ headers: req.headers, defParamCharset: 'utf8' }); + let fileRecordPromise: Promise; - // eslint-disable-next-line @typescript-eslint/no-misused-promises - bb.on('file', async (_name, file, info) => { + bb.on('file', (_name, file, info) => { const fileDto = FileDtoBuilder.buildFromRequest(info, file); - try { - const record = await this.filesStorageService.uploadFile(userId, params, fileDto); - resolve(record); - } catch (error) { - req.unpipe(bb); - reject(error); - } + fileRecordPromise = RequestContext.createAsync(this.em, () => { + const record = this.filesStorageService.uploadFile(userId, params, fileDto); + + return record; + }); + }); + + bb.on('finish', () => { + fileRecordPromise + .then((result) => resolve(result)) + .catch((error) => { + req.unpipe(bb); + reject(error); + }); }); req.pipe(bb); diff --git a/apps/server/src/modules/files/entity/file-security-check.entity.ts b/apps/server/src/modules/files/entity/file-security-check.entity.ts index a3fb5dd0fb3..21348339f47 100644 --- a/apps/server/src/modules/files/entity/file-security-check.entity.ts +++ b/apps/server/src/modules/files/entity/file-security-check.entity.ts @@ -19,10 +19,10 @@ export class FileSecurityCheckEntity { @Property() requestToken?: string = uuid(); - @Property() + @Property({ type: Date }) createdAt = new Date(); - @Property() + @Property({ type: Date, onUpdate: () => new Date() }) updatedAt = new Date(); constructor(props: FileSecurityCheckEntityProps) { diff --git a/apps/server/src/modules/files/entity/file.entity.ts b/apps/server/src/modules/files/entity/file.entity.ts index 335e0f4d4b1..8b4fb8f2aa6 100644 --- a/apps/server/src/modules/files/entity/file.entity.ts +++ b/apps/server/src/modules/files/entity/file.entity.ts @@ -39,10 +39,12 @@ export class FileEntity extends BaseEntityWithTimestamps { @Property({ nullable: true }) deletedAt?: Date; - @Property() + // you have to set the type explicitly to boolean, otherwise metadata will be wrong + @Property({ type: 'boolean' }) deleted = false; - @Property() + // you have to set the type explicitly to boolean, otherwise metadata will be wrong + @Property({ type: 'boolean' }) isDirectory = false; @Property() diff --git a/apps/server/src/modules/files/files.module.ts b/apps/server/src/modules/files/files.module.ts index d451381171d..9eaaf93b71b 100644 --- a/apps/server/src/modules/files/files.module.ts +++ b/apps/server/src/modules/files/files.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { StorageProviderRepo } from '@shared/repo/storageprovider'; import { LoggerModule } from '@src/core/logger'; +import { CqrsModule } from '@nestjs/cqrs'; import { DeleteFilesConsole } from './job'; import { DeleteFilesUc } from './uc'; import { FilesRepo } from './repo'; import { FilesService } from './service'; @Module({ - imports: [LoggerModule], + imports: [CqrsModule, LoggerModule], providers: [DeleteFilesConsole, DeleteFilesUc, FilesRepo, StorageProviderRepo, FilesService], exports: [FilesService], }) diff --git a/apps/server/src/modules/files/service/files.service.spec.ts b/apps/server/src/modules/files/service/files.service.spec.ts index 702906ddd25..34fea658e6b 100644 --- a/apps/server/src/modules/files/service/files.service.spec.ts +++ b/apps/server/src/modules/files/service/files.service.spec.ts @@ -3,6 +3,15 @@ import { Test, TestingModule } from '@nestjs/testing'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { setupEntities } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { EventBus } from '@nestjs/cqrs'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DataDeletedEvent, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { FilesService } from './files.service'; import { FilesRepo } from '../repo'; import { fileEntityFactory, filePermissionEntityFactory } from '../entity/testing'; @@ -12,6 +21,7 @@ describe(FilesService.name, () => { let module: TestingModule; let service: FilesService; let repo: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -25,11 +35,18 @@ describe(FilesService.name, () => { provide: Logger, useValue: createMock(), }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); service = module.get(FilesService); repo = module.get(FilesRepo); + eventBus = module.get(EventBus); await setupEntities(); }); @@ -103,75 +120,110 @@ describe(FilesService.name, () => { }); describe('removeUserPermissionsOrCreatorReferenceToAnyFiles', () => { - it('should not modify any files if there are none that user has permission to access or is creator', async () => { + const setup = () => { const userId = new ObjectId().toHexString(); + const userPermission = filePermissionEntityFactory.build({ refId: userId }); + const anotherUserPermission = filePermissionEntityFactory.build(); + const yetAnotherUserPermission = filePermissionEntityFactory.build(); + + const entity1 = fileEntityFactory.buildWithId({ + permissions: [userPermission], + creatorId: userId, + }); + const entity2 = fileEntityFactory.buildWithId({ + permissions: [userPermission], + creatorId: userId, + }); + const entity3 = fileEntityFactory.buildWithId({ + permissions: [userPermission], + }); + const entity4 = fileEntityFactory.buildWithId({ + permissions: [anotherUserPermission, yetAnotherUserPermission, userPermission], + creatorId: userId, + }); + const entity5 = fileEntityFactory.buildWithId({ + permissions: [anotherUserPermission, yetAnotherUserPermission, userPermission], + creatorId: userId, + }); + const entity6 = fileEntityFactory.buildWithId({ + permissions: [yetAnotherUserPermission, userPermission, anotherUserPermission], + }); + + const entities = [entity4, entity5, entity6]; + + const expectedResultWhenFilesNotExists = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 0, []), + ]); + + const expectedResultWhenFilesExistsWithOnlyUserId = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 3, [entity1.id, entity2.id, entity3.id]), + ]); + + const expectedResultWhenManyFilesExistsWithOtherUsers = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 3, [entity4.id, entity5.id, entity6.id]), + ]); + + return { + entity1, + entity2, + entity3, + entities, + expectedResultWhenFilesExistsWithOnlyUserId, + expectedResultWhenManyFilesExistsWithOtherUsers, + expectedResultWhenFilesNotExists, + userId, + userPermission, + anotherUserPermission, + yetAnotherUserPermission, + }; + }; + + it('should not modify any files if there are none that user has permission to access or is creator', async () => { + const { expectedResultWhenFilesNotExists, userId } = setup(); + repo.findByPermissionRefIdOrCreatorId.mockResolvedValueOnce([]); const result = await service.removeUserPermissionsOrCreatorReferenceToAnyFiles(userId); - expect(result).toEqual(0); + expect(result).toEqual(expectedResultWhenFilesNotExists); expect(repo.findByPermissionRefIdOrCreatorId).toBeCalledWith(userId); - expect(repo.save).not.toBeCalled(); }); describe('should properly remove user permissions, creatorId reference', () => { - const setup = () => { - const userId = new ObjectId().toHexString(); - const userPermission = filePermissionEntityFactory.build({ refId: userId }); - - const entity = fileEntityFactory.buildWithId({ permissions: [userPermission], creatorId: userId }); - const entity2 = fileEntityFactory.buildWithId({ creatorId: userId }); - const entity3 = fileEntityFactory.buildWithId({ permissions: [userPermission] }); - - repo.findByPermissionRefIdOrCreatorId.mockResolvedValueOnce([entity, entity2, entity3]); - return { userId, userPermission, entity, entity2, entity3 }; - }; it('in case of just a single file (permission) accessible by given user and couple of files created', async () => { - const { userId, userPermission, entity, entity2, entity3 } = setup(); + const { entity1, entity2, entity3, expectedResultWhenFilesExistsWithOnlyUserId, userId, userPermission } = + setup(); + + repo.findByPermissionRefIdOrCreatorId.mockResolvedValueOnce([entity1, entity2, entity3]); const result = await service.removeUserPermissionsOrCreatorReferenceToAnyFiles(userId); - expect(result).toEqual(3); + expect(result).toEqual(expectedResultWhenFilesExistsWithOnlyUserId); expect(entity3.permissions).not.toContain(userPermission); - expect(entity._creatorId).toBe(undefined); + expect(entity1._creatorId).toBe(undefined); expect(entity3.permissions).not.toContain(userPermission); expect(entity2._creatorId).toBe(undefined); expect(repo.findByPermissionRefIdOrCreatorId).toBeCalledWith(userId); - expect(repo.save).toBeCalledWith([entity, entity2, entity3]); + expect(repo.save).toBeCalledWith([entity1, entity2, entity3]); }); it('in case of many files accessible or created by given user', async () => { - const userId = new ObjectId().toHexString(); - const userPermission = filePermissionEntityFactory.build({ refId: userId }); - const anotherUserPermission = filePermissionEntityFactory.build(); - const yetAnotherUserPermission = filePermissionEntityFactory.build(); - const entities = [ - fileEntityFactory.buildWithId({ - permissions: [userPermission, anotherUserPermission, yetAnotherUserPermission], - }), - fileEntityFactory.buildWithId({ - permissions: [yetAnotherUserPermission, userPermission, anotherUserPermission], - creatorId: userId, - }), - fileEntityFactory.buildWithId({ - permissions: [anotherUserPermission, yetAnotherUserPermission, userPermission], - }), - fileEntityFactory.buildWithId({ - permissions: [userPermission, yetAnotherUserPermission, anotherUserPermission], - creatorId: userId, - }), - fileEntityFactory.buildWithId({ - permissions: [yetAnotherUserPermission, anotherUserPermission, userPermission], - }), - ]; + const { + entities, + expectedResultWhenManyFilesExistsWithOtherUsers, + userId, + userPermission, + anotherUserPermission, + yetAnotherUserPermission, + } = setup(); repo.findByPermissionRefIdOrCreatorId.mockResolvedValueOnce(entities); const result = await service.removeUserPermissionsOrCreatorReferenceToAnyFiles(userId); - expect(result).toEqual(5); + expect(result).toEqual(expectedResultWhenManyFilesExistsWithOtherUsers); for (let i = 0; i < entities.length; i += 1) { expect(entities[i].permissions).not.toContain(userPermission); @@ -236,6 +288,35 @@ describe(FilesService.name, () => { }); describe('markFilesOwnedByUserForDeletion', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const entity1 = fileEntityFactory.buildWithId({ ownerId: userId }); + const entity2 = fileEntityFactory.buildWithId({ ownerId: userId }); + const entity3 = fileEntityFactory.buildWithId({ ownerId: userId }); + const entities = [entity1, entity2, entity3]; + + const expectedResultWhenFilesNotExists = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 0, []), + ]); + + const expectedResultWhenOneFileExists = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 1, [entity1.id]), + ]); + + const expectedResultWhenManyFilesExists = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 3, [entity1.id, entity2.id, entity3.id]), + ]); + + return { + entities, + entity1, + expectedResultWhenOneFileExists, + expectedResultWhenManyFilesExists, + expectedResultWhenFilesNotExists, + userId, + }; + }; + const verifyEntityChanges = (entity: FileEntity) => { expect(entity.deleted).toEqual(true); @@ -246,46 +327,37 @@ describe(FilesService.name, () => { }; it('should not mark any files for deletion if there are none owned by given user', async () => { - const userId = new ObjectId().toHexString(); - repo.findByOwnerUserId.mockResolvedValueOnce([]); + const { expectedResultWhenFilesNotExists, userId } = setup(); + repo.findByOwnerUserId.mockResolvedValueOnce([]); const result = await service.markFilesOwnedByUserForDeletion(userId); - expect(result).toEqual(0); + expect(result).toEqual(expectedResultWhenFilesNotExists); expect(repo.findByOwnerUserId).toBeCalledWith(userId); - expect(repo.save).not.toBeCalled(); }); describe('should properly mark files for deletion', () => { it('in case of just a single file owned by given user', async () => { - const entity = fileEntityFactory.buildWithId(); - const userId = entity.ownerId; - repo.findByOwnerUserId.mockResolvedValueOnce([entity]); + const { entity1, expectedResultWhenOneFileExists, userId } = setup(); + repo.findByOwnerUserId.mockResolvedValueOnce([entity1]); const result = await service.markFilesOwnedByUserForDeletion(userId); - expect(result).toEqual(1); - verifyEntityChanges(entity); + expect(result).toEqual(expectedResultWhenOneFileExists); + verifyEntityChanges(entity1); expect(repo.findByOwnerUserId).toBeCalledWith(userId); - expect(repo.save).toBeCalledWith([entity]); + expect(repo.save).toBeCalledWith([entity1]); }); it('in case of many files owned by the user', async () => { - const userId = new ObjectId().toHexString(); - const entities = [ - fileEntityFactory.buildWithId({ ownerId: userId }), - fileEntityFactory.buildWithId({ ownerId: userId }), - fileEntityFactory.buildWithId({ ownerId: userId }), - fileEntityFactory.buildWithId({ ownerId: userId }), - fileEntityFactory.buildWithId({ ownerId: userId }), - ]; + const { entities, expectedResultWhenManyFilesExists, userId } = setup(); repo.findByOwnerUserId.mockResolvedValueOnce(entities); const result = await service.markFilesOwnedByUserForDeletion(userId); - expect(result).toEqual(5); + expect(result).toEqual(expectedResultWhenManyFilesExists); entities.forEach((entity) => verifyEntityChanges(entity)); expect(repo.findByOwnerUserId).toBeCalledWith(userId); @@ -293,4 +365,133 @@ describe(FilesService.name, () => { }); }); }); + + describe('deleteUserData', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const entity1 = fileEntityFactory.buildWithId({ ownerId: userId }); + const entity2 = fileEntityFactory.buildWithId({ ownerId: userId }); + const entity3 = fileEntityFactory.buildWithId({ ownerId: userId }); + const userPermission = filePermissionEntityFactory.build({ refId: userId }); + + const entity4 = fileEntityFactory.buildWithId({ + permissions: [userPermission], + creatorId: userId, + }); + const entity5 = fileEntityFactory.buildWithId({ + permissions: [userPermission], + creatorId: userId, + }); + const entity6 = fileEntityFactory.buildWithId({ + permissions: [userPermission], + }); + + const expectedResultForMarkingFiles = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 3, [entity1.id, entity2.id, entity3.id]), + ]); + + const expectedResultForRemoveUserPermission = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 3, [entity4.id, entity5.id, entity6.id]), + ]); + + const expectedResult = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 6, [ + entity1.id, + entity2.id, + entity3.id, + entity4.id, + entity5.id, + entity6.id, + ]), + ]); + + return { + expectedResult, + expectedResultForMarkingFiles, + expectedResultForRemoveUserPermission, + userId, + }; + }; + + describe('when deleteUserData', () => { + it('should call markFilesOwnedByUserForDeletion with userId', async () => { + const { userId, expectedResultForMarkingFiles } = setup(); + jest.spyOn(service, 'markFilesOwnedByUserForDeletion').mockResolvedValueOnce(expectedResultForMarkingFiles); + + await service.deleteUserData(userId); + + expect(service.markFilesOwnedByUserForDeletion).toHaveBeenCalledWith(userId); + }); + + it('should call removeUserPermissionsOrCreatorReferenceToAnyFiles with userId', async () => { + const { userId, expectedResultForRemoveUserPermission } = setup(); + + jest + .spyOn(service, 'removeUserPermissionsOrCreatorReferenceToAnyFiles') + .mockResolvedValueOnce(expectedResultForRemoveUserPermission); + + await service.deleteUserData(userId); + + expect(service.removeUserPermissionsOrCreatorReferenceToAnyFiles).toHaveBeenCalledWith(userId); + }); + + it('should return domainOperation object with information about deleted user data', async () => { + const { userId, expectedResult, expectedResultForMarkingFiles, expectedResultForRemoveUserPermission } = + setup(); + + jest.spyOn(service, 'markFilesOwnedByUserForDeletion').mockResolvedValueOnce(expectedResultForMarkingFiles); + jest + .spyOn(service, 'removeUserPermissionsOrCreatorReferenceToAnyFiles') + .mockResolvedValueOnce(expectedResultForRemoveUserPermission); + + const result = await service.deleteUserData(userId); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILE; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in classService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(service.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); + }); + }); + }); }); diff --git a/apps/server/src/modules/files/service/files.service.ts b/apps/server/src/modules/files/service/files.service.ts index 1b3941aaffc..5a2976f418b 100644 --- a/apps/server/src/modules/files/service/files.service.ts +++ b/apps/server/src/modules/files/service/files.service.ts @@ -1,35 +1,49 @@ import { Injectable } from '@nestjs/common'; -import { DomainModel, EntityId, StatusModel } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; -import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; +import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + DataDeletedEvent, + DataDeletionDomainOperationLoggable, + DeletionService, + DomainDeletionReport, + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + UserDeletedEvent, + StatusModel, +} from '@modules/deletion'; import { FileEntity } from '../entity'; import { FilesRepo } from '../repo'; @Injectable() -export class FilesService { - constructor(private readonly repo: FilesRepo, private readonly logger: Logger) { +@EventsHandler(UserDeletedEvent) +export class FilesService implements DeletionService, IEventHandler { + constructor(private readonly repo: FilesRepo, private readonly logger: Logger, private readonly eventBus: EventBus) { this.logger.setContext(FilesService.name); } + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + async findFilesAccessibleOrCreatedByUser(userId: EntityId): Promise { return this.repo.findByPermissionRefIdOrCreatorId(userId); } - async removeUserPermissionsOrCreatorReferenceToAnyFiles(userId: EntityId): Promise { + async removeUserPermissionsOrCreatorReferenceToAnyFiles(userId: EntityId): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting user data from Files', - DomainModel.FILE, + DomainName.FILE, userId, StatusModel.PENDING ) ); const entities = await this.repo.findByPermissionRefIdOrCreatorId(userId); - if (entities.length === 0) { - return 0; - } - entities.forEach((entity) => { entity.removePermissionsByRefId(userId); entity.removeCreatorId(userId); @@ -39,10 +53,14 @@ export class FilesService { const numberOfUpdatedFiles = entities.length; + const result = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, numberOfUpdatedFiles, this.getFilesId(entities)), + ]); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully removed user data from Files', - DomainModel.FILE, + DomainName.FILE, userId, StatusModel.FINISHED, numberOfUpdatedFiles, @@ -50,38 +68,42 @@ export class FilesService { ) ); - return numberOfUpdatedFiles; + return result; } async findFilesOwnedByUser(userId: EntityId): Promise { return this.repo.findByOwnerUserId(userId); } - async markFilesOwnedByUserForDeletion(userId: EntityId): Promise { + async markFilesOwnedByUserForDeletion(userId: EntityId): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Marking user files to deletion', - DomainModel.FILE, + DomainName.FILE, userId, StatusModel.PENDING ) ); const entities = await this.repo.findByOwnerUserId(userId); - if (entities.length === 0) { - return 0; - } - entities.forEach((entity) => entity.markForDeletion()); await this.repo.save(entities); const numberOfMarkedForDeletionFiles = entities.length; + const result = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build( + OperationType.UPDATE, + numberOfMarkedForDeletionFiles, + this.getFilesId(entities) + ), + ]); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully marked user files for deletion', - DomainModel.FILE, + DomainName.FILE, userId, StatusModel.FINISHED, numberOfMarkedForDeletionFiles, @@ -89,6 +111,30 @@ export class FilesService { ) ); - return numberOfMarkedForDeletionFiles; + return result; + } + + public async deleteUserData(userId: EntityId): Promise { + const [markedFilesForDeletion, removedUserPermissionsToFiles] = await Promise.all([ + this.markFilesOwnedByUserForDeletion(userId), + this.removeUserPermissionsOrCreatorReferenceToAnyFiles(userId), + ]); + + const modifiedFilesCount = + markedFilesForDeletion.operations[0].count + removedUserPermissionsToFiles.operations[0].count; + const modifiedFilesRef = [ + ...markedFilesForDeletion.operations[0].refs, + ...removedUserPermissionsToFiles.operations[0].refs, + ]; + + const result = DomainDeletionReportBuilder.build(DomainName.FILE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, modifiedFilesCount, modifiedFilesRef), + ]); + + return result; + } + + private getFilesId(files: FileEntity[]): EntityId[] { + return files.map((file) => file.id); } } diff --git a/apps/server/src/modules/files/uc/delete-files.uc.spec.ts b/apps/server/src/modules/files/uc/delete-files.uc.spec.ts index ec0187a4cc4..1409d31400d 100644 --- a/apps/server/src/modules/files/uc/delete-files.uc.spec.ts +++ b/apps/server/src/modules/files/uc/delete-files.uc.spec.ts @@ -2,7 +2,7 @@ import { S3Client } from '@aws-sdk/client-s3'; import { AwsClientStub, mockClient } from 'aws-sdk-client-mock'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { StorageProviderRepo } from '@shared/repo/storageprovider'; import { storageProviderFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; diff --git a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts index 6866d51c134..cbe00c17bc7 100644 --- a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts +++ b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents-test.module.ts @@ -2,21 +2,24 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { MongoDatabaseModuleOptions } from '@infra/database/mongo-memory-database/types'; import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; import { S3ClientModule } from '@infra/s3-client'; +import { AccountEntity } from '@modules/account/entity/account.entity'; import { AuthenticationModule } from '@modules/authentication/authentication.module'; import { AuthorizationModule } from '@modules/authorization'; import { HttpModule } from '@nestjs/axios'; import { DynamicModule, Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { Account, Role, SchoolEntity, SchoolYearEntity, SystemEntity, User } from '@shared/domain/entity'; +import { Role, SchoolEntity, SchoolYearEntity, SystemEntity, User } from '@shared/domain/entity'; import { createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; +import { FwuLearningContentsUc } from './uc/fwu-learning-contents.uc'; import { FwuLearningContentsController } from './controller/fwu-learning-contents.controller'; import { config, s3Config } from './fwu-learning-contents.config'; -import { FwuLearningContentsUc } from './uc/fwu-learning-contents.uc'; const imports = [ - MongoMemoryDatabaseModule.forRoot({ entities: [User, Account, Role, SchoolEntity, SystemEntity, SchoolYearEntity] }), + MongoMemoryDatabaseModule.forRoot({ + entities: [User, AccountEntity, Role, SchoolEntity, SystemEntity, SchoolYearEntity], + }), AuthorizationModule, AuthenticationModule, ConfigModule.forRoot(createConfigModuleOptions(config)), diff --git a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.module.ts b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.module.ts index 8ff4c2c403f..e4699188bd3 100644 --- a/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.module.ts +++ b/apps/server/src/modules/fwu-learning-contents/fwu-learning-contents.module.ts @@ -6,7 +6,7 @@ import { AuthorizationModule } from '@modules/authorization'; import { HttpModule } from '@nestjs/axios'; import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { Account, Role, SchoolEntity, SchoolYearEntity, SystemEntity, User } from '@shared/domain/entity'; +import { Role, SchoolEntity, SchoolYearEntity, SystemEntity, User } from '@shared/domain/entity'; import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; import { CoreModule } from '@src/core'; import { LoggerModule } from '@src/core/logger'; @@ -14,6 +14,7 @@ import { AuthenticationModule } from '../authentication/authentication.module'; import { FwuLearningContentsController } from './controller/fwu-learning-contents.controller'; import { config, s3Config } from './fwu-learning-contents.config'; import { FwuLearningContentsUc } from './uc/fwu-learning-contents.uc'; +import { AccountEntity } from '../account/entity/account.entity'; const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => @@ -36,7 +37,7 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { clientUrl: DB_URL, password: DB_PASSWORD, user: DB_USERNAME, - entities: [User, Account, Role, SchoolEntity, SystemEntity, SchoolYearEntity], + entities: [User, AccountEntity, Role, SchoolEntity, SystemEntity, SchoolYearEntity], // debug: true, // use it for locally debugging of querys }), diff --git a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts index 32a9740cd7d..75c71ffad15 100644 --- a/apps/server/src/modules/group/controller/api-test/group.api.spec.ts +++ b/apps/server/src/modules/group/controller/api-test/group.api.spec.ts @@ -1,22 +1,29 @@ -import { EntityManager } from '@mikro-orm/mongodb'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ClassEntity } from '@modules/class/entity'; import { classEntityFactory } from '@modules/class/entity/testing'; -import { ServerTestModule } from '@modules/server'; +import { serverConfig, ServerConfig, ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Role, SchoolEntity, SchoolYearEntity, SystemEntity, User } from '@shared/domain/entity'; +import { + Course as CourseEntity, + Role, + SchoolEntity, + SchoolYearEntity, + SystemEntity, + User, +} from '@shared/domain/entity'; import { RoleName, SortOrder } from '@shared/domain/interface'; import { - TestApiClient, - UserAndAccountTestFactory, + courseFactory as courseEntityFactory, groupEntityFactory, roleFactory, - schoolFactory, + schoolEntityFactory, schoolYearFactory, systemEntityFactory, + TestApiClient, + UserAndAccountTestFactory, userFactory, } from '@shared/testing'; -import { ObjectId } from 'bson'; import { GroupEntity, GroupEntityTypes } from '../../entity'; import { ClassRootType } from '../../uc/dto/class-root-type'; import { ClassInfoSearchListResponse } from '../dto'; @@ -30,6 +37,8 @@ describe('Group (API)', () => { let testApiClient: TestApiClient; beforeAll(async () => { + const config: ServerConfig = serverConfig(); + config.FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED = true; const module: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], }).compile(); @@ -48,7 +57,7 @@ describe('Group (API)', () => { describe('when an admin requests a list of classes', () => { const setup = async () => { const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId({ currentYear: schoolYear }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ currentYear: schoolYear }); const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); const teacherRole: Role = roleFactory.buildWithId({ name: RoleName.TEACHER }); @@ -76,6 +85,7 @@ describe('Group (API)', () => { }, ], }); + const course: CourseEntity = courseEntityFactory.buildWithId({ syncedWithGroup: group }); await em.persistAndFlush([ school, @@ -87,6 +97,7 @@ describe('Group (API)', () => { clazz, group, schoolYear, + course, ]); em.clear(); @@ -100,11 +111,12 @@ describe('Group (API)', () => { adminUser, teacherUser, schoolYear, + course, }; }; it('should return the classes of his school', async () => { - const { adminClient, group, clazz, system, adminUser, teacherUser, schoolYear } = await setup(); + const { adminClient, group, clazz, system, schoolYear, course } = await setup(); const response = await adminClient.get(`/class`).query({ skip: 0, @@ -121,14 +133,15 @@ describe('Group (API)', () => { type: ClassRootType.GROUP, name: group.name, externalSourceName: system.displayName, - teachers: [adminUser.lastName], + teacherNames: [], studentCount: 0, + synchronizedCourses: [{ id: course.id, name: course.name }], }, { id: clazz.id, type: ClassRootType.CLASS, name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, - teachers: [teacherUser.lastName], + teacherNames: [], schoolYear: schoolYear.name, isUpgradable: false, studentCount: 0, @@ -145,7 +158,7 @@ describe('Group (API)', () => { describe('when authorized user requests a group', () => { describe('when group exists', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); const group: GroupEntity = groupEntityFactory.buildWithId({ @@ -179,6 +192,7 @@ describe('Group (API)', () => { expect(response.body).toEqual({ id: group.id, name: group.name, + organizationId: group.organization?.id, type: group.type, users: [ { @@ -255,4 +269,383 @@ describe('Group (API)', () => { }); }); }); + + describe('[GET] /groups', () => { + describe('when admin requests groups', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId(); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + const groupInSchool: GroupEntity = groupEntityFactory.buildWithId({ + organization: school, + users: [ + { + user: adminUser, + role: adminUser.roles[0], + }, + ], + }); + const availableGroupInSchool: GroupEntity = groupEntityFactory.buildWithId({ + organization: school, + users: [ + { + user: adminUser, + role: adminUser.roles[0], + }, + ], + }); + const groupInOtherSchool: GroupEntity = groupEntityFactory.buildWithId({ + organization: otherSchool, + users: [ + { + user: adminUser, + role: adminUser.roles[0], + }, + ], + }); + + const syncedCourse: CourseEntity = courseEntityFactory.build({ + school, + syncedWithGroup: groupInSchool, + }); + + const expectGroup = { + id: availableGroupInSchool.id, + name: availableGroupInSchool.name, + organizationId: availableGroupInSchool.organization?.id, + type: availableGroupInSchool.type, + users: [ + { + id: adminUser.id, + firstName: adminUser.firstName, + lastName: adminUser.lastName, + role: adminUser.roles[0].name, + }, + ], + externalSource: { + externalId: availableGroupInSchool.externalSource?.externalId, + systemId: availableGroupInSchool.externalSource?.system.id, + }, + }; + + const nameQuery: string = availableGroupInSchool.name.slice(-2); + + await em.persistAndFlush([ + adminAccount, + adminUser, + groupInSchool, + availableGroupInSchool, + groupInOtherSchool, + school, + otherSchool, + syncedCourse, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { + loggedInClient, + groupInSchool, + adminUser, + expectGroup, + nameQuery, + }; + }; + + describe('when requesting all groups', () => { + it('should return all groups of the school', async () => { + const { loggedInClient, groupInSchool, expectGroup, adminUser } = await setup(); + + const response = await loggedInClient.get(); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + data: [ + { + id: groupInSchool.id, + name: groupInSchool.name, + organizationId: groupInSchool.organization?.id, + type: groupInSchool.type, + users: [ + { + id: adminUser.id, + firstName: adminUser.firstName, + lastName: adminUser.lastName, + role: adminUser.roles[0].name, + }, + ], + externalSource: { + externalId: groupInSchool.externalSource?.externalId, + systemId: groupInSchool.externalSource?.system.id, + }, + }, + expectGroup, + ], + limit: 10, + skip: 0, + total: 2, + }); + }); + + it('should return groups according to pagination', async () => { + const { loggedInClient, expectGroup } = await setup(); + + const response = await loggedInClient.get().query({ skip: 1, limit: 1 }); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + data: [expectGroup], + limit: 1, + skip: 1, + total: 2, + }); + }); + + it('should return groups according to name query', async () => { + const { loggedInClient, expectGroup, nameQuery } = await setup(); + + const response = await loggedInClient.get().query({ nameQuery }); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + data: [expectGroup], + limit: 10, + skip: 0, + total: 1, + }); + }); + }); + + describe('when requesting all available groups', () => { + it('should return all available groups for course sync', async () => { + const { loggedInClient, expectGroup } = await setup(); + + const response = await loggedInClient.get().query({ availableGroupsForCourseSync: true }); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + data: [expectGroup], + limit: 10, + skip: 0, + total: 1, + }); + }); + + it('should return available groups according to pagination', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get().query({ availableGroupsForCourseSync: true, skip: 1, limit: 1 }); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ data: [], limit: 1, skip: 1, total: 1 }); + }); + + it('should return available groups according to name query', async () => { + const { loggedInClient, expectGroup, nameQuery } = await setup(); + + const response = await loggedInClient.get().query({ availableGroupsForCourseSync: true, nameQuery }); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + data: [expectGroup], + limit: 10, + skip: 0, + total: 1, + }); + }); + }); + }); + + describe('when teacher requests groups', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + + const teachersGroup: GroupEntity = groupEntityFactory.buildWithId({ + organization: school, + users: [{ user: teacherUser, role: teacherUser.roles[0] }], + }); + const availableTeachersGroup: GroupEntity = groupEntityFactory.buildWithId({ + organization: school, + users: [{ user: teacherUser, role: teacherUser.roles[0] }], + }); + const groupWithoutTeacher: GroupEntity = groupEntityFactory.buildWithId({ organization: school }); + + const syncedCourse: CourseEntity = courseEntityFactory.build({ + school, + syncedWithGroup: teachersGroup, + }); + + const expectGroup = { + id: availableTeachersGroup.id, + name: availableTeachersGroup.name, + organizationId: availableTeachersGroup.organization?.id, + type: availableTeachersGroup.type, + users: [ + { + id: teacherUser.id, + firstName: teacherUser.firstName, + lastName: teacherUser.lastName, + role: teacherUser.roles[0].name, + }, + ], + externalSource: { + externalId: availableTeachersGroup.externalSource?.externalId, + systemId: availableTeachersGroup.externalSource?.system.id, + }, + }; + + const nameQuery: string = availableTeachersGroup.name.slice(-2); + + await em.persistAndFlush([ + teacherAccount, + teacherUser, + teachersGroup, + availableTeachersGroup, + groupWithoutTeacher, + school, + syncedCourse, + ]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + teachersGroup, + expectGroup, + teacherUser, + nameQuery, + }; + }; + + describe('when requesting all groups', () => { + it('should return all groups the teacher is part of', async () => { + const { loggedInClient, teachersGroup, expectGroup, teacherUser } = await setup(); + + const response = await loggedInClient.get(); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + data: [ + { + id: teachersGroup.id, + name: teachersGroup.name, + organizationId: teachersGroup.organization?.id, + type: teachersGroup.type, + users: [ + { + id: teacherUser.id, + firstName: teacherUser.firstName, + lastName: teacherUser.lastName, + role: teacherUser.roles[0].name, + }, + ], + externalSource: { + externalId: teachersGroup.externalSource?.externalId, + systemId: teachersGroup.externalSource?.system.id, + }, + }, + expectGroup, + ], + limit: 10, + skip: 0, + total: 2, + }); + }); + + it('should return all groups according to pagination', async () => { + const { loggedInClient, expectGroup } = await setup(); + + const response = await loggedInClient.get().query({ skip: 1, limit: 1 }); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + data: [expectGroup], + limit: 1, + skip: 1, + total: 2, + }); + }); + + it('should return all groups according to name query', async () => { + const { loggedInClient, expectGroup, nameQuery } = await setup(); + + const response = await loggedInClient.get().query({ nameQuery }); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + data: [expectGroup], + limit: 10, + skip: 0, + total: 1, + }); + }); + }); + + describe('when requesting all available groups', () => { + it('should return all available groups for course sync the teacher is part of', async () => { + const { loggedInClient, expectGroup } = await setup(); + + const response = await loggedInClient.get().query({ availableGroupsForCourseSync: true }); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + data: [expectGroup], + limit: 10, + skip: 0, + total: 1, + }); + }); + + it('should return all available groups according to pagination', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get().query({ availableGroupsForCourseSync: true, skip: 1, limit: 1 }); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ data: [], limit: 1, skip: 1, total: 1 }); + }); + + it('should return all available groups according to name query', async () => { + const { loggedInClient, expectGroup, nameQuery } = await setup(); + + const response = await loggedInClient.get().query({ availableGroupsForCourseSync: true, nameQuery }); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ + data: [expectGroup], + limit: 10, + skip: 0, + total: 1, + }); + }); + }); + }); + + describe('when unauthorized user requests groups', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + }; + + it('should return unauthorized', async () => { + await setup(); + + const response = await testApiClient.get(); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); }); diff --git a/apps/server/src/modules/group/controller/dto/interface/class-sort-by.enum.ts b/apps/server/src/modules/group/controller/dto/interface/class-sort-by.enum.ts index 24dda3c9382..b0c91d19e36 100644 --- a/apps/server/src/modules/group/controller/dto/interface/class-sort-by.enum.ts +++ b/apps/server/src/modules/group/controller/dto/interface/class-sort-by.enum.ts @@ -1,4 +1,7 @@ export enum ClassSortBy { NAME = 'name', EXTERNAL_SOURCE_NAME = 'externalSourceName', + SYNCHRONIZED_COURSES = 'synchronizedCourses', + STUDENT_COUNT = 'studentCount', + TEACHER_NAMES = 'teacherNames', } diff --git a/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts b/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts index 980146c92d4..6200dfe0977 100644 --- a/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts +++ b/apps/server/src/modules/group/controller/dto/request/class-sort-params.ts @@ -6,6 +6,6 @@ import { ClassSortBy } from '../interface'; export class ClassSortParams extends SortingParams { @IsOptional() @IsEnum(ClassSortBy) - @ApiPropertyOptional({ enum: ClassSortBy }) + @ApiPropertyOptional({ enum: ClassSortBy, enumName: 'ClassSortBy' }) sortBy?: ClassSortBy; } diff --git a/apps/server/src/modules/group/controller/dto/request/group-params.ts b/apps/server/src/modules/group/controller/dto/request/group-params.ts new file mode 100644 index 00000000000..5ebbcfef23e --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/request/group-params.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { StringToBoolean } from '@shared/controller'; +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class GroupParams { + @IsOptional() + @IsBoolean() + @StringToBoolean() + @ApiPropertyOptional({ description: 'if true only available groups for a course sync are returned.' }) + availableGroupsForCourseSync?: boolean; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: 'search string for group names.' }) + nameQuery?: string; +} diff --git a/apps/server/src/modules/group/controller/dto/request/index.ts b/apps/server/src/modules/group/controller/dto/request/index.ts index e7da3889f23..fd621a700d7 100644 --- a/apps/server/src/modules/group/controller/dto/request/index.ts +++ b/apps/server/src/modules/group/controller/dto/request/index.ts @@ -3,3 +3,4 @@ export * from './group-id-params'; export * from './class-filter-params'; export { GroupPaginationParams } from './group-pagination.params'; export { ClassCallerParams } from './class-caller-params'; +export { GroupParams } from './group-params'; diff --git a/apps/server/src/modules/group/controller/dto/response/class-info.response.ts b/apps/server/src/modules/group/controller/dto/response/class-info.response.ts index c1e394174a6..48a5ea87f0c 100644 --- a/apps/server/src/modules/group/controller/dto/response/class-info.response.ts +++ b/apps/server/src/modules/group/controller/dto/response/class-info.response.ts @@ -1,9 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain/types'; import { ClassRootType } from '../../../uc/dto/class-root-type'; +import { CourseInfoResponse } from './course-info.response'; export class ClassInfoResponse { @ApiProperty() - id: string; + id: EntityId; @ApiProperty({ enum: ClassRootType }) type: ClassRootType; @@ -15,7 +17,7 @@ export class ClassInfoResponse { externalSourceName?: string; @ApiProperty({ type: [String] }) - teachers: string[]; + teacherNames: string[]; @ApiPropertyOptional() schoolYear?: string; @@ -26,14 +28,18 @@ export class ClassInfoResponse { @ApiProperty() studentCount: number; + @ApiPropertyOptional({ type: [CourseInfoResponse] }) + synchronizedCourses?: CourseInfoResponse[]; + constructor(props: ClassInfoResponse) { this.id = props.id; this.type = props.type; this.name = props.name; this.externalSourceName = props.externalSourceName; - this.teachers = props.teachers; + this.teacherNames = props.teacherNames; this.schoolYear = props.schoolYear; this.isUpgradable = props.isUpgradable; this.studentCount = props.studentCount; + this.synchronizedCourses = props.synchronizedCourses; } } diff --git a/apps/server/src/modules/group/controller/dto/response/course-info.response.ts b/apps/server/src/modules/group/controller/dto/response/course-info.response.ts new file mode 100644 index 00000000000..0ab497653c0 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/course-info.response.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain/types'; + +export class CourseInfoResponse { + @ApiProperty() + id: EntityId; + + @ApiProperty() + name: string; + + constructor(props: CourseInfoResponse) { + this.id = props.id; + this.name = props.name; + } +} diff --git a/apps/server/src/modules/group/controller/dto/response/group-list.response.ts b/apps/server/src/modules/group/controller/dto/response/group-list.response.ts new file mode 100644 index 00000000000..cdc6308b9d4 --- /dev/null +++ b/apps/server/src/modules/group/controller/dto/response/group-list.response.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationResponse } from '@shared/controller'; +import { GroupResponse } from './group.response'; + +export class GroupListResponse extends PaginationResponse { + constructor(data: GroupResponse[], total: number, skip?: number, limit?: number) { + super(total, skip, limit); + this.data = data; + } + + @ApiProperty({ type: [GroupResponse] }) + data: GroupResponse[]; +} diff --git a/apps/server/src/modules/group/controller/dto/response/group-user.response.ts b/apps/server/src/modules/group/controller/dto/response/group-user.response.ts index e398369c10d..3b649f0973d 100644 --- a/apps/server/src/modules/group/controller/dto/response/group-user.response.ts +++ b/apps/server/src/modules/group/controller/dto/response/group-user.response.ts @@ -11,7 +11,7 @@ export class GroupUserResponse { @ApiProperty() lastName: string; - @ApiProperty({ enum: RoleName }) + @ApiProperty({ enum: RoleName, enumName: 'RoleName' }) role: RoleName; constructor(user: GroupUserResponse) { diff --git a/apps/server/src/modules/group/controller/dto/response/index.ts b/apps/server/src/modules/group/controller/dto/response/index.ts index 9593930f21e..774e85fa355 100644 --- a/apps/server/src/modules/group/controller/dto/response/index.ts +++ b/apps/server/src/modules/group/controller/dto/response/index.ts @@ -4,3 +4,4 @@ export * from './external-source.response'; export * from './group.response'; export * from './group-type.response'; export * from './group-user.response'; +export { GroupListResponse } from './group-list.response'; diff --git a/apps/server/src/modules/group/controller/group.controller.ts b/apps/server/src/modules/group/controller/group.controller.ts index e5df1dec514..e19f867c394 100644 --- a/apps/server/src/modules/group/controller/group.controller.ts +++ b/apps/server/src/modules/group/controller/group.controller.ts @@ -1,18 +1,22 @@ import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; -import { Controller, Get, HttpStatus, Param, Query } from '@nestjs/common'; +import { Controller, ForbiddenException, Get, HttpStatus, Param, Query, UnauthorizedException } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiValidationError } from '@shared/common'; import { Page } from '@shared/domain/domainobject'; +import { IFindQuery } from '@shared/domain/interface'; import { ErrorResponse } from '@src/core/error/dto'; import { GroupUc } from '../uc'; import { ClassInfoDto, ResolvedGroupDto } from '../uc/dto'; import { + ClassCallerParams, ClassFilterParams, ClassInfoSearchListResponse, ClassSortParams, GroupIdParams, - GroupResponse, + GroupListResponse, GroupPaginationParams, - ClassCallerParams, + GroupParams, + GroupResponse, } from './dto'; import { GroupResponseMapper } from './mapper'; @@ -69,4 +73,28 @@ export class GroupController { return response; } + + @Get() + @ApiOperation({ summary: 'Get a list of all groups.' }) + @ApiResponse({ status: HttpStatus.OK, type: GroupListResponse }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 401, type: UnauthorizedException }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: '5XX', type: ErrorResponse }) + public async getAllGroups( + @CurrentUser() currentUser: ICurrentUser, + @Query() pagination: GroupPaginationParams, + @Query() params: GroupParams + ): Promise { + const query: IFindQuery = { pagination, nameQuery: params.nameQuery }; + const groups: Page = await this.groupUc.getAllGroups( + currentUser.userId, + currentUser.schoolId, + query, + params.availableGroupsForCourseSync + ); + const response: GroupListResponse = GroupResponseMapper.mapToGroupListResponse(groups, pagination); + + return response; + } } diff --git a/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts index 3b715b7db01..b658c399bc1 100644 --- a/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts +++ b/apps/server/src/modules/group/controller/mapper/group-response.mapper.ts @@ -1,14 +1,17 @@ import { Page } from '@shared/domain/domainobject'; import { GroupTypes } from '../../domain'; -import { ClassInfoDto, ResolvedGroupDto } from '../../uc/dto'; +import { ClassInfoDto, CourseInfoDto, ResolvedGroupDto } from '../../uc/dto'; import { ClassInfoResponse, ClassInfoSearchListResponse, ExternalSourceResponse, + GroupListResponse, + GroupPaginationParams, GroupResponse, GroupTypeResponse, GroupUserResponse, } from '../dto'; +import { CourseInfoResponse } from '../dto/response/course-info.response'; const typeMapping: Record = { [GroupTypes.CLASS]: GroupTypeResponse.CLASS, @@ -37,15 +40,18 @@ export class GroupResponseMapper { } private static mapToClassInfoToResponse(classInfo: ClassInfoDto): ClassInfoResponse { - const mapped = new ClassInfoResponse({ + const mapped: ClassInfoResponse = new ClassInfoResponse({ id: classInfo.id, type: classInfo.type, name: classInfo.name, externalSourceName: classInfo.externalSourceName, - teachers: classInfo.teacherNames, + teacherNames: classInfo.teacherNames, schoolYear: classInfo.schoolYear, isUpgradable: classInfo.isUpgradable, studentCount: classInfo.studentCount, + synchronizedCourses: classInfo.synchronizedCourses?.map( + (synchronizedCourse: CourseInfoDto): CourseInfoResponse => new CourseInfoResponse(synchronizedCourse) + ), }); return mapped; @@ -76,4 +82,19 @@ export class GroupResponseMapper { return mapped; } + + static mapToGroupListResponse(groups: Page, pagination: GroupPaginationParams): GroupListResponse { + const groupResponseData: GroupResponse[] = groups.data.map( + (group: ResolvedGroupDto): GroupResponse => this.mapToGroupResponse(group) + ); + + const response: GroupListResponse = new GroupListResponse( + groupResponseData, + groups.total, + pagination.skip, + pagination.limit + ); + + return response; + } } diff --git a/apps/server/src/modules/group/domain/event/group-deleted.event.ts b/apps/server/src/modules/group/domain/event/group-deleted.event.ts new file mode 100644 index 00000000000..f83f4646ce8 --- /dev/null +++ b/apps/server/src/modules/group/domain/event/group-deleted.event.ts @@ -0,0 +1,9 @@ +import { Group } from '../group'; + +export class GroupDeletedEvent { + target: Group; + + constructor(target: Group) { + this.target = target; + } +} diff --git a/apps/server/src/modules/group/domain/event/index.ts b/apps/server/src/modules/group/domain/event/index.ts new file mode 100644 index 00000000000..03bb5ece801 --- /dev/null +++ b/apps/server/src/modules/group/domain/event/index.ts @@ -0,0 +1 @@ +export { GroupDeletedEvent } from './group-deleted.event'; diff --git a/apps/server/src/modules/group/domain/group.spec.ts b/apps/server/src/modules/group/domain/group.spec.ts index d11549e71c8..65018e0eb45 100644 --- a/apps/server/src/modules/group/domain/group.spec.ts +++ b/apps/server/src/modules/group/domain/group.spec.ts @@ -1,7 +1,7 @@ import { RoleReference, UserDO } from '@shared/domain/domainobject'; import { groupFactory, roleFactory, userDoFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Group } from './group'; import { GroupUser } from './group-user'; diff --git a/apps/server/src/modules/group/domain/group.ts b/apps/server/src/modules/group/domain/group.ts index 6438b4a3a56..9cf0603a2bd 100644 --- a/apps/server/src/modules/group/domain/group.ts +++ b/apps/server/src/modules/group/domain/group.ts @@ -47,6 +47,14 @@ export class Group extends DomainObject { return this.props.type; } + get validFrom(): Date | undefined { + return this.props.validFrom; + } + + get validUntil(): Date | undefined { + return this.props.validUntil; + } + removeUser(user: UserDO): void { this.props.users = this.props.users.filter((groupUser: GroupUser): boolean => groupUser.userId !== user.id); } @@ -56,7 +64,7 @@ export class Group extends DomainObject { } addUser(user: GroupUser): void { - if (!this.users.find((u) => u.userId === user.userId)) { + if (!this.users.find((u: GroupUser): boolean => u.userId === user.userId)) { this.users.push(user); } } diff --git a/apps/server/src/modules/group/domain/index.ts b/apps/server/src/modules/group/domain/index.ts index f140dc330a6..32b97d8f9f7 100644 --- a/apps/server/src/modules/group/domain/index.ts +++ b/apps/server/src/modules/group/domain/index.ts @@ -1,3 +1,4 @@ export * from './group'; export * from './group-user'; export * from './group-types'; +export { GroupDeletedEvent } from './event'; diff --git a/apps/server/src/modules/group/entity/group.entity.ts b/apps/server/src/modules/group/entity/group.entity.ts index 27d4319042c..29261251463 100644 --- a/apps/server/src/modules/group/entity/group.entity.ts +++ b/apps/server/src/modules/group/entity/group.entity.ts @@ -1,4 +1,5 @@ -import { Embedded, Entity, Enum, ManyToOne, Property } from '@mikro-orm/core'; +import { Collection, Embedded, Entity, Enum, ManyToOne, OneToMany, Property } from '@mikro-orm/core'; +import { Course as CourseEntity } from '@shared/domain/entity'; import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { ExternalSourceEntity } from '@shared/domain/entity/external-source.entity'; import { SchoolEntity } from '@shared/domain/entity/school.entity'; @@ -48,6 +49,9 @@ export class GroupEntity extends BaseEntityWithTimestamps { @ManyToOne(() => SchoolEntity, { nullable: true }) organization?: SchoolEntity; + @OneToMany(() => CourseEntity, (course: CourseEntity) => course.syncedWithGroup) + syncedCourses: Collection = new Collection(this); + constructor(props: GroupEntityProps) { super(); if (props.id) { diff --git a/apps/server/src/modules/group/group-api.module.ts b/apps/server/src/modules/group/group-api.module.ts index 1b3d14645c6..ab6de7f1bd8 100644 --- a/apps/server/src/modules/group/group-api.module.ts +++ b/apps/server/src/modules/group/group-api.module.ts @@ -1,12 +1,13 @@ -import { Module } from '@nestjs/common'; import { AuthorizationModule } from '@modules/authorization'; import { ClassModule } from '@modules/class'; -import { RoleModule } from '@modules/role'; +import { LearnroomModule } from '@modules/learnroom'; import { LegacySchoolModule } from '@modules/legacy-school'; +import { RoleModule } from '@modules/role'; +import { SchoolModule } from '@modules/school'; import { SystemModule } from '@modules/system'; import { UserModule } from '@modules/user'; +import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; -import { SchoolModule } from '@modules/school'; import { GroupController } from './controller'; import { GroupModule } from './group.module'; import { GroupUc } from './uc'; @@ -22,6 +23,7 @@ import { GroupUc } from './uc'; AuthorizationModule, SystemModule, LoggerModule, + LearnroomModule, ], controllers: [GroupController], providers: [GroupUc], diff --git a/apps/server/src/modules/group/group.module.ts b/apps/server/src/modules/group/group.module.ts index 58bce2070d0..fe232eefa2b 100644 --- a/apps/server/src/modules/group/group.module.ts +++ b/apps/server/src/modules/group/group.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; import { GroupRepo } from './repo'; import { GroupService } from './service'; @Module({ + imports: [CqrsModule], providers: [GroupRepo, GroupService], exports: [GroupService], }) diff --git a/apps/server/src/modules/group/repo/group-domain.mapper.ts b/apps/server/src/modules/group/repo/group-domain.mapper.ts index ea9ab7b450c..e400dc54f48 100644 --- a/apps/server/src/modules/group/repo/group-domain.mapper.ts +++ b/apps/server/src/modules/group/repo/group-domain.mapper.ts @@ -1,8 +1,9 @@ +import { EntityData } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; import { ExternalSource } from '@shared/domain/domainobject'; import { ExternalSourceEntity, Role, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; -import { GroupEntity, GroupEntityProps, GroupEntityTypes, GroupUserEntity, GroupValidPeriodEntity } from '../entity'; +import { GroupEntity, GroupEntityTypes, GroupUserEntity, GroupValidPeriodEntity } from '../entity'; const GroupEntityTypesToGroupTypesMapping: Record = { [GroupEntityTypes.CLASS]: GroupTypes.CLASS, @@ -17,7 +18,7 @@ export const GroupTypesToGroupEntityTypesMapping: Record { const props: GroupProps = group.getProps(); let validPeriod: GroupValidPeriodEntity | undefined; @@ -28,8 +29,7 @@ export class GroupDomainMapper { }); } - const mapped: GroupEntityProps = { - id: props.id, + const groupEntityData: EntityData = { name: props.name, type: GroupTypesToGroupEntityTypesMapping[props.type], externalSource: props.externalSource @@ -42,11 +42,11 @@ export class GroupDomainMapper { organization: props.organizationId ? em.getReference(SchoolEntity, props.organizationId) : undefined, }; - return mapped; + return groupEntityData; } - static mapEntityToDomainObjectProperties(entity: GroupEntity): GroupProps { - const mapped: GroupProps = { + static mapEntityToDo(entity: GroupEntity): Group { + const group: Group = new Group({ id: entity.id, users: entity.users.map((groupUser): GroupUser => this.mapGroupUserEntityToGroupUser(groupUser)), validFrom: entity.validPeriod ? entity.validPeriod.from : undefined, @@ -57,47 +57,47 @@ export class GroupDomainMapper { type: GroupEntityTypesToGroupTypesMapping[entity.type], name: entity.name, organizationId: entity.organization?.id, - }; + }); - return mapped; + return group; } static mapExternalSourceToExternalSourceEntity( externalSource: ExternalSource, em: EntityManager ): ExternalSourceEntity { - const mapped = new ExternalSourceEntity({ + const externalSourceEntity: ExternalSourceEntity = new ExternalSourceEntity({ externalId: externalSource.externalId, system: em.getReference(SystemEntity, externalSource.systemId), }); - return mapped; + return externalSourceEntity; } static mapExternalSourceEntityToExternalSource(entity: ExternalSourceEntity): ExternalSource { - const mapped = new ExternalSource({ + const externalSource: ExternalSource = new ExternalSource({ externalId: entity.externalId, systemId: entity.system.id, }); - return mapped; + return externalSource; } static mapGroupUserToGroupUserEntity(groupUser: GroupUser, em: EntityManager): GroupUserEntity { - const mapped = new GroupUserEntity({ + const groupUserEntity: GroupUserEntity = new GroupUserEntity({ user: em.getReference(User, groupUser.userId), role: em.getReference(Role, groupUser.roleId), }); - return mapped; + return groupUserEntity; } static mapGroupUserEntityToGroupUser(entity: GroupUserEntity): GroupUser { - const mapped = new GroupUser({ + const groupUser: GroupUser = new GroupUser({ userId: entity.user.id, roleId: entity.role.id, }); - return mapped; + return groupUser; } } diff --git a/apps/server/src/modules/group/repo/group.repo.spec.ts b/apps/server/src/modules/group/repo/group.repo.spec.ts index e127155fd25..e726c5bdc8d 100644 --- a/apps/server/src/modules/group/repo/group.repo.spec.ts +++ b/apps/server/src/modules/group/repo/group.repo.spec.ts @@ -1,20 +1,23 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { School } from '@modules/school'; +import { SchoolEntityMapper } from '@modules/school/repo/mikro-orm/mapper'; import { Test, TestingModule } from '@nestjs/testing'; -import { ExternalSource, UserDO } from '@shared/domain/domainobject'; -import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { ExternalSource, Page, UserDO } from '@shared/domain/domainobject'; +import { Course as CourseEntity, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; import { cleanupCollections, + courseFactory, groupEntityFactory, groupFactory, roleFactory, - schoolFactory, + schoolEntityFactory, systemEntityFactory, userDoFactory, userFactory, } from '@shared/testing'; import { Group, GroupProps, GroupTypes, GroupUser } from '../domain'; -import { GroupEntity, GroupEntityTypes } from '../entity'; +import { GroupEntity, GroupEntityTypes, GroupUserEntity } from '../entity'; import { GroupRepo } from './group.repo'; describe('GroupRepo', () => { @@ -56,7 +59,7 @@ describe('GroupRepo', () => { it('should return the group', async () => { const { group } = await setup(); - const result: Group | null = await repo.findById(group.id); + const result: Group | null = await repo.findGroupById(group.id); expect(result?.getProps()).toEqual({ id: group.id, @@ -85,7 +88,7 @@ describe('GroupRepo', () => { describe('when no entity with the id exists', () => { it('should return null', async () => { - const result: Group | null = await repo.findById(new ObjectId().toHexString()); + const result: Group | null = await repo.findGroupById(new ObjectId().toHexString()); expect(result).toBeNull(); }); @@ -103,6 +106,8 @@ describe('GroupRepo', () => { groups[1].type = GroupEntityTypes.COURSE; groups[2].type = GroupEntityTypes.OTHER; + const nameQuery = groups[1].name.slice(-3); + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); await em.persistAndFlush([userEntity, ...groups, ...otherGroups]); @@ -111,38 +116,66 @@ describe('GroupRepo', () => { return { user, groups, + nameQuery, }; }; it('should return the groups', async () => { const { user, groups } = await setup(); - const result: Group[] = await repo.findByUserAndGroupTypes(user, [ + const result: Page = await repo.findByUserAndGroupTypes(user, [ GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER, ]); - expect(result.map((group) => group.id).sort((a, b) => a.localeCompare(b))).toEqual( + expect(result.data.map((group) => group.id).sort((a, b) => a.localeCompare(b))).toEqual( groups.map((group) => group.id).sort((a, b) => a.localeCompare(b)) ); }); + it('should return groups according to pagination', async () => { + const { user, groups } = await setup(); + + const result: Page = await repo.findByUserAndGroupTypes( + user, + [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], + { pagination: { skip: 1, limit: 1 } } + ); + + expect(result.total).toEqual(groups.length); + expect(result.data.length).toEqual(1); + expect(result.data[0].id).toEqual(groups[1].id); + }); + + it('should return groups according to name query', async () => { + const { user, groups, nameQuery } = await setup(); + + const result: Page = await repo.findByUserAndGroupTypes( + user, + [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], + { nameQuery } + ); + + expect(result.data.length).toEqual(1); + expect(result.data[0].id).toEqual(groups[1].id); + }); + it('should return only groups of the given group types', async () => { const { user } = await setup(); - const result: Group[] = await repo.findByUserAndGroupTypes(user, [GroupTypes.CLASS]); + const result: Page = await repo.findByUserAndGroupTypes(user, [GroupTypes.CLASS]); - expect(result).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); + expect(result.data).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); }); describe('when no group type is given', () => { it('should return all groups', async () => { const { user, groups } = await setup(); - const result: Group[] = await repo.findByUserAndGroupTypes(user); + const result: Page = await repo.findByUserAndGroupTypes(user); - expect(result.map((group) => group.id).sort((a, b) => a.localeCompare(b))).toEqual( + expect(result.data.map((group) => group.id).sort((a, b) => a.localeCompare(b))).toEqual( groups.map((group) => group.id).sort((a, b) => a.localeCompare(b)) ); }); @@ -167,13 +200,96 @@ describe('GroupRepo', () => { it('should return an empty array', async () => { const { user } = await setup(); - const result: Group[] = await repo.findByUserAndGroupTypes(user, [ + const result: Page = await repo.findByUserAndGroupTypes(user, [ GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER, ]); - expect(result).toHaveLength(0); + expect(result.data).toHaveLength(0); + }); + }); + }); + + describe('findAvailableByUser', () => { + describe('when the user has groups', () => { + const setup = async () => { + const userEntity: User = userFactory.buildWithId(); + const user: UserDO = userDoFactory.build({ id: userEntity.id }); + const groupUserEntity: GroupUserEntity = new GroupUserEntity({ + user: userEntity, + role: roleFactory.buildWithId(), + }); + const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { + users: [groupUserEntity], + }); + const nameQuery = groups[2].name.slice(-3); + const course: CourseEntity = courseFactory.build({ syncedWithGroup: groups[0] }); + const availableGroupsCount = 2; + + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); + + await em.persistAndFlush([userEntity, ...groups, ...otherGroups, course]); + em.clear(); + + return { + user, + groups, + availableGroupsCount, + nameQuery, + }; + }; + + it('should return the available groups', async () => { + const { user, availableGroupsCount } = await setup(); + + const result: Page = await repo.findAvailableByUser(user); + + expect(result.total).toEqual(availableGroupsCount); + expect(result.data.every((group) => group.users[0].userId === user.id)).toEqual(true); + }); + + it('should return groups according to pagination', async () => { + const { user, groups, availableGroupsCount } = await setup(); + + const result: Page = await repo.findAvailableByUser(user, { pagination: { skip: 1, limit: 1 } }); + + expect(result.total).toEqual(availableGroupsCount); + expect(result.data.length).toEqual(1); + expect(result.data[0].id).toEqual(groups[2].id); + }); + + it('should return groups according to name query', async () => { + const { user, groups, nameQuery } = await setup(); + + const result: Page = await repo.findAvailableByUser(user, { nameQuery }); + + expect(result.data.length).toEqual(1); + expect(result.data[0].id).toEqual(groups[2].id); + }); + }); + + describe('when the user has no groups exists', () => { + const setup = async () => { + const userEntity: User = userFactory.buildWithId(); + const user: UserDO = userDoFactory.build({ id: userEntity.id }); + + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2); + + await em.persistAndFlush([userEntity, ...otherGroups]); + em.clear(); + + return { + user, + }; + }; + + it('should return an empty array', async () => { + const { user } = await setup(); + + const result: Page = await repo.findAvailableByUser(user); + + expect(result.total).toEqual(0); }); }); }); @@ -181,7 +297,7 @@ describe('GroupRepo', () => { describe('findBySchoolIdAndGroupTypes', () => { describe('when groups for the school exist', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { type: GroupEntityTypes.CLASS, organization: school, @@ -189,82 +305,199 @@ describe('GroupRepo', () => { groups[1].type = GroupEntityTypes.COURSE; groups[2].type = GroupEntityTypes.OTHER; - const otherSchool: SchoolEntity = schoolFactory.buildWithId(); + const nameQuery = groups[1].name.slice(-3); + + const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId(); const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2, { type: GroupEntityTypes.CLASS, organization: otherSchool, }); + const schoolDO: School = SchoolEntityMapper.mapToDo(school); + await em.persistAndFlush([school, ...groups, otherSchool, ...otherGroups]); em.clear(); return { - school, otherSchool, groups, + nameQuery, + schoolDO, }; }; it('should return the groups', async () => { - const { school, groups } = await setup(); + const { schoolDO, groups } = await setup(); - const result: Group[] = await repo.findBySchoolIdAndGroupTypes(school.id, [ + const result: Page = await repo.findBySchoolIdAndGroupTypes(schoolDO, [ GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER, ]); - expect(result).toHaveLength(groups.length); + expect(result.data).toHaveLength(groups.length); }); it('should not return groups from another school', async () => { - const { school, otherSchool } = await setup(); + const { schoolDO, otherSchool } = await setup(); - const result: Group[] = await repo.findBySchoolIdAndGroupTypes(school.id, [ + const result: Page = await repo.findBySchoolIdAndGroupTypes(schoolDO, [ GroupTypes.CLASS, GroupTypes.COURSE, ]); - expect(result.map((group) => group.organizationId)).not.toContain(otherSchool.id); + expect(result.data.map((group) => group.organizationId)).not.toContain(otherSchool.id); + }); + + it('should return groups according to pagination', async () => { + const { schoolDO, groups } = await setup(); + + const result: Page = await repo.findBySchoolIdAndGroupTypes( + schoolDO, + [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], + { pagination: { skip: 1, limit: 1 } } + ); + + expect(result.total).toEqual(groups.length); + expect(result.data.length).toEqual(1); + expect(result.data[0].id).toEqual(groups[1].id); + }); + + it('should return groups according to name query', async () => { + const { schoolDO, groups, nameQuery } = await setup(); + + const result: Page = await repo.findBySchoolIdAndGroupTypes( + schoolDO, + [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], + { nameQuery } + ); + + expect(result.data.length).toEqual(1); + expect(result.data[0].id).toEqual(groups[1].id); }); it('should return only groups of the given group types', async () => { - const { school } = await setup(); + const { schoolDO } = await setup(); - const result: Group[] = await repo.findBySchoolIdAndGroupTypes(school.id, [GroupTypes.CLASS]); + const result: Page = await repo.findBySchoolIdAndGroupTypes(schoolDO, [GroupTypes.CLASS]); - expect(result).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); + expect(result.data).toEqual([expect.objectContaining>({ type: GroupTypes.CLASS })]); }); describe('when no group type is given', () => { it('should return all groups', async () => { - const { school, groups } = await setup(); + const { schoolDO, groups } = await setup(); - const result: Group[] = await repo.findBySchoolIdAndGroupTypes(school.id); + const result: Page = await repo.findBySchoolIdAndGroupTypes(schoolDO); - expect(result).toHaveLength(groups.length); + expect(result.data).toHaveLength(groups.length); }); }); }); describe('when no group exists', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const schoolDO: School = SchoolEntityMapper.mapToDo(school); await em.persistAndFlush(school); em.clear(); return { - school, + schoolDO, }; }; it('should return an empty array', async () => { - const { school } = await setup(); + const { schoolDO } = await setup(); - const result: Group[] = await repo.findBySchoolIdAndGroupTypes(school.id, [GroupTypes.CLASS]); + const result: Page = await repo.findBySchoolIdAndGroupTypes(schoolDO, [GroupTypes.CLASS]); - expect(result).toHaveLength(0); + expect(result.data).toHaveLength(0); + }); + }); + }); + + describe('findAvailableBySchoolId', () => { + describe('when available groups for the school exist', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { + type: GroupEntityTypes.CLASS, + organization: school, + }); + const nameQuery = groups[2].name.slice(-3); + const course: CourseEntity = courseFactory.build({ school, syncedWithGroup: groups[0] }); + const availableGroupsCount = 2; + + const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId(); + const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2, { + type: GroupEntityTypes.CLASS, + organization: otherSchool, + }); + + const schoolDO: School = SchoolEntityMapper.mapToDo(school); + + await em.persistAndFlush([school, ...groups, otherSchool, ...otherGroups, course]); + em.clear(); + + return { + schoolDO, + otherSchool, + groups, + availableGroupsCount, + nameQuery, + }; + }; + + it('should return the available groups from selected school', async () => { + const { schoolDO, availableGroupsCount } = await setup(); + + const result: Page = await repo.findAvailableBySchoolId(schoolDO); + + expect(result.data).toHaveLength(availableGroupsCount); + expect(result.data.every((group) => group.organizationId === schoolDO.id)).toEqual(true); + }); + + it('should return groups according to pagination', async () => { + const { schoolDO, groups, availableGroupsCount } = await setup(); + + const result: Page = await repo.findAvailableBySchoolId(schoolDO, { pagination: { skip: 1, limit: 1 } }); + + expect(result.total).toEqual(availableGroupsCount); + expect(result.data.length).toEqual(1); + expect(result.data[0].id).toEqual(groups[2].id); + }); + + it('should return groups according to name query', async () => { + const { schoolDO, groups, nameQuery } = await setup(); + + const result: Page = await repo.findAvailableBySchoolId(schoolDO, { nameQuery }); + + expect(result.data.length).toEqual(1); + expect(result.data[0].id).toEqual(groups[2].id); + }); + }); + + describe('when no group exists', () => { + const setup = async () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const schoolDO: School = SchoolEntityMapper.mapToDo(school); + + await em.persistAndFlush([school]); + em.clear(); + + return { + schoolDO, + }; + }; + + it('should return an empty array', async () => { + const { schoolDO } = await setup(); + + const result: Page = await repo.findAvailableBySchoolId(schoolDO); + + expect(result.total).toEqual(0); }); }); }); @@ -273,7 +506,7 @@ describe('GroupRepo', () => { describe('when groups for the school exist', () => { const setup = async () => { const system: SystemEntity = systemEntityFactory.buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId({ systems: [system] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [system] }); const groups: GroupEntity[] = groupEntityFactory.buildListWithId(3, { type: GroupEntityTypes.CLASS, organization: school, @@ -284,7 +517,7 @@ describe('GroupRepo', () => { groups[1].type = GroupEntityTypes.COURSE; groups[2].type = GroupEntityTypes.OTHER; - const otherSchool: SchoolEntity = schoolFactory.buildWithId({ systems: [system] }); + const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [system] }); const otherGroups: GroupEntity[] = groupEntityFactory.buildListWithId(2, { type: GroupEntityTypes.CLASS, organization: otherSchool, @@ -352,7 +585,7 @@ describe('GroupRepo', () => { describe('when no group exists', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const system: SystemEntity = systemEntityFactory.buildWithId(); await em.persistAndFlush([school, system]); @@ -471,32 +704,6 @@ describe('GroupRepo', () => { expect(await em.findOne(GroupEntity, groupId)).toBeNull(); }); - - it('should return true', async () => { - const { group } = await setup(); - - const result: boolean = await repo.delete(group); - - expect(result).toEqual(true); - }); - }); - - describe('when no entity exists', () => { - const setup = () => { - const group: Group = groupFactory.build(); - - return { - group, - }; - }; - - it('should return false', async () => { - const { group } = setup(); - - const result: boolean = await repo.delete(group); - - expect(result).toEqual(false); - }); }); }); diff --git a/apps/server/src/modules/group/repo/group.repo.ts b/apps/server/src/modules/group/repo/group.repo.ts index 768a6f3f733..f10321eb5b5 100644 --- a/apps/server/src/modules/group/repo/group.repo.ts +++ b/apps/server/src/modules/group/repo/group.repo.ts @@ -1,27 +1,38 @@ -import { EntityManager } from '@mikro-orm/mongodb'; +import { EntityData, EntityName, QueryOrder } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { School } from '@modules/school'; import { Injectable } from '@nestjs/common'; -import { type UserDO } from '@shared/domain/domainobject'; +import { StringValidator } from '@shared/common'; +import { Page, type UserDO } from '@shared/domain/domainobject'; +import { IFindQuery } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { Scope } from '@shared/repo'; -import { Group, GroupProps, GroupTypes } from '../domain'; -import { GroupEntity, GroupEntityProps, GroupEntityTypes } from '../entity'; +import { MongoPatterns } from '@shared/repo'; +import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; +import { Group, GroupTypes } from '../domain'; +import { GroupEntity, GroupEntityTypes } from '../entity'; import { GroupDomainMapper, GroupTypesToGroupEntityTypesMapping } from './group-domain.mapper'; import { GroupScope } from './group.scope'; @Injectable() -export class GroupRepo { - constructor(private readonly em: EntityManager) {} +export class GroupRepo extends BaseDomainObjectRepo { + protected get entityName(): EntityName { + return GroupEntity; + } + + protected mapDOToEntityProperties(entityDO: Group): EntityData { + const entityProps: EntityData = GroupDomainMapper.mapDoToEntityData(entityDO, this.em); - public async findById(id: EntityId): Promise { + return entityProps; + } + + public async findGroupById(id: EntityId): Promise { const entity: GroupEntity | null = await this.em.findOne(GroupEntity, { id }); if (!entity) { return null; } - const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); - - const domainObject: Group = new Group(props); + const domainObject: Group = GroupDomainMapper.mapEntityToDo(entity); return domainObject; } @@ -38,49 +49,92 @@ export class GroupRepo { return null; } - const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); - - const domainObject: Group = new Group(props); + const domainObject: Group = GroupDomainMapper.mapEntityToDo(entity); return domainObject; } - public async findByUserAndGroupTypes(user: UserDO, groupTypes?: GroupTypes[]): Promise { - let groupEntityTypes: GroupEntityTypes[] | undefined; + public async findByUserAndGroupTypes( + user: UserDO, + groupTypes?: GroupTypes[], + query?: IFindQuery + ): Promise> { + const scope: GroupScope = new GroupScope().byUserId(user.id); if (groupTypes) { - groupEntityTypes = groupTypes.map((type: GroupTypes) => GroupTypesToGroupEntityTypesMapping[type]); + const groupEntityTypes = groupTypes.map((type: GroupTypes) => GroupTypesToGroupEntityTypesMapping[type]); + scope.byTypes(groupEntityTypes); } - const scope: Scope = new GroupScope().byUserId(user.id).byTypes(groupEntityTypes); + const escapedName = query?.nameQuery?.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim(); + if (StringValidator.isNotEmptyString(escapedName, true)) { + scope.byNameQuery(escapedName); + } - const entities: GroupEntity[] = await this.em.find(GroupEntity, scope.query); + const [entities, total] = await this.em.findAndCount(GroupEntity, scope.query, { + offset: query?.pagination?.skip, + limit: query?.pagination?.limit, + orderBy: { name: QueryOrder.ASC }, + }); - const domainObjects: Group[] = entities.map((entity) => { - const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); + const domainObjects: Group[] = entities.map((entity) => GroupDomainMapper.mapEntityToDo(entity)); - return new Group(props); - }); + const page: Page = new Page(domainObjects, total); - return domainObjects; + return page; + } + + public async findAvailableByUser(user: UserDO, query?: IFindQuery): Promise> { + const pipelineStage: unknown[] = [{ $match: { users: { $elemMatch: { user: new ObjectId(user.id) } } } }]; + const availableGroups: Page = await this.findAvailableGroup( + pipelineStage, + query?.pagination?.skip, + query?.pagination?.limit, + query?.nameQuery + ); + + return availableGroups; } - public async findBySchoolIdAndGroupTypes(schoolId: EntityId, groupTypes?: GroupTypes[]): Promise { - let groupEntityTypes: GroupEntityTypes[] | undefined; + public async findBySchoolIdAndGroupTypes( + school: School, + groupTypes?: GroupTypes[], + query?: IFindQuery + ): Promise> { + const scope: GroupScope = new GroupScope().byOrganizationId(school.id); if (groupTypes) { - groupEntityTypes = groupTypes.map((type: GroupTypes) => GroupTypesToGroupEntityTypesMapping[type]); + const groupEntityTypes = groupTypes.map((type: GroupTypes) => GroupTypesToGroupEntityTypesMapping[type]); + scope.byTypes(groupEntityTypes); } - const scope: Scope = new GroupScope().byOrganizationId(schoolId).byTypes(groupEntityTypes); + const escapedName = query?.nameQuery?.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim(); + if (StringValidator.isNotEmptyString(escapedName, true)) { + scope.byNameQuery(escapedName); + } - const entities: GroupEntity[] = await this.em.find(GroupEntity, scope.query); + const [entities, total] = await this.em.findAndCount(GroupEntity, scope.query, { + offset: query?.pagination?.skip, + limit: query?.pagination?.limit, + orderBy: { name: QueryOrder.ASC }, + }); - const domainObjects: Group[] = entities.map((entity) => { - const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); + const domainObjects: Group[] = entities.map((entity) => GroupDomainMapper.mapEntityToDo(entity)); - return new Group(props); - }); + const page: Page = new Page(domainObjects, total); - return domainObjects; + return page; + } + + public async findAvailableBySchoolId(school: School, query?: IFindQuery): Promise> { + const pipelineStage: unknown[] = [{ $match: { organization: new ObjectId(school.id) } }]; + + const availableGroups: Page = await this.findAvailableGroup( + pipelineStage, + query?.pagination?.skip, + query?.pagination?.limit, + query?.nameQuery + ); + + return availableGroups; } public async findGroupsBySchoolIdAndSystemIdAndGroupType( @@ -90,56 +144,76 @@ export class GroupRepo { ): Promise { const groupEntityType: GroupEntityTypes = GroupTypesToGroupEntityTypesMapping[groupType]; - const scope: Scope = new GroupScope() + const scope: GroupScope = new GroupScope() .byOrganizationId(schoolId) .bySystemId(systemId) .byTypes([groupEntityType]); const entities: GroupEntity[] = await this.em.find(GroupEntity, scope.query); - const domainObjects: Group[] = entities.map((entity) => { - const props: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(entity); - - return new Group(props); - }); + const domainObjects: Group[] = entities.map((entity) => GroupDomainMapper.mapEntityToDo(entity)); return domainObjects; } - public async save(domainObject: Group): Promise { - const entityProps: GroupEntityProps = GroupDomainMapper.mapDomainObjectToEntityProperties(domainObject, this.em); - - const newEntity: GroupEntity = new GroupEntity(entityProps); - - const existingEntity: GroupEntity | null = await this.em.findOne(GroupEntity, { id: domainObject.id }); + private async findAvailableGroup( + pipelineStage: unknown[], + skip = 0, + limit?: number, + nameQuery?: string + ): Promise> { + let nameRegexFilter = {}; + const pipeline: unknown[] = pipelineStage; + + const escapedName = nameQuery?.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim(); + if (StringValidator.isNotEmptyString(escapedName, true)) { + nameRegexFilter = { name: { $regex: escapedName, $options: 'i' } }; + } - let savedEntity: GroupEntity; - if (existingEntity) { - savedEntity = this.em.assign(existingEntity, newEntity); + pipeline.push( + { $match: nameRegexFilter }, + { + $lookup: { + from: 'courses', + localField: '_id', + foreignField: 'syncedWithGroup', + as: 'syncedCourses', + }, + }, + { $match: { syncedCourses: { $size: 0 } } }, + { $sort: { name: 1 } } + ); + + if (limit) { + pipeline.push({ + $facet: { + total: [{ $count: 'count' }], + data: [{ $skip: skip }, { $limit: limit }], + }, + }); } else { - this.em.persist(newEntity); - - savedEntity = newEntity; + pipeline.push({ + $facet: { + total: [{ $count: 'count' }], + data: [{ $skip: skip }], + }, + }); } - await this.em.flush(); - - const savedProps: GroupProps = GroupDomainMapper.mapEntityToDomainObjectProperties(savedEntity); + const mongoEntitiesFacet = (await this.em.aggregate(GroupEntity, pipeline)) as [ + { total: [{ count: number }]; data: GroupEntity[] } + ]; - const savedDomainObject: Group = new Group(savedProps); + const total: number = mongoEntitiesFacet[0]?.total[0]?.count ?? 0; - return savedDomainObject; - } - - public async delete(domainObject: Group): Promise { - const entity: GroupEntity | null = await this.em.findOne(GroupEntity, { id: domainObject.id }); + const entities: GroupEntity[] = mongoEntitiesFacet[0].data.map((entity: GroupEntity) => + this.em.map(GroupEntity, entity) + ); - if (!entity) { - return false; - } + const domainObjects: Group[] = entities.map((entity) => GroupDomainMapper.mapEntityToDo(entity)); - await this.em.removeAndFlush(entity); + const page: Page = new Page(domainObjects, total); - return true; + return page; } } diff --git a/apps/server/src/modules/group/repo/group.scope.ts b/apps/server/src/modules/group/repo/group.scope.ts index e7e0a5f7b3d..12e3ba735e7 100644 --- a/apps/server/src/modules/group/repo/group.scope.ts +++ b/apps/server/src/modules/group/repo/group.scope.ts @@ -1,6 +1,6 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { EntityId } from '@shared/domain/types'; -import { Scope } from '@shared/repo'; +import { MongoPatterns, Scope } from '@shared/repo'; import { GroupEntity, GroupEntityTypes } from '../entity'; export class GroupScope extends Scope { @@ -31,4 +31,12 @@ export class GroupScope extends Scope { } return this; } + + byNameQuery(nameQuery: string | undefined): this { + if (nameQuery) { + const escapedName = nameQuery.replace(MongoPatterns.REGEX_MONGO_LANGUAGE_PATTERN_WHITELIST, '').trim(); + this.addQuery({ name: new RegExp(escapedName, 'i') }); + } + return this; + } } diff --git a/apps/server/src/modules/group/service/group.service.spec.ts b/apps/server/src/modules/group/service/group.service.spec.ts index 3acc7dab5ca..0ee2c08ba00 100644 --- a/apps/server/src/modules/group/service/group.service.spec.ts +++ b/apps/server/src/modules/group/service/group.service.spec.ts @@ -1,10 +1,13 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; +import { EventBus } from '@nestjs/cqrs'; +import { School } from '@modules/school'; +import { schoolFactory } from '@modules/school/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { UserDO } from '@shared/domain/domainobject'; +import { Page, UserDO } from '@shared/domain/domainobject'; import { groupFactory, userDoFactory } from '@shared/testing'; -import { Group, GroupTypes } from '../domain'; +import { Group, GroupDeletedEvent, GroupTypes } from '../domain'; import { GroupRepo } from '../repo'; import { GroupService } from './group.service'; @@ -13,6 +16,7 @@ describe('GroupService', () => { let service: GroupService; let groupRepo: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -22,11 +26,16 @@ describe('GroupService', () => { provide: GroupRepo, useValue: createMock(), }, + { + provide: EventBus, + useValue: createMock(), + }, ], }).compile(); service = module.get(GroupService); groupRepo = module.get(GroupRepo); + eventBus = module.get(EventBus); }); afterAll(async () => { @@ -42,7 +51,7 @@ describe('GroupService', () => { const setup = () => { const group: Group = groupFactory.build(); - groupRepo.findById.mockResolvedValue(group); + groupRepo.findGroupById.mockResolvedValue(group); return { group, @@ -62,7 +71,7 @@ describe('GroupService', () => { const setup = () => { const group: Group = groupFactory.build(); - groupRepo.findById.mockResolvedValue(null); + groupRepo.findGroupById.mockResolvedValue(null); return { group, @@ -84,7 +93,7 @@ describe('GroupService', () => { const setup = () => { const group: Group = groupFactory.build(); - groupRepo.findById.mockResolvedValue(group); + groupRepo.findGroupById.mockResolvedValue(group); return { group, @@ -104,7 +113,7 @@ describe('GroupService', () => { const setup = () => { const group: Group = groupFactory.build(); - groupRepo.findById.mockResolvedValue(null); + groupRepo.findGroupById.mockResolvedValue(null); return { group, @@ -126,8 +135,9 @@ describe('GroupService', () => { const setup = () => { const user: UserDO = userDoFactory.buildWithId(); const groups: Group[] = groupFactory.buildList(2); + const page: Page = new Page(groups, groups.length); - groupRepo.findByUserAndGroupTypes.mockResolvedValue(groups); + groupRepo.findByUserAndGroupTypes.mockResolvedValue(page); return { user, @@ -138,9 +148,9 @@ describe('GroupService', () => { it('should return the groups', async () => { const { user, groups } = setup(); - const result: Group[] = await service.findGroupsByUserAndGroupTypes(user, [GroupTypes.CLASS]); + const result: Page = await service.findGroupsByUserAndGroupTypes(user, [GroupTypes.CLASS]); - expect(result).toEqual(groups); + expect(result.data).toEqual(groups); }); it('should call the repo with given group types', async () => { @@ -148,11 +158,63 @@ describe('GroupService', () => { await service.findGroupsByUserAndGroupTypes(user, [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER]); - expect(groupRepo.findByUserAndGroupTypes).toHaveBeenCalledWith(user, [ - GroupTypes.CLASS, - GroupTypes.COURSE, - GroupTypes.OTHER, - ]); + expect(groupRepo.findByUserAndGroupTypes).toHaveBeenCalledWith( + user, + [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], + undefined + ); + }); + }); + + describe('when no groups with the user exists', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + + groupRepo.findByUserAndGroupTypes.mockResolvedValue(new Page([], 0)); + + return { + user, + }; + }; + + it('should return empty array', async () => { + const { user } = setup(); + + const result: Page = await service.findGroupsByUserAndGroupTypes(user, [GroupTypes.CLASS]); + + expect(result.data).toEqual([]); + }); + }); + }); + + describe('findAvailableGroupByUser', () => { + describe('when available groups exist for user', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + const groups: Group[] = groupFactory.buildList(2); + + groupRepo.findAvailableByUser.mockResolvedValue(new Page([groups[1]], 1)); + + return { + user, + groups, + }; + }; + + it('should call repo', async () => { + const { user } = setup(); + + await service.findAvailableGroupsByUser(user); + + expect(groupRepo.findAvailableByUser).toHaveBeenCalledWith(user, undefined); + }); + + it('should return groups', async () => { + const { user, groups } = setup(); + + const result: Page = await service.findAvailableGroupsByUser(user); + + expect(result.data).toEqual([groups[1]]); }); }); @@ -160,7 +222,7 @@ describe('GroupService', () => { const setup = () => { const user: UserDO = userDoFactory.buildWithId(); - groupRepo.findByUserAndGroupTypes.mockResolvedValue([]); + groupRepo.findAvailableByUser.mockResolvedValue(new Page([], 0)); return { user, @@ -170,9 +232,9 @@ describe('GroupService', () => { it('should return empty array', async () => { const { user } = setup(); - const result: Group[] = await service.findGroupsByUserAndGroupTypes(user, [GroupTypes.CLASS]); + const result: Page = await service.findAvailableGroupsByUser(user); - expect(result).toEqual([]); + expect(result.data).toEqual([]); }); }); }); @@ -180,39 +242,92 @@ describe('GroupService', () => { describe('findGroupsBySchoolIdAndGroupTypes', () => { describe('when the school has groups of type class', () => { const setup = () => { - const schoolId: string = new ObjectId().toHexString(); + const school: School = schoolFactory.build(); const groups: Group[] = groupFactory.buildList(3); + const page: Page = new Page(groups, groups.length); - groupRepo.findBySchoolIdAndGroupTypes.mockResolvedValue(groups); + groupRepo.findBySchoolIdAndGroupTypes.mockResolvedValue(page); return { - schoolId, + school, groups, }; }; it('should call the repo', async () => { - const { schoolId } = setup(); + const { school } = setup(); - await service.findGroupsBySchoolIdAndGroupTypes(schoolId, [ + await service.findGroupsBySchoolIdAndGroupTypes(school, [ GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER, ]); - expect(groupRepo.findBySchoolIdAndGroupTypes).toHaveBeenCalledWith(schoolId, [ - GroupTypes.CLASS, - GroupTypes.COURSE, - GroupTypes.OTHER, - ]); + expect(groupRepo.findBySchoolIdAndGroupTypes).toHaveBeenCalledWith( + school, + [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], + undefined + ); }); it('should return the groups', async () => { - const { schoolId, groups } = setup(); + const { school, groups } = setup(); - const result: Group[] = await service.findGroupsBySchoolIdAndGroupTypes(schoolId, [GroupTypes.CLASS]); + const result: Page = await service.findGroupsBySchoolIdAndGroupTypes(school, [GroupTypes.CLASS]); - expect(result).toEqual(groups); + expect(result.data).toEqual(groups); + }); + }); + }); + + describe('findAvailableGroupBySchoolId', () => { + describe('when available groups exist for school', () => { + const setup = () => { + const school: School = schoolFactory.build(); + const groups: Group[] = groupFactory.buildList(2); + + groupRepo.findAvailableBySchoolId.mockResolvedValue(new Page([groups[1]], 1)); + + return { + school, + groups, + }; + }; + + it('should call repo', async () => { + const { school } = setup(); + + await service.findAvailableGroupsBySchoolId(school); + + expect(groupRepo.findAvailableBySchoolId).toHaveBeenCalledWith(school, undefined); + }); + + it('should return groups', async () => { + const { school, groups } = setup(); + + const result: Page = await service.findAvailableGroupsBySchoolId(school); + + expect(result.data).toEqual([groups[1]]); + }); + }); + + describe('when no groups with the user exists', () => { + const setup = () => { + const school: School = schoolFactory.build(); + + groupRepo.findAvailableBySchoolId.mockResolvedValue(new Page([], 0)); + + return { + school, + }; + }; + + it('should return empty array', async () => { + const { school } = setup(); + + const result: Page = await service.findAvailableGroupsBySchoolId(school); + + expect(result.data).toEqual([]); }); }); }); @@ -290,7 +405,7 @@ describe('GroupService', () => { }); describe('delete', () => { - describe('when saving a group', () => { + describe('when deleting a group', () => { const setup = () => { const group: Group = groupFactory.build(); @@ -306,6 +421,14 @@ describe('GroupService', () => { expect(groupRepo.delete).toHaveBeenCalledWith(group); }); + + it('should send an event', async () => { + const { group } = setup(); + + await service.delete(group); + + expect(eventBus.publish).toHaveBeenCalledWith(new GroupDeletedEvent(group)); + }); }); }); diff --git a/apps/server/src/modules/group/service/group.service.ts b/apps/server/src/modules/group/service/group.service.ts index 5dfd41256bb..a91db81b6f1 100644 --- a/apps/server/src/modules/group/service/group.service.ts +++ b/apps/server/src/modules/group/service/group.service.ts @@ -1,17 +1,20 @@ import { AuthorizationLoaderServiceGeneric } from '@modules/authorization'; +import { School } from '@modules/school'; import { Injectable } from '@nestjs/common'; +import { EventBus } from '@nestjs/cqrs'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { type UserDO } from '@shared/domain/domainobject'; +import { Page, type UserDO } from '@shared/domain/domainobject'; +import { IFindQuery } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { Group, GroupTypes } from '../domain'; +import { Group, GroupDeletedEvent, GroupTypes } from '../domain'; import { GroupRepo } from '../repo'; @Injectable() export class GroupService implements AuthorizationLoaderServiceGeneric { - constructor(private readonly groupRepo: GroupRepo) {} + constructor(private readonly groupRepo: GroupRepo, private readonly eventBus: EventBus) {} public async findById(id: EntityId): Promise { - const group: Group | null = await this.groupRepo.findById(id); + const group: Group | null = await this.groupRepo.findGroupById(id); if (!group) { throw new NotFoundLoggableException(Group.name, { id }); @@ -21,7 +24,7 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { } public async tryFindById(id: EntityId): Promise { - const group: Group | null = await this.groupRepo.findById(id); + const group: Group | null = await this.groupRepo.findGroupById(id); return group; } @@ -32,18 +35,38 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { return group; } - public async findGroupsByUserAndGroupTypes(user: UserDO, groupTypes?: GroupTypes[]): Promise { - const groups: Group[] = await this.groupRepo.findByUserAndGroupTypes(user, groupTypes); + public async findGroupsByUserAndGroupTypes( + user: UserDO, + groupTypes?: GroupTypes[], + query?: IFindQuery + ): Promise> { + const groups: Page = await this.groupRepo.findByUserAndGroupTypes(user, groupTypes, query); return groups; } - public async findGroupsBySchoolIdAndGroupTypes(schoolId: EntityId, groupTypes: GroupTypes[]): Promise { - const group: Group[] = await this.groupRepo.findBySchoolIdAndGroupTypes(schoolId, groupTypes); + public async findAvailableGroupsByUser(user: UserDO, query?: IFindQuery): Promise> { + const groups: Page = await this.groupRepo.findAvailableByUser(user, query); + + return groups; + } + + public async findGroupsBySchoolIdAndGroupTypes( + school: School, + groupTypes?: GroupTypes[], + query?: IFindQuery + ): Promise> { + const group: Page = await this.groupRepo.findBySchoolIdAndGroupTypes(school, groupTypes, query); return group; } + public async findAvailableGroupsBySchoolId(school: School, query?: IFindQuery): Promise> { + const groups: Page = await this.groupRepo.findAvailableBySchoolId(school, query); + + return groups; + } + public async findGroupsBySchoolIdAndSystemIdAndGroupType( schoolId: EntityId, systemId: EntityId, @@ -66,5 +89,7 @@ export class GroupService implements AuthorizationLoaderServiceGeneric { public async delete(group: Group): Promise { await this.groupRepo.delete(group); + + await this.eventBus.publish(new GroupDeletedEvent(group)); } } diff --git a/apps/server/src/modules/group/uc/dto/class-info.dto.ts b/apps/server/src/modules/group/uc/dto/class-info.dto.ts index c17689fe0fa..0fc601325d7 100644 --- a/apps/server/src/modules/group/uc/dto/class-info.dto.ts +++ b/apps/server/src/modules/group/uc/dto/class-info.dto.ts @@ -1,7 +1,9 @@ +import { EntityId } from '@shared/domain/types'; import { ClassRootType } from './class-root-type'; +import { CourseInfoDto } from './course-info.dto'; export class ClassInfoDto { - id: string; + id: EntityId; type: ClassRootType; @@ -17,6 +19,8 @@ export class ClassInfoDto { studentCount: number; + synchronizedCourses?: CourseInfoDto[]; + constructor(props: ClassInfoDto) { this.id = props.id; this.type = props.type; @@ -26,5 +30,6 @@ export class ClassInfoDto { this.schoolYear = props.schoolYear; this.isUpgradable = props.isUpgradable; this.studentCount = props.studentCount; + this.synchronizedCourses = props.synchronizedCourses; } } diff --git a/apps/server/src/modules/group/uc/dto/course-info.dto.ts b/apps/server/src/modules/group/uc/dto/course-info.dto.ts new file mode 100644 index 00000000000..3723fd95246 --- /dev/null +++ b/apps/server/src/modules/group/uc/dto/course-info.dto.ts @@ -0,0 +1,12 @@ +import { EntityId } from '@shared/domain/types'; + +export class CourseInfoDto { + id: EntityId; + + name: string; + + constructor(props: CourseInfoDto) { + this.id = props.id; + this.name = props.name; + } +} diff --git a/apps/server/src/modules/group/uc/dto/index.ts b/apps/server/src/modules/group/uc/dto/index.ts index d795f1c30d3..ba2856b8720 100644 --- a/apps/server/src/modules/group/uc/dto/index.ts +++ b/apps/server/src/modules/group/uc/dto/index.ts @@ -1,3 +1,4 @@ export * from './class-info.dto'; export * from './resolved-group-user'; export * from './resolved-group.dto'; +export { CourseInfoDto } from './course-info.dto'; diff --git a/apps/server/src/modules/group/uc/group.uc.spec.ts b/apps/server/src/modules/group/uc/group.uc.spec.ts index 3a53d6d2f80..6c4285122eb 100644 --- a/apps/server/src/modules/group/uc/group.uc.spec.ts +++ b/apps/server/src/modules/group/uc/group.uc.spec.ts @@ -4,22 +4,28 @@ import { Action, AuthorizationContext, AuthorizationService } from '@modules/aut import { ClassService } from '@modules/class'; import { Class } from '@modules/class/domain'; import { classFactory } from '@modules/class/domain/testing/factory/class.factory'; +import { Course } from '@modules/learnroom/domain'; +import { CourseDoService } from '@modules/learnroom/service/course-do.service'; +import { courseFactory } from '@modules/learnroom/testing'; import { SchoolYearService } from '@modules/legacy-school'; +import { ProvisioningConfig } from '@modules/provisioning'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; +import { School, SchoolService } from '@modules/school/domain'; +import { schoolFactory } from '@modules/school/testing'; import { LegacySystemService, SystemDto } from '@modules/system'; import { UserService } from '@modules/user'; import { ForbiddenException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { ReferencedEntityNotFoundLoggable } from '@shared/common/loggable'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { Page, UserDO } from '@shared/domain/domainobject'; -import { SchoolYearEntity, User } from '@shared/domain/entity'; -import { Permission, SortOrder } from '@shared/domain/interface'; -import { EntityId } from '@shared/domain/types'; +import { Role, SchoolYearEntity, User } from '@shared/domain/entity'; +import { IFindQuery, Permission, SortOrder } from '@shared/domain/interface'; import { groupFactory, roleDtoFactory, + roleFactory, schoolYearFactory, setupEntities, UserAndAccountTestFactory, @@ -27,8 +33,6 @@ import { userFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { School, SchoolService } from '@modules/school/domain'; -import { schoolFactory } from '@modules/school/testing'; import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; import { Group, GroupTypes } from '../domain'; import { UnknownQueryTypeLoggableException } from '../loggable'; @@ -49,6 +53,9 @@ describe('GroupUc', () => { let schoolService: DeepMocked; let authorizationService: DeepMocked; let schoolYearService: DeepMocked; + let courseService: DeepMocked; + let configService: DeepMocked>; + // eslint-disable-next-line @typescript-eslint/no-unused-vars let logger: DeepMocked; beforeAll(async () => { @@ -87,6 +94,14 @@ describe('GroupUc', () => { provide: SchoolYearService, useValue: createMock(), }, + { + provide: CourseDoService, + useValue: createMock(), + }, + { + provide: ConfigService, + useValue: createMock(), + }, { provide: Logger, useValue: createMock(), @@ -103,6 +118,8 @@ describe('GroupUc', () => { schoolService = module.get(SchoolService); authorizationService = module.get(AuthorizationService); schoolYearService = module.get(SchoolYearService); + courseService = module.get(CourseDoService); + configService = module.get(ConfigService); logger = module.get(Logger); await setupEntities(); @@ -207,14 +224,17 @@ describe('GroupUc', () => { { userId: studentUser.id, roleId: studentUser.roles[0].id }, ], }); + const synchronizedCourse: Course = courseFactory.build({ syncedWithGroup: group.id }); schoolService.getSchoolById.mockResolvedValueOnce(school); authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); authorizationService.hasAllPermissions.mockReturnValueOnce(false); classService.findAllByUserId.mockResolvedValueOnce([clazz, successorClass, classWithoutSchoolYear]); - groupService.findGroupsByUserAndGroupTypes.mockResolvedValueOnce([group, groupWithSystem]); + groupService.findGroupsByUserAndGroupTypes.mockResolvedValueOnce(new Page([group, groupWithSystem], 2)); classService.findClassesForSchool.mockResolvedValueOnce([clazz, successorClass, classWithoutSchoolYear]); - groupService.findGroupsBySchoolIdAndGroupTypes.mockResolvedValueOnce([group, groupWithSystem]); + groupService.findGroupsBySchoolIdAndGroupTypes.mockResolvedValueOnce( + new Page([group, groupWithSystem], 2) + ); systemService.findById.mockResolvedValue(system); userService.findById.mockImplementation((userId: string): Promise => { if (userId === teacherUser.id) { @@ -252,6 +272,9 @@ describe('GroupUc', () => { schoolYearService.findById.mockResolvedValueOnce(schoolYear); schoolYearService.findById.mockResolvedValueOnce(nextSchoolYear); schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + configService.get.mockReturnValueOnce(true); + courseService.findBySyncedGroup.mockResolvedValueOnce([synchronizedCourse]); + courseService.findBySyncedGroup.mockResolvedValueOnce([]); return { teacherUser, @@ -264,6 +287,7 @@ describe('GroupUc', () => { system, schoolYear, nextSchoolYear, + synchronizedCourse, }; }; @@ -325,6 +349,7 @@ describe('GroupUc', () => { system, schoolYear, nextSchoolYear, + synchronizedCourse, } = setup(); const result: Page = await uc.findAllClasses(teacherUser.id, teacherUser.school.id, undefined); @@ -336,7 +361,7 @@ describe('GroupUc', () => { name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, type: ClassRootType.CLASS, externalSourceName: clazz.source, - teacherNames: [teacherUser.lastName], + teacherNames: [], schoolYear: schoolYear.name, isUpgradable: false, studentCount: 2, @@ -348,7 +373,7 @@ describe('GroupUc', () => { : successorClass.name, type: ClassRootType.CLASS, externalSourceName: successorClass.source, - teacherNames: [teacherUser.lastName], + teacherNames: [], schoolYear: nextSchoolYear.name, isUpgradable: false, studentCount: 2, @@ -360,7 +385,7 @@ describe('GroupUc', () => { : classWithoutSchoolYear.name, type: ClassRootType.CLASS, externalSourceName: classWithoutSchoolYear.source, - teacherNames: [teacherUser.lastName], + teacherNames: [], isUpgradable: false, studentCount: 2, }, @@ -368,16 +393,18 @@ describe('GroupUc', () => { id: group.id, name: group.name, type: ClassRootType.GROUP, - teacherNames: [teacherUser.lastName], + teacherNames: [], studentCount: 0, + synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], }, { id: groupWithSystem.id, name: groupWithSystem.name, type: ClassRootType.GROUP, externalSourceName: system.displayName, - teacherNames: [teacherUser.lastName], - studentCount: 1, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], }, ], total: 5, @@ -389,16 +416,26 @@ describe('GroupUc', () => { await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - expect(groupService.findGroupsByUserAndGroupTypes).toHaveBeenCalledWith<[UserDO, GroupTypes[]]>( + expect(groupService.findGroupsByUserAndGroupTypes).toHaveBeenCalledWith<[UserDO, GroupTypes[], IFindQuery]>( expect.any(UserDO), - [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER] + [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER], + { pagination: { skip: 0 } } ); }); }); describe('when sorting by external source name in descending order', () => { it('should return all classes sorted by external source name in descending order', async () => { - const { teacherUser, clazz, classWithoutSchoolYear, group, groupWithSystem, system, schoolYear } = setup(); + const { + teacherUser, + clazz, + classWithoutSchoolYear, + group, + groupWithSystem, + system, + schoolYear, + synchronizedCourse, + } = setup(); const result: Page = await uc.findAllClasses( teacherUser.id, @@ -420,7 +457,7 @@ describe('GroupUc', () => { : classWithoutSchoolYear.name, type: ClassRootType.CLASS, externalSourceName: classWithoutSchoolYear.source, - teacherNames: [teacherUser.lastName], + teacherNames: [], isUpgradable: false, studentCount: 2, }, @@ -429,7 +466,7 @@ describe('GroupUc', () => { name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, type: ClassRootType.CLASS, externalSourceName: clazz.source, - teacherNames: [teacherUser.lastName], + teacherNames: [], schoolYear: schoolYear.name, isUpgradable: false, studentCount: 2, @@ -439,15 +476,17 @@ describe('GroupUc', () => { name: groupWithSystem.name, type: ClassRootType.GROUP, externalSourceName: system.displayName, - teacherNames: [teacherUser.lastName], - studentCount: 1, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], }, { id: group.id, name: group.name, type: ClassRootType.GROUP, - teacherNames: [teacherUser.lastName], + teacherNames: [], studentCount: 0, + synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], }, ], total: 4, @@ -457,7 +496,7 @@ describe('GroupUc', () => { describe('when using pagination', () => { it('should return the selected page', async () => { - const { teacherUser, group } = setup(); + const { teacherUser, group, synchronizedCourse } = setup(); const result: Page = await uc.findAllClasses( teacherUser.id, @@ -476,8 +515,9 @@ describe('GroupUc', () => { id: group.id, name: group.name, type: ClassRootType.GROUP, - teacherNames: [teacherUser.lastName], + teacherNames: [], studentCount: 0, + synchronizedCourses: [{ id: synchronizedCourse.id, name: synchronizedCourse.name }], }, ], total: 4, @@ -504,7 +544,7 @@ describe('GroupUc', () => { : successorClass.name, externalSourceName: successorClass.source, type: ClassRootType.CLASS, - teacherNames: [teacherUser.lastName], + teacherNames: [], schoolYear: nextSchoolYear.name, isUpgradable: false, studentCount: 2, @@ -612,7 +652,9 @@ describe('GroupUc', () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(adminUser); authorizationService.hasAllPermissions.mockReturnValueOnce(true); classService.findClassesForSchool.mockResolvedValueOnce([...clazzes, clazz]); - groupService.findGroupsBySchoolIdAndGroupTypes.mockResolvedValueOnce([group, groupWithSystem]); + groupService.findGroupsBySchoolIdAndGroupTypes.mockResolvedValueOnce( + new Page([group, groupWithSystem], 2) + ); systemService.findById.mockResolvedValue(system); userService.findById.mockImplementation((userId: string): Promise => { @@ -657,6 +699,9 @@ describe('GroupUc', () => { throw new Error(); }); schoolYearService.findById.mockResolvedValue(schoolYear); + configService.get.mockReturnValueOnce(true); + courseService.findBySyncedGroup.mockResolvedValueOnce([]); + courseService.findBySyncedGroup.mockResolvedValueOnce([]); return { adminUser, @@ -698,7 +743,7 @@ describe('GroupUc', () => { describe('when no pagination is given', () => { it('should return all classes sorted by name', async () => { - const { adminUser, teacherUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); + const { adminUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); const result: Page = await uc.findAllClasses(adminUser.id, adminUser.school.id); @@ -709,7 +754,7 @@ describe('GroupUc', () => { name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, type: ClassRootType.CLASS, externalSourceName: clazz.source, - teacherNames: [teacherUser.lastName], + teacherNames: [], schoolYear: schoolYear.name, isUpgradable: false, studentCount: 2, @@ -718,16 +763,18 @@ describe('GroupUc', () => { id: group.id, name: group.name, type: ClassRootType.GROUP, - teacherNames: [teacherUser.lastName], + teacherNames: [], studentCount: 0, + synchronizedCourses: [], }, { id: groupWithSystem.id, name: groupWithSystem.name, type: ClassRootType.GROUP, externalSourceName: system.displayName, - teacherNames: [teacherUser.lastName], - studentCount: 1, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], }, ], total: 3, @@ -735,20 +782,21 @@ describe('GroupUc', () => { }); it('should call group service with allowed group types', async () => { - const { teacherUser } = setup(); + const { teacherUser, school } = setup(); await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - expect(groupService.findGroupsBySchoolIdAndGroupTypes).toHaveBeenCalledWith<[EntityId, GroupTypes[]]>( - teacherUser.school.id, - [GroupTypes.CLASS, GroupTypes.COURSE, GroupTypes.OTHER] - ); + expect(groupService.findGroupsBySchoolIdAndGroupTypes).toHaveBeenCalledWith<[School, GroupTypes[]]>(school, [ + GroupTypes.CLASS, + GroupTypes.COURSE, + GroupTypes.OTHER, + ]); }); }); describe('when sorting by external source name in descending order', () => { it('should return all classes sorted by external source name in descending order', async () => { - const { adminUser, teacherUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); + const { adminUser, clazz, group, groupWithSystem, system, schoolYear } = setup(); const result: Page = await uc.findAllClasses( adminUser.id, @@ -768,7 +816,7 @@ describe('GroupUc', () => { name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, type: ClassRootType.CLASS, externalSourceName: clazz.source, - teacherNames: [teacherUser.lastName], + teacherNames: [], schoolYear: schoolYear.name, isUpgradable: false, studentCount: 2, @@ -778,15 +826,17 @@ describe('GroupUc', () => { name: groupWithSystem.name, type: ClassRootType.GROUP, externalSourceName: system.displayName, - teacherNames: [teacherUser.lastName], - studentCount: 1, + teacherNames: [], + studentCount: 0, + synchronizedCourses: [], }, { id: group.id, name: group.name, type: ClassRootType.GROUP, - teacherNames: [teacherUser.lastName], + teacherNames: [], studentCount: 0, + synchronizedCourses: [], }, ], total: 3, @@ -796,7 +846,7 @@ describe('GroupUc', () => { describe('when using pagination', () => { it('should return the selected page', async () => { - const { adminUser, teacherUser, group } = setup(); + const { adminUser, group } = setup(); const result: Page = await uc.findAllClasses( adminUser.id, @@ -815,8 +865,9 @@ describe('GroupUc', () => { id: group.id, name: group.name, type: ClassRootType.GROUP, - teacherNames: [teacherUser.lastName], + teacherNames: [], studentCount: 0, + synchronizedCourses: [], }, ], total: 3, @@ -897,7 +948,7 @@ describe('GroupUc', () => { authorizationService.getUserWithPermissions.mockResolvedValueOnce(teacherUser); authorizationService.hasAllPermissions.mockReturnValueOnce(false); classService.findAllByUserId.mockResolvedValueOnce([clazz]); - groupService.findGroupsByUserAndGroupTypes.mockResolvedValueOnce([group]); + groupService.findGroupsByUserAndGroupTypes.mockResolvedValueOnce(new Page([group], 1)); systemService.findById.mockResolvedValue(system); userService.findById.mockImplementation((userId: string): Promise => { @@ -926,6 +977,8 @@ describe('GroupUc', () => { throw new Error(); }); schoolYearService.findById.mockResolvedValue(schoolYear); + configService.get.mockReturnValueOnce(true); + courseService.findBySyncedGroup.mockResolvedValueOnce([]); return { teacherUser, @@ -948,7 +1001,7 @@ describe('GroupUc', () => { name: clazz.gradeLevel ? `${clazz.gradeLevel}${clazz.name}` : clazz.name, type: ClassRootType.CLASS, externalSourceName: clazz.source, - teacherNames: [teacherUser.lastName], + teacherNames: [], schoolYear: schoolYear.name, isUpgradable: false, studentCount: 2, @@ -957,26 +1010,14 @@ describe('GroupUc', () => { id: group.id, name: group.name, type: ClassRootType.GROUP, - teacherNames: [teacherUser.lastName], + teacherNames: [], studentCount: 0, + synchronizedCourses: [], }, ], total: 2, }); }); - - it('should log the missing user', async () => { - const { teacherUser, clazz, group, notFoundReferenceId } = setup(); - - await uc.findAllClasses(teacherUser.id, teacherUser.school.id); - - expect(logger.warning).toHaveBeenCalledWith( - new ReferencedEntityNotFoundLoggable(Class.name, clazz.id, UserDO.name, notFoundReferenceId) - ); - expect(logger.warning).toHaveBeenCalledWith( - new ReferencedEntityNotFoundLoggable(Group.name, group.id, UserDO.name, notFoundReferenceId) - ); - }); }); }); @@ -1121,4 +1162,376 @@ describe('GroupUc', () => { }); }); }); + + describe('getAllGroups', () => { + describe('when the user has no permission', () => { + const setup = () => { + const school: School = schoolFactory.build(); + const user: User = userFactory.buildWithId(); + const error = new ForbiddenException(); + + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.checkPermission.mockImplementation(() => { + throw error; + }); + + return { + user, + error, + school, + }; + }; + + it('should throw forbidden', async () => { + const { user, error, school } = setup(); + + const func = () => uc.getAllGroups(user.id, school.id); + + await expect(func).rejects.toThrow(error); + }); + }); + + describe('when admin requests groups', () => { + const setup = () => { + const school: School = schoolFactory.build(); + const otherSchool: School = schoolFactory.build(); + const roles: Role = roleFactory.build({ permissions: [Permission.GROUP_FULL_ADMIN, Permission.GROUP_VIEW] }); + const user: User = userFactory.buildWithId({ roles: [roles], school }); + + const groupInSchool: Group = groupFactory.build({ organizationId: school.id }); + const availableGroupInSchool: Group = groupFactory.build({ organizationId: school.id }); + const groupInOtherSchool: Group = groupFactory.build({ organizationId: otherSchool.id }); + + const userRole: RoleDto = roleDtoFactory.build({ + id: user.roles[0].id, + name: user.roles[0].name, + }); + const userDto: UserDO = userDoFactory.build({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + roles: [{ id: user.roles[0].id, name: user.roles[0].name }], + }); + + schoolService.getSchoolById.mockResolvedValue(school); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.hasAllPermissions.mockReturnValueOnce(true); + groupService.findAvailableGroupsBySchoolId.mockResolvedValue(new Page([availableGroupInSchool], 1)); + groupService.findGroupsBySchoolIdAndGroupTypes.mockResolvedValue( + new Page([groupInSchool, availableGroupInSchool], 2) + ); + userService.findByIdOrNull.mockResolvedValue(userDto); + roleService.findById.mockResolvedValue(userRole); + + configService.get.mockReturnValueOnce(true); + + return { + user, + school, + groupInSchool, + availableGroupInSchool, + groupInOtherSchool, + }; + }; + + describe('when requesting all groups', () => { + it('should return all groups of the school', async () => { + const { user, groupInSchool, availableGroupInSchool, school } = setup(); + + const response = await uc.getAllGroups(user.id, school.id); + + expect(response).toMatchObject({ + data: [ + { + id: groupInSchool.id, + name: groupInSchool.name, + type: GroupTypes.CLASS, + externalSource: groupInSchool.externalSource, + organizationId: groupInSchool.organizationId, + users: [ + { + user: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }, + role: { + id: user.roles[0].id, + name: user.roles[0].name, + }, + }, + ], + }, + { + id: availableGroupInSchool.id, + name: availableGroupInSchool.name, + type: GroupTypes.CLASS, + externalSource: availableGroupInSchool.externalSource, + organizationId: availableGroupInSchool.organizationId, + users: [ + { + user: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }, + role: { + id: user.roles[0].id, + name: user.roles[0].name, + }, + }, + ], + }, + ], + total: 2, + }); + }); + + it('should not return group not in school', async () => { + const { user, groupInOtherSchool, school } = setup(); + + const response = await uc.getAllGroups(user.id, school.id); + + expect(response).not.toMatchObject({ + data: [ + { + id: groupInOtherSchool.id, + name: groupInOtherSchool.name, + type: GroupTypes.CLASS, + externalSource: groupInOtherSchool.externalSource, + organizationId: groupInOtherSchool.organizationId, + users: [ + { + user: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }, + role: { + id: user.roles[0].id, + name: user.roles[0].name, + }, + }, + ], + }, + ], + total: 1, + }); + }); + }); + + describe('when requesting all available groups', () => { + it('should return all available groups for course sync', async () => { + const { user, availableGroupInSchool, school } = setup(); + + const response = await uc.getAllGroups(user.id, school.id, undefined, true); + + expect(response).toMatchObject({ + data: [ + { + id: availableGroupInSchool.id, + name: availableGroupInSchool.name, + type: GroupTypes.CLASS, + externalSource: availableGroupInSchool.externalSource, + organizationId: availableGroupInSchool.organizationId, + users: [ + { + user: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }, + role: { + id: user.roles[0].id, + name: user.roles[0].name, + }, + }, + ], + }, + ], + total: 1, + }); + }); + }); + }); + + describe('when teacher requests groups', () => { + const setup = () => { + const school: School = schoolFactory.build(); + const roles: Role = roleFactory.build({ permissions: [Permission.GROUP_VIEW] }); + const user: User = userFactory.buildWithId({ roles: [roles], school }); + + const teachersGroup: Group = groupFactory.build({ + organizationId: school.id, + users: [{ userId: user.id, roleId: user.roles[0].id }], + }); + const availableTeachersGroup: Group = groupFactory.build({ + organizationId: school.id, + users: [{ userId: user.id, roleId: user.roles[0].id }], + }); + const notTeachersGroup: Group = groupFactory.build({ organizationId: school.id }); + + const userRole: RoleDto = roleDtoFactory.build({ + id: user.roles[0].id, + name: user.roles[0].name, + }); + const userDto: UserDO = userDoFactory.build({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + roles: [{ id: user.roles[0].id, name: user.roles[0].name }], + }); + + schoolService.getSchoolById.mockResolvedValue(school); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.hasAllPermissions.mockReturnValue(false); + groupService.findAvailableGroupsByUser.mockResolvedValue(new Page([availableTeachersGroup], 1)); + groupService.findGroupsByUserAndGroupTypes.mockResolvedValue( + new Page([teachersGroup, availableTeachersGroup], 2) + ); + userService.findByIdOrNull.mockResolvedValue(userDto); + roleService.findById.mockResolvedValue(userRole); + + configService.get.mockReturnValueOnce(true); + + return { + user, + school, + teachersGroup, + availableTeachersGroup, + notTeachersGroup, + }; + }; + + describe('when requesting all groups', () => { + it('should return all groups the teacher is part of', async () => { + const { user, teachersGroup, availableTeachersGroup, school } = setup(); + + const response = await uc.getAllGroups(user.id, school.id); + + expect(response).toMatchObject({ + data: [ + { + id: teachersGroup.id, + name: teachersGroup.name, + type: GroupTypes.CLASS, + externalSource: teachersGroup.externalSource, + organizationId: teachersGroup.organizationId, + users: [ + { + user: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }, + role: { + id: user.roles[0].id, + name: user.roles[0].name, + }, + }, + ], + }, + { + id: availableTeachersGroup.id, + name: availableTeachersGroup.name, + type: GroupTypes.CLASS, + externalSource: availableTeachersGroup.externalSource, + organizationId: availableTeachersGroup.organizationId, + users: [ + { + user: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }, + role: { + id: user.roles[0].id, + name: user.roles[0].name, + }, + }, + ], + }, + ], + total: 2, + }); + }); + + it('should not return group without the teacher', async () => { + const { user, notTeachersGroup, school } = setup(); + + const response = await uc.getAllGroups(user.id, school.id); + + expect(response).not.toMatchObject({ + data: [ + { + id: notTeachersGroup.id, + name: notTeachersGroup.name, + type: GroupTypes.CLASS, + externalSource: notTeachersGroup.externalSource, + organizationId: notTeachersGroup.organizationId, + users: [ + { + user: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }, + role: { + id: user.roles[0].id, + name: user.roles[0].name, + }, + }, + ], + }, + ], + total: 1, + }); + }); + }); + + describe('when requesting all available groups', () => { + it('should return all available groups for course sync the teacher is part of', async () => { + const { user, availableTeachersGroup, school } = setup(); + + const response = await uc.getAllGroups(user.id, school.id, undefined, true); + + expect(response).toMatchObject({ + data: [ + { + id: availableTeachersGroup.id, + name: availableTeachersGroup.name, + type: GroupTypes.CLASS, + externalSource: availableTeachersGroup.externalSource, + organizationId: availableTeachersGroup.organizationId, + users: [ + { + user: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }, + role: { + id: user.roles[0].id, + name: user.roles[0].name, + }, + }, + ], + }, + ], + total: 1, + }); + }); + }); + }); + }); }); diff --git a/apps/server/src/modules/group/uc/group.uc.ts b/apps/server/src/modules/group/uc/group.uc.ts index 18bb1c5e466..9b3dae9c272 100644 --- a/apps/server/src/modules/group/uc/group.uc.ts +++ b/apps/server/src/modules/group/uc/group.uc.ts @@ -1,20 +1,23 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { ClassService } from '@modules/class'; import { Class } from '@modules/class/domain'; +import { Course } from '@modules/learnroom/domain'; +import { CourseDoService } from '@modules/learnroom/service/course-do.service'; import { SchoolYearService } from '@modules/legacy-school'; +import { ProvisioningConfig } from '@modules/provisioning'; import { RoleService } from '@modules/role'; import { RoleDto } from '@modules/role/service/dto/role.dto'; +import { School, SchoolService } from '@modules/school/domain'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { SortHelper } from '@shared/common'; -import { ReferencedEntityNotFoundLoggable } from '@shared/common/loggable'; import { Page, UserDO } from '@shared/domain/domainobject'; import { SchoolYearEntity, User } from '@shared/domain/entity'; -import { Permission, SortOrder } from '@shared/domain/interface'; +import { IFindQuery, Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; import { LegacySystemService, SystemDto } from '@src/modules/system'; -import { School, SchoolService } from '@modules/school/domain'; import { ClassRequestContext, SchoolYearQueryType } from '../controller/dto/interface'; import { Group, GroupTypes, GroupUser } from '../domain'; import { UnknownQueryTypeLoggableException } from '../loggable'; @@ -33,6 +36,8 @@ export class GroupUc { private readonly schoolService: SchoolService, private readonly authorizationService: AuthorizationService, private readonly schoolYearService: SchoolYearService, + private readonly courseService: CourseDoService, + private readonly configService: ConfigService, private readonly logger: Logger ) {} @@ -67,7 +72,7 @@ export class GroupUc { let combinedClassInfo: ClassInfoDto[]; if (canSeeFullList || calledFromCourse) { - combinedClassInfo = await this.findCombinedClassListForSchool(schoolId, schoolYearQueryType); + combinedClassInfo = await this.findCombinedClassListForSchool(school, schoolYearQueryType); } else { combinedClassInfo = await this.findCombinedClassListForUser(userId, schoolYearQueryType); } @@ -84,15 +89,15 @@ export class GroupUc { } private async findCombinedClassListForSchool( - schoolId: EntityId, + school: School, schoolYearQueryType?: SchoolYearQueryType ): Promise { let classInfosFromGroups: ClassInfoDto[] = []; - const classInfosFromClasses = await this.findClassesForSchool(schoolId, schoolYearQueryType); + const classInfosFromClasses = await this.findClassesForSchool(school.id, schoolYearQueryType); if (!schoolYearQueryType || schoolYearQueryType === SchoolYearQueryType.CURRENT_YEAR) { - classInfosFromGroups = await this.findGroupsForSchool(schoolId); + classInfosFromGroups = await this.findGroupsForSchool(school); } const combinedClassInfo: ClassInfoDto[] = [...classInfosFromClasses, ...classInfosFromGroups]; @@ -153,7 +158,7 @@ export class GroupUc { this.isClassOfQueryType(currentYear, classWithSchoolYear.schoolYear, schoolYearQueryType) ); - const classInfosFromClasses: ClassInfoDto[] = await this.mapClassInfosFromClasses(filteredClassesForSchoolYear); + const classInfosFromClasses: ClassInfoDto[] = this.mapClassInfosFromClasses(filteredClassesForSchoolYear); return classInfosFromClasses; } @@ -201,13 +206,12 @@ export class GroupUc { } } - private async mapClassInfosFromClasses( + private mapClassInfosFromClasses( filteredClassesForSchoolYear: { clazz: Class; schoolYear?: SchoolYearEntity }[] - ): Promise { - const classInfosFromClasses: ClassInfoDto[] = await Promise.all( - filteredClassesForSchoolYear.map(async (classWithSchoolYear): Promise => { - const { teacherIds } = classWithSchoolYear.clazz; - const teachers: UserDO[] = await this.getTeachersByIds(teacherIds, classWithSchoolYear.clazz.id); + ): ClassInfoDto[] { + const classInfosFromClasses: ClassInfoDto[] = filteredClassesForSchoolYear.map( + (classWithSchoolYear): ClassInfoDto => { + const teachers: UserDO[] = []; const mapped: ClassInfoDto = GroupUcMapper.mapClassToClassInfoDto( classWithSchoolYear.clazz, @@ -216,41 +220,18 @@ export class GroupUc { ); return mapped; - }) - ); - - return classInfosFromClasses; - } - - private async getTeachersByIds(teacherIds: EntityId[], classId: EntityId): Promise { - const teacherPromises: Promise[] = teacherIds.map( - async (teacherId: EntityId): Promise => { - const teacher: UserDO | null = await this.userService.findByIdOrNull(teacherId); - if (!teacher) { - this.logger.warning(new ReferencedEntityNotFoundLoggable(Class.name, classId, UserDO.name, teacherId)); - } - return teacher; } ); - - const teachers: UserDO[] = (await Promise.all(teacherPromises)).filter( - (teacher: UserDO | null): teacher is UserDO => teacher !== null - ); - - return teachers; + return classInfosFromClasses; } - private async findGroupsForSchool(schoolId: EntityId): Promise { - const groups: Group[] = await this.groupService.findGroupsBySchoolIdAndGroupTypes( - schoolId, + private async findGroupsForSchool(school: School): Promise { + const groups: Page = await this.groupService.findGroupsBySchoolIdAndGroupTypes( + school, this.ALLOWED_GROUP_TYPES ); - const systemMap: Map = await this.findSystemNamesForGroups(groups); - - const classInfosFromGroups: ClassInfoDto[] = await Promise.all( - groups.map(async (group: Group): Promise => this.getClassInfoFromGroup(group, systemMap)) - ); + const classInfosFromGroups: ClassInfoDto[] = await this.getClassInfosFromGroups(groups.data); return classInfosFromGroups; } @@ -258,15 +239,20 @@ export class GroupUc { private async findGroupsForUser(userId: EntityId): Promise { const user: UserDO = await this.userService.findById(userId); - const groupsOfTypeClass: Group[] = await this.groupService.findGroupsByUserAndGroupTypes( - user, - this.ALLOWED_GROUP_TYPES - ); + const groups: Page = await this.groupService.findGroupsByUserAndGroupTypes(user, this.ALLOWED_GROUP_TYPES, { + pagination: { skip: 0 }, + }); + + const classInfosFromGroups: ClassInfoDto[] = await this.getClassInfosFromGroups(groups.data); - const systemMap: Map = await this.findSystemNamesForGroups(groupsOfTypeClass); + return classInfosFromGroups; + } + + private async getClassInfosFromGroups(groups: Group[]): Promise { + const systemMap: Map = await this.findSystemNamesForGroups(groups); const classInfosFromGroups: ClassInfoDto[] = await Promise.all( - groupsOfTypeClass.map(async (group: Group): Promise => this.getClassInfoFromGroup(group, systemMap)) + groups.map(async (group: Group): Promise => this.getClassInfoFromGroup(group, systemMap)) ); return classInfosFromGroups; @@ -278,9 +264,19 @@ export class GroupUc { system = systemMap.get(group.externalSource.systemId); } - const resolvedUsers: ResolvedGroupUser[] = await this.findUsersForGroup(group); + const resolvedUsers: ResolvedGroupUser[] = []; + + let synchronizedCourses: Course[] = []; + if (this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { + synchronizedCourses = await this.courseService.findBySyncedGroup(group); + } - const mapped: ClassInfoDto = GroupUcMapper.mapGroupToClassInfoDto(group, resolvedUsers, system); + const mapped: ClassInfoDto = GroupUcMapper.mapGroupToClassInfoDto( + group, + resolvedUsers, + synchronizedCourses, + system + ); return mapped; } @@ -295,7 +291,7 @@ export class GroupUc { const systems: Map = new Map(); await Promise.all( - uniqueSystemIds.map(async (systemId: string) => { + uniqueSystemIds.map(async (systemId: string): Promise => { const system: SystemDto = await this.systemService.findById(systemId); systems.set(systemId, system); @@ -311,11 +307,11 @@ export class GroupUc { const user: UserDO | null = await this.userService.findByIdOrNull(groupUser.userId); let resolvedGroup: ResolvedGroupUser | null = null; - if (!user) { - this.logger.warning( + /* TODO add this log back later + this.logger.warning( new ReferencedEntityNotFoundLoggable(Group.name, group.id, UserDO.name, groupUser.userId) - ); - } else { + ); */ + if (user) { const role: RoleDto = await this.roleService.findById(groupUser.roleId); resolvedGroup = new ResolvedGroupUser({ @@ -367,4 +363,69 @@ export class GroupUc { AuthorizationContextBuilder.read([Permission.GROUP_VIEW]) ); } + + public async getAllGroups( + userId: EntityId, + schoolId: EntityId, + query?: IFindQuery, + availableGroupsForCourseSync?: boolean + ): Promise> { + const school: School = await this.schoolService.getSchoolById(schoolId); + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission(user, school, AuthorizationContextBuilder.read([Permission.GROUP_VIEW])); + + const canSeeFullList: boolean = this.authorizationService.hasAllPermissions(user, [Permission.GROUP_FULL_ADMIN]); + + let groups: Page; + if (canSeeFullList) { + groups = await this.getGroupsForSchool(school, query, availableGroupsForCourseSync); + } else { + groups = await this.getGroupsForUser(userId, query, availableGroupsForCourseSync); + } + + const resolvedGroups: ResolvedGroupDto[] = await Promise.all( + groups.data.map(async (group: Group) => { + const resolvedUsers: ResolvedGroupUser[] = await this.findUsersForGroup(group); + const resolvedGroup: ResolvedGroupDto = GroupUcMapper.mapToResolvedGroupDto(group, resolvedUsers); + + return resolvedGroup; + }) + ); + + const page: Page = new Page(resolvedGroups, groups.total); + + return page; + } + + private async getGroupsForSchool( + school: School, + query?: IFindQuery, + availableGroupsForCourseSync?: boolean + ): Promise> { + let foundGroups: Page; + if (availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { + foundGroups = await this.groupService.findAvailableGroupsBySchoolId(school, query); + } else { + foundGroups = await this.groupService.findGroupsBySchoolIdAndGroupTypes(school, undefined, query); + } + + return foundGroups; + } + + private async getGroupsForUser( + userId: EntityId, + query?: IFindQuery, + availableGroupsForCourseSync?: boolean + ): Promise> { + let foundGroups: Page; + const user: UserDO = await this.userService.findById(userId); + if (availableGroupsForCourseSync && this.configService.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED')) { + foundGroups = await this.groupService.findAvailableGroupsByUser(user, query); + } else { + foundGroups = await this.groupService.findGroupsByUserAndGroupTypes(user, undefined, query); + } + + return foundGroups; + } } diff --git a/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts index 9ba04682fe0..9dee917acdd 100644 --- a/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts +++ b/apps/server/src/modules/group/uc/mapper/group-uc.mapper.ts @@ -1,17 +1,18 @@ import { Class } from '@modules/class/domain'; import { SystemDto } from '@modules/system'; - import { UserDO } from '@shared/domain/domainobject'; import { SchoolYearEntity } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; +import { Course } from '@src/modules/learnroom/domain'; import { Group } from '../../domain'; -import { ClassInfoDto, ResolvedGroupDto, ResolvedGroupUser } from '../dto'; +import { ClassInfoDto, CourseInfoDto, ResolvedGroupDto, ResolvedGroupUser } from '../dto'; import { ClassRootType } from '../dto/class-root-type'; export class GroupUcMapper { public static mapGroupToClassInfoDto( group: Group, resolvedUsers: ResolvedGroupUser[], + synchronizedCourses: Course[], system?: SystemDto ): ClassInfoDto { const mapped: ClassInfoDto = new ClassInfoDto({ @@ -24,6 +25,13 @@ export class GroupUcMapper { .map((groupUser: ResolvedGroupUser) => groupUser.user.lastName), studentCount: resolvedUsers.filter((groupUser: ResolvedGroupUser) => groupUser.role.name === RoleName.STUDENT) .length, + synchronizedCourses: synchronizedCourses.map( + (course: Course): CourseInfoDto => + new CourseInfoDto({ + id: course.id, + name: course.name, + }) + ), }); return mapped; @@ -54,6 +62,7 @@ export class GroupUcMapper { type: group.type, externalSource: group.externalSource, users: resolvedGroupUsers, + organizationId: group.organizationId, }); return mapped; diff --git a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-delete.api.spec.ts b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-delete.api.spec.ts index 276e86236ec..a45e05fc981 100644 --- a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-delete.api.spec.ts +++ b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-delete.api.spec.ts @@ -4,7 +4,13 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; -import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; +import { + cleanupCollections, + mapUserToCurrentUser, + roleFactory, + schoolEntityFactory, + userFactory, +} from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { Request } from 'express'; @@ -73,7 +79,7 @@ describe('H5PEditor Controller (api)', () => { describe('delete h5p content', () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); diff --git a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-files.api.spec.ts b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-files.api.spec.ts index 5ee9d7f15f7..5800d5eb99f 100644 --- a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-files.api.spec.ts +++ b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-files.api.spec.ts @@ -1,8 +1,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { S3ClientAdapter } from '@infra/s3-client'; -import { ILibraryName } from '@lumieducation/h5p-server'; +import { IFileStats, ILibraryName } from '@lumieducation/h5p-server'; import { ContentMetadata } from '@lumieducation/h5p-server/build/src/ContentMetadata'; -import { EntityManager } from '@mikro-orm/mongodb'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { @@ -12,9 +12,8 @@ import { TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; -import { ObjectID } from 'bson'; import { Readable } from 'stream'; -import { H5PContent, H5PContentParentType, H5PContentProperties, H5pEditorTempFile } from '../../entity'; +import { H5PContent, H5PContentParentType, H5PContentProperties } from '../../entity'; import { H5PEditorTestModule } from '../../h5p-editor-test.module'; import { H5P_CONTENT_S3_CONNECTION, H5P_LIBRARIES_S3_CONNECTION } from '../../h5p-editor.config'; import { ContentStorage, LibraryStorage, TemporaryFileStorage } from '../../service'; @@ -46,9 +45,9 @@ const helpers = { data: `Data #${n}`, }; const h5pContentProperties: H5PContentProperties = { - creatorId: new ObjectID().toString(), - parentId: new ObjectID().toString(), - schoolId: new ObjectID().toString(), + creatorId: new ObjectId().toString(), + parentId: new ObjectId().toString(), + schoolId: new ObjectId().toString(), metadata, content, parentType: H5PContentParentType.Lesson, @@ -57,7 +56,7 @@ const helpers = { return { withID(id?: number) { - const objectId = new ObjectID(id); + const objectId = new ObjectId(id); h5pContent._id = objectId; h5pContent.id = objectId.toString(); @@ -270,34 +269,31 @@ describe('H5PEditor Controller (api)', () => { content: 'File Content', }; - const mockTempFile = new H5pEditorTempFile({ - filename: mockFile.name, - ownedByUserId: studentUser.id, - expiresAt: new Date(), + const mockFileStats: IFileStats = { birthtime: new Date(), size: mockFile.content.length, - }); + }; - return { loggedInClient, mockFile, mockTempFile }; + return { loggedInClient, mockFile, mockFileStats }; }; it('should return the content file', async () => { - const { loggedInClient, mockFile, mockTempFile } = await setup(); + const { loggedInClient, mockFile, mockFileStats } = await setup(); temporaryStorage.getFileStream.mockResolvedValueOnce(Readable.from(mockFile.content)); - temporaryStorage.getFileStats.mockResolvedValueOnce(mockTempFile); + temporaryStorage.getFileStats.mockResolvedValueOnce(mockFileStats); const response = await loggedInClient.get(`temp-files/${mockFile.name}`); - expect(response.statusCode).toEqual(HttpStatus.PARTIAL_CONTENT); + expect(response.statusCode).toEqual(HttpStatus.OK); expect(response.text).toBe(mockFile.content); }); it('should work with range requests', async () => { - const { loggedInClient, mockFile, mockTempFile } = await setup(); + const { loggedInClient, mockFile, mockFileStats } = await setup(); temporaryStorage.getFileStream.mockResolvedValueOnce(Readable.from(mockFile.content)); - temporaryStorage.getFileStats.mockResolvedValueOnce(mockTempFile); + temporaryStorage.getFileStats.mockResolvedValueOnce(mockFileStats); const response = await loggedInClient.get(`temp-files/${mockFile.name}`).set('Range', 'bytes=2-4'); diff --git a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-editor.api.spec.ts b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-editor.api.spec.ts index a2144cc37ba..c1212567e13 100644 --- a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-editor.api.spec.ts +++ b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-editor.api.spec.ts @@ -4,7 +4,13 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; -import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; +import { + cleanupCollections, + mapUserToCurrentUser, + roleFactory, + schoolEntityFactory, + userFactory, +} from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { Request } from 'express'; @@ -91,7 +97,7 @@ describe('H5PEditor Controller (api)', () => { describe('get new h5p editor', () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); @@ -123,7 +129,7 @@ describe('H5PEditor Controller (api)', () => { describe('get h5p editor', () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); diff --git a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-player.api.spec.ts b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-player.api.spec.ts index bf728d2dd90..ca3e04bf226 100644 --- a/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-player.api.spec.ts +++ b/apps/server/src/modules/h5p-editor/controller/api-test/h5p-editor-get-player.api.spec.ts @@ -5,7 +5,13 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; -import { cleanupCollections, mapUserToCurrentUser, roleFactory, schoolFactory, userFactory } from '@shared/testing'; +import { + cleanupCollections, + mapUserToCurrentUser, + roleFactory, + schoolEntityFactory, + userFactory, +} from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; import { Request } from 'express'; @@ -83,7 +89,7 @@ describe('H5PEditor Controller (api)', () => { describe('get h5p player', () => { beforeEach(async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.FILESTORAGE_CREATE, Permission.FILESTORAGE_VIEW], }); diff --git a/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.transform-pipe.spec.ts b/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.transform-pipe.spec.ts index 8db32320969..4a17d2a30d5 100644 --- a/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.transform-pipe.spec.ts +++ b/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.transform-pipe.spec.ts @@ -64,7 +64,8 @@ describe('transform', () => { }); it('when contentId in value', async () => { - await expect(ajaxBodyTransformPipe.transform(emptyAjaxPostBodyParams2)).rejects.toThrowError('Mocked Error'); + const result = await ajaxBodyTransformPipe.transform(emptyAjaxPostBodyParams2); + expect(result).toBeDefined(); }); it('when libaryParameters in value', async () => { diff --git a/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.ts b/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.ts index 496616810c6..bc3698cdac7 100644 --- a/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.ts +++ b/apps/server/src/modules/h5p-editor/controller/dto/ajax/post.body.params.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsMongoId, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsOptional, IsString } from 'class-validator'; export class LibrariesBodyParams { @ApiProperty() @@ -10,7 +10,6 @@ export class LibrariesBodyParams { export class ContentBodyParams { @ApiProperty() - @IsMongoId() contentId!: string; @ApiProperty() diff --git a/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor.params.ts b/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor.params.ts index 91c67645854..602af94d8a3 100644 --- a/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor.params.ts +++ b/apps/server/src/modules/h5p-editor/controller/dto/h5p-editor.params.ts @@ -2,7 +2,7 @@ import { IContentMetadata } from '@lumieducation/h5p-server'; import { ApiProperty } from '@nestjs/swagger'; import { SanitizeHtml } from '@shared/controller'; -import { LanguageType } from '@shared/domain/entity'; +import { LanguageType } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { IsEnum, IsMongoId, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; import { H5PContentParentType } from '../../entity'; diff --git a/apps/server/src/modules/h5p-editor/entity/h5p-editor-tempfile.entity.ts b/apps/server/src/modules/h5p-editor/entity/h5p-editor-tempfile.entity.ts deleted file mode 100644 index 00374718c00..00000000000 --- a/apps/server/src/modules/h5p-editor/entity/h5p-editor-tempfile.entity.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { IFileStats, ITemporaryFile } from '@lumieducation/h5p-server'; -import { Entity, Property } from '@mikro-orm/core'; -import { BaseEntityWithTimestamps } from '@shared/domain/entity'; - -export interface TemporaryFileProperties { - filename: string; - ownedByUserId: string; - expiresAt: Date; - birthtime: Date; - size: number; -} - -@Entity({ tableName: 'h5p-editor-temp-file' }) -export class H5pEditorTempFile extends BaseEntityWithTimestamps implements ITemporaryFile, IFileStats { - /** - * The name by which the file can be identified; can be a path including subdirectories (e.g. 'images/xyz.png') - */ - @Property() - filename: string; - - @Property() - expiresAt: Date; - - @Property() - ownedByUserId: string; - - @Property() - birthtime: Date; - - @Property() - size: number; - - constructor({ filename, ownedByUserId, expiresAt, birthtime, size }: TemporaryFileProperties) { - super(); - this.filename = filename; - this.ownedByUserId = ownedByUserId; - this.expiresAt = expiresAt; - this.birthtime = birthtime; - this.size = size; - } -} diff --git a/apps/server/src/modules/h5p-editor/entity/index.ts b/apps/server/src/modules/h5p-editor/entity/index.ts index e95c0f12c94..be9af332dbb 100644 --- a/apps/server/src/modules/h5p-editor/entity/index.ts +++ b/apps/server/src/modules/h5p-editor/entity/index.ts @@ -1,3 +1,2 @@ export * from './h5p-content.entity'; export * from './library.entity'; -export * from './h5p-editor-tempfile.entity'; diff --git a/apps/server/src/modules/h5p-editor/h5p-editor-test.module.ts b/apps/server/src/modules/h5p-editor/h5p-editor-test.module.ts index 9d0d46b2d28..73b218380e5 100644 --- a/apps/server/src/modules/h5p-editor/h5p-editor-test.module.ts +++ b/apps/server/src/modules/h5p-editor/h5p-editor-test.module.ts @@ -14,7 +14,7 @@ import { H5PContent } from './entity'; import { s3ConfigContent, s3ConfigLibraries } from './h5p-editor.config'; import { H5PEditorModule } from './h5p-editor.module'; import { H5PAjaxEndpointProvider, H5PEditorProvider, H5PPlayerProvider } from './provider'; -import { H5PContentRepo, LibraryRepo, TemporaryFileRepo } from './repo'; +import { H5PContentRepo, LibraryRepo } from './repo'; import { ContentStorage, LibraryStorage, TemporaryFileStorage } from './service'; import { H5PEditorUc } from './uc/h5p.uc'; @@ -38,7 +38,6 @@ const providers = [ H5PAjaxEndpointProvider, H5PContentRepo, LibraryRepo, - TemporaryFileRepo, ContentStorage, LibraryStorage, TemporaryFileStorage, diff --git a/apps/server/src/modules/h5p-editor/h5p-editor.config.ts b/apps/server/src/modules/h5p-editor/h5p-editor.config.ts index 9509cf66a76..e2364120443 100644 --- a/apps/server/src/modules/h5p-editor/h5p-editor.config.ts +++ b/apps/server/src/modules/h5p-editor/h5p-editor.config.ts @@ -1,13 +1,14 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { S3Config } from '@infra/s3-client'; +import { LanguageType } from '@shared/domain/interface'; const h5pEditorConfig = { NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, - INCOMING_REQUEST_TIMEOUT: Configuration.get('INCOMING_REQUEST_TIMEOUT_API') as number, + INCOMING_REQUEST_TIMEOUT: Configuration.get('H5P_EDITOR__INCOMING_REQUEST_TIMEOUT') as number, }; export const translatorConfig = { - AVAILABLE_LANGUAGES: (Configuration.get('I18N__AVAILABLE_LANGUAGES') as string).split(','), + AVAILABLE_LANGUAGES: (Configuration.get('I18N__AVAILABLE_LANGUAGES') as string).split(',') as LanguageType[], }; export const H5P_CONTENT_S3_CONNECTION = 'H5P_CONTENT_S3_CONNECTION'; @@ -18,8 +19,8 @@ export const s3ConfigContent: S3Config = { endpoint: Configuration.get('H5P_EDITOR__S3_ENDPOINT') as string, region: Configuration.get('H5P_EDITOR__S3_REGION') as string, bucket: Configuration.get('H5P_EDITOR__S3_BUCKET_CONTENT') as string, - accessKeyId: Configuration.get('H5P_EDITOR__S3_ACCESS_KEY_ID_RW') as string, - secretAccessKey: Configuration.get('H5P_EDITOR__S3_SECRET_ACCESS_KEY_RW') as string, + accessKeyId: Configuration.get('H5P_EDITOR__S3_ACCESS_KEY_ID') as string, + secretAccessKey: Configuration.get('H5P_EDITOR__S3_SECRET_ACCESS_KEY') as string, }; export const s3ConfigLibraries: S3Config = { @@ -27,8 +28,8 @@ export const s3ConfigLibraries: S3Config = { endpoint: Configuration.get('H5P_EDITOR__S3_ENDPOINT') as string, region: Configuration.get('H5P_EDITOR__S3_REGION') as string, bucket: Configuration.get('H5P_EDITOR__S3_BUCKET_LIBRARIES') as string, - accessKeyId: Configuration.get('H5P_EDITOR__S3_ACCESS_KEY_ID_R') as string, - secretAccessKey: Configuration.get('H5P_EDITOR__S3_SECRET_ACCESS_KEY_R') as string, + accessKeyId: Configuration.get('H5P_EDITOR__LIBRARIES_S3_ACCESS_KEY_ID') as string, + secretAccessKey: Configuration.get('H5P_EDITOR__LIBRARIES_S3_SECRET_ACCESS_KEY') as string, }; export const config = () => h5pEditorConfig; diff --git a/apps/server/src/modules/h5p-editor/h5p-editor.module.ts b/apps/server/src/modules/h5p-editor/h5p-editor.module.ts index a321bbc4e58..5045d097d92 100644 --- a/apps/server/src/modules/h5p-editor/h5p-editor.module.ts +++ b/apps/server/src/modules/h5p-editor/h5p-editor.module.ts @@ -8,14 +8,14 @@ import { UserModule } from '@modules/user'; import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ALL_ENTITIES } from '@shared/domain/entity'; -import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@src/config'; +import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; import { Logger } from '@src/core/logger'; import { H5PEditorController } from './controller/h5p-editor.controller'; -import { H5PContent, H5pEditorTempFile, InstalledLibrary } from './entity'; +import { H5PContent, InstalledLibrary } from './entity'; import { config, s3ConfigContent, s3ConfigLibraries } from './h5p-editor.config'; import { H5PAjaxEndpointProvider, H5PEditorProvider, H5PPlayerProvider } from './provider'; -import { H5PContentRepo, LibraryRepo, TemporaryFileRepo } from './repo'; +import { H5PContentRepo, LibraryRepo } from './repo'; import { ContentStorage, LibraryStorage, TemporaryFileStorage } from './service'; import { H5PEditorUc } from './uc/h5p.uc'; @@ -40,7 +40,7 @@ const imports = [ user: DB_USERNAME, // Needs ALL_ENTITIES for authorization allowGlobalContext: true, - entities: [...ALL_ENTITIES, H5PContent, H5pEditorTempFile, InstalledLibrary], + entities: [...ALL_ENTITIES, H5PContent, InstalledLibrary], }), ConfigModule.forRoot(createConfigModuleOptions(config)), S3ClientModule.register([s3ConfigContent, s3ConfigLibraries]), @@ -53,7 +53,6 @@ const providers = [ H5PEditorUc, H5PContentRepo, LibraryRepo, - TemporaryFileRepo, H5PEditorProvider, H5PPlayerProvider, H5PAjaxEndpointProvider, diff --git a/apps/server/src/modules/h5p-editor/repo/index.ts b/apps/server/src/modules/h5p-editor/repo/index.ts index 7d38e6ba404..aa291c2e35d 100644 --- a/apps/server/src/modules/h5p-editor/repo/index.ts +++ b/apps/server/src/modules/h5p-editor/repo/index.ts @@ -1,3 +1,2 @@ export * from './h5p-content.repo'; export * from './library.repo'; -export * from './temporary-file.repo'; diff --git a/apps/server/src/modules/h5p-editor/repo/temporary-file.repo.integration.spec.ts b/apps/server/src/modules/h5p-editor/repo/temporary-file.repo.integration.spec.ts deleted file mode 100644 index e5e763b6216..00000000000 --- a/apps/server/src/modules/h5p-editor/repo/temporary-file.repo.integration.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { EntityManager } from '@mikro-orm/mongodb'; -import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { cleanupCollections, h5pTemporaryFileFactory } from '@shared/testing'; -import { H5pEditorTempFile } from '../entity'; -import { TemporaryFileRepo } from './temporary-file.repo'; - -describe('TemporaryFileRepo', () => { - let module: TestingModule; - let repo: TemporaryFileRepo; - let em: EntityManager; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot({ entities: [H5pEditorTempFile] })], - providers: [TemporaryFileRepo], - }).compile(); - - repo = module.get(TemporaryFileRepo); - em = module.get(EntityManager); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(async () => { - await cleanupCollections(em); - }); - - it('should implement entityName getter', () => { - expect(repo.entityName).toBe(H5pEditorTempFile); - }); - - describe('createTemporaryFile', () => { - it('should be able to retrieve entity', async () => { - const tempFile = h5pTemporaryFileFactory.build(); - await em.persistAndFlush(tempFile); - - const result = await repo.findById(tempFile.id); - - expect(result).toBeDefined(); - expect(result).toEqual(tempFile); - }); - }); - - describe('findByUserAndFilename', () => { - it('should be able to retrieve entity', async () => { - const tempFile = h5pTemporaryFileFactory.build(); - await em.persistAndFlush(tempFile); - - const result = await repo.findByUserAndFilename(tempFile.ownedByUserId, tempFile.filename); - - expect(result).toBeDefined(); - expect(result).toEqual(tempFile); - }); - - it('should fail if entity does not exist', async () => { - const user = 'wrong-user-id'; - const filename = 'file.txt'; - - const findBy = repo.findByUserAndFilename(user, filename); - - await expect(findBy).rejects.toThrow(); - }); - }); - - describe('findAllByUserAndFilename', () => { - it('should be able to retrieve entity', async () => { - const tempFile = h5pTemporaryFileFactory.build(); - await em.persistAndFlush(tempFile); - - const result = await repo.findAllByUserAndFilename(tempFile.ownedByUserId, tempFile.filename); - - expect(result).toBeDefined(); - expect(result).toEqual([tempFile]); - }); - - it('should return empty array', async () => { - const user = 'wrong-user-id'; - const filename = 'file.txt'; - - const findBy = await repo.findAllByUserAndFilename(user, filename); - - expect(findBy).toEqual([]); - }); - }); - - describe('findExpired', () => { - it('should return expired files', async () => { - const [expiredFile, validFile] = [h5pTemporaryFileFactory.isExpired().build(), h5pTemporaryFileFactory.build()]; - await em.persistAndFlush([expiredFile, validFile]); - - const result = await repo.findExpired(); - - expect(result.length).toBe(1); - expect(result[0]).toEqual(expiredFile); - }); - }); - - describe('findByUser', () => { - it('should return files for user', async () => { - const [firstFile, secondFile] = [h5pTemporaryFileFactory.build(), h5pTemporaryFileFactory.build()]; - await em.persistAndFlush([firstFile, secondFile]); - - const result = await repo.findByUser(firstFile.ownedByUserId); - - expect(result.length).toBe(1); - expect(result[0]).toEqual(firstFile); - }); - }); - - describe('findExpiredByUser', () => { - it('should return expired files for user', async () => { - const [firstFile, secondFile] = [ - h5pTemporaryFileFactory.isExpired().build(), - h5pTemporaryFileFactory.isExpired().build(), - ]; - await em.persistAndFlush([firstFile, secondFile]); - - const result = await repo.findExpiredByUser(firstFile.ownedByUserId); - - expect(result.length).toBe(1); - expect(result[0]).toEqual(firstFile); - }); - }); -}); diff --git a/apps/server/src/modules/h5p-editor/repo/temporary-file.repo.ts b/apps/server/src/modules/h5p-editor/repo/temporary-file.repo.ts deleted file mode 100644 index 0d6e4572c37..00000000000 --- a/apps/server/src/modules/h5p-editor/repo/temporary-file.repo.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; -import { BaseRepo } from '@shared/repo/base.repo'; -import { H5pEditorTempFile } from '../entity'; - -@Injectable() -export class TemporaryFileRepo extends BaseRepo { - get entityName() { - return H5pEditorTempFile; - } - - async findByUserAndFilename(userId: EntityId, filename: string): Promise { - return this._em.findOneOrFail(this.entityName, { ownedByUserId: userId, filename }); - } - - async findAllByUserAndFilename(userId: EntityId, filename: string): Promise { - return this._em.find(this.entityName, { ownedByUserId: userId, filename }); - } - - async findExpired(): Promise { - const now = new Date(); - return this._em.find(this.entityName, { expiresAt: { $lt: now } }); - } - - async findByUser(userId: EntityId): Promise { - return this._em.find(this.entityName, { ownedByUserId: userId }); - } - - async findExpiredByUser(userId: EntityId): Promise { - const now = new Date(); - return this._em.find(this.entityName, { $and: [{ ownedByUserId: userId }, { expiresAt: { $lt: now } }] }); - } -} diff --git a/apps/server/src/modules/h5p-editor/service/config/h5p-service-config.ts b/apps/server/src/modules/h5p-editor/service/config/h5p-service-config.ts index f9c8063dffd..11943b74858 100644 --- a/apps/server/src/modules/h5p-editor/service/config/h5p-service-config.ts +++ b/apps/server/src/modules/h5p-editor/service/config/h5p-service-config.ts @@ -1,27 +1,25 @@ import { H5PConfig, UrlGenerator } from '@lumieducation/h5p-server'; -const API_BASE = '/api/v3/h5p-editor'; const STATIC_FILES_BASE = '/h5pstatics'; -export const h5pConfig = new H5PConfig(undefined, { - baseUrl: '', - - ajaxUrl: `${API_BASE}/ajax`, - contentFilesUrl: `${API_BASE}/content`, - contentFilesUrlPlayerOverride: undefined, - contentUserDataUrl: `${API_BASE}/contentUserData`, - downloadUrl: undefined, - librariesUrl: `${API_BASE}/libraries`, - paramsUrl: `${API_BASE}/params`, - playUrl: `${API_BASE}/play`, - setFinishedUrl: `${API_BASE}/finishedData`, - temporaryFilesUrl: `${API_BASE}/temp-files`, - - coreUrl: `${STATIC_FILES_BASE}/core`, - editorLibraryUrl: `${STATIC_FILES_BASE}/editor`, - - contentUserStateSaveInterval: false, - setFinishedEnabled: false, -}); +export const h5pConfig = new H5PConfig(undefined); + +h5pConfig.baseUrl = '/api/v3/h5p-editor'; + +h5pConfig.ajaxUrl = '/ajax'; +h5pConfig.contentFilesUrl = '/content'; +h5pConfig.contentUserDataUrl = '/contentUserData'; + +h5pConfig.librariesUrl = '/libraries'; +h5pConfig.paramsUrl = '/params'; +h5pConfig.playUrl = '/play'; +h5pConfig.setFinishedUrl = '/finishedData'; +h5pConfig.temporaryFilesUrl = '/temp-files'; + +h5pConfig.coreUrl = `${STATIC_FILES_BASE}/core`; +h5pConfig.editorLibraryUrl = `${STATIC_FILES_BASE}/editor`; + +h5pConfig.contentUserStateSaveInterval = false; +h5pConfig.setFinishedEnabled = false; export const h5pUrlGenerator = new UrlGenerator(h5pConfig); diff --git a/apps/server/src/modules/h5p-editor/service/contentStorage.service.spec.ts b/apps/server/src/modules/h5p-editor/service/contentStorage.service.spec.ts index 9642d0a849c..2b976bfce10 100644 --- a/apps/server/src/modules/h5p-editor/service/contentStorage.service.spec.ts +++ b/apps/server/src/modules/h5p-editor/service/contentStorage.service.spec.ts @@ -2,10 +2,10 @@ import { HeadObjectCommandOutput } from '@aws-sdk/client-s3'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { S3ClientAdapter } from '@infra/s3-client'; import { IContentMetadata, ILibraryName, IUser, LibraryName } from '@lumieducation/h5p-server'; +import { ObjectId } from '@mikro-orm/mongodb'; import { HttpException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { IEntity } from '@shared/domain/interface'; -import { ObjectID } from 'bson'; import { Readable } from 'stream'; import { GetH5PFileResponse } from '../controller/dto'; import { H5PContent, H5PContentParentType, H5PContentProperties } from '../entity'; @@ -41,9 +41,9 @@ const helpers = { data: `Data #${n}`, }; const h5pContentProperties: H5PContentProperties = { - creatorId: new ObjectID().toString(), - parentId: new ObjectID().toString(), - schoolId: new ObjectID().toString(), + creatorId: new ObjectId().toString(), + parentId: new ObjectId().toString(), + schoolId: new ObjectId().toString(), metadata, content, parentType: H5PContentParentType.Lesson, @@ -52,7 +52,7 @@ const helpers = { return { withID(id?: number) { - const objectId = new ObjectID(id); + const objectId = new ObjectId(id); h5pContent._id = objectId; h5pContent.id = objectId.toString(); @@ -83,7 +83,7 @@ const helpers = { for (const entity of entities) { if (!entity._id) { - const id = new ObjectID(); + const id = new ObjectId(); entity._id = id; entity.id = id.toString(); } @@ -135,14 +135,14 @@ describe('ContentStorage', () => { canInstallRecommended: false, canUpdateAndInstallLibraries: false, email: 'example@schul-cloud.org', - id: new ObjectID().toHexString(), + id: new ObjectId().toHexString(), name: 'Example User', type: 'user', }; const parentParams: H5PContentParentParams = { - schoolId: new ObjectID().toHexString(), + schoolId: new ObjectId().toHexString(), parentType: H5PContentParentType.Lesson, - parentId: new ObjectID().toHexString(), + parentId: new ObjectId().toHexString(), }; const user = new LumiUserWithContentData(iUser, parentParams); @@ -239,7 +239,7 @@ describe('ContentStorage', () => { const filename = 'filename.txt'; const stream = Readable.from('content'); - const contentID = new ObjectID(); + const contentID = new ObjectId(); const contentIDString = contentID.toString(); const user = helpers.createUser(); @@ -276,7 +276,7 @@ describe('ContentStorage', () => { expect.objectContaining({ name: filename, data: stream, - mimeType: 'application/json', + mimeType: 'application/octet-stream', }) ); }); @@ -423,7 +423,7 @@ describe('ContentStorage', () => { const deleteError = new Error('Could not delete'); - const contentID = new ObjectID().toString(); + const contentID = new ObjectId().toString(); return { contentID, @@ -473,7 +473,7 @@ describe('ContentStorage', () => { const deleteError = new Error('Could not delete'); - const contentID = new ObjectID().toString(); + const contentID = new ObjectId().toString(); return { contentID, @@ -534,7 +534,7 @@ describe('ContentStorage', () => { const user = helpers.createUser(); - const contentID = new ObjectID().toString(); + const contentID = new ObjectId().toString(); const birthtime = new Date(); const size = 100; @@ -623,7 +623,7 @@ describe('ContentStorage', () => { const setup = () => { const filename = 'testfile.txt'; const fileStream = Readable.from('content'); - const contentID = new ObjectID().toString(); + const contentID = new ObjectId().toString(); const fileResponse = createMock({ data: fileStream }); const user = helpers.createUser(); @@ -631,10 +631,10 @@ describe('ContentStorage', () => { // [start, end, expected range] const testRanges = [ - [undefined, undefined, '0-'], - [100, undefined, '100-'], - [undefined, 100, '0-100'], - [100, 999, '100-999'], + [undefined, undefined, undefined], + [100, undefined, undefined], + [undefined, 100, undefined], + [100, 999, 'bytes=100-999'], ] as const; return { filename, contentID, fileStream, fileResponse, testRanges, user, getError }; diff --git a/apps/server/src/modules/h5p-editor/service/contentStorage.service.ts b/apps/server/src/modules/h5p-editor/service/contentStorage.service.ts index 753f40201e3..caad97b0145 100644 --- a/apps/server/src/modules/h5p-editor/service/contentStorage.service.ts +++ b/apps/server/src/modules/h5p-editor/service/contentStorage.service.ts @@ -88,7 +88,7 @@ export class ContentStorage implements IContentStorage { const file: H5pFileDto = { name: filename, data: stream, - mimeType: 'application/json', + mimeType: 'application/octet-stream', }; await this.storageClient.create(fullPath, file); @@ -157,17 +157,15 @@ export class ContentStorage implements IContentStorage { ): Promise { const filePath = this.getFilePath(contentId, file); - let range: string; - if (rangeEnd === undefined) { - // Open ended range - range = `${rangeStart}-`; - } else { + let range: string | undefined; + if (rangeStart && rangeEnd) { // Closed range - range = `${rangeStart}-${rangeEnd}`; + range = `bytes=${rangeStart}-${rangeEnd}`; } - const fileResponse = await this.storageClient.get(filePath, range); - return fileResponse.data; + const { data } = await this.storageClient.get(filePath, range); + + return data; } public async getMetadata(contentId: string): Promise { diff --git a/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.spec.ts b/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.spec.ts index b7d65e25cb4..4a51d5010a2 100644 --- a/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.spec.ts +++ b/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.spec.ts @@ -1,39 +1,43 @@ -import { ServiceOutputTypes } from '@aws-sdk/client-s3'; +import { HeadObjectCommandOutput } from '@aws-sdk/client-s3'; +import { faker } from '@faker-js/faker'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { IUser } from '@lumieducation/h5p-server'; +import { S3ClientAdapter } from '@infra/s3-client'; +import { HttpException, InternalServerErrorException, NotAcceptableException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { File, S3ClientAdapter } from '@infra/s3-client'; import { ReadStream } from 'fs'; import { Readable } from 'node:stream'; -import { GetH5pFileResponse } from '../controller/dto'; -import { H5pEditorTempFile } from '../entity/h5p-editor-tempfile.entity'; +import { GetH5PFileResponse } from '../controller/dto'; import { H5P_CONTENT_S3_CONNECTION } from '../h5p-editor.config'; -import { TemporaryFileRepo } from '../repo/temporary-file.repo'; import { TemporaryFileStorage } from './temporary-file-storage.service'; -const today = new Date(); -const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); +const helpers = { + createUser() { + return { + canCreateRestricted: false, + canInstallRecommended: false, + canUpdateAndInstallLibraries: false, + email: 'example@schul-cloud.org', + id: '12345', + name: 'Example User', + type: 'user', + }; + }, +}; describe('TemporaryFileStorage', () => { let module: TestingModule; let storage: TemporaryFileStorage; let s3clientAdapter: DeepMocked; - let repo: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ providers: [ TemporaryFileStorage, - { - provide: TemporaryFileRepo, - useValue: createMock(), - }, { provide: H5P_CONTENT_S3_CONNECTION, useValue: createMock() }, ], }).compile(); storage = module.get(TemporaryFileStorage); s3clientAdapter = module.get(H5P_CONTENT_S3_CONNECTION); - repo = module.get(TemporaryFileRepo); }); afterAll(async () => { @@ -44,265 +48,383 @@ describe('TemporaryFileStorage', () => { jest.resetAllMocks(); }); - const fileContent = (userId: string, filename: string) => `Test content of ${userId}'s ${filename}`; - - const setup = () => { - const user1: Required = { - email: 'user1@example.org', - id: '12345-12345', - name: 'Marla Mathe', - type: 'local', - canCreateRestricted: false, - canInstallRecommended: false, - canUpdateAndInstallLibraries: false, - }; - const filename1 = 'abc/def.txt'; - const file1 = new H5pEditorTempFile({ - filename: filename1, - ownedByUserId: user1.id, - expiresAt: tomorrow, - birthtime: new Date(), - size: fileContent(user1.id, filename1).length, - }); - - const user2: Required = { - email: 'user2@example.org', - id: '54321-54321', - name: 'Mirjam Mathe', - type: 'local', - canCreateRestricted: false, - canInstallRecommended: false, - canUpdateAndInstallLibraries: false, - }; - const filename2 = 'uvw/xyz.txt'; - const file2 = new H5pEditorTempFile({ - filename: filename2, - ownedByUserId: user2.id, - expiresAt: tomorrow, - birthtime: new Date(), - size: fileContent(user2.id, filename2).length, - }); - - return { - user1, - user2, - file1, - file2, - }; - }; - it('service should be defined', () => { expect(storage).toBeDefined(); }); describe('deleteFile is called', () => { + const setup = () => { + const filename = 'file.txt'; + const invalidFilename = '..test.txt'; + + const user = helpers.createUser(); + const userID = user.id; + + const deleteError = new Error('Could not delete'); + + return { + deleteError, + filename, + invalidFilename, + user, + userID, + }; + }; + describe('WHEN file exists', () => { it('should delete file', async () => { - const { user1, file1 } = setup(); - const res = [`h5p-tempfiles/${user1.id}/${file1.filename}`]; - repo.findByUserAndFilename.mockResolvedValueOnce(file1); + const { userID, filename } = setup(); + const res = [`h5p-tempfiles/${userID}/${filename}`]; - await storage.deleteFile(file1.filename, user1.id); + await storage.deleteFile(filename, userID); - expect(repo.delete).toHaveBeenCalled(); expect(s3clientAdapter.delete).toHaveBeenCalledTimes(1); expect(s3clientAdapter.delete).toHaveBeenCalledWith(res); }); }); - describe('WHEN file does not exist', () => { + + describe('WHEN filename is invalid', () => { it('should throw error', async () => { - const { user1, file1 } = setup(); - repo.findByUserAndFilename.mockImplementation(() => { - throw new Error('Not found'); - }); + const { userID, invalidFilename } = setup(); - await expect(async () => { - await storage.deleteFile(file1.filename, user1.id); - }).rejects.toThrow(); + const deletePromise = storage.deleteFile(invalidFilename, userID); - expect(repo.delete).not.toHaveBeenCalled(); - expect(s3clientAdapter.delete).not.toHaveBeenCalled(); + await expect(deletePromise).rejects.toThrow(); + }); + }); + + describe('WHEN S3ClientAdapter throws an error', () => { + it('should throw along the error', async () => { + const { userID, filename, deleteError } = setup(); + s3clientAdapter.delete.mockRejectedValueOnce(deleteError); + + const deletePromise = storage.deleteFile(userID, filename); + + await expect(deletePromise).rejects.toBe(deleteError); }); }); }); describe('fileExists is called', () => { + const setup = () => { + const filename = 'file.txt'; + const invalidFilename = '..test.txt'; + + const user = helpers.createUser(); + const userID = user.id; + + const deleteError = new Error('Could not delete'); + + return { + deleteError, + filename, + invalidFilename, + user, + userID, + }; + }; + describe('WHEN file exists', () => { it('should return true', async () => { - const { user1, file1 } = setup(); - repo.findByUserAndFilename.mockResolvedValueOnce(file1); + const { user, filename } = setup(); + s3clientAdapter.head.mockResolvedValueOnce(createMock()); - const result = await storage.fileExists(file1.filename, user1); + const exists = await storage.fileExists(filename, user); - expect(result).toBe(true); + expect(exists).toBe(true); }); }); + describe('WHEN file does not exist', () => { it('should return false', async () => { - const { user1 } = setup(); - repo.findAllByUserAndFilename.mockResolvedValue([]); + const { user, filename } = setup(); + s3clientAdapter.get.mockRejectedValue(new NotFoundException('NoSuchKey')); - const exists = await storage.fileExists('abc/nonexistingfile.txt', user1); + const exists = await storage.fileExists(filename, user); expect(exists).toBe(false); }); }); + + describe('WHEN S3ClientAdapter.head throws error', () => { + it('should throw HttpException', async () => { + const { user, filename } = setup(); + s3clientAdapter.get.mockRejectedValueOnce(new Error()); + + const existsPromise = storage.fileExists(filename, user); + + await expect(existsPromise).rejects.toThrow(HttpException); + }); + }); + + describe('WHEN filename is invalid', () => { + it('should throw error', async () => { + const { user, invalidFilename } = setup(); + + const existsPromise = storage.fileExists(invalidFilename, user); + + await expect(existsPromise).rejects.toThrow(); + }); + }); }); - describe('getFileStats is called', () => { + describe('getFileStats', () => { + const setup = () => { + const filename = 'file.txt'; + + const user = helpers.createUser(); + const userID = user.id; + + const birthtime = new Date(); + const size = 100; + + const headResponse = createMock({ + ContentLength: size, + LastModified: birthtime, + }); + + const headResponseWithoutContentLength = createMock({ + ContentLength: undefined, + LastModified: birthtime, + }); + + const headResponseWithoutLastModified = createMock({ + ContentLength: size, + LastModified: undefined, + }); + + const headError = new Error('Head'); + + return { + size, + birthtime, + userID, + filename, + user, + headResponse, + headResponseWithoutContentLength, + headResponseWithoutLastModified, + headError, + }; + }; + describe('WHEN file exists', () => { it('should return file stats', async () => { - const { user1, file1 } = setup(); - repo.findByUserAndFilename.mockResolvedValueOnce(file1); + const { filename, user, headResponse, size, birthtime } = setup(); + s3clientAdapter.head.mockResolvedValueOnce(headResponse); - const filestats = await storage.getFileStats(file1.filename, user1); + const stats = await storage.getFileStats(filename, user); - expect(filestats.size).toBe(file1.size); - expect(filestats.birthtime).toBe(file1.birthtime); + expect(stats).toEqual( + expect.objectContaining({ + birthtime, + size, + }) + ); }); }); - describe('WHEN file does not exist', () => { - it('should throw error', async () => { - const { user1 } = setup(); - repo.findByUserAndFilename.mockImplementation(() => { - throw new Error('Not found'); - }); - const fileStatsPromise = storage.getFileStats('abc/nonexistingfile.txt', user1); + describe('WHEN response from S3 is missing ContentLength field', () => { + it('should throw InternalServerError', async () => { + const { filename, user, headResponseWithoutContentLength } = setup(); + s3clientAdapter.head.mockResolvedValueOnce(headResponseWithoutContentLength); - await expect(fileStatsPromise).rejects.toThrow(); + const statsPromise = storage.getFileStats(filename, user); + + await expect(statsPromise).rejects.toThrow(InternalServerErrorException); }); }); - describe('WHEN filename is invalid', () => { - it('should throw error', async () => { - const { user1 } = setup(); - const fileStatsPromise = storage.getFileStats('/../&$!.txt', user1); - await expect(fileStatsPromise).rejects.toThrow(); + + describe('WHEN response from S3 is missing LastModified field', () => { + it('should throw InternalServerError', async () => { + const { filename, user, headResponseWithoutLastModified } = setup(); + s3clientAdapter.head.mockResolvedValueOnce(headResponseWithoutLastModified); + + const statsPromise = storage.getFileStats(filename, user); + + await expect(statsPromise).rejects.toThrow(InternalServerErrorException); + }); + }); + + describe('WHEN S3ClientAdapter.head throws error', () => { + it('should throw the error', async () => { + const { filename, user, headError } = setup(); + s3clientAdapter.head.mockRejectedValueOnce(headError); + + const statsPromise = storage.getFileStats(filename, user); + + await expect(statsPromise).rejects.toBe(headError); }); }); }); describe('getFileStream is called', () => { - describe('WHEN file exists and no range is given', () => { - it('should return readable file stream', async () => { - const { user1, file1 } = setup(); - const actualContent = fileContent(user1.id, file1.filename); - const response: Required = { - data: Readable.from(actualContent), - etag: '', - contentType: '', - contentLength: 0, - contentRange: '', - name: '', - }; - - repo.findByUserAndFilename.mockResolvedValueOnce(file1); - s3clientAdapter.get.mockResolvedValueOnce(response); - - const stream = await storage.getFileStream(file1.filename, user1); - - let content = Buffer.alloc(0); - await new Promise((resolve, reject) => { - stream.on('data', (chunk) => { - content += chunk; - }); - stream.on('error', reject); - stream.on('end', resolve); - }); + const setup = () => { + const filename = 'testfile.txt'; + const fileStream = Readable.from('content'); + const fileResponse = createMock({ data: fileStream }); + const user = helpers.createUser(); + const userID = user.id; + + const getError = new Error('Could not get file'); + + // [start, end, expected range] + const testRanges = [ + [undefined, undefined, undefined], + [100, undefined, undefined], + [undefined, 100, undefined], + [100, 999, 'bytes=100-999'], + ] as const; + + return { filename, userID, fileStream, fileResponse, testRanges, user, getError }; + }; + + describe('WHEN file exists', () => { + it('should S3ClientAdapter.get with range', async () => { + const { testRanges, filename, user, fileResponse } = setup(); + + for (const range of testRanges) { + s3clientAdapter.get.mockResolvedValueOnce(fileResponse); + + // eslint-disable-next-line no-await-in-loop + await storage.getFileStream(filename, user, range[0], range[1]); + + expect(s3clientAdapter.get).toHaveBeenCalledWith(expect.stringContaining(filename), range[2]); + } + }); + + it('should return stream from S3ClientAdapter', async () => { + const { fileStream, filename, user, fileResponse } = setup(); + s3clientAdapter.get.mockResolvedValueOnce(fileResponse); - expect(content).not.toBe(null); - expect(content.toString()).toEqual(actualContent); + const stream = await storage.getFileStream(filename, user); + + expect(stream).toBe(fileStream); }); }); - describe('WHEN file does not exist', () => { - it('should throw error', async () => { - const { user1 } = setup(); - repo.findByUserAndFilename.mockImplementation(() => { - throw new Error('Not found'); - }); - const fileStreamPromise = storage.getFileStream('abc/nonexistingfile.txt', user1); + describe('WHEN S3ClientAdapter.get throws error', () => { + it('should throw the error', async () => { + const { filename, user, getError } = setup(); + s3clientAdapter.get.mockRejectedValueOnce(getError); - await expect(fileStreamPromise).rejects.toThrow(); + const streamPromise = storage.getFileStream(filename, user); + + await expect(streamPromise).rejects.toBe(getError); }); }); }); describe('listFiles is called', () => { - describe('WHEN existing user is given', () => { - it('should return only users file', async () => { - const { user1, file1 } = setup(); - repo.findByUser.mockResolvedValueOnce([file1]); + const setup = () => { + const user = helpers.createUser(); + + return { user }; + }; - const files = await storage.listFiles(user1); + describe('WHEN user is given', () => { + it('should return empty array', async () => { + const { user } = setup(); + const files = await storage.listFiles(user); - expect(files.length).toBe(1); - expect(files[0].ownedByUserId).toBe(user1.id); - expect(files[0].filename).toBe(file1.filename); + expect(files).toHaveLength(0); }); }); - describe('WHEN no user is given', () => { - it('should return all expired files)', async () => { - const { user1, user2, file1, file2 } = setup(); - repo.findExpired.mockResolvedValueOnce([file1, file2]); + describe('WHEN no user is given', () => { + it('should return empty array', async () => { const files = await storage.listFiles(); - expect(files.length).toBe(2); - expect(files[0].ownedByUserId).toBe(user1.id); - expect(files[1].ownedByUserId).toBe(user2.id); + expect(files).toHaveLength(0); }); }); }); + describe('saveFile is called', () => { - describe('WHEN file exists', () => { - it('should overwrite file', async () => { - const { user1, file1 } = setup(); - const newData = 'This is new fake H5P content.'; - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const readStream = Readable.from(newData) as ReadStream; - repo.findByUserAndFilename.mockResolvedValueOnce(file1); - let savedData = Buffer.alloc(0); - s3clientAdapter.create.mockImplementation(async (path: string, file: File) => { - savedData += file.data.read(); - return Promise.resolve({} as ServiceOutputTypes); + const setup = () => { + const filename = 'filename.txt'; + const invalidFilename = '..test.txt'; + const stream = Readable.from('content') as ReadStream; + + const user = helpers.createUser(); + const userID = user.id; + + const fileDeleteError = new Error('Could not delete file'); + const fileCreateError = new Error('Could not create file'); + + const recentDate = faker.date.recent(); + const soonDate = faker.date.soon(); + + return { + filename, + invalidFilename, + stream, + user, + userID, + fileDeleteError, + fileCreateError, + recentDate, + soonDate, + }; + }; + + describe('WHEN saving a valid files', () => { + it('should call s3client.create', async () => { + const { filename, stream, user, soonDate } = setup(); + + await storage.saveFile(filename, stream, user, soonDate); + + expect(s3clientAdapter.create).toHaveBeenCalledWith( + expect.stringContaining(filename), + expect.objectContaining({ + name: filename, + data: stream, + mimeType: 'application/octet-stream', + }) + ); + }); + + it('should return ITemporaryFile', async () => { + const { filename, stream, user, soonDate } = setup(); + + const result = await storage.saveFile(filename, stream, user, soonDate); + + expect(result).toEqual({ + expiresAt: soonDate, + filename, + ownedByUserId: user.id, }); + }); + }); - await storage.saveFile(file1.filename, readStream, user1, tomorrow); + describe('WHEN filename is invalid', () => { + it('should throw error', async () => { + const { user, invalidFilename } = setup(); + + const existsPromise = storage.fileExists(invalidFilename, user); - expect(s3clientAdapter.delete).toHaveBeenCalled(); - expect(savedData.toString()).toBe(newData); + await expect(existsPromise).rejects.toThrow(); }); }); - describe('WHEN file does not exist', () => { - it('should create and overwrite new file', async () => { - const { user1 } = setup(); - const filename = 'newfile.txt'; - const newData = 'This is new fake H5P content.'; - const readStream = Readable.from(newData) as ReadStream; - let savedData = Buffer.alloc(0); - s3clientAdapter.create.mockImplementation(async (path: string, file: File) => { - savedData += file.data.read(); - return Promise.resolve({} as ServiceOutputTypes); - }); + describe('WHEN expiration is in the past', () => { + it('should throw NotAcceptableAcception', async () => { + const { filename, stream, user, recentDate } = setup(); - await storage.saveFile(filename, readStream, user1, tomorrow); + const savePromise = storage.saveFile(filename, stream, user, recentDate); - expect(s3clientAdapter.delete).toHaveBeenCalled(); - expect(savedData.toString()).toBe(newData); + await expect(savePromise).rejects.toThrow(NotAcceptableException); }); }); - describe('WHEN expirationTime is in the past', () => { - it('should throw error', async () => { - const { user1, file1 } = setup(); - const newData = 'This is new fake H5P content.'; - const readStream = Readable.from(newData) as ReadStream; + describe('WHEN S3ClientAdapter throws error', () => { + it('should throw the error', async () => { + const { filename, stream, fileCreateError, user, soonDate } = setup(); + s3clientAdapter.create.mockRejectedValueOnce(fileCreateError); - const saveFile = storage.saveFile(file1.filename, readStream, user1, new Date(2023, 0, 1)); + const addFilePromise = storage.saveFile(filename, stream, user, soonDate); - await expect(saveFile).rejects.toThrow(); + await expect(addFilePromise).rejects.toBe(fileCreateError); }); }); }); diff --git a/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.ts b/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.ts index 7921b52a27b..88018659920 100644 --- a/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.ts +++ b/apps/server/src/modules/h5p-editor/service/temporary-file-storage.service.ts @@ -1,79 +1,110 @@ -import { ITemporaryFile, ITemporaryFileStorage, IUser } from '@lumieducation/h5p-server'; -import { Inject, Injectable, NotAcceptableException } from '@nestjs/common'; import { S3ClientAdapter } from '@infra/s3-client'; +import { IFileStats, ITemporaryFile, ITemporaryFileStorage, IUser } from '@lumieducation/h5p-server'; +import { + HttpException, + Inject, + Injectable, + InternalServerErrorException, + NotAcceptableException, + NotFoundException, +} from '@nestjs/common'; +import { ErrorUtils } from '@src/core/error/utils'; import { ReadStream } from 'fs'; import { Readable } from 'stream'; import { H5pFileDto } from '../controller/dto/h5p-file.dto'; -import { H5pEditorTempFile } from '../entity/h5p-editor-tempfile.entity'; import { H5P_CONTENT_S3_CONNECTION } from '../h5p-editor.config'; -import { TemporaryFileRepo } from '../repo/temporary-file.repo'; @Injectable() export class TemporaryFileStorage implements ITemporaryFileStorage { - constructor( - private readonly repo: TemporaryFileRepo, - @Inject(H5P_CONTENT_S3_CONNECTION) private readonly s3Client: S3ClientAdapter - ) {} + constructor(@Inject(H5P_CONTENT_S3_CONNECTION) private readonly s3Client: S3ClientAdapter) {} private checkFilename(filename: string): void { - if (!/^[a-zA-Z0-9/._-]+$/g.test(filename) && filename.includes('..') && filename.startsWith('/')) { - throw new NotAcceptableException(`Filename contains forbidden characters or is empty: '${filename}'`); + filename = filename.split('.').slice(0, -1).join('.'); + if (/^[a-zA-Z0-9/._-]*$/.test(filename) && !filename.includes('..') && !filename.startsWith('/')) { + return; } - } - - private getFileInfo(filename: string, userId: string): Promise { - this.checkFilename(filename); - return this.repo.findByUserAndFilename(userId, filename); + throw new HttpException('message', 406, { + cause: new NotAcceptableException(`Filename contains forbidden characters ${filename}`), + }); } public async deleteFile(filename: string, userId: string): Promise { this.checkFilename(filename); - const meta = await this.repo.findByUserAndFilename(userId, filename); - await this.s3Client.delete([this.getFilePath(userId, filename)]); - await this.repo.delete(meta); + const filePath = this.getFilePath(userId, filename); + await this.s3Client.delete([filePath]); } public async fileExists(filename: string, user: IUser): Promise { this.checkFilename(filename); - const files = await this.repo.findAllByUserAndFilename(user.id, filename); - const exists = files.length !== 0; - return exists; + + const filePath = this.getFilePath(user.id, filename); + + try { + await this.s3Client.get(filePath); + } catch (err) { + if (err instanceof NotFoundException) { + return false; + } + + throw new InternalServerErrorException( + null, + ErrorUtils.createHttpExceptionOptions(err, 'TemporaryFileStorage:fileExists') + ); + } + + return true; } - public async getFileStats(filename: string, user: IUser): Promise { - return this.getFileInfo(filename, user.id); + public async getFileStats(filename: string, user: IUser): Promise { + const filePath = this.getFilePath(user.id, filename); + const { ContentLength, LastModified } = await this.s3Client.head(filePath); + + if (ContentLength === undefined || LastModified === undefined) { + throw new InternalServerErrorException( + { ContentLength, LastModified }, + 'TemporaryFileStorage:getFileStats ContentLength or LastModified are undefined' + ); + } + + const fileStats: IFileStats = { + birthtime: LastModified, + size: ContentLength, + }; + + return fileStats; } public async getFileStream( filename: string, user: IUser, - rangeStart = 0, + rangeStart?: number | undefined, rangeEnd?: number | undefined ): Promise { - this.checkFilename(filename); - const tempFile = await this.repo.findByUserAndFilename(user.id, filename); - const path = this.getFilePath(user.id, filename); - let rangeEndNew = 0; - if (rangeEnd === undefined) { - rangeEndNew = tempFile.size - 1; + const filePath = this.getFilePath(user.id, filename); + + let range: string | undefined; + if (rangeStart && rangeEnd) { + // Closed range + range = `bytes=${rangeStart}-${rangeEnd}`; } - const response = await this.s3Client.get(path, `${rangeStart}-${rangeEndNew}`); - return response.data; - } + const { data } = await this.s3Client.get(filePath, range); - public async listFiles(user?: IUser): Promise { - // method is expected to support listing all files in database - // Lumi uses the variant without a user to search for expired files, so we only return those + data.pause(); - let files: ITemporaryFile[]; - if (user) { - files = await this.repo.findByUser(user.id); - } else { - files = await this.repo.findExpired(); - } + return data; + } - return files; + /** + * @deprecated do not use this function + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async listFiles(_user?: IUser): Promise { + // Lumi uses this method to find expired files that should be deleted. + // Since we use S3 to delete expired files, we just use a barebones implementation + // Lumi's reference implementation does it the same way + + return Promise.resolve([]); } public async saveFile( @@ -83,42 +114,37 @@ export class TemporaryFileStorage implements ITemporaryFileStorage { expirationTime: Date ): Promise { this.checkFilename(filename); + const now = new Date(); if (expirationTime < now) { throw new NotAcceptableException('expirationTime must be in the future'); } const path = this.getFilePath(user.id, filename); - let tempFile: H5pEditorTempFile | undefined; - try { - tempFile = await this.repo.findByUserAndFilename(user.id, filename); - await this.s3Client.delete([path]); - } finally { - if (tempFile === undefined) { - tempFile = new H5pEditorTempFile({ - filename, - ownedByUserId: user.id, - expiresAt: expirationTime, - birthtime: new Date(), - size: dataStream.bytesRead, - }); - } else { - tempFile.expiresAt = expirationTime; - tempFile.size = dataStream.bytesRead; - } - } - await this.s3Client.create( - path, - new H5pFileDto({ name: path, mimeType: 'application/octet-stream', data: dataStream }) - ); - await this.repo.save(tempFile); - return tempFile; + const file: H5pFileDto = { + name: filename, + data: dataStream, + mimeType: 'application/octet-stream', + }; + await this.s3Client.create(path, file); + + const temporaryFile: ITemporaryFile = { + filename, + ownedByUserId: user.id, + expiresAt: expirationTime, + }; + + return temporaryFile; } - private getFilePath(userId: string, filename: string): string { - const path = `h5p-tempfiles/${userId}/${filename}`; + private getUserPath(userId: string): string { + const path = `h5p-tempfiles/${userId}/`; + return path; + } + private getFilePath(userId: string, filename: string): string { + const path = `${this.getUserPath(userId)}${filename}`; return path; } } diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-ajax.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-ajax.uc.spec.ts index 5918cde42a4..b016182bce8 100644 --- a/apps/server/src/modules/h5p-editor/uc/h5p-ajax.uc.spec.ts +++ b/apps/server/src/modules/h5p-editor/uc/h5p-ajax.uc.spec.ts @@ -1,12 +1,12 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { H5PAjaxEndpoint, H5PEditor, H5PPlayer, H5pError } from '@lumieducation/h5p-server'; +import { HttpException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { UserDO } from '@shared/domain/domainobject'; -import { LanguageType } from '@shared/domain/entity'; +import { LanguageType } from '@shared/domain/interface'; import { setupEntities } from '@shared/testing'; import { AuthorizationReferenceService } from '@src/modules/authorization/domain'; import { UserService } from '@src/modules/user'; -import { H5PErrorMapper } from '../mapper/h5p-error.mapper'; import { H5PContentRepo } from '../repo'; import { LibraryStorage } from '../service'; import { H5PEditorUc } from './h5p.uc'; @@ -74,7 +74,6 @@ describe('H5P Ajax', () => { accountId: 'dummyAccountId', isExternalUser: false, }; - const spy = jest.spyOn(H5PErrorMapper.prototype, 'mapH5pError'); it('should call H5PAjaxEndpoint.getAjax and return the result', async () => { const dummyResponse = { @@ -102,10 +101,10 @@ describe('H5P Ajax', () => { ); }); - it('should invoce h5p-error mapper', async () => { + it('should rethrow H5pError as HttpException', async () => { ajaxEndpoint.getAjax.mockRejectedValueOnce(new Error('Dummy Error')); - await uc.getAjax({ action: 'content-type-cache' }, userMock); - expect(spy).toHaveBeenCalledTimes(1); + const getPromise = uc.getAjax({ action: 'content-type-cache' }, userMock); + await expect(getPromise).rejects.toThrow(HttpException); }); }); @@ -117,7 +116,6 @@ describe('H5P Ajax', () => { accountId: 'dummyAccountId', isExternalUser: false, }; - const spy = jest.spyOn(H5PErrorMapper.prototype, 'mapH5pError'); it('should call H5PAjaxEndpoint.postAjax and return the result', async () => { const dummyResponse = [ @@ -214,15 +212,16 @@ describe('H5P Ajax', () => { ); }); - it('should invoce h5p-error.mapper', async () => { + it('should rethrow H5pError as HttpException', async () => { ajaxEndpoint.postAjax.mockRejectedValueOnce(new H5pError('dummy-error', { error: 'Dummy Error' }, 400)); - await uc.postAjax( + const postPromise = uc.postAjax( userMock, { action: 'libraries' }, { contentId: 'id', field: 'field', libraries: ['dummyLibrary-1.0'], libraryParameters: '' } ); - expect(spy).toHaveBeenCalledTimes(1); + + await expect(postPromise).rejects.toThrow(HttpException); }); }); }); diff --git a/apps/server/src/modules/h5p-editor/uc/h5p-get-editor.uc.spec.ts b/apps/server/src/modules/h5p-editor/uc/h5p-get-editor.uc.spec.ts index e593b29b821..386ba608fc0 100644 --- a/apps/server/src/modules/h5p-editor/uc/h5p-get-editor.uc.spec.ts +++ b/apps/server/src/modules/h5p-editor/uc/h5p-get-editor.uc.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { H5PEditor, H5PPlayer, IEditorModel } from '@lumieducation/h5p-server'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { LanguageType } from '@shared/domain/entity'; +import { LanguageType } from '@shared/domain/interface'; import { UserRepo } from '@shared/repo'; import { h5pContentFactory, setupEntities } from '@shared/testing'; import { ICurrentUser } from '@src/modules/authentication'; diff --git a/apps/server/src/modules/h5p-editor/uc/h5p.uc.ts b/apps/server/src/modules/h5p-editor/uc/h5p.uc.ts index 41803917bbe..deca6dd0a7d 100644 --- a/apps/server/src/modules/h5p-editor/uc/h5p.uc.ts +++ b/apps/server/src/modules/h5p-editor/uc/h5p.uc.ts @@ -21,7 +21,7 @@ import { NotAcceptableException, NotFoundException, } from '@nestjs/common'; -import { LanguageType } from '@shared/domain/entity'; +import { LanguageType } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { ICurrentUser } from '@src/modules/authentication'; import { AuthorizationContext, AuthorizationContextBuilder } from '@src/modules/authorization'; @@ -111,8 +111,7 @@ export class H5PEditorUc { return result; } catch (err) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - h5pErrorMapper.mapH5pError(err); - return undefined; + throw h5pErrorMapper.mapH5pError(err); } } @@ -163,8 +162,7 @@ export class H5PEditorUc { return result; } catch (err) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - h5pErrorMapper.mapH5pError(err); - return undefined; + throw h5pErrorMapper.mapH5pError(err); } } @@ -233,24 +231,8 @@ export class H5PEditorUc { try { const rangeCallback = this.getRange(req); - const adapterRangeCallback: (filesize: number) => { end: number; start: number } = (filesize) => { - let returnValue = { start: 0, end: 0 }; - - if (rangeCallback) { - const result = rangeCallback(filesize); - - if (result) { - returnValue = { start: result.start, end: result.end }; - } - } - - return returnValue; - }; - const { mimetype, range, stats, stream } = await this.h5pAjaxEndpoint.getTemporaryFile( - file, - user, - adapterRangeCallback - ); + // @ts-expect-error rangeCallback can return undefined, typings from @lumieducation/h5p-server are wrong + const { mimetype, range, stats, stream } = await this.h5pAjaxEndpoint.getTemporaryFile(file, user, rangeCallback); return { data: stream, diff --git a/apps/server/src/modules/health/controller/api-test/health-checks.api.spec.ts b/apps/server/src/modules/health/controller/api-test/health-checks.api.spec.ts new file mode 100644 index 00000000000..ac5f6d815bc --- /dev/null +++ b/apps/server/src/modules/health/controller/api-test/health-checks.api.spec.ts @@ -0,0 +1,86 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityManager } from '@mikro-orm/mongodb'; +import request from 'supertest'; + +import { cleanupCollections } from '@shared/testing'; +import { InternalServerTestModule } from '@src/modules/internal-server/internal-server-test.module'; +import { HealthStatusResponse } from '../dto'; +import { HealthStatuses } from '../../domain'; + +class API { + app: INestApplication; + + constructor(app: INestApplication) { + this.app = app; + } + + async get(pathSuffix: string) { + const response = await request(this.app.getHttpServer()).get(`/health${pathSuffix}`); + + return { + result: response.body as HealthStatusResponse, + status: response.status, + }; + } +} + +describe('health checks (api)', () => { + let app: INestApplication; + let em: EntityManager; + let api: API; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [InternalServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + api = new API(app); + }); + + beforeEach(async () => { + await cleanupCollections(em); + em.clear(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('the self-only health check', () => { + it('should return 200 OK HTTP status', async () => { + const response = await api.get('/self'); + + expect(response.status).toEqual(200); + }); + + it( + `should return '${HealthStatuses.STATUS_PASS}' health status` + + 'and no additional checks info (as none are performed underneath)', + async () => { + const response = await api.get('/self'); + + expect(response.result.status).toEqual(HealthStatuses.STATUS_PASS); + expect(response.result.checks).toBeUndefined(); + } + ); + }); + + describe(`the overall health check`, () => { + it('should return 200 OK HTTP status', async () => { + const response = await api.get(''); + + expect(response.status).toEqual(200); + }); + + it(`should return '${HealthStatuses.STATUS_PASS}' health status and some additional checks info`, async () => { + const response = await api.get(''); + + expect(response.result.status).toEqual(HealthStatuses.STATUS_PASS); + expect(response.result.checks).toBeDefined(); + }); + }); +}); diff --git a/apps/server/src/modules/health/controller/dto/index.ts b/apps/server/src/modules/health/controller/dto/index.ts new file mode 100644 index 00000000000..faf38252741 --- /dev/null +++ b/apps/server/src/modules/health/controller/dto/index.ts @@ -0,0 +1,2 @@ +export * from './response'; +export * from './mapper'; diff --git a/apps/server/src/modules/health/controller/dto/mapper/health-status-check-response.mapper.spec.ts b/apps/server/src/modules/health/controller/dto/mapper/health-status-check-response.mapper.spec.ts new file mode 100644 index 00000000000..782fbb9b60d --- /dev/null +++ b/apps/server/src/modules/health/controller/dto/mapper/health-status-check-response.mapper.spec.ts @@ -0,0 +1,58 @@ +import { HealthStatusCheckResponseMapper } from './health-status-check-response.mapper'; +import { HealthStatusCheck } from '../../../domain'; +import { HealthStatusCheckResponse } from '../response'; + +describe(HealthStatusCheckResponseMapper.name, () => { + describe(HealthStatusCheckResponseMapper.mapToResponse.name, () => { + const testComponentType = 'system'; + const testStatus = 'warn'; + + describe('when called with just the required fields', () => { + const setup = () => { + const testRequiredCheckProps = { + componentType: testComponentType, + status: testStatus, + }; + + const testHealthStatusCheck = new HealthStatusCheck(testRequiredCheckProps); + const expectedMappedResponse = new HealthStatusCheckResponse(testRequiredCheckProps); + + return { testHealthStatusCheck, expectedMappedResponse }; + }; + + it('should map to a valid object', () => { + const { testHealthStatusCheck, expectedMappedResponse } = setup(); + + const mappedResponse = HealthStatusCheckResponseMapper.mapToResponse(testHealthStatusCheck); + + expect(mappedResponse).toStrictEqual(expectedMappedResponse); + }); + }); + + describe('when called with all the available fields', () => { + const setup = () => { + const testAllCheckProps = { + componentType: testComponentType, + componentId: 'c67e11c8-f1dd-402e-887c-2cadc3d604db', + observedValue: 42, + observedUnit: 'percent', + status: testStatus, + time: new Date(), + output: 'High RAM usage', + }; + const testHealthStatusCheck = new HealthStatusCheck(testAllCheckProps); + const expectedMappedResponse = new HealthStatusCheckResponse(testAllCheckProps); + + return { testHealthStatusCheck, expectedMappedResponse }; + }; + + it('should map to a valid object', () => { + const { testHealthStatusCheck, expectedMappedResponse } = setup(); + + const mappedResponse = HealthStatusCheckResponseMapper.mapToResponse(testHealthStatusCheck); + + expect(mappedResponse).toStrictEqual(expectedMappedResponse); + }); + }); + }); +}); diff --git a/apps/server/src/modules/health/controller/dto/mapper/health-status-check-response.mapper.ts b/apps/server/src/modules/health/controller/dto/mapper/health-status-check-response.mapper.ts new file mode 100644 index 00000000000..9286f6d1cb3 --- /dev/null +++ b/apps/server/src/modules/health/controller/dto/mapper/health-status-check-response.mapper.ts @@ -0,0 +1,16 @@ +import { HealthStatusCheck } from '../../../domain'; +import { HealthStatusCheckResponse } from '../response'; + +export class HealthStatusCheckResponseMapper { + static mapToResponse(healthStatusCheck: HealthStatusCheck): HealthStatusCheckResponse { + return new HealthStatusCheckResponse({ + componentType: healthStatusCheck.componentType, + componentId: healthStatusCheck.componentId, + observedValue: healthStatusCheck.observedValue, + observedUnit: healthStatusCheck.observedUnit, + status: healthStatusCheck.status, + time: healthStatusCheck.time, + output: healthStatusCheck.output, + }); + } +} diff --git a/apps/server/src/modules/health/controller/dto/mapper/health-status-response.mapper.spec.ts b/apps/server/src/modules/health/controller/dto/mapper/health-status-response.mapper.spec.ts new file mode 100644 index 00000000000..b44c76aa0da --- /dev/null +++ b/apps/server/src/modules/health/controller/dto/mapper/health-status-response.mapper.spec.ts @@ -0,0 +1,141 @@ +import { HealthStatusResponseMapper } from './health-status-response.mapper'; +import { HealthStatus, HealthStatusCheck } from '../../../domain'; +import { HealthStatusResponse, HealthStatusCheckResponse } from '../response'; + +describe(HealthStatusResponseMapper.name, () => { + describe(HealthStatusResponseMapper.mapToResponse.name, () => { + const testStatus = 'warn'; + const testRequiredStatusProps = { status: testStatus }; + + describe('when called with just the required fields', () => { + const setup = () => { + const testHealthStatus = new HealthStatus(testRequiredStatusProps); + const expectedMappedResponse = new HealthStatusResponse(testRequiredStatusProps); + + return { testHealthStatus, expectedMappedResponse }; + }; + + it('should map to a valid object', () => { + const { testHealthStatus, expectedMappedResponse } = setup(); + + const mappedResponse = HealthStatusResponseMapper.mapToResponse(testHealthStatus); + + expect(mappedResponse).toStrictEqual(expectedMappedResponse); + }); + }); + + describe('when called with all the top-level fields', () => { + const testDescription = 'System health status'; + const testOutput = 'High RAM usage'; + const testStatusProps = { + status: testStatus, + description: testDescription, + output: testOutput, + }; + const testCheckKey = 'memory:utilization'; + const testCheckProps = { + componentType: 'system', + componentId: '163e5371-f2ee-4a19-b02d-8bfecb769219', + observedValue: 42, + observedUnit: 'percent', + status: testStatus, + time: new Date(), + output: 'High RAM usage', + }; + + describe('without any checks', () => { + const setup = () => { + const testHealthStatus = new HealthStatus(testStatusProps); + const expectedMappedResponse = new HealthStatusResponse(testStatusProps); + + return { testHealthStatus, expectedMappedResponse }; + }; + + it('should map to a valid object', () => { + const { testHealthStatus, expectedMappedResponse } = setup(); + + const mappedResponse = HealthStatusResponseMapper.mapToResponse(testHealthStatus); + + expect(mappedResponse).toStrictEqual(expectedMappedResponse); + }); + }); + + describe('with a single check', () => { + const setup = () => { + const testHealthStatus = new HealthStatus({ + ...testStatusProps, + checks: { + [testCheckKey]: [new HealthStatusCheck(testCheckProps)], + }, + }); + const expectedMappedResponse = new HealthStatusResponse({ + ...testStatusProps, + checks: { + [testCheckKey]: [new HealthStatusCheckResponse(testCheckProps)], + }, + }); + + return { testHealthStatus, expectedMappedResponse }; + }; + + it('should map to a valid object', () => { + const { testHealthStatus, expectedMappedResponse } = setup(); + + const mappedResponse = HealthStatusResponseMapper.mapToResponse(testHealthStatus); + + expect(mappedResponse).toStrictEqual(expectedMappedResponse); + }); + }); + + describe('with three checks', () => { + const setup = () => { + const secondTestCheckKey = 'mongoDB:totalSpaceUsage'; + const secondTestCheckProps = { + componentType: 'datastore', + componentId: 'd83419eb-d0d7-4237-a2de-265a14d15531', + observedValue: 10.32, + observedUnit: 'PB', + status: testStatus, + time: new Date(), + output: 'High total space usage', + }; + const thirdTestCheckKey = 'disk:spaceUsage'; + const thirdTestCheckProps = { + componentType: 'system', + componentId: 'dd7babd7-932a-44d2-9c3f-a359b8ccd86d', + observedValue: 24, + observedUnit: 'percent', + status: 'pass', + time: new Date(), + }; + const testHealthStatus = new HealthStatus({ + ...testStatusProps, + checks: { + [testCheckKey]: [new HealthStatusCheck(testCheckProps)], + [secondTestCheckKey]: [new HealthStatusCheck(secondTestCheckProps)], + [thirdTestCheckKey]: [new HealthStatusCheck(thirdTestCheckProps)], + }, + }); + const expectedMappedResponse = new HealthStatusResponse({ + ...testStatusProps, + checks: { + [testCheckKey]: [new HealthStatusCheckResponse(testCheckProps)], + [secondTestCheckKey]: [new HealthStatusCheckResponse(secondTestCheckProps)], + [thirdTestCheckKey]: [new HealthStatusCheckResponse(thirdTestCheckProps)], + }, + }); + + return { testHealthStatus, expectedMappedResponse }; + }; + + it('should map to a valid object', () => { + const { testHealthStatus, expectedMappedResponse } = setup(); + + const mappedResponse = HealthStatusResponseMapper.mapToResponse(testHealthStatus); + + expect(mappedResponse).toStrictEqual(expectedMappedResponse); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/health/controller/dto/mapper/health-status-response.mapper.ts b/apps/server/src/modules/health/controller/dto/mapper/health-status-response.mapper.ts new file mode 100644 index 00000000000..d85b8fb9d49 --- /dev/null +++ b/apps/server/src/modules/health/controller/dto/mapper/health-status-response.mapper.ts @@ -0,0 +1,31 @@ +import { HealthStatus } from '../../../domain'; +import { HealthStatusResponse, HealthStatusCheckResponse } from '../response'; +import { HealthStatusCheckResponseMapper } from './health-status-check-response.mapper'; + +export class HealthStatusResponseMapper { + static mapToResponse(healthStatus: HealthStatus): HealthStatusResponse { + const response = new HealthStatusResponse({ + status: healthStatus.status, + description: healthStatus.description, + output: healthStatus.output, + }); + + if (healthStatus.checks !== undefined) { + response.checks = {}; + + for (const key of Object.keys(healthStatus.checks)) { + const checks = healthStatus.checks[key]; + + const responseChecks: Array = []; + + checks.forEach((check) => { + responseChecks.push(HealthStatusCheckResponseMapper.mapToResponse(check)); + }); + + response.checks[key] = responseChecks; + } + } + + return response; + } +} diff --git a/apps/server/src/modules/health/controller/dto/mapper/index.ts b/apps/server/src/modules/health/controller/dto/mapper/index.ts new file mode 100644 index 00000000000..b4a0707ded6 --- /dev/null +++ b/apps/server/src/modules/health/controller/dto/mapper/index.ts @@ -0,0 +1,2 @@ +export * from './health-status-response.mapper'; +export * from './health-status-check-response.mapper'; diff --git a/apps/server/src/modules/health/controller/dto/response/health-status-check.response.ts b/apps/server/src/modules/health/controller/dto/response/health-status-check.response.ts new file mode 100644 index 00000000000..dcb53056bbd --- /dev/null +++ b/apps/server/src/modules/health/controller/dto/response/health-status-check.response.ts @@ -0,0 +1,33 @@ +export class HealthStatusCheckResponse { + componentType: string; + + componentId?: string; + + observedValue?: string | number | object; + + observedUnit?: string; + + status: string; + + time?: Date; + + output?: string; + + constructor({ + componentType, + componentId, + observedValue, + observedUnit, + status, + time, + output, + }: HealthStatusCheckResponse) { + this.componentType = componentType; + this.componentId = componentId; + this.observedValue = observedValue; + this.observedUnit = observedUnit; + this.status = status; + this.time = time; + this.output = output; + } +} diff --git a/apps/server/src/modules/health/controller/dto/response/health-status.response.ts b/apps/server/src/modules/health/controller/dto/response/health-status.response.ts new file mode 100644 index 00000000000..73683a8535a --- /dev/null +++ b/apps/server/src/modules/health/controller/dto/response/health-status.response.ts @@ -0,0 +1,18 @@ +import { HealthStatusCheckResponse } from './health-status-check.response'; + +export class HealthStatusResponse { + status: string; + + description?: string; + + output?: string; + + checks?: Record>; + + constructor({ status, description, output, checks }: HealthStatusResponse) { + this.status = status; + this.description = description; + this.output = output; + this.checks = checks; + } +} diff --git a/apps/server/src/modules/health/controller/dto/response/index.ts b/apps/server/src/modules/health/controller/dto/response/index.ts new file mode 100644 index 00000000000..67432a06df9 --- /dev/null +++ b/apps/server/src/modules/health/controller/dto/response/index.ts @@ -0,0 +1,2 @@ +export * from './health-status.response'; +export * from './health-status-check.response'; diff --git a/apps/server/src/modules/health/controller/health.controller.spec.ts b/apps/server/src/modules/health/controller/health.controller.spec.ts new file mode 100644 index 00000000000..1e24f0768ff --- /dev/null +++ b/apps/server/src/modules/health/controller/health.controller.spec.ts @@ -0,0 +1,110 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpStatus } from '@nestjs/common'; +import { Response } from 'express'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; + +import { HealthUC } from '@src/modules/health/uc'; +import { setupEntities } from '@shared/testing'; +import { HealthController } from './health.controller'; +import { HealthStatus, HealthStatuses } from '../domain'; + +describe(HealthController.name, () => { + const contentTypeApplicationHealthJSON = 'application/health+json'; + const testPassedHealthStatus = new HealthStatus({ status: HealthStatuses.STATUS_PASS }); + const testFailedHealthStatus = new HealthStatus({ status: HealthStatuses.STATUS_FAIL }); + + let module: TestingModule; + let controller: HealthController; + let uc: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + { + provide: HealthUC, + useValue: createMock(), + }, + ], + controllers: [HealthController], + }).compile(); + controller = module.get(HealthController); + uc = module.get(HealthUC); + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getSelfHealth', () => { + const setup = (testHealthStatus: HealthStatus) => { + uc.checkSelfHealth.mockReturnValueOnce(testHealthStatus); + + const mockedRes = {} as unknown as Response; + mockedRes.contentType = jest.fn(); + mockedRes.status = jest.fn(); + const response = controller.getSelfHealth(mockedRes); + + return { response, mockedRes }; + }; + + it(`should return '${HealthStatuses.STATUS_PASS}' health status in a response`, () => { + const { response } = setup(testPassedHealthStatus); + + expect(response.status).toEqual(HealthStatuses.STATUS_PASS); + }); + + it(`should set proper Content-Type in a response`, () => { + const { mockedRes } = setup(testPassedHealthStatus); + + expect(mockedRes.contentType).toBeCalledWith(contentTypeApplicationHealthJSON); + }); + }); + + describe('getOverallHealth', () => { + const setup = async (testHealthStatus: HealthStatus) => { + uc.checkOverallHealth.mockResolvedValueOnce(testHealthStatus); + + const mockedRes = {} as unknown as Response; + mockedRes.contentType = jest.fn(); + mockedRes.status = jest.fn(); + const response = await controller.getOverallHealth(mockedRes); + + return { response, mockedRes }; + }; + + describe(`in case of a '${HealthStatuses.STATUS_PASS}' health status`, () => { + it(`should return '${HealthStatuses.STATUS_PASS}' status in a response`, async () => { + const { response } = await setup(testPassedHealthStatus); + + expect(response.status).toEqual(HealthStatuses.STATUS_PASS); + }); + + it(`should set 200 OK HTTP status and proper Content-Type in a response`, async () => { + const { mockedRes } = await setup(testPassedHealthStatus); + + expect(mockedRes.contentType).toBeCalledWith(contentTypeApplicationHealthJSON); + expect(mockedRes.status).toBeCalledWith(HttpStatus.OK); + }); + }); + + describe(`in case of a '${HealthStatuses.STATUS_FAIL}' health status`, () => { + it(`should return '${HealthStatuses.STATUS_FAIL}' status in a response`, async () => { + const { response } = await setup(testFailedHealthStatus); + + expect(response.status).toEqual(HealthStatuses.STATUS_FAIL); + }); + + it(`should set 500 Internal Server Error HTTP status and proper Content-Type in a response`, async () => { + const { mockedRes } = await setup(testFailedHealthStatus); + + expect(mockedRes.contentType).toBeCalledWith(contentTypeApplicationHealthJSON); + expect(mockedRes.status).toBeCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + }); + }); + }); +}); diff --git a/apps/server/src/modules/health/controller/health.controller.ts b/apps/server/src/modules/health/controller/health.controller.ts new file mode 100644 index 00000000000..5a0c345aa12 --- /dev/null +++ b/apps/server/src/modules/health/controller/health.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, HttpStatus, Res } from '@nestjs/common'; +import { Response } from 'express'; + +import { HealthUC } from '../uc'; +import { HealthStatusResponse, HealthStatusResponseMapper } from './dto'; + +@Controller('health') +export class HealthController { + constructor(private readonly healthUc: HealthUC) {} + + private readonly contentTypeApplicationHealthJSON = 'application/health+json'; + + @Get('self') + getSelfHealth(@Res({ passthrough: true }) res: Response): HealthStatusResponse { + res.contentType(this.contentTypeApplicationHealthJSON); + + const healthStatus = this.healthUc.checkSelfHealth(); + + return HealthStatusResponseMapper.mapToResponse(healthStatus); + } + + @Get() + async getOverallHealth(@Res({ passthrough: true }) res: Response): Promise { + res.contentType(this.contentTypeApplicationHealthJSON); + + const healthStatus = await this.healthUc.checkOverallHealth(); + const httpStatus = healthStatus.isPassed() ? HttpStatus.OK : HttpStatus.INTERNAL_SERVER_ERROR; + + res.status(httpStatus); + + return HealthStatusResponseMapper.mapToResponse(healthStatus); + } +} diff --git a/apps/server/src/modules/health/controller/index.ts b/apps/server/src/modules/health/controller/index.ts new file mode 100644 index 00000000000..6b3bc30eeb5 --- /dev/null +++ b/apps/server/src/modules/health/controller/index.ts @@ -0,0 +1 @@ +export * from './health.controller'; diff --git a/apps/server/src/modules/health/domain/health-check.do.ts b/apps/server/src/modules/health/domain/health-check.do.ts new file mode 100644 index 00000000000..df1dbe742ef --- /dev/null +++ b/apps/server/src/modules/health/domain/health-check.do.ts @@ -0,0 +1,10 @@ +export class HealthCheck { + id: string; + + updatedAt: Date; + + constructor(id: string, updatedAt: Date) { + this.id = id; + this.updatedAt = updatedAt; + } +} diff --git a/apps/server/src/modules/health/domain/health-status-check.do.spec.ts b/apps/server/src/modules/health/domain/health-status-check.do.spec.ts new file mode 100644 index 00000000000..0b162924106 --- /dev/null +++ b/apps/server/src/modules/health/domain/health-status-check.do.spec.ts @@ -0,0 +1,44 @@ +import { HealthStatusCheck } from './health-status-check.do'; +import { HealthStatuses } from './health-statuses.do'; + +describe(HealthStatusCheck.name, () => { + describe('isPassed', () => { + describe(`when called on a status check with a '${HealthStatuses.STATUS_PASS}' health status`, () => { + const setup = () => { + const testHealthStatusCheck = new HealthStatusCheck({ + componentType: 'system', + status: HealthStatuses.STATUS_PASS, + }); + + return { testHealthStatusCheck }; + }; + + it(`should return 'true'`, () => { + const { testHealthStatusCheck } = setup(); + + const result = testHealthStatusCheck.isPassed(); + + expect(result).toEqual(true); + }); + }); + + describe(`when called on a status check with a '${HealthStatuses.STATUS_FAIL}' health status`, () => { + const setup = () => { + const testHealthStatusCheck = new HealthStatusCheck({ + componentType: 'system', + status: HealthStatuses.STATUS_FAIL, + }); + + return { testHealthStatusCheck }; + }; + + it(`should return 'false'`, () => { + const { testHealthStatusCheck } = setup(); + + const result = testHealthStatusCheck.isPassed(); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/health/domain/health-status-check.do.ts b/apps/server/src/modules/health/domain/health-status-check.do.ts new file mode 100644 index 00000000000..dfa3b766a6d --- /dev/null +++ b/apps/server/src/modules/health/domain/health-status-check.do.ts @@ -0,0 +1,41 @@ +import { HealthStatuses } from './health-statuses.do'; + +export interface HealthStatusCheckProps { + componentType: string; + componentId?: string; + observedValue?: string | number | object; + observedUnit?: string; + status: string; + time?: Date; + output?: string; +} + +export class HealthStatusCheck { + componentType: string; + + componentId?: string; + + observedValue?: string | number | object; + + observedUnit?: string; + + status: string; + + time?: Date; + + output?: string; + + constructor(props: HealthStatusCheckProps) { + this.componentType = props.componentType; + this.componentId = props.componentId; + this.observedValue = props.observedValue; + this.observedUnit = props.observedUnit; + this.status = props.status; + this.time = props.time; + this.output = props.output; + } + + isPassed(): boolean { + return this.status === HealthStatuses.STATUS_PASS; + } +} diff --git a/apps/server/src/modules/health/domain/health-status.do.spec.ts b/apps/server/src/modules/health/domain/health-status.do.spec.ts new file mode 100644 index 00000000000..17d07b4c552 --- /dev/null +++ b/apps/server/src/modules/health/domain/health-status.do.spec.ts @@ -0,0 +1,203 @@ +import { HealthStatuses } from './health-statuses.do'; +import { HealthStatus } from './health-status.do'; +import { HealthStatusCheck } from './health-status-check.do'; + +describe(HealthStatus.name, () => { + describe('isPassed', () => { + describe(`when called on a health status with a '${HealthStatuses.STATUS_PASS}' status`, () => { + describe('in the general health check (without internal checks)', () => { + const setup = () => { + const testHealthStatus = new HealthStatus({ status: HealthStatuses.STATUS_PASS }); + + return { testHealthStatus }; + }; + + it(`should return 'true'`, () => { + const { testHealthStatus } = setup(); + + const result = testHealthStatus.isPassed(); + + expect(result).toEqual(true); + }); + }); + + describe('in all of the internal checks', () => { + const setup = () => { + const testHealthStatus = new HealthStatus({ + status: HealthStatuses.STATUS_PASS, + checks: { + uptime: [ + new HealthStatusCheck({ + componentType: 'system', + observedValue: 12345.67, + observedUnit: 's', + status: HealthStatuses.STATUS_PASS, + time: new Date(), + }), + ], + 'cpu:utilization': [ + new HealthStatusCheck({ + componentId: '488a75b5-4873-438b-abdc-1be06e9a7f19', + componentType: 'system', + observedValue: 42, + observedUnit: 'percent', + status: HealthStatuses.STATUS_PASS, + time: new Date(), + }), + ], + 'memory:utilization': [ + new HealthStatusCheck({ + componentId: '77c79744-029d-4598-ae5f-7b7bb1ef0944', + componentType: 'system', + observedValue: 1.23, + observedUnit: 'GiB', + status: HealthStatuses.STATUS_PASS, + time: new Date(), + }), + new HealthStatusCheck({ + componentId: 'e1a99bd2-3847-411d-836d-e902c9850270', + componentType: 'system', + observedValue: 4321, + observedUnit: 'MiB', + status: HealthStatuses.STATUS_PASS, + time: new Date(), + }), + ], + }, + }); + + return { testHealthStatus }; + }; + + it(`should return 'true'`, () => { + const { testHealthStatus } = setup(); + + const result = testHealthStatus.isPassed(); + + expect(result).toEqual(true); + }); + }); + }); + + describe(`when called on a health status with a '${HealthStatuses.STATUS_FAIL}' status`, () => { + describe('in the general health check (without internal checks)', () => { + const setup = () => { + const testHealthStatus = new HealthStatus({ status: HealthStatuses.STATUS_FAIL }); + + return { testHealthStatus }; + }; + + it(`should return 'false'`, () => { + const { testHealthStatus } = setup(); + + const result = testHealthStatus.isPassed(); + + expect(result).toEqual(false); + }); + }); + + describe('in a single internal check', () => { + const setup = () => { + const testHealthStatus = new HealthStatus({ + // The main health status should be HealthStatuses.STATUS_FAIL (if any of the + // checks fails, the whole health check should also fail), but it was set to + // HealthStatuses.STATUS_PASS to make sure that 'false' is returned from + // within the checks verification block. The same logic applies to all the + // test cases below that verifies the 'false' value returned in case of any + // of the internal checks fail. + status: HealthStatuses.STATUS_PASS, + checks: { + 'cpu:utilization': [ + new HealthStatusCheck({ + componentId: 'e77f3233-f040-4b6a-94cd-2a1f9a890d5d', + componentType: 'system', + observedValue: 87, + observedUnit: 'percent', + status: HealthStatuses.STATUS_FAIL, + time: new Date(), + output: 'High CPU utilization', + }), + ], + }, + }); + + return { testHealthStatus }; + }; + + it(`should return 'false'`, () => { + const { testHealthStatus } = setup(); + + const result = testHealthStatus.isPassed(); + + expect(result).toEqual(false); + }); + }); + + describe('in any of the many internal checks', () => { + const setup = () => { + const testHealthStatus = new HealthStatus({ + status: HealthStatuses.STATUS_PASS, + checks: { + uptime: [ + new HealthStatusCheck({ + componentType: 'system', + observedValue: 12345.67, + observedUnit: 's', + status: HealthStatuses.STATUS_PASS, + time: new Date(), + }), + ], + 'cpu:utilization': [ + new HealthStatusCheck({ + componentId: '488a75b5-4873-438b-abdc-1be06e9a7f19', + componentType: 'system', + observedValue: 42, + observedUnit: 'percent', + status: HealthStatuses.STATUS_PASS, + time: new Date(), + }), + new HealthStatusCheck({ + componentId: 'e77f3233-f040-4b6a-94cd-2a1f9a890d5d', + componentType: 'system', + observedValue: 87, + observedUnit: 'percent', + status: HealthStatuses.STATUS_FAIL, + time: new Date(), + output: 'High CPU utilization', + }), + ], + 'memory:utilization': [ + new HealthStatusCheck({ + componentId: '77c79744-029d-4598-ae5f-7b7bb1ef0944', + componentType: 'system', + observedValue: 1.23, + observedUnit: 'GiB', + status: HealthStatuses.STATUS_PASS, + time: new Date(), + }), + new HealthStatusCheck({ + componentId: 'e1a99bd2-3847-411d-836d-e902c9850270', + componentType: 'system', + observedValue: 4321, + observedUnit: 'MiB', + status: HealthStatuses.STATUS_PASS, + time: new Date(), + }), + ], + }, + }); + + return { testHealthStatus }; + }; + + it(`should return 'false'`, () => { + const { testHealthStatus } = setup(); + + const result = testHealthStatus.isPassed(); + + expect(result).toEqual(false); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/health/domain/health-status.do.ts b/apps/server/src/modules/health/domain/health-status.do.ts new file mode 100644 index 00000000000..69f9140bf97 --- /dev/null +++ b/apps/server/src/modules/health/domain/health-status.do.ts @@ -0,0 +1,40 @@ +import { HealthStatusCheck } from './health-status-check.do'; +import { HealthStatuses } from './health-statuses.do'; + +export interface HealthStatusProps { + status: string; + description?: string; + output?: string; + checks?: Record>; +} + +export class HealthStatus { + status: string; + + description?: string; + + output?: string; + + checks?: Record>; + + constructor(props: HealthStatusProps) { + this.status = props.status; + this.description = props.description; + this.output = props.output; + this.checks = props.checks; + } + + isPassed(): boolean { + if (this.checks !== undefined) { + for (const key of Object.keys(this.checks)) { + for (const check of this.checks[key]) { + if (!check.isPassed()) { + return false; + } + } + } + } + + return this.status === HealthStatuses.STATUS_PASS; + } +} diff --git a/apps/server/src/modules/health/domain/health-statuses.do.ts b/apps/server/src/modules/health/domain/health-statuses.do.ts new file mode 100644 index 00000000000..dc9b62b6727 --- /dev/null +++ b/apps/server/src/modules/health/domain/health-statuses.do.ts @@ -0,0 +1,4 @@ +export const enum HealthStatuses { + STATUS_PASS = 'pass', + STATUS_FAIL = 'fail', +} diff --git a/apps/server/src/modules/health/domain/index.ts b/apps/server/src/modules/health/domain/index.ts new file mode 100644 index 00000000000..7e7dc54833c --- /dev/null +++ b/apps/server/src/modules/health/domain/index.ts @@ -0,0 +1,4 @@ +export * from './health-statuses.do'; +export * from './health-status.do'; +export * from './health-status-check.do'; +export * from './health-check.do'; diff --git a/apps/server/src/modules/health/health-api.module.ts b/apps/server/src/modules/health/health-api.module.ts new file mode 100644 index 00000000000..21cb4da0302 --- /dev/null +++ b/apps/server/src/modules/health/health-api.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { HealthController } from './controller'; +import { HealthCheckRepo } from './repo'; +import { HealthService } from './service'; +import { HealthUC } from './uc'; + +@Module({ + controllers: [HealthController], + providers: [HealthCheckRepo, HealthService, HealthUC], +}) +export class HealthApiModule {} diff --git a/apps/server/src/modules/health/health.config.spec.ts b/apps/server/src/modules/health/health.config.spec.ts new file mode 100644 index 00000000000..694d73edf4e --- /dev/null +++ b/apps/server/src/modules/health/health.config.spec.ts @@ -0,0 +1,49 @@ +import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; +import { Configuration } from '@hpi-schul-cloud/commons'; + +import { HealthConfig } from './health.config'; + +describe(HealthConfig.name, () => { + describe('singleton instance', () => { + let configBefore: IConfig; + + beforeAll(() => { + configBefore = Configuration.toObject({ plainSecrets: true }); + }); + + beforeEach(() => { + Configuration.reset(configBefore); + }); + + afterAll(() => { + Configuration.reset(configBefore); + }); + + describe('should have correct default value for the', () => { + it("'excludeMongoDB' toggle", () => { + expect(HealthConfig.instance.excludeMongoDB).toEqual(false); + }); + }); + + describe('should have correct value loaded from the configuration for the', () => { + const setup = (configVarKey: string, configVarValue: any) => { + Configuration.set(configVarKey, configVarValue); + HealthConfig.reload(); + }; + + it("'hostname' field", () => { + const expectedHostname = 'test-hostname'; + setup('HOSTNAME', expectedHostname); + + expect(HealthConfig.instance.hostname).toEqual(expectedHostname); + }); + + it("'excludeMongoDB' toggle", () => { + const expectedHealthChecksExcludeMongoDB = true; + setup('HEALTH_CHECKS_EXCLUDE_MONGODB', expectedHealthChecksExcludeMongoDB); + + expect(HealthConfig.instance.excludeMongoDB).toEqual(expectedHealthChecksExcludeMongoDB); + }); + }); + }); +}); diff --git a/apps/server/src/modules/health/health.config.ts b/apps/server/src/modules/health/health.config.ts new file mode 100644 index 00000000000..09e48f69d08 --- /dev/null +++ b/apps/server/src/modules/health/health.config.ts @@ -0,0 +1,34 @@ +import { Configuration } from '@hpi-schul-cloud/commons'; + +export class HealthConfig { + private static _instance: HealthConfig; + + private readonly _hostname: string; + + get hostname(): string { + return this._hostname; + } + + private readonly _exclude_mongodb: boolean; + + get excludeMongoDB(): boolean { + return this._exclude_mongodb; + } + + private constructor() { + this._hostname = Configuration.has('HOSTNAME') ? (Configuration.get('HOSTNAME') as string) : ''; + this._exclude_mongodb = Configuration.get('HEALTH_CHECKS_EXCLUDE_MONGODB') as boolean; + } + + public static get instance() { + if (this._instance === undefined) { + this._instance = new this(); + } + + return this._instance; + } + + public static reload() { + this._instance = new this(); + } +} diff --git a/apps/server/src/modules/health/health.entities.ts b/apps/server/src/modules/health/health.entities.ts new file mode 100644 index 00000000000..9d97b7e5d3a --- /dev/null +++ b/apps/server/src/modules/health/health.entities.ts @@ -0,0 +1,6 @@ +import { AnyEntity } from '@mikro-orm/core'; +import { EntityName } from '@mikro-orm/nestjs/typings'; + +import { HealthCheckEntity } from './repo/entity'; + +export const HealthEntities: EntityName[] = [HealthCheckEntity]; diff --git a/apps/server/src/modules/health/index.ts b/apps/server/src/modules/health/index.ts new file mode 100644 index 00000000000..8d90f6f03f0 --- /dev/null +++ b/apps/server/src/modules/health/index.ts @@ -0,0 +1,2 @@ +export * from './health.entities'; +export * from './health-api.module'; diff --git a/apps/server/src/modules/health/repo/entity/health-check.entity.ts b/apps/server/src/modules/health/repo/entity/health-check.entity.ts new file mode 100644 index 00000000000..30c7948b146 --- /dev/null +++ b/apps/server/src/modules/health/repo/entity/health-check.entity.ts @@ -0,0 +1,23 @@ +import { Entity, PrimaryKeyType, PrimaryKey, Property, Index } from '@mikro-orm/core'; + +export interface HealthCheckEntityProps { + id: string; + updatedAt: Date; +} + +@Entity({ tableName: 'healthchecks' }) +export class HealthCheckEntity { + [PrimaryKeyType]?: string; + + @PrimaryKey({ name: '_id' }) + id!: string; + + @Property() + @Index({ options: { expireAfterSeconds: 60 * 60 } }) + updatedAt!: Date; + + constructor(props: HealthCheckEntityProps) { + this.id = props.id; + this.updatedAt = props.updatedAt; + } +} diff --git a/apps/server/src/modules/health/repo/entity/index.ts b/apps/server/src/modules/health/repo/entity/index.ts new file mode 100644 index 00000000000..c5d55fe6e10 --- /dev/null +++ b/apps/server/src/modules/health/repo/entity/index.ts @@ -0,0 +1 @@ +export * from './health-check.entity'; diff --git a/apps/server/src/modules/health/repo/health-check.repo.mapper.spec.ts b/apps/server/src/modules/health/repo/health-check.repo.mapper.spec.ts new file mode 100644 index 00000000000..c4d6400693e --- /dev/null +++ b/apps/server/src/modules/health/repo/health-check.repo.mapper.spec.ts @@ -0,0 +1,26 @@ +import { HealthCheckRepoMapper } from './health-check.repo.mapper'; +import { HealthCheckEntity } from './entity'; +import { HealthCheck } from '../domain'; + +describe(HealthCheckRepoMapper.name, () => { + describe(HealthCheckRepoMapper.mapHealthCheckEntityToDO.name, () => { + describe('when called with all the available fields', () => { + const setup = () => { + const testId = 'test_health_check_id'; + const testUpdatedAt = new Date(); + const testEntity = new HealthCheckEntity({ id: testId, updatedAt: testUpdatedAt }); + const expectedDO = new HealthCheck(testId, testUpdatedAt); + + return { testEntity, expectedDO }; + }; + + it('should map to a valid object', () => { + const { testEntity, expectedDO } = setup(); + + const mappedDO = HealthCheckRepoMapper.mapHealthCheckEntityToDO(testEntity); + + expect(mappedDO).toEqual(expectedDO); + }); + }); + }); +}); diff --git a/apps/server/src/modules/health/repo/health-check.repo.mapper.ts b/apps/server/src/modules/health/repo/health-check.repo.mapper.ts new file mode 100644 index 00000000000..e221ed1edde --- /dev/null +++ b/apps/server/src/modules/health/repo/health-check.repo.mapper.ts @@ -0,0 +1,8 @@ +import { HealthCheckEntity } from './entity'; +import { HealthCheck } from '../domain'; + +export class HealthCheckRepoMapper { + static mapHealthCheckEntityToDO(entity: HealthCheckEntity): HealthCheck { + return new HealthCheck(entity.id, entity.updatedAt); + } +} diff --git a/apps/server/src/modules/health/repo/health-check.repo.spec.ts b/apps/server/src/modules/health/repo/health-check.repo.spec.ts new file mode 100644 index 00000000000..5deaf084c24 --- /dev/null +++ b/apps/server/src/modules/health/repo/health-check.repo.spec.ts @@ -0,0 +1,53 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { TestingModule, Test } from '@nestjs/testing'; + +import { cleanupCollections } from '@shared/testing'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { HealthCheckEntity } from './entity'; +import { HealthCheckRepo } from './health-check.repo'; + +describe(HealthCheckRepo.name, () => { + let module: TestingModule; + let em: EntityManager; + let repo: HealthCheckRepo; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + MongoMemoryDatabaseModule.forRoot({ + entities: [HealthCheckEntity], + }), + ], + providers: [HealthCheckRepo], + }).compile(); + em = module.get(EntityManager); + repo = module.get(HealthCheckRepo); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('upsertById', () => { + describe('when called with some ID', () => { + const setup = () => { + const testId = 'test_health_check_id'; + + return { testId }; + }; + + it('should return valid object', async () => { + const { testId } = setup(); + + const upsertedDO = await repo.upsertById(testId); + + expect(upsertedDO.id).not.toEqual(''); + expect(upsertedDO.updatedAt).not.toEqual(new Date(0)); + }); + }); + }); +}); diff --git a/apps/server/src/modules/health/repo/health-check.repo.ts b/apps/server/src/modules/health/repo/health-check.repo.ts new file mode 100644 index 00000000000..7eba51641b1 --- /dev/null +++ b/apps/server/src/modules/health/repo/health-check.repo.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/mongodb'; + +import { HealthCheck } from '../domain'; +import { HealthCheckEntity } from './entity'; +import { HealthCheckRepoMapper } from './health-check.repo.mapper'; + +@Injectable() +export class HealthCheckRepo { + constructor(private readonly em: EntityManager) {} + + async upsertById(id: string): Promise { + const entity = await this.em.upsert(HealthCheckEntity, { id, updatedAt: new Date() }); + + return HealthCheckRepoMapper.mapHealthCheckEntityToDO(entity); + } +} diff --git a/apps/server/src/modules/health/repo/index.ts b/apps/server/src/modules/health/repo/index.ts new file mode 100644 index 00000000000..9d40bfe8e0f --- /dev/null +++ b/apps/server/src/modules/health/repo/index.ts @@ -0,0 +1 @@ +export * from './health-check.repo'; diff --git a/apps/server/src/modules/health/service/health.service.spec.ts b/apps/server/src/modules/health/service/health.service.spec.ts new file mode 100644 index 00000000000..4cff871a433 --- /dev/null +++ b/apps/server/src/modules/health/service/health.service.spec.ts @@ -0,0 +1,65 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; + +import { setupEntities } from '@shared/testing'; +import { HealthService } from './health.service'; +import { HealthCheckRepo } from '../repo'; +import { HealthCheck } from '../domain'; +import { HealthCheckEntity } from '../repo/entity'; + +describe(HealthService.name, () => { + const testId = 'test_health_check_id'; + const testUpdatedAt = new Date(); + const testDO = new HealthCheck(testId, testUpdatedAt); + + let module: TestingModule; + let service: HealthService; + let repo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + HealthService, + { + provide: HealthCheckRepo, + useValue: createMock(), + }, + ], + }).compile(); + service = module.get(HealthService); + repo = module.get(HealthCheckRepo); + + await setupEntities([HealthCheckEntity]); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('upsertHealthCheckById', () => { + describe('should call', () => { + it('the proper health check repository method with given ID', async () => { + await service.upsertHealthCheckById(testId); + + expect(repo.upsertById).toHaveBeenCalledWith(testId); + }); + }); + + describe('should return', () => { + const setup = () => { + repo.upsertById.mockResolvedValueOnce(testDO); + const expectedDO = new HealthCheck(testId, testUpdatedAt); + + return { expectedDO }; + }; + + it('the upserted health check domain object with given ID', async () => { + const { expectedDO } = setup(); + + const foundDO = await service.upsertHealthCheckById(testId); + + expect(foundDO).toEqual(expectedDO); + }); + }); + }); +}); diff --git a/apps/server/src/modules/health/service/health.service.ts b/apps/server/src/modules/health/service/health.service.ts new file mode 100644 index 00000000000..ff1320d13a1 --- /dev/null +++ b/apps/server/src/modules/health/service/health.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; + +import { HealthCheck } from '../domain'; +import { HealthCheckRepo } from '../repo'; + +@Injectable() +export class HealthService { + constructor(private readonly healthCheckRepo: HealthCheckRepo) {} + + async upsertHealthCheckById(id: string): Promise { + return this.healthCheckRepo.upsertById(id); + } +} diff --git a/apps/server/src/modules/health/service/index.ts b/apps/server/src/modules/health/service/index.ts new file mode 100644 index 00000000000..1e2e6d75377 --- /dev/null +++ b/apps/server/src/modules/health/service/index.ts @@ -0,0 +1 @@ +export * from './health.service'; diff --git a/apps/server/src/modules/health/uc/health.uc.spec.ts b/apps/server/src/modules/health/uc/health.uc.spec.ts new file mode 100644 index 00000000000..95265162a94 --- /dev/null +++ b/apps/server/src/modules/health/uc/health.uc.spec.ts @@ -0,0 +1,103 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; + +import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; +import { Configuration } from '@hpi-schul-cloud/commons'; +import { HealthUC } from './health.uc'; +import { HealthService } from '../service'; +import { HealthStatuses } from '../domain'; +import { HealthConfig } from '../health.config'; + +describe(HealthUC.name, () => { + let configBefore: IConfig; + let module: TestingModule; + let uc: HealthUC; + let service: DeepMocked; + + beforeAll(async () => { + configBefore = Configuration.toObject({ plainSecrets: true }); + module = await Test.createTestingModule({ + providers: [ + HealthUC, + { + provide: HealthService, + useValue: createMock(), + }, + ], + }).compile(); + uc = module.get(HealthUC); + service = module.get(HealthService); + }); + + beforeEach(() => { + Configuration.reset(configBefore); + }); + + afterAll(async () => { + Configuration.reset(configBefore); + await module.close(); + }); + + describe('checkSelfHealth', () => { + it(`should return '${HealthStatuses.STATUS_PASS}' health status`, () => { + const healthStatus = uc.checkSelfHealth(); + + expect(healthStatus.status).toEqual(HealthStatuses.STATUS_PASS); + }); + }); + + describe('checkOverallHealth', () => { + describe('should return', () => { + describe(`'${HealthStatuses.STATUS_PASS}' health status if MongoDB`, () => { + it('has been excluded from the checks', async () => { + Configuration.set('HEALTH_CHECKS_EXCLUDE_MONGODB', true); + HealthConfig.reload(); + + const healthStatus = await uc.checkOverallHealth(); + + expect(healthStatus.status).toEqual(HealthStatuses.STATUS_PASS); + expect(healthStatus.checks).toBeUndefined(); + }); + + it("hasn't been excluded from the checks and health service did not return any error", async () => { + Configuration.set('HOSTNAME', 'test-hostname'); + Configuration.set('HEALTH_CHECKS_EXCLUDE_MONGODB', false); + HealthConfig.reload(); + + const healthStatus = await uc.checkOverallHealth(); + + expect(healthStatus.status).toEqual(HealthStatuses.STATUS_PASS); + expect(healthStatus.checks).toBeDefined(); + }); + }); + + describe(`'${HealthStatuses.STATUS_FAIL}' health status if health service returned an error which`, () => { + it('contains a message', async () => { + service.upsertHealthCheckById.mockRejectedValueOnce(new Error('some test error message...')); + + Configuration.set('HOSTNAME', 'test-hostname'); + Configuration.set('HEALTH_CHECKS_EXCLUDE_MONGODB', false); + HealthConfig.reload(); + + const healthStatus = await uc.checkOverallHealth(); + + expect(healthStatus.status).toEqual(HealthStatuses.STATUS_FAIL); + expect(healthStatus.checks).toBeDefined(); + }); + + it("doesn't contain a message", async () => { + service.upsertHealthCheckById.mockRejectedValueOnce('just some plain string...'); + + Configuration.set('HOSTNAME', 'test-hostname'); + Configuration.set('HEALTH_CHECKS_EXCLUDE_MONGODB', false); + HealthConfig.reload(); + + const healthStatus = await uc.checkOverallHealth(); + + expect(healthStatus.status).toEqual(HealthStatuses.STATUS_FAIL); + expect(healthStatus.checks).toBeDefined(); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/health/uc/health.uc.ts b/apps/server/src/modules/health/uc/health.uc.ts new file mode 100644 index 00000000000..a50767dfd98 --- /dev/null +++ b/apps/server/src/modules/health/uc/health.uc.ts @@ -0,0 +1,119 @@ +import { Injectable } from '@nestjs/common'; + +import { HealthService } from '../service'; +import { HealthConfig } from '../health.config'; +import { HealthStatuses, HealthStatusCheck, HealthStatus } from '../domain'; + +const selfOnlyHealthDescription = 'Service health status (self-only)'; + +const overallHealthDescription = 'Service health status (overall)'; + +const mongoDBUpsertOperationTime = 'mongoDB:upsertOperationTime'; + +const datastoreComponentType = 'datastore'; + +const observedUnitMs = 'ms'; + +function hasMessage(error: unknown): error is { message: string } { + // Check whether given error of an unknown type + // has a 'message' field of a string type or not. + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as Record).message === 'string' + ); +} + +@Injectable() +export class HealthUC { + constructor(private readonly healthService: HealthService) {} + + checkSelfHealth(): HealthStatus { + // This health check verifies just the correct module setup and doesn't include + // any additional check on any of the internal or external 3rd party services. + // It can be used to verify if application is just alive, but it doesn't provide + // any verification on the complete application's readiness. + return new HealthStatus({ + status: HealthStatuses.STATUS_PASS, + description: selfOnlyHealthDescription, + }); + } + + async checkOverallHealth(): Promise { + // The below check allows for turning off the MongoDB dependency on the health check - + // it shouldn't be typically used, but if this health check will be used e.g. in the k8s + // liveness or readiness probes and, for any reason, there would be a need to stop + // including MongoDB check in the overall API health checks, the HEALTH_CHECKS_EXCLUDE_MONGODB + // config var can be set to 'true' to disable it. This way, as currently only this single + // MongoDB check is included in the overall API health checks, the whole health check will + // not perform any additional checks on any of the 3rd party services and thus will behave + // like the self-only API health check. + if (HealthConfig.instance.excludeMongoDB) { + return new HealthStatus({ + status: HealthStatuses.STATUS_PASS, + description: overallHealthDescription, + }); + } + + const upsertOperationDate = new Date(); + const startTime = performance.now(); + + try { + let healthCheckID = 'db-health-check'; + + // If hostname is available in the health module + // config, append it to the health check ID. + if (HealthConfig.instance.hostname !== '') { + healthCheckID += `-${HealthConfig.instance.hostname}`; + } + + await this.healthService.upsertHealthCheckById(healthCheckID); + } catch (error) { + // If any error occurred in the database operation execution it should be indicated + // as a MongoDB check failure (and thus the whole health check should fail). + + const endTime = performance.now(); + + const errorMessage = hasMessage(error) ? error.message : JSON.stringify(error); + + return new HealthStatus({ + status: HealthStatuses.STATUS_FAIL, + output: `'${mongoDBUpsertOperationTime}' check error: ${errorMessage}`, + description: overallHealthDescription, + checks: { + [mongoDBUpsertOperationTime]: [ + new HealthStatusCheck({ + componentType: datastoreComponentType, + observedValue: endTime - startTime, + observedUnit: observedUnitMs, + status: HealthStatuses.STATUS_FAIL, + time: upsertOperationDate, + output: errorMessage, + }), + ], + }, + }); + } + + const endTime = performance.now(); + + // Prepare the passed health status with + // the performed upsert operation time. + return new HealthStatus({ + status: HealthStatuses.STATUS_PASS, + description: overallHealthDescription, + checks: { + [mongoDBUpsertOperationTime]: [ + new HealthStatusCheck({ + componentType: datastoreComponentType, + observedValue: endTime - startTime, + observedUnit: observedUnitMs, + status: HealthStatuses.STATUS_PASS, + time: upsertOperationDate, + }), + ], + }, + }); + } +} diff --git a/apps/server/src/modules/health/uc/index.ts b/apps/server/src/modules/health/uc/index.ts new file mode 100644 index 00000000000..623e8e97d91 --- /dev/null +++ b/apps/server/src/modules/health/uc/index.ts @@ -0,0 +1 @@ +export * from './health.uc'; diff --git a/apps/server/src/modules/idp-console/builder/index.ts b/apps/server/src/modules/idp-console/builder/index.ts new file mode 100644 index 00000000000..79657a71e2c --- /dev/null +++ b/apps/server/src/modules/idp-console/builder/index.ts @@ -0,0 +1 @@ +export * from './users-sync-options.builder'; diff --git a/apps/server/src/modules/idp-console/builder/users-sync-options.builder.spec.ts b/apps/server/src/modules/idp-console/builder/users-sync-options.builder.spec.ts new file mode 100644 index 00000000000..3893011323b --- /dev/null +++ b/apps/server/src/modules/idp-console/builder/users-sync-options.builder.spec.ts @@ -0,0 +1,26 @@ +import { ObjectId } from 'bson'; +import { SystemType, UsersSyncOptions } from '../interface'; +import { UsersSyncOptionsBuilder } from './users-sync-options.builder'; + +describe(UsersSyncOptionsBuilder.name, () => { + describe(UsersSyncOptionsBuilder.build.name, () => { + describe('when called with valid arguments', () => { + const setup = () => { + const systemType = SystemType.MOIN_SCHULE; + const systemId = new ObjectId().toHexString(); + + const expectedOutput: UsersSyncOptions = { systemType, systemId }; + + return { systemType, systemId, expectedOutput }; + }; + + it('should return valid options object with expected values', () => { + const { systemType, systemId, expectedOutput } = setup(); + + const output = UsersSyncOptionsBuilder.build(systemType, systemId); + + expect(output).toStrictEqual(expectedOutput); + }); + }); + }); +}); diff --git a/apps/server/src/modules/idp-console/builder/users-sync-options.builder.ts b/apps/server/src/modules/idp-console/builder/users-sync-options.builder.ts new file mode 100644 index 00000000000..aedb51030a6 --- /dev/null +++ b/apps/server/src/modules/idp-console/builder/users-sync-options.builder.ts @@ -0,0 +1,11 @@ +import { EntityId } from '@shared/domain/types'; +import { SystemType, UsersSyncOptions } from '../interface'; + +export class UsersSyncOptionsBuilder { + static build(systemType: SystemType, systemId: EntityId): UsersSyncOptions { + return { + systemType, + systemId, + }; + } +} diff --git a/apps/server/src/modules/idp-console/idp-console.module.ts b/apps/server/src/modules/idp-console/idp-console.module.ts new file mode 100644 index 00000000000..f2be287c729 --- /dev/null +++ b/apps/server/src/modules/idp-console/idp-console.module.ts @@ -0,0 +1,47 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { SchulconnexClientModule } from '@infra/schulconnex-client'; +import { SynchronizationEntity, SynchronizationModule } from '@modules/synchronization'; +import { defaultMikroOrmOptions } from '@modules/server'; +import { DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; +import { ALL_ENTITIES } from '@shared/domain/entity'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { UserModule } from '@modules/user'; +import { LoggerModule } from '@src/core/logger'; +import { RabbitMQWrapperModule } from '@infra/rabbitmq'; +import { ConsoleWriterModule } from '@infra/console'; +import { ConsoleModule } from 'nestjs-console'; +import { SynchronizationUc } from './uc'; +import { IdpSyncConsole } from './idp-sync-console'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + SchulconnexClientModule.register({ + apiUrl: Configuration.get('SCHULCONNEX_CLIENT__API_URL') as string, + tokenEndpoint: Configuration.get('SCHULCONNEX_CLIENT__TOKEN_ENDPOINT') as string, + clientId: Configuration.get('SCHULCONNEX_CLIENT__CLIENT_ID') as string, + clientSecret: Configuration.get('SCHULCONNEX_CLIENT__CLIENT_SECRET') as string, + personenInfoTimeoutInMs: Configuration.get('SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS') as number, + }), + SynchronizationModule, + MikroOrmModule.forRoot({ + ...defaultMikroOrmOptions, + type: 'mongo', + clientUrl: DB_URL, + password: DB_PASSWORD, + user: DB_USERNAME, + allowGlobalContext: true, + entities: [...ALL_ENTITIES, SynchronizationEntity], + debug: true, + }), + UserModule, + LoggerModule, + RabbitMQWrapperModule, + ConsoleWriterModule, + ConsoleModule, + ], + providers: [SynchronizationUc, IdpSyncConsole], +}) +export class IdpConsoleModule {} diff --git a/apps/server/src/modules/idp-console/idp-sync-console.spec.ts b/apps/server/src/modules/idp-console/idp-sync-console.spec.ts new file mode 100644 index 00000000000..b15186273cd --- /dev/null +++ b/apps/server/src/modules/idp-console/idp-sync-console.spec.ts @@ -0,0 +1,99 @@ +import { ObjectId } from 'bson'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConsoleWriterService } from '@infra/console'; +import { createMock } from '@golevelup/ts-jest'; +import { SynchronizationUc } from './uc'; +import { IdpSyncConsole } from './idp-sync-console'; +import { SystemType } from './interface'; +import { UsersSyncOptionsBuilder } from './builder'; + +describe(IdpSyncConsole.name, () => { + let module: TestingModule; + let console: IdpSyncConsole; + let synchronizationUc: SynchronizationUc; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + IdpSyncConsole, + { + provide: ConsoleWriterService, + useValue: createMock(), + }, + { + provide: SynchronizationUc, + useValue: createMock(), + }, + ], + }).compile(); + + console = module.get(IdpSyncConsole); + synchronizationUc = module.get(SynchronizationUc); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + it('console should be defined', () => { + expect(console).toBeDefined(); + }); + + describe('users', () => { + describe('when called with an invalid system type', () => { + const setup = () => { + const systemType = 'test'; + const systemId = new ObjectId().toHexString(); + + const options = UsersSyncOptionsBuilder.build(systemType as SystemType, systemId); + + return { + systemType, + systemId, + options, + }; + }; + + it('should throw an exception', async () => { + const { options } = setup(); + + await expect(console.users(options)).rejects.toThrow(); + }); + }); + + describe('when called with valid options', () => { + const setup = () => { + const systemType = SystemType.MOIN_SCHULE; + const systemId = new ObjectId().toHexString(); + + const options = UsersSyncOptionsBuilder.build(systemType, systemId); + + return { + systemType, + systemId, + options, + }; + }; + + it(`should not throw an exception indicating invalid system type`, async () => { + const { options } = setup(); + + await expect(console.users(options)).resolves.not.toThrow(); + }); + + it(`should call ${SynchronizationUc.name} with proper arguemnts`, async () => { + const { options } = setup(); + + const spy = jest.spyOn(synchronizationUc, 'updateSystemUsersLastSyncedAt'); + + await console.users(options); + + expect(spy).toBeCalledWith(options.systemId); + }); + }); + }); +}); diff --git a/apps/server/src/modules/idp-console/idp-sync-console.ts b/apps/server/src/modules/idp-console/idp-sync-console.ts new file mode 100644 index 00000000000..dab943e8249 --- /dev/null +++ b/apps/server/src/modules/idp-console/idp-sync-console.ts @@ -0,0 +1,50 @@ +import { Console, Command } from 'nestjs-console'; +import { ConsoleWriterService } from '@infra/console'; +import { SynchronizationUc } from './uc'; +import { UsersSyncOptions, SystemType } from './interface'; + +@Console({ + command: 'sync', + description: 'Console providing an access to the IDP-provisioned data synchronization operations.', +}) +export class IdpSyncConsole { + constructor(private consoleWriter: ConsoleWriterService, private synchronizationUc: SynchronizationUc) {} + + @Command({ + command: 'users', + description: 'Synchronize IDP-provisioned users.', + options: [ + { + flags: '-st, --systemType ', + description: 'Type of a synchronized system.', + required: true, + }, + { + flags: '-si, --systemId ', + description: 'ID of a synchronized system.', + required: true, + }, + ], + }) + async users(options: UsersSyncOptions): Promise { + if (options.systemType !== SystemType.MOIN_SCHULE) { + throw new Error(`invalid system type (currently the only supported system type is "${SystemType.MOIN_SCHULE}")`); + } + + this.consoleWriter.info( + JSON.stringify({ + message: 'starting synchronization', + options, + }) + ); + + await this.synchronizationUc.updateSystemUsersLastSyncedAt(options.systemId); + + this.consoleWriter.info( + JSON.stringify({ + message: 'successfully finished synchronization', + options, + }) + ); + } +} diff --git a/apps/server/src/modules/idp-console/index.ts b/apps/server/src/modules/idp-console/index.ts new file mode 100644 index 00000000000..c59204f5141 --- /dev/null +++ b/apps/server/src/modules/idp-console/index.ts @@ -0,0 +1,2 @@ +export * from './idp-console.module'; +export * from './interface'; diff --git a/apps/server/src/modules/idp-console/interface/index.ts b/apps/server/src/modules/idp-console/interface/index.ts new file mode 100644 index 00000000000..57dc47a1902 --- /dev/null +++ b/apps/server/src/modules/idp-console/interface/index.ts @@ -0,0 +1,3 @@ +export * from './system-type.enum'; +export * from './users-sync-options.interface'; +export * from './synchronization.config'; diff --git a/apps/server/src/modules/idp-console/interface/synchronization.config.ts b/apps/server/src/modules/idp-console/interface/synchronization.config.ts new file mode 100644 index 00000000000..9b0e05d6aa5 --- /dev/null +++ b/apps/server/src/modules/idp-console/interface/synchronization.config.ts @@ -0,0 +1,3 @@ +export interface SynchronizationConfig { + SYNCHRONIZATION_CHUNK: number; +} diff --git a/apps/server/src/modules/idp-console/interface/system-type.enum.ts b/apps/server/src/modules/idp-console/interface/system-type.enum.ts new file mode 100644 index 00000000000..c7ccdf8265a --- /dev/null +++ b/apps/server/src/modules/idp-console/interface/system-type.enum.ts @@ -0,0 +1,3 @@ +export const enum SystemType { + MOIN_SCHULE = 'moin.schule', +} diff --git a/apps/server/src/modules/idp-console/interface/users-sync-options.interface.ts b/apps/server/src/modules/idp-console/interface/users-sync-options.interface.ts new file mode 100644 index 00000000000..d25b0a4aaa1 --- /dev/null +++ b/apps/server/src/modules/idp-console/interface/users-sync-options.interface.ts @@ -0,0 +1,7 @@ +import { EntityId } from '@shared/domain/types'; +import { SystemType } from './system-type.enum'; + +export interface UsersSyncOptions { + systemType: SystemType; + systemId: EntityId; +} diff --git a/apps/server/src/modules/idp-console/uc/index.ts b/apps/server/src/modules/idp-console/uc/index.ts new file mode 100644 index 00000000000..01d3ae1087b --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/index.ts @@ -0,0 +1 @@ +export * from './synchronization.uc'; diff --git a/apps/server/src/modules/idp-console/uc/loggable-exception/failed-update-lastsyncedat.loggable-exception.spec.ts b/apps/server/src/modules/idp-console/uc/loggable-exception/failed-update-lastsyncedat.loggable-exception.spec.ts new file mode 100644 index 00000000000..004b8f12470 --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/loggable-exception/failed-update-lastsyncedat.loggable-exception.spec.ts @@ -0,0 +1,35 @@ +import { ObjectId } from 'bson'; +import { FailedUpdateLastSyncedAtLoggableException } from './failed-update-lastsyncedat.loggable-exception'; + +describe(FailedUpdateLastSyncedAtLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const message = 'Failed to update lastSyncedAt field for users provisioned by system'; + const systemId = new ObjectId().toHexString(); + + const exception = new FailedUpdateLastSyncedAtLoggableException(systemId); + + const expectedErrorLogMessage = { + type: 'SYNCHRONIZATION_ERROR', + stack: expect.any(String), + data: { + systemId, + errorMessage: message, + }, + }; + + return { + exception, + expectedErrorLogMessage, + }; + }; + + it('should log the correct message', () => { + const { exception, expectedErrorLogMessage } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual(expectedErrorLogMessage); + }); + }); +}); diff --git a/apps/server/src/modules/idp-console/uc/loggable-exception/failed-update-lastsyncedat.loggable-exception.ts b/apps/server/src/modules/idp-console/uc/loggable-exception/failed-update-lastsyncedat.loggable-exception.ts new file mode 100644 index 00000000000..bd4682092b9 --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/loggable-exception/failed-update-lastsyncedat.loggable-exception.ts @@ -0,0 +1,22 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage } from '@src/core/logger/types'; + +export class FailedUpdateLastSyncedAtLoggableException extends InternalServerErrorException implements Loggable { + constructor(private readonly systemId: string) { + super(); + } + + getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: 'SYNCHRONIZATION_ERROR', + stack: this.stack, + data: { + systemId: this.systemId, + errorMessage: 'Failed to update lastSyncedAt field for users provisioned by system', + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/idp-console/uc/loggable-exception/index.ts b/apps/server/src/modules/idp-console/uc/loggable-exception/index.ts new file mode 100644 index 00000000000..5269768f68e --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/loggable-exception/index.ts @@ -0,0 +1,3 @@ +export * from './no-users-to-synchronization.loggable-exception'; +export * from './failed-update-lastsyncedat.loggable-exception'; +export * from './synchronization-unknown-error.loggable-exception'; diff --git a/apps/server/src/modules/idp-console/uc/loggable-exception/no-users-to-synchronization.loggable-exception.spec.ts b/apps/server/src/modules/idp-console/uc/loggable-exception/no-users-to-synchronization.loggable-exception.spec.ts new file mode 100644 index 00000000000..f804a00a93b --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/loggable-exception/no-users-to-synchronization.loggable-exception.spec.ts @@ -0,0 +1,35 @@ +import { ObjectId } from 'bson'; +import { NoUsersToSynchronizationLoggableException } from './no-users-to-synchronization.loggable-exception'; + +describe(NoUsersToSynchronizationLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const message = 'No users to check from system'; + const systemId = new ObjectId().toHexString(); + + const exception = new NoUsersToSynchronizationLoggableException(systemId); + + const expectedErrorLogMessage = { + type: 'SYNCHRONIZATION_ERROR', + stack: expect.any(String), + data: { + systemId, + errorMessage: message, + }, + }; + + return { + exception, + expectedErrorLogMessage, + }; + }; + + it('should log the correct message', () => { + const { exception, expectedErrorLogMessage } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual(expectedErrorLogMessage); + }); + }); +}); diff --git a/apps/server/src/modules/idp-console/uc/loggable-exception/no-users-to-synchronization.loggable-exception.ts b/apps/server/src/modules/idp-console/uc/loggable-exception/no-users-to-synchronization.loggable-exception.ts new file mode 100644 index 00000000000..44632dc4639 --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/loggable-exception/no-users-to-synchronization.loggable-exception.ts @@ -0,0 +1,22 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage } from '@src/core/logger/types'; + +export class NoUsersToSynchronizationLoggableException extends InternalServerErrorException implements Loggable { + constructor(private readonly systemId: string) { + super(); + } + + getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: 'SYNCHRONIZATION_ERROR', + stack: this.stack, + data: { + systemId: this.systemId, + errorMessage: 'No users to check from system', + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/idp-console/uc/loggable-exception/synchronization-unknown-error.loggable-exception.spec.ts b/apps/server/src/modules/idp-console/uc/loggable-exception/synchronization-unknown-error.loggable-exception.spec.ts new file mode 100644 index 00000000000..140d01c810e --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/loggable-exception/synchronization-unknown-error.loggable-exception.spec.ts @@ -0,0 +1,36 @@ +import { ObjectId } from 'bson'; +import { SynchronizationUnknownErrorLoggableException } from './synchronization-unknown-error.loggable-exception'; + +describe(SynchronizationUnknownErrorLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const message = + 'Unknown error occurred during synchronization process of users provisioned by an external system'; + const systemId = new ObjectId().toHexString(); + + const exception = new SynchronizationUnknownErrorLoggableException(systemId); + + const expectedErrorLogMessage = { + type: 'SYNCHRONIZATION_ERROR', + stack: expect.any(String), + data: { + systemId, + errorMessage: message, + }, + }; + + return { + exception, + expectedErrorLogMessage, + }; + }; + + it('should log the correct message', () => { + const { exception, expectedErrorLogMessage } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual(expectedErrorLogMessage); + }); + }); +}); diff --git a/apps/server/src/modules/idp-console/uc/loggable-exception/synchronization-unknown-error.loggable-exception.ts b/apps/server/src/modules/idp-console/uc/loggable-exception/synchronization-unknown-error.loggable-exception.ts new file mode 100644 index 00000000000..2fa2d98f956 --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/loggable-exception/synchronization-unknown-error.loggable-exception.ts @@ -0,0 +1,23 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage } from '@src/core/logger/types'; + +export class SynchronizationUnknownErrorLoggableException extends InternalServerErrorException implements Loggable { + constructor(private readonly systemId: string) { + super(); + } + + getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: 'SYNCHRONIZATION_ERROR', + stack: this.stack, + data: { + systemId: this.systemId, + errorMessage: + 'Unknown error occurred during synchronization process of users provisioned by an external system', + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/idp-console/uc/loggable/index.ts b/apps/server/src/modules/idp-console/uc/loggable/index.ts new file mode 100644 index 00000000000..59524eccf82 --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/loggable/index.ts @@ -0,0 +1,2 @@ +export * from './sucess-synchronization-loggable'; +export * from './start-synchronization-loggable'; diff --git a/apps/server/src/modules/idp-console/uc/loggable/start-synchronization-loggable.spec.ts b/apps/server/src/modules/idp-console/uc/loggable/start-synchronization-loggable.spec.ts new file mode 100644 index 00000000000..5a75e4e8322 --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/loggable/start-synchronization-loggable.spec.ts @@ -0,0 +1,32 @@ +import { ObjectId } from 'bson'; +import { StartSynchronizationLoggable } from './start-synchronization-loggable'; + +describe(StartSynchronizationLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const message = 'Start synchronization users from systemId'; + const systemId = new ObjectId().toHexString(); + + const loggable = new StartSynchronizationLoggable(systemId); + + const expectedLogMessage = { + message, + data: { + systemId, + }, + }; + + return { + expectedLogMessage, + loggable, + systemId, + }; + }; + + it('should return the correct log message', () => { + const { expectedLogMessage, loggable } = setup(); + + expect(loggable.getLogMessage()).toEqual(expectedLogMessage); + }); + }); +}); diff --git a/apps/server/src/modules/idp-console/uc/loggable/start-synchronization-loggable.ts b/apps/server/src/modules/idp-console/uc/loggable/start-synchronization-loggable.ts new file mode 100644 index 00000000000..e135d123b80 --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/loggable/start-synchronization-loggable.ts @@ -0,0 +1,14 @@ +import { LogMessage, Loggable } from '@src/core/logger'; + +export class StartSynchronizationLoggable implements Loggable { + constructor(private readonly systemId: string) {} + + getLogMessage(): LogMessage { + return { + message: 'Start synchronization users from systemId', + data: { + systemId: this.systemId, + }, + }; + } +} diff --git a/apps/server/src/modules/idp-console/uc/loggable/success-synchronization-loggable.spec.ts b/apps/server/src/modules/idp-console/uc/loggable/success-synchronization-loggable.spec.ts new file mode 100644 index 00000000000..d255c88df22 --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/loggable/success-synchronization-loggable.spec.ts @@ -0,0 +1,36 @@ +import { ObjectId } from 'bson'; +import { SucessSynchronizationLoggable } from './sucess-synchronization-loggable'; + +describe(SucessSynchronizationLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const message = 'Synchronization proccess end with success'; + const systemId = new ObjectId().toHexString(); + const usersSynchronizedCount = 10; + + const loggable = new SucessSynchronizationLoggable(systemId, usersSynchronizedCount); + + const expectedLogMessage = { + message, + data: { + systemId, + usersSynchronizedCount, + }, + }; + + return { + expectedLogMessage, + loggable, + message, + systemId, + usersSynchronizedCount, + }; + }; + + it('should return the correct log message', () => { + const { expectedLogMessage, loggable } = setup(); + + expect(loggable.getLogMessage()).toEqual(expectedLogMessage); + }); + }); +}); diff --git a/apps/server/src/modules/idp-console/uc/loggable/sucess-synchronization-loggable.ts b/apps/server/src/modules/idp-console/uc/loggable/sucess-synchronization-loggable.ts new file mode 100644 index 00000000000..4933050a928 --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/loggable/sucess-synchronization-loggable.ts @@ -0,0 +1,15 @@ +import { LogMessage, Loggable } from '@src/core/logger'; + +export class SucessSynchronizationLoggable implements Loggable { + constructor(private readonly systemId: string, private readonly usersSynchronizedCount?: number) {} + + getLogMessage(): LogMessage { + return { + message: 'Synchronization proccess end with success', + data: { + systemId: this.systemId, + usersSynchronizedCount: this.usersSynchronizedCount, + }, + }; + } +} diff --git a/apps/server/src/modules/idp-console/uc/synchronization.uc.spec.ts b/apps/server/src/modules/idp-console/uc/synchronization.uc.spec.ts new file mode 100644 index 00000000000..bd530547648 --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/synchronization.uc.spec.ts @@ -0,0 +1,443 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { setupEntities } from '@shared/testing'; +import { ObjectId } from 'bson'; +import { Logger } from '@src/core/logger'; +import { UserService } from '@modules/user'; +import { SanisResponse, schulconnexResponseFactory, SchulconnexRestClient } from '@infra/schulconnex-client'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { + Synchronization, + SynchronizationService, + SynchronizationStatusModel, + synchronizationFactory, +} from '@modules/synchronization'; +import { SynchronizationUc } from './synchronization.uc'; +import { synchronizationTestConfig } from './testing'; +import { + FailedUpdateLastSyncedAtLoggableException, + NoUsersToSynchronizationLoggableException, +} from './loggable-exception'; + +describe(SynchronizationUc.name, () => { + let module: TestingModule; + let uc: SynchronizationUc; + let userService: DeepMocked; + let synchronizationService: DeepMocked; + let schulconnexRestClient: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(createConfigModuleOptions(synchronizationTestConfig))], + providers: [ + SynchronizationUc, + { + provide: SynchronizationService, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: SchulconnexRestClient, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(SynchronizationUc); + synchronizationService = module.get(SynchronizationService); + userService = module.get(UserService); + schulconnexRestClient = module.get(SchulconnexRestClient); + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('updateSystemUsersLastSyncedAt', () => { + describe('when update users lastSynceAt for systemId', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const synchronizationId = new ObjectId().toHexString(); + const usersToCheck = [new ObjectId().toHexString()]; + const userSyncCount = 1; + const status = SynchronizationStatusModel.SUCCESS; + + synchronizationService.createSynchronization.mockResolvedValueOnce(synchronizationId); + jest.spyOn(uc, 'findUsersToSynchronize').mockResolvedValueOnce(usersToCheck); + const spyUpdateLastSyncedAt = jest.spyOn(uc, 'updateLastSyncedAt'); + const spyUpdateSynchronization = jest.spyOn(uc, 'updateSynchronization'); + + return { + spyUpdateLastSyncedAt, + spyUpdateSynchronization, + status, + synchronizationId, + systemId, + userSyncCount, + usersToCheck, + }; + }; + + it('should call the synchronizationService.createSynchronization to create the synchronization', async () => { + const { systemId } = setup(); + + await uc.updateSystemUsersLastSyncedAt(systemId); + + expect(synchronizationService.createSynchronization).toHaveBeenCalled(); + }); + + it('should call the uc.updateLastSyncedAt to update users for systemId twice', async () => { + const { spyUpdateLastSyncedAt, systemId, usersToCheck } = setup(); + + await uc.updateSystemUsersLastSyncedAt(systemId); + + expect(spyUpdateLastSyncedAt).toHaveBeenCalledWith([usersToCheck[0]], systemId); + expect(spyUpdateLastSyncedAt).toHaveBeenCalledTimes(1); + }); + + it('should call the uc.updateSynchronization to log detainls about synchronization of systemId', async () => { + const { spyUpdateSynchronization, status, synchronizationId, systemId, userSyncCount } = setup(); + jest.spyOn(uc, 'updateLastSyncedAt').mockResolvedValueOnce(userSyncCount); + + await uc.updateSystemUsersLastSyncedAt(systemId); + + expect(spyUpdateSynchronization).toHaveBeenCalledWith(synchronizationId, status, userSyncCount); + }); + }); + + describe('when found no users to update for systemId', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const synchronizationId = new ObjectId().toHexString(); + const userSyncCount = 0; + const status = SynchronizationStatusModel.FAILED; + + const errorMessage = { + type: 'SYNCHRONIZATION_ERROR', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + data: expect.objectContaining({ + systemId, + errorMessage: 'No users to check from system', + }), + }; + + synchronizationService.createSynchronization.mockResolvedValueOnce(synchronizationId); + schulconnexRestClient.getPersonenInfo.mockResolvedValueOnce([]); + const spyUpdateSynchronization = jest.spyOn(uc, 'updateSynchronization'); + + return { + errorMessage, + spyUpdateSynchronization, + status, + synchronizationId, + systemId, + userSyncCount, + }; + }; + + it('should call the uc.updateSynchronization to log details about synchronization of systemId', async () => { + const { errorMessage, spyUpdateSynchronization, status, synchronizationId, systemId, userSyncCount } = setup(); + + await uc.updateSystemUsersLastSyncedAt(systemId); + + expect(spyUpdateSynchronization).toHaveBeenCalledWith( + synchronizationId, + status, + userSyncCount, + expect.objectContaining(errorMessage) + ); + }); + }); + + describe('when failed to update lastSyncedAt field for users provisioned by system', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const synchronizationId = new ObjectId().toHexString(); + const usersToCheck = [new ObjectId().toHexString()]; + const userSyncCount = 0; + const status = SynchronizationStatusModel.FAILED; + + const error = new Error('testError'); + const errorMessage = { + type: 'SYNCHRONIZATION_ERROR', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + data: expect.objectContaining({ + systemId, + errorMessage: 'Failed to update lastSyncedAt field for users provisioned by system', + }), + }; + + synchronizationService.createSynchronization.mockResolvedValueOnce(synchronizationId); + jest.spyOn(uc, 'findUsersToSynchronize').mockResolvedValueOnce(usersToCheck); + userService.updateLastSyncedAt.mockRejectedValueOnce(error); + const spyUpdateSynchronization = jest.spyOn(uc, 'updateSynchronization'); + + return { + errorMessage, + spyUpdateSynchronization, + status, + synchronizationId, + systemId, + userSyncCount, + usersToCheck, + }; + }; + + it('should call the uc.updateSynchronization to log details about synchronization of systemId', async () => { + const { errorMessage, spyUpdateSynchronization, status, synchronizationId, systemId, userSyncCount } = setup(); + + await uc.updateSystemUsersLastSyncedAt(systemId); + + expect(spyUpdateSynchronization).toHaveBeenCalledWith( + synchronizationId, + status, + userSyncCount, + expect.objectContaining(errorMessage) + ); + }); + }); + + describe('when an error occurred during the synchronisation process ', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const synchronizationId = new ObjectId().toHexString(); + const userSyncCount = 0; + const status = SynchronizationStatusModel.FAILED; + + const errorMessage = { + type: 'SYNCHRONIZATION_ERROR', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + data: expect.objectContaining({ + systemId, + errorMessage: + 'Unknown error occurred during synchronization process of users provisioned by an external system', + }), + }; + + synchronizationService.createSynchronization.mockResolvedValueOnce(synchronizationId); + schulconnexRestClient.getPersonenInfo.mockRejectedValueOnce(new Error('fail')); + const spyUpdateSynchronization = jest.spyOn(uc, 'updateSynchronization'); + + return { + errorMessage, + spyUpdateSynchronization, + status, + synchronizationId, + systemId, + userSyncCount, + }; + }; + + it('should call the uc.updateSynchronization to log detainls about synchronization of systemId', async () => { + const { errorMessage, spyUpdateSynchronization, status, synchronizationId, systemId, userSyncCount } = setup(); + + await uc.updateSystemUsersLastSyncedAt(systemId); + + expect(spyUpdateSynchronization).toHaveBeenCalledWith( + synchronizationId, + status, + userSyncCount, + expect.objectContaining(errorMessage) + ); + }); + }); + }); + + describe('findUsersToSynchronize', () => { + describe('when users was found', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalUserData: SanisResponse = schulconnexResponseFactory.build(); + + schulconnexRestClient.getPersonenInfo.mockResolvedValueOnce([externalUserData]); + + return { + systemId, + externalUserData, + }; + }; + + it('should call the schulconnex rest client', async () => { + const { systemId } = setup(); + + await uc.findUsersToSynchronize(systemId); + + expect(schulconnexRestClient.getPersonenInfo).toHaveBeenCalled(); + }); + + it('should split array to 3 chunks', () => { + const array = ['a', 'b', 'c']; + + expect(uc.chunkArray(array, 1).length).toBe(3); + }); + + it('should return users to synchronization', async () => { + const { systemId, externalUserData } = setup(); + + const result = await uc.findUsersToSynchronize(systemId); + + expect(result).toHaveLength(1); + expect(result).toEqual([externalUserData.pid]); + }); + }); + + describe('when users was not found', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + + schulconnexRestClient.getPersonenInfo.mockResolvedValueOnce([]); + + const expectedError = new NoUsersToSynchronizationLoggableException(systemId); + + return { + expectedError, + systemId, + }; + }; + + it('should throw an error', async () => { + const { systemId, expectedError } = setup(); + + await expect(uc.findUsersToSynchronize(systemId)).rejects.toThrowError(expectedError); + }); + }); + }); + + describe('updateSynchronization', () => { + describe('when update synchronization', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const synchronization = synchronizationFactory.buildWithId(); + const synchronizationId = synchronization.id; + const usersToCheck = [new ObjectId().toHexString(), new ObjectId().toHexString()]; + const userSyncCount = 2; + const status = SynchronizationStatusModel.SUCCESS; + const synchronizationToUpdate = new Synchronization({ + id: synchronizationId, + status, + count: userSyncCount, + }); + + return { + status, + synchronizationId, + synchronizationToUpdate, + systemId, + userSyncCount, + usersToCheck, + }; + }; + + it('should call the synchronizationService.update to log details about synchronization of systemId', async () => { + const { synchronizationId, synchronizationToUpdate, status, userSyncCount } = setup(); + + await uc.updateSynchronization(synchronizationId, status, userSyncCount); + + expect(synchronizationService.update).toHaveBeenCalledWith(synchronizationToUpdate); + }); + }); + }); + + describe('updateLastSyncedAt', () => { + describe('when searching users to update and update them', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const userAId = new ObjectId().toHexString(); + const userBId = new ObjectId().toHexString(); + const usersToCheck = [userAId, userBId]; + const usersToSync = [userAId, userBId]; + const userSyncCount = 2; + + userService.findByExternalIdsAndProvidedBySystemId.mockResolvedValueOnce(usersToSync); + + return { + systemId, + userSyncCount, + usersToCheck, + usersToSync, + }; + }; + + it('should call the userService.findByExternalIdsAndProvidedBySystemId to get array of users to sync', async () => { + const { systemId, usersToCheck } = setup(); + + await uc.updateLastSyncedAt(usersToCheck, systemId); + + expect(userService.findByExternalIdsAndProvidedBySystemId).toHaveBeenCalledWith(usersToCheck, systemId); + }); + + it('should call the userService.updateLastSyncedAt to update users', async () => { + const { systemId, usersToCheck, usersToSync } = setup(); + + await uc.updateLastSyncedAt(usersToCheck, systemId); + + expect(userService.updateLastSyncedAt).toHaveBeenCalledWith(usersToSync); + }); + + it('should return number of user with updateLastCyncedAt', async () => { + const { systemId, usersToCheck, userSyncCount } = setup(); + + const result = await uc.updateLastSyncedAt(usersToCheck, systemId); + + expect(result).toEqual(userSyncCount); + }); + }); + + describe('when updating users and got error from userService', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const userAId = new ObjectId().toHexString(); + const userBId = new ObjectId().toHexString(); + const usersToCheck = [userAId, userBId]; + const usersToSync = [userAId, userBId]; + + userService.findByExternalIdsAndProvidedBySystemId.mockResolvedValueOnce(usersToSync); + + const error = new Error('testError'); + const expectedError = new FailedUpdateLastSyncedAtLoggableException(systemId); + userService.updateLastSyncedAt.mockRejectedValueOnce(error); + + return { + expectedError, + systemId, + usersToCheck, + }; + }; + + it('should throw an error', async () => { + const { expectedError, usersToCheck, systemId } = setup(); + + await expect(uc.updateLastSyncedAt(usersToCheck, systemId)).rejects.toThrowError(expectedError); + }); + }); + }); + + describe('chunkArray', () => { + describe('when chunkArray is called', () => { + const setup = () => { + const array = ['a', 'b', 'c']; + const chunkSize = 1; + + return { + array, + chunkSize, + }; + }; + + it('should split array to 3 chunks', () => { + const { array, chunkSize } = setup(); + + expect(uc.chunkArray(array, chunkSize).length).toBe(3); + }); + }); + }); +}); diff --git a/apps/server/src/modules/idp-console/uc/synchronization.uc.ts b/apps/server/src/modules/idp-console/uc/synchronization.uc.ts new file mode 100644 index 00000000000..a2de4db850c --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/synchronization.uc.ts @@ -0,0 +1,110 @@ +import { UserService } from '@modules/user'; +import { Injectable } from '@nestjs/common'; +import { Logger } from '@src/core/logger'; +import { SanisResponse, SchulconnexRestClient } from '@infra/schulconnex-client'; +import { ConfigService } from '@nestjs/config'; +import { ErrorLogMessage } from '@src/core/logger/types'; +import { Synchronization, SynchronizationService, SynchronizationStatusModel } from '@modules/synchronization'; +import { StartSynchronizationLoggable, SucessSynchronizationLoggable } from './loggable'; +import { + FailedUpdateLastSyncedAtLoggableException, + NoUsersToSynchronizationLoggableException, + SynchronizationUnknownErrorLoggableException, +} from './loggable-exception'; +import { SynchronizationConfig } from '../interface'; + +@Injectable() +export class SynchronizationUc { + constructor( + private readonly configService: ConfigService, + private readonly schulconnexRestClient: SchulconnexRestClient, + private readonly synchronizationService: SynchronizationService, + private readonly userService: UserService, + private readonly logger: Logger + ) { + this.logger.setContext(SynchronizationUc.name); + } + + public async updateSystemUsersLastSyncedAt(systemId: string): Promise { + this.logger.info(new StartSynchronizationLoggable(systemId)); + + const synchronizationId = await this.synchronizationService.createSynchronization(systemId); + + try { + const usersToCheck = await this.findUsersToSynchronize(systemId); + const chunkSize = this.configService.get('SYNCHRONIZATION_CHUNK'); + const chunks = this.chunkArray(usersToCheck, chunkSize); + const promises = chunks.map((chunk) => this.updateLastSyncedAt(chunk, systemId)); + const results = await Promise.all(promises); + const userSyncCount = results.reduce((acc, curr) => +acc + +curr, 0); + + await this.updateSynchronization(synchronizationId, SynchronizationStatusModel.SUCCESS, userSyncCount); + this.logger.info(new SucessSynchronizationLoggable(systemId, userSyncCount)); + } catch (error) { + const loggable = + error instanceof NoUsersToSynchronizationLoggableException || + error instanceof FailedUpdateLastSyncedAtLoggableException + ? error + : new SynchronizationUnknownErrorLoggableException(systemId); + + await this.updateSynchronization( + synchronizationId, + SynchronizationStatusModel.FAILED, + 0, + loggable.getLogMessage() + ); + } + } + + public async findUsersToSynchronize(systemId: string): Promise { + let usersToCheck: string[] = []; + const usersDownloaded: SanisResponse[] = await this.schulconnexRestClient.getPersonenInfo({}); + + if (usersDownloaded.length === 0) { + throw new NoUsersToSynchronizationLoggableException(systemId); + } + usersToCheck = usersDownloaded.map((user) => user.pid); + + return usersToCheck; + } + + public async updateLastSyncedAt(usersToCheck: string[], systemId: string): Promise { + try { + const usersToSync = await this.userService.findByExternalIdsAndProvidedBySystemId(usersToCheck, systemId); + + await this.userService.updateLastSyncedAt(usersToSync); + + return usersToSync.length; + } catch { + throw new FailedUpdateLastSyncedAtLoggableException(systemId); + } + } + + public async updateSynchronization( + synchronizationId: string, + status: SynchronizationStatusModel, + userSyncCount: number, + error?: ErrorLogMessage + ): Promise { + const newSynchronization = new Synchronization({ + id: synchronizationId, + status, + count: userSyncCount, + failureCause: error ? `${error?.data?.errorMessage as string}: ${error?.data?.systemId as string}` : undefined, + }); + + await this.synchronizationService.update(newSynchronization); + } + + chunkArray(array: string[], chunkSize: number): string[][] { + const chunkedArray: string[][] = []; + let index = 0; + + while (index < array.length) { + chunkedArray.push(array.slice(index, index + chunkSize)); + index += chunkSize; + } + + return chunkedArray; + } +} diff --git a/apps/server/src/modules/idp-console/uc/testing/index.ts b/apps/server/src/modules/idp-console/uc/testing/index.ts new file mode 100644 index 00000000000..d1626266255 --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/testing/index.ts @@ -0,0 +1 @@ +export * from './test-config'; diff --git a/apps/server/src/modules/idp-console/uc/testing/test-config.ts b/apps/server/src/modules/idp-console/uc/testing/test-config.ts new file mode 100644 index 00000000000..c30472af82e --- /dev/null +++ b/apps/server/src/modules/idp-console/uc/testing/test-config.ts @@ -0,0 +1,13 @@ +import { Configuration } from '@hpi-schul-cloud/commons'; + +const synchronizationConfig = { + SYNCHRONIZATION_CHUNK: Configuration.get('SYNCHRONIZATION_CHUNK') as number, +}; + +const config = () => synchronizationConfig; + +export const synchronizationTestConfig = () => { + const conf = config(); + conf.SYNCHRONIZATION_CHUNK = 1; + return conf; +}; diff --git a/apps/server/src/modules/internal-server/index.ts b/apps/server/src/modules/internal-server/index.ts new file mode 100644 index 00000000000..c432a32edae --- /dev/null +++ b/apps/server/src/modules/internal-server/index.ts @@ -0,0 +1 @@ +export * from './internal-server.module'; diff --git a/apps/server/src/modules/internal-server/internal-server-test.module.ts b/apps/server/src/modules/internal-server/internal-server-test.module.ts new file mode 100644 index 00000000000..5b565be67a2 --- /dev/null +++ b/apps/server/src/modules/internal-server/internal-server-test.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { HealthApiModule, HealthEntities } from '@src/modules/health'; + +/** + * Internal server module used for testing. + * Use the same modules as the InternalServerModule, but with + * the in-memory MongoDB implementation for testing purposes. + */ +@Module({ + imports: [MongoMemoryDatabaseModule.forRoot({ entities: [...HealthEntities] }), HealthApiModule], +}) +export class InternalServerTestModule {} diff --git a/apps/server/src/modules/internal-server/internal-server.module.ts b/apps/server/src/modules/internal-server/internal-server.module.ts new file mode 100644 index 00000000000..dae816e533a --- /dev/null +++ b/apps/server/src/modules/internal-server/internal-server.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; + +import { DB_URL, DB_USERNAME, DB_PASSWORD } from '@src/config'; +import { HealthApiModule, HealthEntities } from '@src/modules/health'; + +@Module({ + imports: [ + MikroOrmModule.forRoot({ + type: 'mongo', + clientUrl: DB_URL, + password: DB_PASSWORD, + user: DB_USERNAME, + entities: [...HealthEntities], + ensureIndexes: true, + // debug: true, // use it only for the local queries debugging + }), + HealthApiModule, + ], +}) +export class InternalServerModule {} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-element.interface.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-element.interface.ts deleted file mode 100644 index 400b31dabb1..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-element.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface CommonCartridgeElement { - transform(): Record; -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-enums.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-enums.ts deleted file mode 100644 index 52ccd5c5818..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-enums.ts +++ /dev/null @@ -1,18 +0,0 @@ -export enum CommonCartridgeVersion { - V_1_1_0 = '1.1.0', - V_1_3_0 = '1.3.0', -} - -export enum CommonCartridgeResourceType { - LTI = 'imsbasiclti_xmlv1p0', - WEB_CONTENT = 'webcontent', - WEB_LINK_V1 = 'imswl_xmlv1p1', - WEB_LINK_V3 = 'imswl_xmlv1p3', -} - -export enum CommonCartridgeIntendedUseType { - ASSIGNMENT = 'assignment', - LESSON_PLAN = 'lessonplan', - SYLLABUS = 'syllabus', - UNSPECIFIED = 'unspecified', -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.spec.ts deleted file mode 100644 index 7b5709f8a9e..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import AdmZip from 'adm-zip'; -import { parseStringPromise } from 'xml2js'; -import { CommonCartridgeResourceType, CommonCartridgeVersion } from './common-cartridge-enums'; -import { CommonCartridgeFileBuilder, CommonCartridgeFileBuilderOptions } from './common-cartridge-file-builder'; -import { ICommonCartridgeOrganizationProps } from './common-cartridge-organization-item-element'; -import { ICommonCartridgeResourceProps } from './common-cartridge-resource-item-element'; - -describe('CommonCartridgeFileBuilder', () => { - let archive: AdmZip; - - const getFileContentAsString = (path: string): string | undefined => archive.getEntry(path)?.getData().toString(); - const fileBuilderOptions: CommonCartridgeFileBuilderOptions = { - identifier: 'file-identifier', - copyrightOwners: 'Placeholder Copyright', - creationYear: 'Placeholder Creation Year', - title: 'file-title', - version: CommonCartridgeVersion.V_1_1_0, - }; - const organizationProps: ICommonCartridgeOrganizationProps = { - version: CommonCartridgeVersion.V_1_1_0, - identifier: 'organization-identifier', - title: 'organization-title', - resources: [], - }; - const ltiResourceProps: ICommonCartridgeResourceProps = { - version: CommonCartridgeVersion.V_1_1_0, - type: CommonCartridgeResourceType.LTI, - identifier: 'lti-identifier', - href: 'lti-identifier/lti.xml', - title: 'lti-title', - description: 'lti-description', - url: 'https://to-a-lti-tool.tld', - }; - const webContentResourceProps: ICommonCartridgeResourceProps = { - version: CommonCartridgeVersion.V_1_1_0, - type: CommonCartridgeResourceType.WEB_CONTENT, - identifier: 'web-content-identifier', - href: 'web-content-identifier/web-content.html', - title: 'web-content-title', - html: '

Text Resource Title

Text Resource Description

', - }; - - beforeAll(async () => { - const fileBuilder = new CommonCartridgeFileBuilder(fileBuilderOptions).addResourceToFile(webContentResourceProps); - fileBuilder.addOrganization(organizationProps).addResourceToOrganization(ltiResourceProps); - - archive = new AdmZip(await fileBuilder.build()); - }); - - describe('addOrganization', () => { - describe('when adding an organization to the common cartridge file', () => { - it('should add organization to manifest', () => { - const manifest = getFileContentAsString('imsmanifest.xml'); - expect(manifest).toContain(organizationProps.identifier); - expect(manifest).toContain(organizationProps.title); - expect(manifest).toContain(organizationProps.version); - }); - }); - - describe('when adding a resource to an organization', () => { - it('should add resource to organization', () => { - const manifest = getFileContentAsString('imsmanifest.xml'); - expect(manifest).toContain(`${ltiResourceProps.title}`); - }); - - it('should add resource to manifest', () => { - const manifest = getFileContentAsString('imsmanifest.xml'); - expect(manifest).toContain(``); - }); - - it('should create corresponding resource file in archive', () => { - expect(getFileContentAsString(ltiResourceProps.href)).toBeTruthy(); - }); - }); - }); - - describe('addResourceToFile', () => { - describe('when adding a resource to the common cartridge file', () => { - it('should add resource to manifest', () => { - const manifest = getFileContentAsString('imsmanifest.xml'); - expect(manifest).toContain(webContentResourceProps.identifier); - expect(manifest).toContain(``); - expect(manifest).not.toContain(webContentResourceProps.title); - }); - - it('should create corresponding file in archive', () => { - expect(getFileContentAsString(webContentResourceProps.href)).toBeTruthy(); - }); - }); - }); - - describe('build', () => { - describe('when creating common cartridge archive', () => { - it('should create manifest file at archive root', () => { - const manifest = getFileContentAsString('imsmanifest.xml'); - expect(manifest).toBeTruthy(); - }); - - it('should create valid manifest file', async () => { - const manifest = getFileContentAsString('imsmanifest.xml'); - await expect(parseStringPromise(manifest as string)).resolves.not.toThrow(); - }); - }); - }); -}); diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.ts deleted file mode 100644 index 5a40269c57b..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file-builder.ts +++ /dev/null @@ -1,109 +0,0 @@ -import AdmZip from 'adm-zip'; -import { Builder } from 'xml2js'; -import { CommonCartridgeElement } from './common-cartridge-element.interface'; -import { CommonCartridgeVersion } from './common-cartridge-enums'; -import { CommonCartridgeManifestElement } from './common-cartridge-manifest-element'; -import { - CommonCartridgeOrganizationItemElement, - ICommonCartridgeOrganizationProps, -} from './common-cartridge-organization-item-element'; -import { - CommonCartridgeResourceItemElement, - ICommonCartridgeResourceProps, -} from './common-cartridge-resource-item-element'; - -export type CommonCartridgeFileBuilderOptions = { - identifier: string; - title: string; - copyrightOwners: string; - creationYear: string; - version: CommonCartridgeVersion; -}; - -export interface ICommonCartridgeOrganizationBuilder { - addResourceToOrganization(props: ICommonCartridgeResourceProps): ICommonCartridgeOrganizationBuilder; -} - -export interface ICommonCartridgeFileBuilder { - addOrganization(props: ICommonCartridgeOrganizationProps): ICommonCartridgeOrganizationBuilder; - - addResourceToFile(props: ICommonCartridgeResourceProps): ICommonCartridgeFileBuilder; - - build(): Promise; -} - -class CommonCartridgeOrganizationBuilder implements ICommonCartridgeOrganizationBuilder { - constructor( - private readonly props: ICommonCartridgeOrganizationProps, - private readonly xmlBuilder: Builder, - private readonly zipBuilder: AdmZip - ) {} - - get organization(): CommonCartridgeElement { - return new CommonCartridgeOrganizationItemElement(this.props); - } - - get resources(): CommonCartridgeElement[] { - return this.props.resources.map( - (resourceProps) => new CommonCartridgeResourceItemElement(resourceProps, this.xmlBuilder) - ); - } - - addResourceToOrganization(props: ICommonCartridgeResourceProps): ICommonCartridgeOrganizationBuilder { - const newResource = new CommonCartridgeResourceItemElement(props, this.xmlBuilder); - this.props.resources.push(props); - if (!newResource.canInline()) { - this.zipBuilder.addFile(props.href, Buffer.from(newResource.content())); - } - return this; - } -} - -export class CommonCartridgeFileBuilder implements ICommonCartridgeFileBuilder { - private readonly xmlBuilder = new Builder(); - - private readonly zipBuilder = new AdmZip(); - - private readonly organizations = new Array(); - - private readonly resources = new Array(); - - constructor(private readonly options: CommonCartridgeFileBuilderOptions) {} - - addOrganization(props: ICommonCartridgeOrganizationProps): ICommonCartridgeOrganizationBuilder { - const organizationBuilder = new CommonCartridgeOrganizationBuilder(props, this.xmlBuilder, this.zipBuilder); - this.organizations.push(organizationBuilder); - return organizationBuilder; - } - - addResourceToFile(props: ICommonCartridgeResourceProps): ICommonCartridgeFileBuilder { - const resource = new CommonCartridgeResourceItemElement(props, this.xmlBuilder); - if (!resource.canInline()) { - this.zipBuilder.addFile(props.href, Buffer.from(resource.content())); - } - this.resources.push(resource); - return this; - } - - async build(): Promise { - const organizations = this.organizations.map((organization) => organization.organization); - const resources = this.organizations.flatMap((organization) => organization.resources).concat(this.resources); - const manifest = this.xmlBuilder.buildObject( - new CommonCartridgeManifestElement( - { - identifier: this.options.identifier, - }, - { - title: this.options.title, - copyrightOwners: this.options.copyrightOwners, - creationYear: this.options.creationYear, - version: this.options.version, - }, - organizations, - resources - ).transform() - ); - this.zipBuilder.addFile('imsmanifest.xml', Buffer.from(manifest)); - return this.zipBuilder.toBufferPromise(); - } -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file.interface.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file.interface.ts deleted file mode 100644 index 0969e712b05..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-file.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface CommonCartridgeFile { - canInline(): boolean; - content(): string; -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-lesson-content-element.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-lesson-content-element.ts deleted file mode 100644 index edac1e4f30a..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-lesson-content-element.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * This type represents the content of a Lesson. - */ -export type ICommonCartridgeLessonContentProps = { - identifier: string; - title: string; - content: string; -}; diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-lti-resource.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-lti-resource.spec.ts deleted file mode 100644 index f52493e6f98..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-lti-resource.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Builder } from 'xml2js'; -import { CommonCartridgeResourceType, CommonCartridgeVersion } from './common-cartridge-enums'; -import { CommonCartridgeLtiResource, ICommonCartridgeLtiResourceProps } from './common-cartridge-lti-resource'; - -describe('CommonCartridgeLtiResource', () => { - const propsVersion1: ICommonCartridgeLtiResourceProps = { - type: CommonCartridgeResourceType.LTI, - version: CommonCartridgeVersion.V_1_1_0, - identifier: 'lti-identifier-version1', - href: 'lti-identifier-version1/lti.xml', - title: 'lti-title-version1', - description: 'lti-description-version1', - url: 'https://to-a-lti-tool-version1.tld', - }; - - const propsVersion3: ICommonCartridgeLtiResourceProps = { - type: CommonCartridgeResourceType.LTI, - version: CommonCartridgeVersion.V_1_3_0, - identifier: 'lti-identifier-version3', - href: 'lti-identifier-version3/lti.xml', - title: 'lti-title-version3', - description: 'lti-description-version3', - url: 'https://to-a-lti-tool-version3.tld', - }; - - const ltiResourceVersion1 = new CommonCartridgeLtiResource(propsVersion1, new Builder()); - const ltiResourceVersion3 = new CommonCartridgeLtiResource(propsVersion3, new Builder()); - - describe('content', () => { - describe('When Common Cartridge version 1.1', () => { - it('should return correct content for version 1.1', () => { - const expectedContent = ` - - - lti-title-version1 - lti-description-version1 - https://to-a-lti-tool-version1.tld - https://to-a-lti-tool-version1.tld - - - - `; - - const content = ltiResourceVersion1.content(); - - expect(content.replace(/\s/g, '')).toEqual(expectedContent.replace(/\s/g, '')); - }); - }); - - describe('When Common Cartridge version 1.3', () => { - it('should return correct content for version 1.3', () => { - const expectedContent = ` - - - lti-title-version3 - lti-description-version3 - https://to-a-lti-tool-version3.tld - https://to-a-lti-tool-version3.tld - - - - `; - - const content = ltiResourceVersion3.content(); - - expect(content.replace(/\s/g, '')).toEqual(expectedContent.replace(/\s/g, '')); - }); - }); - }); - - describe('transform', () => { - describe('When Common Cartridge version 1.1', () => { - it('should transform props into the expected resource structure', () => { - const expectedOutput = { - $: { - identifier: propsVersion1.identifier, - type: propsVersion1.type, - }, - file: { - $: { - href: propsVersion1.href, - }, - }, - }; - - const transformed = ltiResourceVersion1.transform(); - expect(transformed).toEqual(expectedOutput); - }); - }); - describe('When Common Cartridge version 1.3', () => { - it('should transform props into the expected resource structure', () => { - const expectedOutput = { - $: { - identifier: propsVersion3.identifier, - type: propsVersion3.type, - }, - file: { - $: { - href: propsVersion3.href, - }, - }, - }; - - const transformed = ltiResourceVersion3.transform(); - expect(transformed).toEqual(expectedOutput); - }); - }); - }); - - describe('canInline', () => { - describe('When Common Cartridge version 1.1', () => { - it('should return false for canInline', () => { - const result = ltiResourceVersion1.canInline(); - expect(result).toBe(false); - }); - }); - describe('When Common Cartridge version 1.3', () => { - it('should return false for canInline', () => { - const result = ltiResourceVersion3.canInline(); - expect(result).toBe(false); - }); - }); - }); -}); diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-lti-resource.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-lti-resource.ts deleted file mode 100644 index a374b3687a6..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-lti-resource.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Builder } from 'xml2js'; -import { CommonCartridgeElement } from './common-cartridge-element.interface'; -import { CommonCartridgeResourceType, CommonCartridgeVersion } from './common-cartridge-enums'; -import { CommonCartridgeFile } from './common-cartridge-file.interface'; - -export type ICommonCartridgeLtiResourceProps = { - type: CommonCartridgeResourceType.LTI; - version: CommonCartridgeVersion; - identifier: string; - href: string; - title: string; - description?: string; - url: string; -}; - -export class CommonCartridgeLtiResource implements CommonCartridgeElement, CommonCartridgeFile { - constructor(private readonly props: ICommonCartridgeLtiResourceProps, private readonly xmlBuilder: Builder) {} - - canInline(): boolean { - return false; - } - - content(): string { - const commonObject = { - cartridge_basiclti_link: { - $: { - xmlns: '', - 'xmlns:blti': '', - 'xmlns:lticm': '', - 'xmlns:lticp': '', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xsi:schemaLocation': '', - }, - blti: { - title: this.props.title, - description: this.props.description, - launch_url: this.props.url, - secure_launch_url: this.props.url, - cartridge_bundle: { - $: { - identifierref: 'BLTI001_Bundle', - }, - }, - cartridge_icon: { - $: { - identifierref: 'BLTI001_Icon', - }, - }, - }, - }, - }; - - switch (this.props.version) { - case CommonCartridgeVersion.V_1_3_0: - commonObject.cartridge_basiclti_link.$.xmlns = 'http://www.imsglobal.org/xsd/imslticc_v1p3'; - commonObject.cartridge_basiclti_link.$['xmlns:blti'] = 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0'; - commonObject.cartridge_basiclti_link.$['xmlns:lticm'] = 'http://www.imsglobal.org/xsd/imslticm_v1p0'; - commonObject.cartridge_basiclti_link.$['xmlns:lticp'] = 'http://www.imsglobal.org/xsd/imslticp_v1p0'; - commonObject.cartridge_basiclti_link.$['xsi:schemaLocation'] = - 'http://www.imsglobal.org/xsd/imslticc_v1p3 http://www.imsglobal.org/xsd/imslticc_v1p3.xsd' + - 'http://www.imsglobal.org/xsd/imslticp_v1p0 imslticp_v1p0.xsd' + - 'http://www.imsglobal.org/xsd/imslticm_v1p0 imslticm_v1p0.xsd' + - 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0 imsbasiclti_v1p0p1.xsd"'; - break; - default: - commonObject.cartridge_basiclti_link.$.xmlns = '/xsd/imslticc_v1p0'; - commonObject.cartridge_basiclti_link.$['xmlns:blti'] = '/xsd/imsbasiclti_v1p0'; - commonObject.cartridge_basiclti_link.$['xmlns:lticm'] = '/xsd/imslticm_v1p0'; - commonObject.cartridge_basiclti_link.$['xmlns:lticp'] = '/xsd/imslticp_v1p0'; - commonObject.cartridge_basiclti_link.$['xsi:schemaLocation'] = - '/xsd/imslticc_v1p0 /xsd/lti/ltiv1p0/imslticc_v1p0.xsd' + - '/xsd/imsbasiclti_v1p0 /xsd/lti/ltiv1p0/imsbasiclti_v1p0.xsd' + - '/xsd/imslticm_v1p0 /xsd/lti/ltiv1p0/imslticm_v1p0.xsd' + - '/xsd/imslticp_v1p0 /xsd/lti/ltiv1p0/imslticp_v1p0.xsd"'; - break; - } - - return this.xmlBuilder.buildObject(commonObject); - } - - transform(): Record { - return { - $: { - identifier: this.props.identifier, - type: this.props.type, - }, - file: { - $: { - href: this.props.href, - }, - }, - }; - } -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-manifest-element.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-manifest-element.spec.ts deleted file mode 100644 index c6d681ab393..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-manifest-element.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { CommonCartridgeManifestElement } from './common-cartridge-manifest-element'; -import { CommonCartridgeVersion } from './common-cartridge-enums'; -import { ICommonCartridgeMetadataProps } from './common-cartridge-metadata-element'; - -describe('CommonCartridgeManifestElement', () => { - const metadataPropsV3: ICommonCartridgeMetadataProps = { - version: CommonCartridgeVersion.V_1_3_0, - title: 'title of test metadata v3', - copyrightOwners: 'test copy right', - creationYear: 'test year', - }; - - const metadataPropsV1: ICommonCartridgeMetadataProps = { - version: CommonCartridgeVersion.V_1_1_0, - title: 'title of test metadata v1', - copyrightOwners: 'test copy right', - creationYear: 'test year', - }; - - const props = { - identifier: 'manifest-1', - }; - describe('commen cartridge version 3', () => { - it('should transform the manifest based on the provided common cartridge version 3', () => { - const manifestElement = new CommonCartridgeManifestElement(props, metadataPropsV3, [], []); - const result = manifestElement.transform(); - - expect(result).toEqual({ - manifest: { - $: { - identifier: 'manifest-1', - xmlns: 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1', - 'xmlns:mnf': 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/manifest', - 'xmlns:res': 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/resource', - 'xmlns:ext': 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_extensionv1p2', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xsi:schemaLocation': - 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/resource http://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lomresource_v1p0.xsd ' + - 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1 http://www.imsglobal.org/profile/cc/ccv1p3/ccv1p3_imscp_v1p2_v1p0.xsd ' + - 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/manifest http://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lommanifest_v1p0.xsd ' + - 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_extensionv1p2 http://www.imsglobal.org/profile/cc/ccv1p3/ccv1p3_cpextensionv1p2_v1p0.xsd', - }, - metadata: { - schema: 'IMS Common Cartridge', - schemaversion: metadataPropsV3.version, - 'mnf:lom': { - 'mnf:general': { - 'mnf:title': { - 'mnf:string': metadataPropsV3.title, - }, - }, - 'mnf:rights': { - 'mnf:copyrightAndOtherRestrictions': { - 'mnf:value': 'yes', - }, - 'mnf:description': { - 'mnf:string': `${metadataPropsV3.creationYear} ${metadataPropsV3.copyrightOwners}`, - }, - }, - }, - }, - organizations: { - organization: [ - { - $: { - identifier: 'org-1', - structure: 'rooted-hierarchy', - }, - item: [ - { - $: { - identifier: 'LearningModules', - }, - item: [], - }, - ], - }, - ], - }, - resources: { - resource: [], - }, - }, - }); - }); - }); - describe('commen cartridge version 1', () => { - it('should transform the manifest based on the provided common cartridge version 1', () => { - const manifestElement = new CommonCartridgeManifestElement(props, metadataPropsV1, [], []); - const result = manifestElement.transform(); - - expect(result).toEqual({ - manifest: { - $: { - identifier: 'manifest-1', - xmlns: 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1', - 'xmlns:mnf': 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest', - 'xmlns:res': 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xsi:schemaLocation': - 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lomresource_v1p0.xsd ' + - 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1 http://www.imsglobal.org/profile/cc/ccv1p1/ccv1p1_imscp_v1p2_v1p0.xsd ' + - 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lommanifest_v1p0.xsd ', - }, - metadata: { - schema: 'IMS Common Cartridge', - schemaversion: metadataPropsV1.version, - 'mnf:lom': { - 'mnf:general': { - 'mnf:title': { - 'mnf:string': metadataPropsV1.title, - }, - }, - 'mnf:rights': { - 'mnf:copyrightAndOtherRestrictions': { - 'mnf:value': 'yes', - }, - 'mnf:description': { - 'mnf:string': `${metadataPropsV1.creationYear} ${metadataPropsV1.copyrightOwners}`, - }, - }, - }, - }, - organizations: { - organization: [ - { - $: { - identifier: 'org-1', - structure: 'rooted-hierarchy', - }, - item: [ - { - $: { - identifier: 'LearningModules', - }, - item: [], - }, - ], - }, - ], - }, - resources: { - resource: [], - }, - }, - }); - }); - }); -}); diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-manifest-element.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-manifest-element.ts deleted file mode 100644 index 8e71b9adee4..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-manifest-element.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { CommonCartridgeElement } from './common-cartridge-element.interface'; -import { CommonCartridgeVersion } from './common-cartridge-enums'; -import { CommonCartridgeMetadataElement, ICommonCartridgeMetadataProps } from './common-cartridge-metadata-element'; -import { CommonCartridgeOrganizationWrapperElement } from './common-cartridge-organization-wrapper-element'; -import { CommonCartridgeResourceWrapperElement } from './common-cartridge-resource-wrapper-element'; - -export type ICommonCartridgeManifestProps = { - identifier: string; -}; - -export class CommonCartridgeManifestElement implements CommonCartridgeElement { - constructor( - private readonly props: ICommonCartridgeManifestProps, - private readonly metadataProps: ICommonCartridgeMetadataProps, - private readonly organizations: CommonCartridgeElement[], - private readonly resources: CommonCartridgeElement[] - ) {} - - transform(): Record { - const versionNumber = this.metadataProps.version; - switch (versionNumber) { - case CommonCartridgeVersion.V_1_3_0: - return { - manifest: { - $: { - identifier: this.props.identifier, - xmlns: 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1', - 'xmlns:mnf': 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/manifest', - 'xmlns:res': 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/resource', - 'xmlns:ext': 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_extensionv1p2', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xsi:schemaLocation': - 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/resource http://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lomresource_v1p0.xsd ' + - 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1 http://www.imsglobal.org/profile/cc/ccv1p3/ccv1p3_imscp_v1p2_v1p0.xsd ' + - 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/manifest http://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lommanifest_v1p0.xsd ' + - 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_extensionv1p2 http://www.imsglobal.org/profile/cc/ccv1p3/ccv1p3_cpextensionv1p2_v1p0.xsd', - }, - metadata: new CommonCartridgeMetadataElement(this.metadataProps).transform(), - organizations: new CommonCartridgeOrganizationWrapperElement(this.organizations).transform(), - resources: new CommonCartridgeResourceWrapperElement(this.resources).transform(), - }, - }; - default: - return { - manifest: { - $: { - identifier: this.props.identifier, - xmlns: 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1', - 'xmlns:mnf': 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest', - 'xmlns:res': 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xsi:schemaLocation': - 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lomresource_v1p0.xsd ' + - 'http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1 http://www.imsglobal.org/profile/cc/ccv1p1/ccv1p1_imscp_v1p2_v1p0.xsd ' + - 'http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lommanifest_v1p0.xsd ', - }, - metadata: new CommonCartridgeMetadataElement(this.metadataProps).transform(), - organizations: new CommonCartridgeOrganizationWrapperElement(this.organizations).transform(), - resources: new CommonCartridgeResourceWrapperElement(this.resources).transform(), - }, - }; - } - } -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-metadata-element.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-metadata-element.spec.ts deleted file mode 100644 index 3e2f648eda0..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-metadata-element.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ICommonCartridgeMetadataProps, CommonCartridgeMetadataElement } from './common-cartridge-metadata-element'; -import { CommonCartridgeVersion } from './common-cartridge-enums'; - -describe('CommonCartridgeMetadataElement', () => { - describe('transform', () => { - it('should return correct metadata regardless of common cartridge version', () => { - const props: ICommonCartridgeMetadataProps = { - title: 'title of metadata', - copyrightOwners: 'owner of course', - creationYear: '2023', - version: CommonCartridgeVersion.V_1_1_0, - }; - - const metadata = new CommonCartridgeMetadataElement(props); - const transformed = metadata.transform(); - expect(transformed).toEqual({ - schema: 'IMS Common Cartridge', - schemaversion: props.version, - 'mnf:lom': { - 'mnf:general': { - 'mnf:title': { - 'mnf:string': props.title, - }, - }, - 'mnf:rights': { - 'mnf:copyrightAndOtherRestrictions': { - 'mnf:value': 'yes', - }, - 'mnf:description': { - 'mnf:string': `${props.creationYear} ${props.copyrightOwners}`, - }, - }, - }, - }); - }); - }); -}); diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-metadata-element.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-metadata-element.ts deleted file mode 100644 index 17a0cf45faa..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-metadata-element.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { CommonCartridgeElement } from './common-cartridge-element.interface'; -import { CommonCartridgeVersion } from './common-cartridge-enums'; - -export type ICommonCartridgeMetadataProps = { - title: string; - copyrightOwners: string; - creationYear: string; - version: CommonCartridgeVersion; -}; - -export class CommonCartridgeMetadataElement implements CommonCartridgeElement { - constructor(private readonly props: ICommonCartridgeMetadataProps) {} - - transform(): Record { - return { - schema: 'IMS Common Cartridge', - schemaversion: this.props.version, - 'mnf:lom': { - 'mnf:general': { - 'mnf:title': { - 'mnf:string': this.props.title, - }, - }, - 'mnf:rights': { - 'mnf:copyrightAndOtherRestrictions': { - 'mnf:value': 'yes', - }, - 'mnf:description': { - 'mnf:string': `${this.props.creationYear} ${this.props.copyrightOwners}`, - }, - }, - }, - }; - } -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-item-element.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-item-element.spec.ts deleted file mode 100644 index 525d301d939..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-item-element.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - ICommonCartridgeOrganizationProps, - CommonCartridgeOrganizationItemElement, -} from './common-cartridge-organization-item-element'; -import { CommonCartridgeVersion, CommonCartridgeResourceType } from './common-cartridge-enums'; -import { ICommonCartridgeResourceProps } from './common-cartridge-resource-item-element'; - -describe('CommonCartridgeOrganizationItemElement', () => { - describe('transform', () => { - it('should return correct organization item element regardless of common cartridge version', () => { - const webContentResourceProps: ICommonCartridgeResourceProps = { - type: CommonCartridgeResourceType.WEB_CONTENT, - version: CommonCartridgeVersion.V_1_3_0, - identifier: 'web-link', - href: 'https://example.com/link', - title: 'Web Link', - html: 'html tags for testing', - }; - const props: ICommonCartridgeOrganizationProps = { - identifier: 'identifier', - title: 'title of organization item element', - version: 'version of common cartridge', - resources: [webContentResourceProps], - }; - const organizationItemElement = new CommonCartridgeOrganizationItemElement(props); - const transformed = organizationItemElement.transform(); - expect(transformed).toEqual({ - $: { - identifier: props.identifier, - }, - title: props.title, - item: [ - { - $: { - identifier: expect.any(String), - identifierref: webContentResourceProps.identifier, - }, - title: webContentResourceProps.title, - }, - ], - }); - }); - }); -}); diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-item-element.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-item-element.ts deleted file mode 100644 index 5d237fc3f98..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-item-element.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { CommonCartridgeElement } from './common-cartridge-element.interface'; -import { ICommonCartridgeResourceProps } from './common-cartridge-resource-item-element'; -import { createIdentifier } from './utils'; - -export type ICommonCartridgeOrganizationProps = { - identifier: string; - title: string; - version: string; - resources: ICommonCartridgeResourceProps[]; -}; - -export class CommonCartridgeOrganizationItemElement implements CommonCartridgeElement { - constructor(private readonly props: ICommonCartridgeOrganizationProps) {} - - transform(): Record { - return { - $: { - identifier: this.props.identifier, - }, - title: this.props.title, - item: this.props.resources.map((content) => { - return { - $: { - identifier: createIdentifier(), - identifierref: content.identifier, - }, - title: content.title, - }; - }), - }; - } -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-wrapper-element.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-wrapper-element.spec.ts deleted file mode 100644 index a26e40bc37c..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-wrapper-element.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { CommonCartridgeElement } from './common-cartridge-element.interface'; -import { CommonCartridgeOrganizationWrapperElement } from './common-cartridge-organization-wrapper-element'; - -describe('CommonCartridgeOrganizationWrapperElement', () => { - it('should transform the organization elements into the expected structure', () => { - const organizationElementsMock: CommonCartridgeElement[] = [ - { - transform: jest.fn().mockReturnValue({ identifier: 'element-1' }), - }, - { - transform: jest.fn().mockReturnValue({ identifier: 'element-2' }), - }, - ]; - - const organizationWrapperElement = new CommonCartridgeOrganizationWrapperElement(organizationElementsMock); - const result = organizationWrapperElement.transform(); - - expect(result).toEqual({ - organization: [ - { - $: { - identifier: 'org-1', - structure: 'rooted-hierarchy', - }, - item: [ - { - $: { - identifier: 'LearningModules', - }, - item: [{ identifier: 'element-1' }, { identifier: 'element-2' }], - }, - ], - }, - ], - }); - - expect(organizationElementsMock[0].transform).toHaveBeenCalled(); - expect(organizationElementsMock[1].transform).toHaveBeenCalled(); - }); -}); diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-wrapper-element.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-wrapper-element.ts deleted file mode 100644 index 34200b31e37..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-organization-wrapper-element.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CommonCartridgeElement } from './common-cartridge-element.interface'; - -export class CommonCartridgeOrganizationWrapperElement implements CommonCartridgeElement { - constructor(private readonly organizationElements: CommonCartridgeElement[]) {} - - transform(): Record { - return { - organization: [ - { - $: { - identifier: 'org-1', - structure: 'rooted-hierarchy', - }, - item: [ - { - $: { - identifier: 'LearningModules', - }, - item: this.organizationElements.map((organizationElement) => organizationElement.transform()), - }, - ], - }, - ], - }; - } -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-resource-item-element.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-resource-item-element.spec.ts deleted file mode 100644 index 0c32f9de0b5..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-resource-item-element.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Builder } from 'xml2js'; -import { - CommonCartridgeResourceItemElement, - ICommonCartridgeResourceProps, -} from './common-cartridge-resource-item-element'; - -describe('CommonCartridgeResourceItemElement', () => { - describe('when creating a common cartridge resouce with unkown type', () => { - it('should throw an error', () => { - expect( - () => new CommonCartridgeResourceItemElement({} as ICommonCartridgeResourceProps, new Builder()) - ).toThrowError(); - }); - }); -}); diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-resource-item-element.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-resource-item-element.ts deleted file mode 100644 index 219e7296075..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-resource-item-element.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Builder } from 'xml2js'; -import { CommonCartridgeElement } from './common-cartridge-element.interface'; -import { CommonCartridgeResourceType } from './common-cartridge-enums'; -import { CommonCartridgeFile } from './common-cartridge-file.interface'; -import { CommonCartridgeLtiResource, ICommonCartridgeLtiResourceProps } from './common-cartridge-lti-resource'; -import { - CommonCartridgeWebContentResource, - ICommonCartridgeWebContentResourceProps, -} from './common-cartridge-web-content-resource'; -import { - CommonCartridgeWebLinkResourceElement, - ICommonCartridgeWebLinkResourceProps, -} from './common-cartridge-web-link-resource'; - -export type ICommonCartridgeResourceProps = - | ICommonCartridgeLtiResourceProps - | ICommonCartridgeWebContentResourceProps - | ICommonCartridgeWebLinkResourceProps; - -export class CommonCartridgeResourceItemElement implements CommonCartridgeElement, CommonCartridgeFile { - private readonly inner: CommonCartridgeElement & CommonCartridgeFile; - - constructor(props: ICommonCartridgeResourceProps, xmlBuilder: Builder) { - if (props.type === CommonCartridgeResourceType.LTI) { - this.inner = new CommonCartridgeLtiResource(props, xmlBuilder); - } else if (props.type === CommonCartridgeResourceType.WEB_CONTENT) { - this.inner = new CommonCartridgeWebContentResource(props); - } else if ( - props.type === CommonCartridgeResourceType.WEB_LINK_V1 || - props.type === CommonCartridgeResourceType.WEB_LINK_V3 - ) { - this.inner = new CommonCartridgeWebLinkResourceElement(props, xmlBuilder); - } else { - throw new Error('Resource type is unknown!'); - } - } - - canInline(): boolean { - return this.inner.canInline(); - } - - content(): string { - return this.inner.content(); - } - - transform(): Record { - return this.inner.transform(); - } -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-resource-wrapper-element.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-resource-wrapper-element.spec.ts deleted file mode 100644 index cd96c9d6784..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-resource-wrapper-element.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { CommonCartridgeElement } from './common-cartridge-element.interface'; -import { CommonCartridgeResourceWrapperElement } from './common-cartridge-resource-wrapper-element'; - -describe('CommonCartridgeResourceWrapperElement', () => { - it('should transform the resource elements into an array of transformed objects', () => { - const resourceElementsMock: CommonCartridgeElement[] = [ - { - transform: jest.fn().mockReturnValue({ identifier: 'resource-1' }), - }, - { - transform: jest.fn().mockReturnValue({ identifier: 'resource-2' }), - }, - ]; - - const resourceWrapperElement = new CommonCartridgeResourceWrapperElement(resourceElementsMock); - const result = resourceWrapperElement.transform(); - - expect(result).toEqual({ - resource: [{ identifier: 'resource-1' }, { identifier: 'resource-2' }], - }); - - expect(resourceElementsMock[0].transform).toHaveBeenCalled(); - expect(resourceElementsMock[1].transform).toHaveBeenCalled(); - }); -}); diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-resource-wrapper-element.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-resource-wrapper-element.ts deleted file mode 100644 index c188651f3d4..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-resource-wrapper-element.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CommonCartridgeElement } from './common-cartridge-element.interface'; - -export class CommonCartridgeResourceWrapperElement implements CommonCartridgeElement { - constructor(private readonly resourceElements: CommonCartridgeElement[]) {} - - transform(): Record { - return { - resource: this.resourceElements.map((resourceElement) => resourceElement.transform()), - }; - } -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.spec.ts deleted file mode 100644 index 5bcb07c0b2f..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { CommonCartridgeVersion, CommonCartridgeResourceType } from './common-cartridge-enums'; -import { - ICommonCartridgeWebContentResourceProps, - CommonCartridgeWebContentResource, -} from './common-cartridge-web-content-resource'; - -describe('CommonCartridgeWebContentResource', () => { - const props: ICommonCartridgeWebContentResourceProps = { - type: CommonCartridgeResourceType.WEB_CONTENT, - version: CommonCartridgeVersion.V_1_3_0, - identifier: 'web-link', - href: 'https://example.com/link', - title: 'Web Link', - html: 'html tages for testing', - }; - const webContentResource = new CommonCartridgeWebContentResource(props); - describe('content', () => { - it('should return html content regardless of common cartridge version', () => { - const content = webContentResource.content(); - expect(content).toContain(props.html); - }); - }); - describe('canInline', () => { - it('check the return value of the method Can Inline ', () => { - expect(webContentResource.canInline()).toBe(false); - }); - }); - describe('transform', () => { - it('should transform XML content regardless of common cartridge version', () => { - const transformed = webContentResource.transform(); - expect(webContentResource.canInline()).toBe(false); - expect(transformed).toEqual({ - $: { - identifier: props.identifier, - type: props.type, - intendeduse: 'unspecified', - }, - file: { - $: { - href: props.href, - }, - }, - }); - }); - }); -}); diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.ts deleted file mode 100644 index c45b981184f..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-content-resource.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { CommonCartridgeElement } from './common-cartridge-element.interface'; -import { - CommonCartridgeIntendedUseType, - CommonCartridgeResourceType, - CommonCartridgeVersion, -} from './common-cartridge-enums'; -import { CommonCartridgeFile } from './common-cartridge-file.interface'; - -export type ICommonCartridgeWebContentResourceProps = { - type: CommonCartridgeResourceType.WEB_CONTENT; - version: CommonCartridgeVersion; - identifier: string; - href: string; - title: string; - html: string; - intendedUse?: CommonCartridgeIntendedUseType; -}; - -export class CommonCartridgeWebContentResource implements CommonCartridgeElement, CommonCartridgeFile { - constructor(private readonly props: ICommonCartridgeWebContentResourceProps) {} - - canInline(): boolean { - return false; - } - - content(): string { - return this.props.html; - } - - transform(): Record { - return { - $: { - identifier: this.props.identifier, - type: this.props.type, - intendeduse: this.props.intendedUse ?? CommonCartridgeIntendedUseType.UNSPECIFIED, - }, - file: { - $: { - href: this.props.href, - }, - }, - }; - } -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-link-resource.spec.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-link-resource.spec.ts deleted file mode 100644 index cd5c374df60..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-link-resource.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Builder } from 'xml2js'; -import { CommonCartridgeVersion, CommonCartridgeResourceType } from './common-cartridge-enums'; -import { - ICommonCartridgeWebLinkResourceProps, - CommonCartridgeWebLinkResourceElement, -} from './common-cartridge-web-link-resource'; - -describe('CommonCartridgeWebLinkResourceElement', () => { - const xmlBuilder = new Builder(); - const propsOfV3: ICommonCartridgeWebLinkResourceProps = { - type: CommonCartridgeResourceType.WEB_LINK_V3, - version: CommonCartridgeVersion.V_1_3_0, - identifier: 'web-link-v3', - href: 'https://example.com/linkv3', - title: 'Web Link v3', - url: 'https://example.com/linkv3', - }; - const propsOfV1: ICommonCartridgeWebLinkResourceProps = { - type: CommonCartridgeResourceType.WEB_LINK_V1, - version: CommonCartridgeVersion.V_1_1_0, - identifier: 'web-link-v1', - href: 'https://example.com/link1', - title: 'Web Link v1', - url: 'https://example.com/link1', - }; - - describe('CommonCartridgeWebLinkResourceElement of version 3', () => { - it('should return XML content of common cartridge version 3', () => { - const webLinkResource = new CommonCartridgeWebLinkResourceElement(propsOfV3, xmlBuilder); - const content = webLinkResource.content(); - const transformed = webLinkResource.transform(); - - expect(content).toContain('webLink'); - expect(content).toContain('http://www.w3.org/2001/XMLSchema-instance'); - expect(content).toContain('http://www.imsglobal.org/xsd/imsccv1p3/imswl_v1p3'); - expect(content).toContain('http://www.imsglobal.org/profile/cc/ccv1p3/ccv1p3_imswl_v1p3.xsd'); - expect(transformed).toEqual({ - $: { - identifier: propsOfV3.identifier, - type: propsOfV3.type, - }, - file: { - $: { - href: propsOfV3.href, - }, - }, - }); - expect(webLinkResource.canInline()).toBe(false); - }); - }); - - describe('CommonCartridgeWebLinkResourceElement of version 1', () => { - it('should return XML content of common cartridge version 1', () => { - const webLinkResource = new CommonCartridgeWebLinkResourceElement(propsOfV1, xmlBuilder); - const content = webLinkResource.content(); - const transformed = webLinkResource.transform(); - - expect(content).toContain('webLink'); - expect(content).toContain('http://www.w3.org/2001/XMLSchema-instance'); - expect(content).toContain('http://www.imsglobal.org/xsd/imsccv1p1/imswl_v1p1'); - expect(content).toContain( - 'https://www.imsglobal.org/sites/default/files/profile/cc/ccv1p1/ccv1p1_imswl_v1p1.xsd' - ); - expect(transformed).toEqual({ - $: { - identifier: propsOfV1.identifier, - type: propsOfV1.type, - }, - file: { - $: { - href: propsOfV1.href, - }, - }, - }); - }); - }); -}); diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-link-resource.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-link-resource.ts deleted file mode 100644 index 09184d5ef0f..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge-web-link-resource.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Builder } from 'xml2js'; -import { CommonCartridgeElement } from './common-cartridge-element.interface'; -import { CommonCartridgeResourceType, CommonCartridgeVersion } from './common-cartridge-enums'; -import { CommonCartridgeFile } from './common-cartridge-file.interface'; - -export type ICommonCartridgeWebLinkResourceProps = { - type: CommonCartridgeResourceType.WEB_LINK_V1 | CommonCartridgeResourceType.WEB_LINK_V3; - version: CommonCartridgeVersion; - identifier: string; - href: string; - title: string; - url: string; -}; - -export class CommonCartridgeWebLinkResourceElement implements CommonCartridgeElement, CommonCartridgeFile { - constructor(private readonly props: ICommonCartridgeWebLinkResourceProps, private readonly xmlBuilder: Builder) {} - - canInline(): boolean { - return false; - } - - content(): string { - const commonTags = { - title: this.props.title, - url: { - $: { - href: this.props.url, - target: '_self', - windowFeatures: 'width=100, height=100', - }, - }, - }; - switch (this.props.version) { - case CommonCartridgeVersion.V_1_3_0: - return this.xmlBuilder.buildObject({ - webLink: { - ...commonTags, - $: { - xmlns: 'http://www.imsglobal.org/xsd/imsccv1p3/imswl_v1p3', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xsi:schemaLocation': - 'http://www.imsglobal.org/xsd/imsccv1p3/imswl_v1p3 http://www.imsglobal.org/profile/cc/ccv1p3/ccv1p3_imswl_v1p3.xsd', - }, - }, - }); - default: - return this.xmlBuilder.buildObject({ - webLink: { - ...commonTags, - $: { - xmlns: 'http://www.imsglobal.org/xsd/imsccv1p1/imswl_v1p1', - 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xsi:schemaLocation': - 'http://www.imsglobal.org/xsd/imsccv1p1/imswl_v1p1 https://www.imsglobal.org/sites/default/files/profile/cc/ccv1p1/ccv1p1_imswl_v1p1.xsd', - }, - }, - }); - } - } - - transform(): Record { - return { - $: { - identifier: this.props.identifier, - type: this.props.type, - }, - file: { - $: { - href: this.props.href, - }, - }, - }; - } -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge.config.ts b/apps/server/src/modules/learnroom/common-cartridge/common-cartridge.config.ts deleted file mode 100644 index afbff1098a5..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/common-cartridge.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface CommonCartridgeConfig { - FEATURE_IMSCC_COURSE_EXPORT_ENABLED: boolean; -} diff --git a/apps/server/src/modules/learnroom/common-cartridge/index.ts b/apps/server/src/modules/learnroom/common-cartridge/index.ts deleted file mode 100644 index ed0b36becf1..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export * from './common-cartridge-element.interface'; -export * from './common-cartridge-enums'; -export * from './common-cartridge-file-builder'; -export * from './common-cartridge-file.interface'; -export * from './common-cartridge-lti-resource'; -export * from './common-cartridge-manifest-element'; -export * from './common-cartridge-metadata-element'; -export * from './common-cartridge-organization-item-element'; -export * from './common-cartridge-organization-wrapper-element'; -export * from './common-cartridge-resource-item-element'; -export * from './common-cartridge-resource-wrapper-element'; -export * from './common-cartridge-web-content-resource'; -export * from './common-cartridge-web-link-resource'; -export * from './common-cartridge.config'; diff --git a/apps/server/src/modules/learnroom/common-cartridge/utils.ts b/apps/server/src/modules/learnroom/common-cartridge/utils.ts deleted file mode 100644 index b83f8ec220a..00000000000 --- a/apps/server/src/modules/learnroom/common-cartridge/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ObjectId } from 'bson'; - -export function createIdentifier(id?: string | ObjectId): string { - id = id ?? new ObjectId(); - return `i${id.toString()}`; -} diff --git a/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts index 8ef38761c19..3cae67e25f9 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/course.api.spec.ts @@ -1,10 +1,19 @@ +import { faker } from '@faker-js/faker/locale/af_ZA'; import { EntityManager } from '@mikro-orm/mongodb'; -import { CourseMetadataListResponse } from '@modules/learnroom/controller/dto'; import { ServerTestModule } from '@modules/server/server.module'; -import { INestApplication, StreamableFile } from '@nestjs/common'; +import { HttpStatus, INestApplication, StreamableFile } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { Course as CourseEntity } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { TestApiClient, UserAndAccountTestFactory, cleanupCollections, courseFactory } from '@shared/testing'; +import { + cleanupCollections, + courseFactory, + groupEntityFactory, + TestApiClient, + UserAndAccountTestFactory, +} from '@shared/testing'; +import { readFile } from 'node:fs/promises'; +import { CourseMetadataListResponse } from '../dto'; const createStudent = () => { const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({}, [Permission.COURSE_VIEW]); @@ -44,13 +53,13 @@ describe('Course Controller (API)', () => { const student = createStudent(); const teacher = createTeacher(); const course = courseFactory.buildWithId({ - name: 'course #1', teachers: [teacher.user], students: [student.user], }); return { student, course, teacher }; }; + it('should find courses as student', async () => { const { student, course } = setup(); await em.persistAndFlush([student.account, student.user, course]); @@ -65,6 +74,7 @@ describe('Course Controller (API)', () => { expect(data[0].startDate).toBe(course.startDate); expect(data[0].untilDate).toBe(course.untilDate); }); + it('should find courses as teacher', async () => { const { teacher, course } = setup(); await em.persistAndFlush([teacher.account, teacher.user, course]); @@ -81,31 +91,33 @@ describe('Course Controller (API)', () => { }); }); - describe('[GET] /courses/:id/export', () => { - const setup = () => { + describe('[POST] /courses/:id/export', () => { + const setup = async () => { const student1 = createStudent(); const student2 = createStudent(); const teacher = createTeacher(); const substitutionTeacher = createTeacher(); - const teacherUnkownToCourse = createTeacher(); + const teacherUnknownToCourse = createTeacher(); const course = courseFactory.build({ - name: 'course #1', teachers: [teacher.user], students: [student1.user, student2.user], }); - return { course, teacher, teacherUnkownToCourse, substitutionTeacher, student1 }; - }; - it('should find course export', async () => { - const { teacher, course } = setup(); await em.persistAndFlush([teacher.account, teacher.user, course]); em.clear(); - const version = { version: '1.1.0' }; const loggedInClient = await testApiClient.login(teacher.account); - const response = await loggedInClient.get(`${course.id}/export`).query(version); - expect(response.statusCode).toEqual(200); + return { course, teacher, teacherUnknownToCourse, substitutionTeacher, student1, loggedInClient }; + }; + + it('should find course export', async () => { + const { course, loggedInClient } = await setup(); + + const body = { topics: [faker.string.uuid()], tasks: [faker.string.uuid()] }; + const response = await loggedInClient.post(`${course.id}/export?version=1.1.0`, body); + + expect(response.statusCode).toEqual(201); const file = response.body as StreamableFile; expect(file).toBeDefined(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access @@ -114,4 +126,121 @@ describe('Course Controller (API)', () => { expect(response.header['content-disposition']).toBe('attachment;'); }); }); + + describe('[POST] /courses/import', () => { + const setup = async () => { + const teacher = createTeacher(); + const course = await readFile( + './apps/server/src/modules/common-cartridge/testing/assets/us_history_since_1877.imscc' + ); + const courseFileName = 'us_history_since_1877.imscc'; + + await em.persistAndFlush([teacher.account, teacher.user]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacher.account); + + return { loggedInClient, course, courseFileName }; + }; + + it('should import course', async () => { + const { loggedInClient, course, courseFileName } = await setup(); + + const response = await loggedInClient.postWithAttachment('import', 'file', course, courseFileName); + + expect(response.statusCode).toEqual(201); + }); + }); + + describe('[POST] /courses/:courseId/stop-sync', () => { + describe('when a course is synchronized', () => { + const setup = async () => { + const teacher = createTeacher(); + const group = groupEntityFactory.buildWithId(); + const course = courseFactory.build({ + teachers: [teacher.user], + syncedWithGroup: group, + }); + + await em.persistAndFlush([teacher.account, teacher.user, course, group]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacher.account); + + return { + loggedInClient, + course, + }; + }; + + it('should stop the synchronization', async () => { + const { loggedInClient, course } = await setup(); + + const response = await loggedInClient.post(`${course.id}/stop-sync`); + + const result: CourseEntity = await em.findOneOrFail(CourseEntity, course.id); + expect(response.statusCode).toEqual(HttpStatus.NO_CONTENT); + expect(result.syncedWithGroup).toBeUndefined(); + }); + }); + + describe('when the user is unauthorized', () => { + const setup = async () => { + const teacher = createTeacher(); + const group = groupEntityFactory.buildWithId(); + const course = courseFactory.build({ + teachers: [teacher.user], + syncedWithGroup: group, + }); + + await em.persistAndFlush([teacher.account, teacher.user, course, group]); + em.clear(); + + return { + course, + }; + }; + + it('should return unauthorized', async () => { + const { course } = await setup(); + + const response = await testApiClient.post(`${course.id}/stop-sync`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + code: HttpStatus.UNAUTHORIZED, + message: 'Unauthorized', + title: 'Unauthorized', + type: 'UNAUTHORIZED', + }); + }); + }); + }); + + describe('[GET] /courses/:courseId/user-permissions', () => { + const setup = () => { + const teacher = createTeacher(); + const course = courseFactory.buildWithId({ + teachers: [teacher.user], + students: [], + }); + + return { course, teacher }; + }; + + it('should return teacher course permissions', async () => { + const { course, teacher } = setup(); + await em.persistAndFlush([teacher.account, teacher.user, course]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacher.account); + const response = await loggedInClient.get(`${course.id}/user-permissions`); + const data = response.body as { [key: string]: string[] }; + + expect(response.statusCode).toBe(200); + expect(data instanceof Object).toBe(true); + expect(Array.isArray(data[teacher.user.id])).toBe(true); + expect(data[teacher.user.id].length).toBeGreaterThan(0); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/controller/api-test/rooms.api.spec.ts b/apps/server/src/modules/learnroom/controller/api-test/rooms.api.spec.ts index 9b2126a7005..55fb9b02665 100644 --- a/apps/server/src/modules/learnroom/controller/api-test/rooms.api.spec.ts +++ b/apps/server/src/modules/learnroom/controller/api-test/rooms.api.spec.ts @@ -9,7 +9,7 @@ import { SingleColumnBoardResponse } from '@modules/learnroom/controller/dto'; import { ServerTestModule } from '@modules/server/server.module'; import { ExecutionContext, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Board, Course, Task } from '@shared/domain/entity'; +import { LegacyBoard, Course, Task } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { boardFactory, @@ -227,7 +227,7 @@ describe('Rooms Controller (API)', () => { const body = response.body as CopyApiResponse; expect(body.id).toBeDefined(); - expect(() => em.findOneOrFail(Board, { course: body.id as string })).not.toThrow(); + expect(() => em.findOneOrFail(LegacyBoard, { course: body.id as string })).not.toThrow(); }); it('complete example', async () => { diff --git a/apps/server/src/modules/learnroom/controller/course.controller.ts b/apps/server/src/modules/learnroom/controller/course.controller.ts index 7f026002ea7..265c52f677f 100644 --- a/apps/server/src/modules/learnroom/controller/course.controller.ts +++ b/apps/server/src/modules/learnroom/controller/course.controller.ts @@ -1,13 +1,37 @@ import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; -import { Controller, Get, NotFoundException, Param, Query, Res, StreamableFile } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Query, + Res, + StreamableFile, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { + ApiBadRequestResponse, + ApiBody, + ApiConsumes, + ApiCreatedResponse, + ApiInternalServerErrorResponse, + ApiNoContentResponse, + ApiOperation, + ApiTags, + ApiUnprocessableEntityResponse, +} from '@nestjs/swagger'; import { PaginationParams } from '@shared/controller/'; import { Response } from 'express'; import { CourseMapper } from '../mapper/course.mapper'; -import { CourseExportUc } from '../uc/course-export.uc'; -import { CourseUc } from '../uc/course.uc'; -import { CourseMetadataListResponse, CourseQueryParams, CourseUrlParams } from './dto'; +import { CourseExportUc, CourseImportUc, CourseSyncUc, CourseUc } from '../uc'; +import { CommonCartridgeFileValidatorPipe } from '../utils'; +import { CourseImportBodyParams, CourseMetadataListResponse, CourseQueryParams, CourseUrlParams } from './dto'; +import { CourseExportBodyParams } from './dto/course-export.body.params'; @ApiTags('Courses') @Authenticate('jwt') @@ -16,7 +40,8 @@ export class CourseController { constructor( private readonly courseUc: CourseUc, private readonly courseExportUc: CourseExportUc, - private readonly configService: ConfigService + private readonly courseImportUc: CourseImportUc, + private readonly courseSyncUc: CourseSyncUc ) {} @Get() @@ -32,19 +57,75 @@ export class CourseController { return result; } - @Get(':courseId/export') + @Post(':courseId/export') async exportCourse( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: CourseUrlParams, @Query() queryParams: CourseQueryParams, + @Body() bodyParams: CourseExportBodyParams, @Res({ passthrough: true }) response: Response ): Promise { - if (!this.configService.get('FEATURE_IMSCC_COURSE_EXPORT_ENABLED')) throw new NotFoundException(); - const result = await this.courseExportUc.exportCourse(urlParams.courseId, currentUser.userId, queryParams.version); + const result = await this.courseExportUc.exportCourse( + urlParams.courseId, + currentUser.userId, + queryParams.version, + bodyParams.topics, + bodyParams.tasks + ); + response.set({ 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment;', }); + return new StreamableFile(result); } + + @Post('import') + @UseInterceptors(FileInterceptor('file')) + @ApiOperation({ summary: 'Imports a course from a Common Cartridge file.' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ type: CourseImportBodyParams, required: true }) + @ApiCreatedResponse({ description: 'Course was successfully imported.' }) + @ApiBadRequestResponse({ description: 'Request data has invalid format.' }) + @ApiInternalServerErrorResponse({ description: 'Internal server error.' }) + public async importCourse( + @CurrentUser() currentUser: ICurrentUser, + @UploadedFile(CommonCartridgeFileValidatorPipe) + file: Express.Multer.File + ): Promise { + await this.courseImportUc.importFromCommonCartridge(currentUser.userId, file.buffer); + } + + @Post(':courseId/stop-sync') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Stop the synchronization of a course with a group.' }) + @ApiNoContentResponse({ description: 'The course was successfully disconnected from a group.' }) + @ApiUnprocessableEntityResponse({ description: 'The course is not synchronized with a group.' }) + public async stopSynchronization( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: CourseUrlParams + ): Promise { + await this.courseSyncUc.stopSynchronization(currentUser.userId, params.courseId); + } + + @Get(':courseId/user-permissions') + @ApiOperation({ summary: 'Get permissions for a user in a course.' }) + @ApiBadRequestResponse({ description: 'Request data has invalid format.' }) + @ApiInternalServerErrorResponse({ description: 'Internal server error.' }) + @ApiUnprocessableEntityResponse({ description: 'Unsupported role.' }) + @ApiCreatedResponse({ + status: 200, + schema: { type: 'object', example: { userId: ['permission1', 'permission2'] } }, + }) + public async getUserPermissions( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: CourseUrlParams + ): Promise<{ [userId: string]: string[] }> { + const permissions = await this.courseUc.getUserPermissionByCourseId(currentUser.userId, params.courseId); + + return { + [currentUser.userId]: permissions, + }; + } } diff --git a/apps/server/src/modules/learnroom/controller/dashboard.controller.spec.ts b/apps/server/src/modules/learnroom/controller/dashboard.controller.spec.ts index 50c851d024a..082ab2949b2 100644 --- a/apps/server/src/modules/learnroom/controller/dashboard.controller.spec.ts +++ b/apps/server/src/modules/learnroom/controller/dashboard.controller.spec.ts @@ -15,6 +15,7 @@ const learnroomMock = (id: string, name: string) => { title: name, shortTitle: name.substr(0, 2), displayColor: '#ACACAC', + isSynchronized: false, }; }, }; diff --git a/apps/server/src/modules/learnroom/controller/dto/course-export.body.params.ts b/apps/server/src/modules/learnroom/controller/dto/course-export.body.params.ts new file mode 100644 index 00000000000..309ddaa0425 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/course-export.body.params.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray } from 'class-validator'; + +export class CourseExportBodyParams { + @IsArray() + @ApiProperty({ + description: 'The list of ids of topics which should be exported. If empty no topics are exported.', + type: [String], + }) + public readonly topics!: string[]; + + @IsArray() + @ApiProperty({ + description: 'The list of ids of tasks which should be exported. If empty no tasks are exported.', + type: [String], + }) + public readonly tasks!: string[]; +} diff --git a/apps/server/src/modules/learnroom/controller/dto/course-import.body.params.ts b/apps/server/src/modules/learnroom/controller/dto/course-import.body.params.ts new file mode 100644 index 00000000000..e31448af2d0 --- /dev/null +++ b/apps/server/src/modules/learnroom/controller/dto/course-import.body.params.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CourseImportBodyParams { + @ApiProperty({ + type: String, + format: 'binary', + required: true, + description: 'The Common Cartridge file to import.', + }) + file!: Express.Multer.File; +} diff --git a/apps/server/src/modules/learnroom/controller/dto/course.query.params.ts b/apps/server/src/modules/learnroom/controller/dto/course.query.params.ts index 59b99af2f33..fc95ae28511 100644 --- a/apps/server/src/modules/learnroom/controller/dto/course.query.params.ts +++ b/apps/server/src/modules/learnroom/controller/dto/course.query.params.ts @@ -1,6 +1,6 @@ +import { CommonCartridgeVersion } from '@modules/common-cartridge'; import { ApiProperty } from '@nestjs/swagger'; import { IsString, Matches } from 'class-validator'; -import { CommonCartridgeVersion } from '../../common-cartridge'; export class CourseQueryParams { @IsString() @@ -11,5 +11,5 @@ export class CourseQueryParams { nullable: false, enum: CommonCartridgeVersion, }) - version!: CommonCartridgeVersion; + public readonly version!: CommonCartridgeVersion; } diff --git a/apps/server/src/modules/learnroom/controller/dto/dashboard.response.ts b/apps/server/src/modules/learnroom/controller/dto/dashboard.response.ts index fab05432a5b..75076d41a9f 100644 --- a/apps/server/src/modules/learnroom/controller/dto/dashboard.response.ts +++ b/apps/server/src/modules/learnroom/controller/dto/dashboard.response.ts @@ -2,13 +2,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { DecodeHtmlEntities } from '@shared/controller'; export class DashboardGridSubElementResponse { - constructor({ id, title, shortTitle, displayColor }: DashboardGridSubElementResponse) { - this.id = id; - this.title = title; - this.shortTitle = shortTitle; - this.displayColor = displayColor; - } - @ApiProperty({ description: 'The id of the Grid element', pattern: '[a-f0-9]{24}', @@ -30,31 +23,16 @@ export class DashboardGridSubElementResponse { description: 'Color of the Grid element', }) displayColor: string; -} -export class DashboardGridElementResponse { - constructor({ - id, - title, - shortTitle, - displayColor, - xPosition, - yPosition, - groupId, - groupElements, - copyingSince = undefined, - }: DashboardGridElementResponse) { - this.id = id; - this.title = title; - this.shortTitle = shortTitle; - this.displayColor = displayColor; - this.xPosition = xPosition; - this.yPosition = yPosition; - this.groupId = groupId; - this.groupElements = groupElements; - this.copyingSince = copyingSince; + constructor(props: DashboardGridSubElementResponse) { + this.id = props.id; + this.title = props.title; + this.shortTitle = props.shortTitle; + this.displayColor = props.displayColor; } +} +export class DashboardGridElementResponse { @ApiProperty({ description: 'The id of the Grid element', pattern: '[a-f0-9]{24}', @@ -103,6 +81,24 @@ export class DashboardGridElementResponse { description: 'Start of the copying process if it is still ongoing - otherwise property is not set.', }) copyingSince?: Date; + + @ApiProperty({ + description: 'Is the course synchronized with a group?', + }) + isSynchronized: boolean; + + constructor(props: DashboardGridElementResponse) { + this.id = props.id; + this.title = props.title; + this.shortTitle = props.shortTitle; + this.displayColor = props.displayColor; + this.xPosition = props.xPosition; + this.yPosition = props.yPosition; + this.groupId = props.groupId; + this.groupElements = props.groupElements; + this.copyingSince = props.copyingSince; + this.isSynchronized = props.isSynchronized; + } } export class DashboardResponse { diff --git a/apps/server/src/modules/learnroom/controller/dto/index.ts b/apps/server/src/modules/learnroom/controller/dto/index.ts index eb17ba3a672..3be2cba46f4 100644 --- a/apps/server/src/modules/learnroom/controller/dto/index.ts +++ b/apps/server/src/modules/learnroom/controller/dto/index.ts @@ -1,5 +1,7 @@ -export * from './course.url.params'; +export * from './course-import.body.params'; export * from './course-metadata.response'; +export * from './course.query.params'; +export * from './course.url.params'; export * from './dashboard.response'; export * from './dashboard.url.params'; export * from './lesson'; @@ -10,4 +12,3 @@ export * from './patch-visibility.params'; export * from './room-element.url.params'; export * from './room.url.params'; export * from './single-column-board'; -export * from './course.query.params'; diff --git a/apps/server/src/modules/learnroom/controller/dto/single-column-board/board.response.ts b/apps/server/src/modules/learnroom/controller/dto/single-column-board/board.response.ts index 6a1e959452b..5f67a20e1cc 100644 --- a/apps/server/src/modules/learnroom/controller/dto/single-column-board/board.response.ts +++ b/apps/server/src/modules/learnroom/controller/dto/single-column-board/board.response.ts @@ -4,14 +4,6 @@ import { BoardElementResponse } from './board-element.response'; // TODO: this and DashboardResponse should be combined export class SingleColumnBoardResponse { - constructor({ roomId, title, displayColor, elements, isArchived }: SingleColumnBoardResponse) { - this.roomId = roomId; - this.title = title; - this.displayColor = displayColor; - this.elements = elements; - this.isArchived = isArchived; - } - @ApiProperty({ description: 'The id of the room this board belongs to', pattern: '[a-f0-9]{24}', @@ -39,4 +31,18 @@ export class SingleColumnBoardResponse { description: 'Boolean if the room this board belongs to is archived', }) isArchived: boolean; + + @ApiProperty({ + description: 'Is the course synchronized with a group?', + }) + isSynchronized: boolean; + + constructor(props: SingleColumnBoardResponse) { + this.roomId = props.roomId; + this.title = props.title; + this.displayColor = props.displayColor; + this.elements = props.elements; + this.isArchived = props.isArchived; + this.isSynchronized = props.isSynchronized; + } } diff --git a/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts b/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts index 9f28c85dc10..ce2c3bf837d 100644 --- a/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts +++ b/apps/server/src/modules/learnroom/controller/rooms.controller.spec.ts @@ -30,7 +30,7 @@ describe('rooms controller', () => { getBoard(roomId: EntityId, userId: EntityId): Promise { throw new Error('please write mock for RoomsUc.getBoard'); }, - updateVisibilityOfBoardElement( + updateVisibilityOfLegacyBoardElement( roomId: EntityId, // eslint-disable-line @typescript-eslint/no-unused-vars elementId: EntityId, // eslint-disable-line @typescript-eslint/no-unused-vars userId: EntityId, // eslint-disable-line @typescript-eslint/no-unused-vars @@ -81,6 +81,7 @@ describe('rooms controller', () => { displayColor: '#FFFFFF', elements: [], isArchived: false, + isSynchronized: false, } as RoomBoardDTO; const ucSpy = jest.spyOn(uc, 'getBoard').mockImplementation(() => Promise.resolve(ucResult)); @@ -90,6 +91,7 @@ describe('rooms controller', () => { displayColor: '#FFFFFF', elements: [], isArchived: false, + isSynchronized: false, }); const mapperSpy = jest.spyOn(mapper, 'mapToResponse').mockImplementation(() => mapperResult); return { currentUser, ucResult, ucSpy, mapperResult, mapperSpy }; @@ -124,7 +126,7 @@ describe('rooms controller', () => { describe('patchVisibility', () => { it('should call uc', async () => { const currentUser = { userId: 'userId' } as ICurrentUser; - const ucSpy = jest.spyOn(uc, 'updateVisibilityOfBoardElement').mockImplementation(() => Promise.resolve()); + const ucSpy = jest.spyOn(uc, 'updateVisibilityOfLegacyBoardElement').mockImplementation(() => Promise.resolve()); await controller.patchElementVisibility( { roomId: 'roomid', elementId: 'elementId' }, { visibility: true }, diff --git a/apps/server/src/modules/learnroom/controller/rooms.controller.ts b/apps/server/src/modules/learnroom/controller/rooms.controller.ts index c81b40d6bba..7caa9149d87 100644 --- a/apps/server/src/modules/learnroom/controller/rooms.controller.ts +++ b/apps/server/src/modules/learnroom/controller/rooms.controller.ts @@ -1,6 +1,5 @@ import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; import { CopyApiResponse, CopyMapper } from '@modules/copy-helper'; -import { serverConfig } from '@modules/server/server.config'; import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { RequestTimeout } from '@shared/common'; @@ -45,7 +44,7 @@ export class RoomsController { @Body() params: PatchVisibilityParams, @CurrentUser() currentUser: ICurrentUser ): Promise { - await this.roomsUc.updateVisibilityOfBoardElement( + await this.roomsUc.updateVisibilityOfLegacyBoardElement( urlParams.roomId, urlParams.elementId, currentUser.userId, @@ -63,18 +62,19 @@ export class RoomsController { } @Post(':roomId/copy') - @RequestTimeout(serverConfig().INCOMING_REQUEST_TIMEOUT_COPY_API) + @RequestTimeout('INCOMING_REQUEST_TIMEOUT_COPY_API') async copyCourse( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: RoomUrlParams ): Promise { const copyStatus = await this.courseCopyUc.copyCourse(currentUser.userId, urlParams.roomId); const dto = CopyMapper.mapToResponse(copyStatus); + dto.elementsTypes = CopyMapper.mapElementsToTypes(copyStatus); return dto; } @Post('lessons/:lessonId/copy') - @RequestTimeout(serverConfig().INCOMING_REQUEST_TIMEOUT_COPY_API) + @RequestTimeout('INCOMING_REQUEST_TIMEOUT_COPY_API') async copyLesson( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: LessonUrlParams, diff --git a/apps/server/src/modules/learnroom/domain/do/course.ts b/apps/server/src/modules/learnroom/domain/do/course.ts new file mode 100644 index 00000000000..067c5aa899f --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/do/course.ts @@ -0,0 +1,83 @@ +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { CourseFeatures } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; + +export interface CourseProps extends AuthorizableObject { + name: string; + + description?: string; + + schoolId: EntityId; + + studentIds: EntityId[]; + + teacherIds: EntityId[]; + + substitutionTeacherIds: EntityId[]; + + courseGroupIds: EntityId[]; + + color: string; + + startDate?: Date; + + untilDate?: Date; + + copyingSince?: Date; + + shareToken?: string; + + features: Set; + + classIds: EntityId[]; + + groupIds: EntityId[]; + + syncedWithGroup?: EntityId; +} + +export class Course extends DomainObject { + get name(): string { + return this.props.name; + } + + set name(value: string) { + this.props.name = value; + } + + get students(): EntityId[] { + return this.props.studentIds; + } + + set students(value: EntityId[]) { + this.props.studentIds = value; + } + + get teachers(): EntityId[] { + return this.props.teacherIds; + } + + set teachers(value: EntityId[]) { + this.props.teacherIds = value; + } + + get substitutionTeachers(): EntityId[] { + return this.props.substitutionTeacherIds; + } + + set startDate(value: Date | undefined) { + this.props.startDate = value; + } + + set untilDate(value: Date | undefined) { + this.props.untilDate = value; + } + + set syncedWithGroup(value: EntityId | undefined) { + this.props.syncedWithGroup = value; + } + + get syncedWithGroup(): EntityId | undefined { + return this.props.syncedWithGroup; + } +} diff --git a/apps/server/src/modules/learnroom/domain/do/index.ts b/apps/server/src/modules/learnroom/domain/do/index.ts new file mode 100644 index 00000000000..53b46b2a008 --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/do/index.ts @@ -0,0 +1 @@ +export { Course, CourseProps } from './course'; diff --git a/apps/server/src/modules/learnroom/domain/error/course-not-synchronized.loggable-exception.spec.ts b/apps/server/src/modules/learnroom/domain/error/course-not-synchronized.loggable-exception.spec.ts new file mode 100644 index 00000000000..d9b1a20a3cb --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/error/course-not-synchronized.loggable-exception.spec.ts @@ -0,0 +1,31 @@ +import { courseFactory } from '../../testing'; +import { CourseNotSynchronizedLoggableException } from './course-not-synchronized.loggable-exception'; + +describe(CourseNotSynchronizedLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const course = courseFactory.build(); + + const exception = new CourseNotSynchronizedLoggableException(course.id); + + return { + exception, + course, + }; + }; + + it('should log the correct message', () => { + const { exception, course } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'COURSE_NOT_SYNCHRONIZED', + stack: expect.any(String), + data: { + courseId: course.id, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/domain/error/course-not-synchronized.loggable-exception.ts b/apps/server/src/modules/learnroom/domain/error/course-not-synchronized.loggable-exception.ts new file mode 100644 index 00000000000..77dd45dcec7 --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/error/course-not-synchronized.loggable-exception.ts @@ -0,0 +1,21 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { ErrorLogMessage, Loggable } from '@src/core/logger'; + +export class CourseNotSynchronizedLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly courseId: EntityId) { + super(); + } + + getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: 'COURSE_NOT_SYNCHRONIZED', + stack: this.stack, + data: { + courseId: this.courseId, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/learnroom/domain/error/index.ts b/apps/server/src/modules/learnroom/domain/error/index.ts new file mode 100644 index 00000000000..0f64cd09bd0 --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/error/index.ts @@ -0,0 +1 @@ +export { CourseNotSynchronizedLoggableException } from './course-not-synchronized.loggable-exception'; diff --git a/apps/server/src/modules/learnroom/domain/index.ts b/apps/server/src/modules/learnroom/domain/index.ts new file mode 100644 index 00000000000..7ac280479fe --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/index.ts @@ -0,0 +1,4 @@ +export { Course, CourseProps } from './do'; +export { CourseRepo, COURSE_REPO } from './interface'; +export { CourseNotSynchronizedLoggableException } from './error'; +export { CourseSynchronizationStoppedLoggable } from './loggable'; diff --git a/apps/server/src/modules/learnroom/domain/interface/course.repo.interface.ts b/apps/server/src/modules/learnroom/domain/interface/course.repo.interface.ts new file mode 100644 index 00000000000..2e40c8bc2a2 --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/interface/course.repo.interface.ts @@ -0,0 +1,12 @@ +import type { Group } from '@modules/group'; +import { EntityId } from '@shared/domain/types'; +import { BaseDomainObjectRepoInterface } from '@shared/repo/base-domain-object.repo.interface'; +import { Course } from '../do'; + +export interface CourseRepo extends BaseDomainObjectRepoInterface { + findCourseById(id: EntityId): Promise; + + findBySyncedGroup(group: Group): Promise; +} + +export const COURSE_REPO = Symbol('COURSE_REPO'); diff --git a/apps/server/src/modules/learnroom/domain/interface/index.ts b/apps/server/src/modules/learnroom/domain/interface/index.ts new file mode 100644 index 00000000000..6c0fd29b1f0 --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/interface/index.ts @@ -0,0 +1 @@ +export { CourseRepo, COURSE_REPO } from './course.repo.interface'; diff --git a/apps/server/src/modules/learnroom/domain/loggable/course-synchronization-stopped.loggable.spec.ts b/apps/server/src/modules/learnroom/domain/loggable/course-synchronization-stopped.loggable.spec.ts new file mode 100644 index 00000000000..e797ca0eb67 --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/loggable/course-synchronization-stopped.loggable.spec.ts @@ -0,0 +1,35 @@ +import { groupFactory } from '@shared/testing'; +import { courseFactory } from '../../testing'; +import { CourseSynchronizationStoppedLoggable } from './course-synchronization-stopped.loggable'; + +describe(CourseSynchronizationStoppedLoggable.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const course = courseFactory.build(); + const group = groupFactory.build(); + + const loggable: CourseSynchronizationStoppedLoggable = new CourseSynchronizationStoppedLoggable( + [course, course], + group + ); + + return { + loggable, + course, + group, + }; + }; + + it('should return the correct log message', () => { + const { loggable, course, group } = setup(); + + expect(loggable.getLogMessage()).toEqual({ + message: expect.any(String), + data: { + courseIds: `${course.id}, ${course.id}`, + groupId: group.id, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/domain/loggable/course-synchronization-stopped.loggable.ts b/apps/server/src/modules/learnroom/domain/loggable/course-synchronization-stopped.loggable.ts new file mode 100644 index 00000000000..be9c74a149f --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/loggable/course-synchronization-stopped.loggable.ts @@ -0,0 +1,17 @@ +import type { Group } from '@modules/group'; +import type { Loggable, LogMessage } from '@src/core/logger'; +import type { Course } from '../do'; + +export class CourseSynchronizationStoppedLoggable implements Loggable { + constructor(private readonly courses: Course[], private readonly group: Group) {} + + getLogMessage(): LogMessage { + return { + message: 'Synchronization between course and group was stopped, due to the deletion of the group', + data: { + courseIds: this.courses.map((course: Course) => course.id).join(', '), + groupId: this.group.id, + }, + }; + } +} diff --git a/apps/server/src/modules/learnroom/domain/loggable/index.ts b/apps/server/src/modules/learnroom/domain/loggable/index.ts new file mode 100644 index 00000000000..9e75aa0e147 --- /dev/null +++ b/apps/server/src/modules/learnroom/domain/loggable/index.ts @@ -0,0 +1 @@ +export { CourseSynchronizationStoppedLoggable } from './course-synchronization-stopped.loggable'; diff --git a/apps/server/src/modules/learnroom/index.ts b/apps/server/src/modules/learnroom/index.ts index 94cbf86ff33..0db918062ac 100644 --- a/apps/server/src/modules/learnroom/index.ts +++ b/apps/server/src/modules/learnroom/index.ts @@ -1,9 +1,10 @@ +export { LearnroomConfig } from './learnroom.config'; export * from './learnroom.module'; export { CommonCartridgeExportService, CourseCopyService, - CourseService, - RoomsService, CourseGroupService, + CourseService, DashboardService, + RoomsService, } from './service'; diff --git a/apps/server/src/modules/learnroom/learnroom-api.module.ts b/apps/server/src/modules/learnroom/learnroom-api.module.ts index a2a407daf21..f0ecaa1855b 100644 --- a/apps/server/src/modules/learnroom/learnroom-api.module.ts +++ b/apps/server/src/modules/learnroom/learnroom-api.module.ts @@ -2,8 +2,9 @@ import { AuthorizationModule } from '@modules/authorization'; import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; import { CopyHelperModule } from '@modules/copy-helper'; import { LessonModule } from '@modules/lesson'; +import { RoleModule } from '@modules/role'; import { Module } from '@nestjs/common'; -import { BoardRepo, CourseRepo, DashboardModelMapper, DashboardRepo, UserRepo } from '@shared/repo'; +import { CourseRepo, DashboardModelMapper, DashboardRepo, LegacyBoardRepo, UserRepo } from '@shared/repo'; import { CourseController } from './controller/course.controller'; import { DashboardController } from './controller/dashboard.controller'; import { RoomsController } from './controller/rooms.controller'; @@ -12,6 +13,8 @@ import { RoomBoardResponseMapper } from './mapper/room-board-response.mapper'; import { CourseCopyUC, CourseExportUc, + CourseImportUc, + CourseSyncUc, CourseUc, DashboardUc, LessonCopyUC, @@ -21,7 +24,14 @@ import { } from './uc'; @Module({ - imports: [AuthorizationModule, LessonModule, CopyHelperModule, LearnroomModule, AuthorizationReferenceModule], + imports: [ + AuthorizationModule, + LessonModule, + CopyHelperModule, + LearnroomModule, + AuthorizationReferenceModule, + RoleModule, + ], controllers: [DashboardController, CourseController, RoomsController], providers: [ DashboardUc, @@ -33,6 +43,8 @@ import { CourseCopyUC, RoomsAuthorisationService, CourseExportUc, + CourseImportUc, + CourseSyncUc, // FIXME Refactor UCs to use services and remove these imports { provide: 'DASHBOARD_REPO', @@ -41,7 +53,7 @@ import { DashboardModelMapper, CourseRepo, UserRepo, - BoardRepo, + LegacyBoardRepo, ], }) export class LearnroomApiModule {} diff --git a/apps/server/src/modules/learnroom/learnroom.config.ts b/apps/server/src/modules/learnroom/learnroom.config.ts new file mode 100644 index 00000000000..b4ed2ff7d96 --- /dev/null +++ b/apps/server/src/modules/learnroom/learnroom.config.ts @@ -0,0 +1,8 @@ +export interface LearnroomConfig { + FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED: boolean; + GEOGEBRA_BASE_URL: string; + FEATURE_COLUMN_BOARD_ENABLED: boolean; + FEATURE_COPY_SERVICE_ENABLED: boolean; + FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED: boolean; + FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE: number; +} diff --git a/apps/server/src/modules/learnroom/learnroom.module.ts b/apps/server/src/modules/learnroom/learnroom.module.ts index d6516fb7f84..b1f53af3da8 100644 --- a/apps/server/src/modules/learnroom/learnroom.module.ts +++ b/apps/server/src/modules/learnroom/learnroom.module.ts @@ -2,65 +2,87 @@ import { BoardModule } from '@modules/board'; import { CopyHelperModule } from '@modules/copy-helper'; import { LessonModule } from '@modules/lesson'; import { TaskModule } from '@modules/task'; -import { Module } from '@nestjs/common'; +import { ContextExternalToolModule } from '@modules/tool/context-external-tool'; +import { ToolConfigModule } from '@modules/tool/tool-config.module'; +import { forwardRef, Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; import { - BoardRepo, CourseGroupRepo, CourseRepo, DashboardElementRepo, DashboardModelMapper, DashboardRepo, + LegacyBoardRepo, UserRepo, } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { ContextExternalToolModule } from '@modules/tool/context-external-tool'; +import { BoardNodeRepo } from '../board/repo'; +import { COURSE_REPO } from './domain'; +import { CommonCartridgeImportMapper } from './mapper/common-cartridge-import.mapper'; +import { CommonCartridgeMapper } from './mapper/common-cartridge.mapper'; +import { CourseMikroOrmRepo } from './repo/mikro-orm/course.repo'; import { BoardCopyService, - ColumnBoardTargetService, CommonCartridgeExportService, + CommonCartridgeImportService, CourseCopyService, + CourseDoService, CourseGroupService, CourseService, DashboardService, + GroupDeletedHandlerService, RoomsService, } from './service'; -import { ToolConfigModule } from '../tool/tool-config.module'; +import { CommonCartridgeFileValidatorPipe } from './utils'; @Module({ imports: [ - LessonModule, - TaskModule, + forwardRef(() => BoardModule), CopyHelperModule, - BoardModule, - LoggerModule, ContextExternalToolModule, + LessonModule, + LoggerModule, + TaskModule, ToolConfigModule, + CqrsModule, ], providers: [ { provide: 'DASHBOARD_REPO', useClass: DashboardRepo, }, - DashboardElementRepo, - DashboardModelMapper, - CourseRepo, - BoardRepo, - UserRepo, BoardCopyService, - CourseCopyService, - RoomsService, - CourseService, + BoardNodeRepo, CommonCartridgeExportService, - ColumnBoardTargetService, - CourseGroupService, + CommonCartridgeFileValidatorPipe, + CommonCartridgeImportService, + CommonCartridgeMapper, + CommonCartridgeImportMapper, + CourseCopyService, CourseGroupRepo, + CourseGroupService, + CourseRepo, + { + provide: COURSE_REPO, + useClass: CourseMikroOrmRepo, + }, + CourseService, + CourseDoService, + DashboardElementRepo, + DashboardModelMapper, DashboardService, + LegacyBoardRepo, + RoomsService, + UserRepo, + GroupDeletedHandlerService, ], exports: [ CourseCopyService, CourseService, + CourseDoService, RoomsService, CommonCartridgeExportService, + CommonCartridgeImportService, CourseGroupService, DashboardService, ], diff --git a/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.spec.ts b/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.spec.ts new file mode 100644 index 00000000000..a6c2356d4fd --- /dev/null +++ b/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.spec.ts @@ -0,0 +1,72 @@ +import { faker } from '@faker-js/faker'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CardInitProps, ColumnInitProps } from '@shared/domain/domainobject'; +import { OrganizationProps } from '@src/modules/common-cartridge'; +import { CommonCartridgeImportMapper } from './common-cartridge-import.mapper'; + +describe('CommonCartridgeImportMapper', () => { + let moduleRef: TestingModule; + let sut: CommonCartridgeImportMapper; + + const setupOrganization = () => { + const organization: OrganizationProps = { + path: faker.string.uuid(), + pathDepth: faker.number.int({ min: 0, max: 3 }), + identifier: faker.string.uuid(), + identifierRef: faker.string.uuid(), + title: faker.lorem.words(3), + }; + + return { organization }; + }; + + // AI next 18 lines + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + providers: [CommonCartridgeImportMapper], + }).compile(); + + sut = moduleRef.get(CommonCartridgeImportMapper); + }); + + afterAll(async () => { + await moduleRef.close(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('mapOrganizationToColumn', () => { + describe('when organization is provided', () => { + const setup = () => setupOrganization(); + + it('should map organization to column', () => { + const { organization } = setup(); + + const result = sut.mapOrganizationToColumn(organization); + + expect(result).toEqual({ + title: organization.title, + }); + }); + }); + }); + + describe('mapOrganizationToCard', () => { + describe('when organization is provided', () => { + const setup = () => setupOrganization(); + + it('should map organization to card', () => { + const { organization } = setup(); + + const result = sut.mapOrganizationToCard(organization); + + expect(result).toEqual({ + title: organization.title, + height: 150, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.ts b/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.ts new file mode 100644 index 00000000000..1295a467abd --- /dev/null +++ b/apps/server/src/modules/learnroom/mapper/common-cartridge-import.mapper.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { CardInitProps, ColumnInitProps } from '@shared/domain/domainobject'; +import { OrganizationProps } from '@src/modules/common-cartridge'; + +@Injectable() +export class CommonCartridgeImportMapper { + public mapOrganizationToColumn(organization: OrganizationProps): ColumnInitProps { + return { + title: organization.title, + }; + } + + public mapOrganizationToCard(organization: OrganizationProps): CardInitProps { + return { + title: organization.title, + height: 150, + }; + } +} diff --git a/apps/server/src/modules/learnroom/mapper/common-cartridge.mapper.spec.ts b/apps/server/src/modules/learnroom/mapper/common-cartridge.mapper.spec.ts new file mode 100644 index 00000000000..566d4488a5e --- /dev/null +++ b/apps/server/src/modules/learnroom/mapper/common-cartridge.mapper.spec.ts @@ -0,0 +1,400 @@ +import { faker } from '@faker-js/faker'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { + CommonCartridgeElementProps, + CommonCartridgeElementType, + CommonCartridgeFileBuilderProps, + CommonCartridgeIntendedUseType, + CommonCartridgeOrganizationBuilderOptions, + CommonCartridgeResourceProps, + CommonCartridgeResourceType, + CommonCartridgeVersion, + OmitVersion, + createIdentifier, +} from '@modules/common-cartridge'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ComponentProperties, ComponentType } from '@shared/domain/entity'; +import { courseFactory, lessonFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; +import { LearnroomConfig } from '../learnroom.config'; +import { CommonCartridgeMapper } from './common-cartridge.mapper'; + +describe('CommonCartridgeMapper', () => { + let module: TestingModule; + let sut: CommonCartridgeMapper; + let configServiceMock: DeepMocked>; + + beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ + providers: [ + CommonCartridgeMapper, + { + provide: ConfigService, + useValue: createMock>(), + }, + ], + }).compile(); + sut = module.get(CommonCartridgeMapper); + configServiceMock = module.get(ConfigService); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('mapCourseToMetadata', () => { + describe('when mapping course to metadata', () => { + const setup = () => { + const course = courseFactory.buildWithId({ + teachers: userFactory.buildListWithId(2), + }); + + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + + return { course }; + }; + + it('should map to metadata', () => { + const { course } = setup(); + const metadataProps = sut.mapCourseToMetadata(course); + + expect(metadataProps).toStrictEqual({ + type: CommonCartridgeElementType.METADATA, + title: course.name, + copyrightOwners: course.teachers.toArray().map((teacher) => `${teacher.firstName} ${teacher.lastName}`), + creationDate: course.createdAt, + }); + }); + }); + }); + + describe('mapLessonToOrganization', () => { + describe('when mapping lesson to organization', () => { + const setup = () => { + const lesson = lessonFactory.buildWithId(); + + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + + return { lesson }; + }; + + it('should map to organization', () => { + const { lesson } = setup(); + const organizationProps = sut.mapLessonToOrganization(lesson); + + expect(organizationProps).toStrictEqual>({ + identifier: createIdentifier(lesson.id), + title: lesson.name, + }); + }); + }); + }); + + describe('mapContentToOrganization', () => { + describe('when mapping content to organization', () => { + const setup = () => { + const componentProps: ComponentProperties = { + title: 'title', + hidden: false, + component: ComponentType.TEXT, + content: { + text: 'text', + }, + }; + + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + + return { componentProps }; + }; + + it('should map to organization', () => { + const { componentProps } = setup(); + const organizationProps = sut.mapContentToOrganization(componentProps); + + expect(organizationProps).toStrictEqual>({ + identifier: expect.any(String), + title: componentProps.title, + }); + }); + }); + }); + + describe('mapTaskToResource', () => { + const setup = () => { + const task = taskFactory.buildWithId(); + + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + + return { task }; + }; + + describe('when mapping task with version 1.3.0', () => { + it('should map task to web content', () => { + const { task } = setup(); + const resourceProps = sut.mapTaskToResource(task, CommonCartridgeVersion.V_1_3_0); + + expect(resourceProps).toStrictEqual({ + type: CommonCartridgeResourceType.WEB_CONTENT, + identifier: createIdentifier(task.id), + title: task.name, + html: `

${task.name}

${task.description}

`, + intendedUse: CommonCartridgeIntendedUseType.ASSIGNMENT, + }); + }); + }); + + describe('when using other version than 1.3.0', () => { + it('should map to web content', () => { + const { task } = setup(); + const versions = [ + CommonCartridgeVersion.V_1_0_0, + CommonCartridgeVersion.V_1_1_0, + CommonCartridgeVersion.V_1_2_0, + CommonCartridgeVersion.V_1_4_0, + ]; + + versions.forEach((version) => { + const resourceProps = sut.mapTaskToResource(task, version); + + expect(resourceProps).toStrictEqual({ + type: CommonCartridgeResourceType.WEB_CONTENT, + identifier: createIdentifier(task.id), + title: task.name, + html: `

${task.name}

${task.description}

`, + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }); + }); + }); + }); + }); + + describe('mapTaskToOrganization', () => { + describe('when mapping task', () => { + const setup = () => { + const task = taskFactory.buildWithId(); + + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + + return { task }; + }; + + it('should map to organization', () => { + const { task } = setup(); + const organizationProps = sut.mapTaskToOrganization(task); + + expect(organizationProps).toStrictEqual>({ + identifier: expect.any(String), + title: task.name, + }); + }); + }); + }); + + describe('mapContentToResources', () => { + describe('when mapping text content', () => { + const setup = () => { + const componentProps: ComponentProperties = { + title: 'title', + hidden: false, + component: ComponentType.TEXT, + content: { + text: 'text', + }, + }; + + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + + return { componentProps }; + }; + + it('should map to web content', () => { + const { componentProps } = setup(); + const resourceProps = sut.mapContentToResources(componentProps); + + expect(resourceProps).toStrictEqual({ + type: CommonCartridgeResourceType.WEB_CONTENT, + identifier: expect.any(String), + title: componentProps.title, + html: `

${componentProps.title}

${componentProps?.content.text}

`, + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }); + }); + }); + + describe('when mapping geogebra content', () => { + const setup = () => { + const componentProps: ComponentProperties = { + title: 'title', + hidden: false, + component: ComponentType.GEOGEBRA, + content: { + materialId: 'material-id', + }, + }; + + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + + return { componentProps }; + }; + + it('should map to web link', () => { + const { componentProps } = setup(); + const resourceProps = sut.mapContentToResources(componentProps); + + expect(resourceProps).toStrictEqual({ + type: CommonCartridgeResourceType.WEB_LINK, + title: componentProps.title, + identifier: expect.any(String), + url: `${configServiceMock.getOrThrow('FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED')}/m/${ + componentProps.content.materialId + }`, + }); + }); + }); + + describe('when mapping etherpad content', () => { + const setup = () => { + const componentProps: ComponentProperties = { + title: 'title', + hidden: false, + component: ComponentType.ETHERPAD, + content: { + description: 'description', + title: 'title', + url: 'url', + }, + }; + + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + + return { componentProps }; + }; + + it('should map to web link', () => { + const { componentProps } = setup(); + const resourceProps = sut.mapContentToResources(componentProps); + + expect(resourceProps).toStrictEqual({ + type: CommonCartridgeResourceType.WEB_LINK, + identifier: expect.any(String), + title: `${componentProps.content.title} - ${componentProps.content.description}`, + url: componentProps.content.url, + }); + }); + }); + + describe('when mapping learn store content to resources', () => { + const setup = () => { + const componentProps: ComponentProperties = { + _id: 'id', + title: 'title', + hidden: false, + component: ComponentType.LERNSTORE, + content: { + resources: [ + { + client: 'client', + description: 'description', + title: 'title', + url: 'url', + }, + ], + }, + }; + + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + + return { componentProps }; + }; + + it('should map to web link', () => { + const { componentProps } = setup(); + const resourceProps = sut.mapContentToResources(componentProps); + + expect(resourceProps).toStrictEqual([ + { + type: CommonCartridgeResourceType.WEB_LINK, + identifier: expect.any(String), + title: componentProps.content?.resources[0].title as string, + url: componentProps.content?.resources[0].url as string, + }, + ]); + }); + }); + + describe('when no learn store content is provided', () => { + // AI next 16 lines + const setup = () => { + const componentProps: ComponentProperties = { + _id: 'id', + title: 'title', + hidden: false, + component: ComponentType.LERNSTORE, + }; + + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + + return { componentProps }; + }; + + it('should map to empty array', () => { + const { componentProps } = setup(); + const resourceProps = sut.mapContentToResources(componentProps); + + expect(resourceProps).toEqual([]); + }); + }); + + describe('when mapping unknown content', () => { + const setup = () => { + const unknownComponentProps: ComponentProperties = { + title: 'title', + hidden: false, + component: ComponentType.INTERNAL, + content: { + url: 'url', + }, + }; + + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + + return { unknownComponentProps }; + }; + + it('should map to empty array', () => { + const { unknownComponentProps } = setup(); + const resourceProps = sut.mapContentToResources(unknownComponentProps); + + expect(resourceProps).toEqual([]); + }); + }); + }); + + describe('mapCourseToManifest', () => { + describe('when mapping course', () => { + const setup = () => { + const course = courseFactory.buildWithId(); + const version = CommonCartridgeVersion.V_1_1_0; + + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + + return { course, version }; + }; + + it('should map to manifest', () => { + const { course, version } = setup(); + const fileBuilderProps = sut.mapCourseToManifest(version, course); + + expect(fileBuilderProps).toStrictEqual({ + version: CommonCartridgeVersion.V_1_1_0, + identifier: createIdentifier(course.id), + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/mapper/common-cartridge.mapper.ts b/apps/server/src/modules/learnroom/mapper/common-cartridge.mapper.ts new file mode 100644 index 00000000000..b5e89980278 --- /dev/null +++ b/apps/server/src/modules/learnroom/mapper/common-cartridge.mapper.ts @@ -0,0 +1,120 @@ +import { + CommonCartridgeElementProps, + CommonCartridgeElementType, + CommonCartridgeFileBuilderProps, + CommonCartridgeIntendedUseType, + CommonCartridgeOrganizationBuilderOptions, + CommonCartridgeResourceProps, + CommonCartridgeResourceType, + CommonCartridgeVersion, + createIdentifier, +} from '@modules/common-cartridge'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ComponentProperties, ComponentType, Course, LessonEntity, Task } from '@shared/domain/entity'; +import { LearnroomConfig } from '../learnroom.config'; + +@Injectable() +export class CommonCartridgeMapper { + constructor(private readonly configService: ConfigService) {} + + public mapCourseToMetadata(course: Course): CommonCartridgeElementProps { + return { + type: CommonCartridgeElementType.METADATA, + title: course.name, + copyrightOwners: course.teachers.toArray().map((teacher) => `${teacher.firstName} ${teacher.lastName}`), + creationDate: course.createdAt, + }; + } + + public mapLessonToOrganization(lesson: LessonEntity): CommonCartridgeOrganizationBuilderOptions { + return { + identifier: createIdentifier(lesson.id), + title: lesson.name, + }; + } + + public mapContentToOrganization(content: ComponentProperties): CommonCartridgeOrganizationBuilderOptions { + return { + identifier: createIdentifier(content._id), + title: content.title, + }; + } + + public mapTaskToOrganization(task: Task): CommonCartridgeOrganizationBuilderOptions { + return { + identifier: createIdentifier(), + title: task.name, + }; + } + + public mapTaskToResource(task: Task, version: CommonCartridgeVersion): CommonCartridgeResourceProps { + const intendedUse = (() => { + switch (version) { + case CommonCartridgeVersion.V_1_1_0: + return CommonCartridgeIntendedUseType.UNSPECIFIED; + case CommonCartridgeVersion.V_1_3_0: + return CommonCartridgeIntendedUseType.ASSIGNMENT; + default: + return CommonCartridgeIntendedUseType.UNSPECIFIED; + } + })(); + + return { + type: CommonCartridgeResourceType.WEB_CONTENT, + identifier: createIdentifier(task.id), + title: task.name, + html: `

${task.name}

${task.description}

`, + intendedUse, + }; + } + + public mapContentToResources( + content: ComponentProperties + ): CommonCartridgeResourceProps | CommonCartridgeResourceProps[] { + switch (content.component) { + case ComponentType.TEXT: + return { + type: CommonCartridgeResourceType.WEB_CONTENT, + identifier: createIdentifier(content._id), + title: content.title, + html: `

${content.title}

${content.content.text}

`, + intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, + }; + case ComponentType.GEOGEBRA: + return { + type: CommonCartridgeResourceType.WEB_LINK, + identifier: createIdentifier(content._id), + title: content.title, + url: `${this.configService.getOrThrow('GEOGEBRA_BASE_URL')}/m/${content.content.materialId}`, + }; + case ComponentType.ETHERPAD: + return { + type: CommonCartridgeResourceType.WEB_LINK, + identifier: createIdentifier(content._id), + title: `${content.title} - ${content.content.description}`, + url: content.content.url, + }; + case ComponentType.LERNSTORE: + return ( + content.content?.resources.map((resource) => { + return { + type: CommonCartridgeResourceType.WEB_LINK, + identifier: createIdentifier(), + title: resource.title, + url: resource.url, + }; + }) || [] + ); + default: + return []; + } + } + + public mapCourseToManifest(version: CommonCartridgeVersion, course: Course): CommonCartridgeFileBuilderProps { + return { + version, + identifier: createIdentifier(course.id), + }; + } +} diff --git a/apps/server/src/modules/learnroom/mapper/dashboard.mapper.spec.ts b/apps/server/src/modules/learnroom/mapper/dashboard.mapper.spec.ts index 7633ae2ef39..08f6e7d1221 100644 --- a/apps/server/src/modules/learnroom/mapper/dashboard.mapper.spec.ts +++ b/apps/server/src/modules/learnroom/mapper/dashboard.mapper.spec.ts @@ -12,6 +12,7 @@ const learnroomMock = (id: string, name: string) => { title: name, shortTitle: name.substr(0, 2), displayColor: '#ACACAC', + isSynchronized: false, }; }, }; diff --git a/apps/server/src/modules/learnroom/mapper/dashboard.mapper.ts b/apps/server/src/modules/learnroom/mapper/dashboard.mapper.ts index 537015f02b2..cd1654889d1 100644 --- a/apps/server/src/modules/learnroom/mapper/dashboard.mapper.ts +++ b/apps/server/src/modules/learnroom/mapper/dashboard.mapper.ts @@ -1,4 +1,4 @@ -import { DashboardEntity, GridElementWithPosition } from '@shared/domain/entity'; +import { DashboardEntity, GridElementContent, GridElementWithPosition, GridPosition } from '@shared/domain/entity'; import { LearnroomMetadata } from '@shared/domain/types'; import { DashboardGridElementResponse, DashboardGridSubElementResponse, DashboardResponse } from '../controller/dto'; @@ -14,15 +14,16 @@ export class DashboardMapper { } private static mapGridElement(data: GridElementWithPosition): DashboardGridElementResponse { - const elementData = data.gridElement.getContent(); - const position = data.pos; - const dto = new DashboardGridElementResponse({ + const elementData: GridElementContent = data.gridElement.getContent(); + const position: GridPosition = data.pos; + const dto: DashboardGridElementResponse = new DashboardGridElementResponse({ title: elementData.title, shortTitle: elementData.shortTitle, displayColor: elementData.displayColor, xPosition: position.x, yPosition: position.y, copyingSince: elementData.copyingSince ?? undefined, + isSynchronized: elementData.isSynchronized, }); if (elementData.referencedId) { dto.id = elementData.referencedId; diff --git a/apps/server/src/modules/learnroom/mapper/rolename.mapper.spec.ts b/apps/server/src/modules/learnroom/mapper/rolename.mapper.spec.ts new file mode 100644 index 00000000000..3123988eb95 --- /dev/null +++ b/apps/server/src/modules/learnroom/mapper/rolename.mapper.spec.ts @@ -0,0 +1,53 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RoleName } from '@shared/domain/interface'; +import { UserAndAccountTestFactory, courseFactory, roleFactory, setupEntities, userFactory } from '@shared/testing'; +import { RoleNameMapper } from './rolename.mapper'; + +describe('rolename mapper', () => { + let module: TestingModule; + + afterAll(async () => { + await module.close(); + }); + + beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ + imports: [], + providers: [], + }).compile(); + }); + + const setup = () => { + const teacherUser = userFactory.asTeacher().buildWithId(); + const studentUser = userFactory.asStudent().buildWithId(); + const course = courseFactory.build({ + teachers: [teacherUser], + students: [studentUser], + }); + + return { teacherUser, studentUser, course }; + }; + it('should map teacher correctly', () => { + const { teacherUser } = UserAndAccountTestFactory.buildTeacher({}, []); + const course = courseFactory.build({ + teachers: [teacherUser], + }); + const value = RoleNameMapper.mapToRoleName(teacherUser, course); + expect(value).toBe(RoleName.TEACHER); + }); + + it('should map student correctly', () => { + const { studentUser, course } = setup(); + const value = RoleNameMapper.mapToRoleName(studentUser, course); + expect(value).toBe(RoleName.STUDENT); + }); + + it('throws with unsupported role', () => { + const { course } = setup(); + const role = roleFactory.build({ name: RoleName.EXPERT }); + const user = userFactory.buildWithId({ roles: [role] }); + + expect(() => RoleNameMapper.mapToRoleName(user, course)).toThrowError('Unsupported role'); + }); +}); diff --git a/apps/server/src/modules/learnroom/mapper/rolename.mapper.ts b/apps/server/src/modules/learnroom/mapper/rolename.mapper.ts new file mode 100644 index 00000000000..a755c734a44 --- /dev/null +++ b/apps/server/src/modules/learnroom/mapper/rolename.mapper.ts @@ -0,0 +1,38 @@ +import { EntityDTO } from '@mikro-orm/core'; +import { UnprocessableEntityException } from '@nestjs/common'; +import { Course, Role, User } from '@shared/domain/entity'; +import { RoleName } from '@shared/domain/interface'; + +export class RoleNameMapper { + private static isSuperHero(roles: EntityDTO[]): boolean { + return roles.some((role) => role.name === RoleName.SUPERHERO); + } + + private static isAdministrator(roles: EntityDTO[], user: User, course: Course): boolean { + const belongsToSameSchool = user.school.id === course.school.id; + const isAdministrator = roles.some((role) => role.name === RoleName.ADMINISTRATOR); + return belongsToSameSchool && isAdministrator; + } + + private static isTeacher(user: User, course: Course): boolean { + return course.getTeacherIds().includes(user.id); + } + + private static isSubstitutionTeacher(user: User, course: Course): boolean { + return course.getSubstitutionTeacherIds().includes(user.id); + } + + private static isStudent(user: User, course: Course): boolean { + return course.getStudentIds().includes(user.id); + } + + static mapToRoleName(user: User, course: Course): RoleName { + if (RoleNameMapper.isSuperHero(user.roles.toArray())) return RoleName.SUPERHERO; + if (RoleNameMapper.isAdministrator(user.roles.toArray(), user, course)) return RoleName.ADMINISTRATOR; + if (RoleNameMapper.isTeacher(user, course)) return RoleName.TEACHER; + if (RoleNameMapper.isSubstitutionTeacher(user, course)) return RoleName.COURSESUBSTITUTIONTEACHER; + if (RoleNameMapper.isStudent(user, course)) return RoleName.STUDENT; + + throw new UnprocessableEntityException('Unsupported role'); + } +} diff --git a/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.spec.ts b/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.spec.ts index 9503a7c82ab..9244b453a21 100644 --- a/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.spec.ts +++ b/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.spec.ts @@ -1,8 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { courseFactory, setupEntities, taskFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BoardElementResponse, SingleColumnBoardResponse } from '../controller/dto'; -import { RoomBoardElementTypes } from '../types'; +import { RoomBoardDTO, RoomBoardElementTypes } from '../types'; import { RoomBoardResponseMapper } from './room-board-response.mapper'; describe('room board response mapper', () => { @@ -25,12 +25,13 @@ describe('room board response mapper', () => { describe('mapToResponse', () => { it('should map plain board into response', () => { - const board = { + const board: RoomBoardDTO = { roomId: 'roomId', displayColor: '#ACACAC', title: 'boardTitle', elements: [], isArchived: false, + isSynchronized: false, }; const result = mapper.mapToResponse(board); @@ -49,15 +50,16 @@ describe('room board response mapper', () => { isFinished: false, isSubstitutionTeacher: false, }; - const board = { + const board: RoomBoardDTO = { roomId: 'roomId', displayColor: '#ACACAC', title: 'boardTitle', elements: [{ type: RoomBoardElementTypes.TASK, content: { task, status } }], isArchived: false, + isSynchronized: false, }; - const result = mapper.mapToResponse(board); + const result: SingleColumnBoardResponse = mapper.mapToResponse(board); expect(result.elements[0] instanceof BoardElementResponse).toEqual(true); }); @@ -73,12 +75,13 @@ describe('room board response mapper', () => { isFinished: false, isSubstitutionTeacher: false, }; - const board = { + const board: RoomBoardDTO = { roomId: 'roomId', displayColor: '#ACACAC', title: 'boardTitle', elements: [{ type: RoomBoardElementTypes.TASK, content: { task: linkedTask, status } }], isArchived: false, + isSynchronized: false, }; const result = mapper.mapToResponse(board); @@ -99,12 +102,13 @@ describe('room board response mapper', () => { numberOfPlannedTasks: 5, courseName: course.name, }; - const board = { + const board: RoomBoardDTO = { roomId: 'roomId', displayColor: '#ACACAC', title: 'boardTitle', elements: [{ type: RoomBoardElementTypes.LESSON, content: lessonMetadata }], isArchived: false, + isSynchronized: false, }; const result = mapper.mapToResponse(board); @@ -134,7 +138,7 @@ describe('room board response mapper', () => { isFinished: false, isSubstitutionTeacher: false, }; - const board = { + const board: RoomBoardDTO = { roomId: 'roomId', displayColor: '#ACACAC', title: 'boardTitle', @@ -143,6 +147,7 @@ describe('room board response mapper', () => { { type: RoomBoardElementTypes.TASK, content: { task, status } }, ], isArchived: false, + isSynchronized: false, }; const result = mapper.mapToResponse(board); @@ -160,12 +165,13 @@ describe('room board response mapper', () => { createdAt: new Date(), updatedAt: new Date(), }; - const board = { + const board: RoomBoardDTO = { roomId: 'roomId', displayColor: '#ACACAC', title: 'boardTitle', elements: [{ type: RoomBoardElementTypes.COLUMN_BOARD, content: columnBoardMetaData }], isArchived: false, + isSynchronized: false, }; const result = mapper.mapToResponse(board); diff --git a/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.ts b/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.ts index d5fb38e1657..66d10b74404 100644 --- a/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.ts +++ b/apps/server/src/modules/learnroom/mapper/room-board-response.mapper.ts @@ -13,14 +13,15 @@ import { BoardTaskStatusMapper } from './board-taskStatus.mapper'; @Injectable() export class RoomBoardResponseMapper { mapToResponse(board: RoomBoardDTO): SingleColumnBoardResponse { - const elements = this.mapBoardElements(board); + const elements: BoardElementResponse[] = this.mapBoardElements(board); - const mapped = new SingleColumnBoardResponse({ + const mapped: SingleColumnBoardResponse = new SingleColumnBoardResponse({ roomId: board.roomId, title: board.title, displayColor: board.displayColor, elements, isArchived: board.isArchived, + isSynchronized: board.isSynchronized, }); return mapped; diff --git a/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.integration.spec.ts b/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.integration.spec.ts new file mode 100644 index 00000000000..c637c041432 --- /dev/null +++ b/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.integration.spec.ts @@ -0,0 +1,177 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { NotFoundError } from '@mikro-orm/core'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ClassEntity } from '@modules/class/entity'; +import { classEntityFactory } from '@modules/class/entity/testing'; +import { Group } from '@modules/group'; +import { GroupEntity } from '@modules/group/entity'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Course as CourseEntity, CourseFeatures, CourseGroup, SchoolEntity, User } from '@shared/domain/entity'; +import { + cleanupCollections, + courseFactory as courseEntityFactory, + courseGroupFactory as courseGroupEntityFactory, + groupEntityFactory, + groupFactory, + schoolEntityFactory, + userFactory, +} from '@shared/testing'; +import { Course, COURSE_REPO, CourseProps } from '../../domain'; +import { courseFactory } from '../../testing'; +import { CourseMikroOrmRepo } from './course.repo'; +import { CourseEntityMapper } from './mapper/course.entity.mapper'; + +describe(CourseMikroOrmRepo.name, () => { + let module: TestingModule; + let repo: CourseMikroOrmRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [{ provide: COURSE_REPO, useClass: CourseMikroOrmRepo }], + }).compile(); + + repo = module.get(COURSE_REPO); + em = module.get(EntityManager); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('findCourseById', () => { + describe('when entity is not found', () => { + it('should throw NotFound', async () => { + const someId = new ObjectId().toHexString(); + + await expect(() => repo.findCourseById(someId)).rejects.toThrow(NotFoundError); + }); + }); + + describe('when entity is found', () => { + const setup = async () => { + const entity: CourseEntity = courseEntityFactory.build(); + + await em.persistAndFlush([entity]); + em.clear(); + + const course: Course = CourseEntityMapper.mapEntityToDo(entity); + + return { course }; + }; + + it('should return course', async () => { + const { course } = await setup(); + + const result: Course = await repo.findCourseById(course.id); + + expect(result).toEqual(course); + }); + }); + }); + + describe('findBySyncedGroup', () => { + describe('when a course is synced with a group', () => { + const setup = async () => { + const groupEntity: GroupEntity = groupEntityFactory.buildWithId(); + const syncedCourseEntity: CourseEntity = courseEntityFactory.build({ syncedWithGroup: groupEntity }); + const otherCourseEntity: CourseEntity = courseEntityFactory.build({ syncedWithGroup: undefined }); + const group: Group = groupFactory.build({ id: groupEntity.id }); + + await em.persistAndFlush([syncedCourseEntity, groupEntity, otherCourseEntity]); + em.clear(); + + const course: Course = CourseEntityMapper.mapEntityToDo(syncedCourseEntity); + + return { + course, + group, + }; + }; + + it('should return courses', async () => { + const { course, group } = await setup(); + + const result: Course[] = await repo.findBySyncedGroup(group); + + expect(result).toEqual([course]); + }); + }); + }); + + describe('save', () => { + describe('when entity is new', () => { + const setup = async () => { + const entity: CourseEntity = courseEntityFactory.build(); + + await em.persistAndFlush([entity.school]); + em.clear(); + + const course: Course = CourseEntityMapper.mapEntityToDo(entity); + + return { course }; + }; + + it('should save entity', async () => { + const { course } = await setup(); + + const result: Course = await repo.save(course); + + expect(result).toEqual(course); + }); + }); + + describe('when entity is existing', () => { + const setup = async () => { + const courseEntity: CourseEntity = courseEntityFactory.buildWithId(); + const userEntity: User = userFactory.buildWithId(); + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId(); + const groupEntity: GroupEntity = groupEntityFactory.buildWithId(); + const classEntity: ClassEntity = classEntityFactory.buildWithId(); + const courseGroupEntity: CourseGroup = courseGroupEntityFactory.buildWithId(); + + await em.persistAndFlush([courseEntity, userEntity, schoolEntity, groupEntity, classEntity, courseGroupEntity]); + em.clear(); + + const expectedProps: CourseProps = { + id: courseEntity.id, + name: `course 1`, + features: new Set([CourseFeatures.VIDEOCONFERENCE]), + schoolId: schoolEntity.id, + studentIds: [userEntity.id], + teacherIds: [userEntity.id], + substitutionTeacherIds: [userEntity.id], + groupIds: [groupEntity.id], + classIds: [classEntity.id], + courseGroupIds: [courseGroupEntity.id], + description: 'description', + color: '#ACACAC', + copyingSince: new Date(), + syncedWithGroup: groupEntity.id, + shareToken: 'shareToken', + untilDate: new Date(), + startDate: new Date(), + }; + const newCourse: Course = courseFactory.build(expectedProps); + + return { newCourse, expectedProps }; + }; + + it('should update entity', async () => { + const { newCourse, expectedProps } = await setup(); + + const result: Course = await repo.save(newCourse); + + expect(result).toEqual(newCourse); + + const updatedCourse = await repo.findCourseById(newCourse.id); + expect(updatedCourse.getProps()).toEqual(expect.objectContaining(expectedProps)); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.ts b/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.ts new file mode 100644 index 00000000000..8b3a15a5c19 --- /dev/null +++ b/apps/server/src/modules/learnroom/repo/mikro-orm/course.repo.ts @@ -0,0 +1,47 @@ +import { EntityData, EntityName } from '@mikro-orm/core'; +import { Group } from '@modules/group'; +import { Course as CourseEntity } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; +import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; +import { Course, CourseRepo } from '../../domain'; +import { CourseEntityMapper } from './mapper/course.entity.mapper'; + +export class CourseMikroOrmRepo extends BaseDomainObjectRepo implements CourseRepo { + protected get entityName(): EntityName { + return CourseEntity; + } + + protected mapDOToEntityProperties(entityDO: Course): EntityData { + const entityProps: EntityData = CourseEntityMapper.mapDoToEntityData(entityDO, this.em); + + return entityProps; + } + + public async findCourseById(id: EntityId): Promise { + const entity: CourseEntity = await super.findById(id); + + if (!entity.courseGroups.isInitialized()) { + await entity.courseGroups.init(); + } + + const course: Course = CourseEntityMapper.mapEntityToDo(entity); + + return course; + } + + public async findBySyncedGroup(group: Group): Promise { + const entities: CourseEntity[] = await this.em.find(CourseEntity, { syncedWithGroup: group.id }); + + await Promise.all( + entities.map(async (entity: CourseEntity): Promise => { + if (!entity.courseGroups.isInitialized()) { + await entity.courseGroups.init(); + } + }) + ); + + const courses: Course[] = entities.map((entity: CourseEntity): Course => CourseEntityMapper.mapEntityToDo(entity)); + + return courses; + } +} diff --git a/apps/server/src/modules/learnroom/repo/mikro-orm/mapper/course.entity.mapper.ts b/apps/server/src/modules/learnroom/repo/mikro-orm/mapper/course.entity.mapper.ts new file mode 100644 index 00000000000..fdb0925ccc2 --- /dev/null +++ b/apps/server/src/modules/learnroom/repo/mikro-orm/mapper/course.entity.mapper.ts @@ -0,0 +1,84 @@ +import { EntityData } from '@mikro-orm/core'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { ClassEntity } from '@modules/class/entity'; +import { GroupEntity } from '@modules/group/entity'; +import { Course as CourseEntity, CourseGroup, SchoolEntity, User as UserEntity } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; +import { Course, CourseProps } from '../../../domain'; + +export class CourseEntityMapper { + public static mapEntityToDo(entity: CourseEntity): Course { + const courseGroupIds: EntityId[] = entity.courseGroups + .getItems() + .map((courseGroup: CourseGroup): EntityId => courseGroup.id); + + const classIds: EntityId[] = entity.classes.getItems().map((clazz: ClassEntity): EntityId => clazz.id); + const groupIds: EntityId[] = entity.groups.getItems().map((group: GroupEntity): EntityId => group.id); + + const studentIds: EntityId[] = entity.students.getItems().map((user: UserEntity): EntityId => user.id); + const teacherIds: EntityId[] = entity.teachers.getItems().map((user: UserEntity): EntityId => user.id); + const substitutionTeacherIds: EntityId[] = entity.substitutionTeachers + .getItems() + .map((user: UserEntity): EntityId => user.id); + + const course = new Course({ + id: entity.id, + name: entity.name, + color: entity.color, + description: entity.description, + startDate: entity.startDate, + untilDate: entity.untilDate, + features: new Set(entity.features), + schoolId: entity.school.id, + studentIds, + teacherIds, + substitutionTeacherIds, + classIds, + groupIds, + courseGroupIds, + copyingSince: entity.copyingSince, + shareToken: entity.shareToken, + syncedWithGroup: entity.syncedWithGroup?.id, + }); + + return course; + } + + public static mapDoToEntityData(domainObject: Course, em: EntityManager): EntityData { + const props: CourseProps = domainObject.getProps(); + const school: SchoolEntity = em.getReference(SchoolEntity, props.schoolId); + const courseGroups: CourseGroup[] = props.courseGroupIds.map( + (id: EntityId): CourseGroup => em.getReference(CourseGroup, id) + ); + + const classes: ClassEntity[] = props.classIds.map((id: EntityId): ClassEntity => em.getReference(ClassEntity, id)); + const groups: GroupEntity[] = props.groupIds.map((id: EntityId): GroupEntity => em.getReference(GroupEntity, id)); + + const students: UserEntity[] = props.studentIds.map((id: EntityId): UserEntity => em.getReference(UserEntity, id)); + const teachers: UserEntity[] = props.teacherIds.map((id: EntityId): UserEntity => em.getReference(UserEntity, id)); + const substitutionTeachers: UserEntity[] = props.substitutionTeacherIds.map( + (id: EntityId): UserEntity => em.getReference(UserEntity, id) + ); + + const courseEntityData: EntityData = { + name: props.name, + color: props.color, + description: props.description, + startDate: props.startDate, + untilDate: props.untilDate, + features: Array.from(props.features), + school, + students, + teachers, + substitutionTeachers, + classes, + groups, + courseGroups, + copyingSince: props.copyingSince, + shareToken: props.shareToken, + syncedWithGroup: props.syncedWithGroup, + }; + + return courseEntityData; + } +} diff --git a/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts b/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts index f64e3725fec..c51df30a606 100644 --- a/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/board-copy.service.spec.ts @@ -6,14 +6,15 @@ import { TaskCopyService } from '@modules/task'; import { Test, TestingModule } from '@nestjs/testing'; import { AuthorizableObject } from '@shared/domain/domain-object'; import { BoardExternalReferenceType } from '@shared/domain/domainobject/board/types'; -import { Board } from '@shared/domain/entity'; +import { LegacyBoard } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; -import { BoardRepo } from '@shared/repo'; +import { LegacyBoardRepo } from '@shared/repo'; +import { BoardNodeRepo } from '@modules/board/repo'; import { boardFactory, columnboardBoardElementFactory, columnBoardFactory, - columnBoardTargetFactory, + columnBoardNodeFactory, courseFactory, lessonBoardElementFactory, lessonFactory, @@ -32,7 +33,7 @@ describe('board copy service', () => { let lessonCopyService: DeepMocked; let columnBoardCopyService: DeepMocked; let copyHelperService: DeepMocked; - let boardRepo: DeepMocked; + let boardRepo: DeepMocked; afterAll(async () => { await module.close(); @@ -60,13 +61,17 @@ describe('board copy service', () => { useValue: createMock(), }, { - provide: BoardRepo, - useValue: createMock(), + provide: LegacyBoardRepo, + useValue: createMock(), }, { provide: LegacyLogger, useValue: createMock(), }, + { + provide: BoardNodeRepo, + useValue: createMock(), + }, ], }).compile(); @@ -75,7 +80,7 @@ describe('board copy service', () => { lessonCopyService = module.get(LessonCopyService); copyHelperService = module.get(CopyHelperService); columnBoardCopyService = module.get(ColumnBoardCopyService); - boardRepo = module.get(BoardRepo); + boardRepo = module.get(LegacyBoardRepo); boardRepo.save = jest.fn(); }); @@ -118,7 +123,7 @@ describe('board copy service', () => { const { destinationCourse, originalBoard, user } = setup(); const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); - const board = status.copyEntity as Board; + const board = status.copyEntity as LegacyBoard; expect(board.id).not.toEqual(originalBoard.id); }); @@ -126,7 +131,7 @@ describe('board copy service', () => { const { destinationCourse, originalBoard, user } = setup(); const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); - const board = status.copyEntity as Board; + const board = status.copyEntity as LegacyBoard; expect(board.course.id).toEqual(destinationCourse.id); }); }); @@ -172,7 +177,7 @@ describe('board copy service', () => { const { destinationCourse, originalBoard, user } = setup(); const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); - const board = status.copyEntity as Board; + const board = status.copyEntity as LegacyBoard; expect(board.getElements().length).toEqual(1); }); @@ -223,7 +228,7 @@ describe('board copy service', () => { it('should add lessonCopy to board copy', async () => { const { destinationCourse, originalBoard, user } = setup(); const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); - const board = status.copyEntity as Board; + const board = status.copyEntity as LegacyBoard; expect(board.getElements().length).toEqual(1); }); @@ -240,8 +245,7 @@ describe('board copy service', () => { describe('when board contains column board', () => { const setup = () => { const originalColumnBoard = columnBoardFactory.build(); - const columnBoardTarget = columnBoardTargetFactory.build({ - columnBoardId: originalColumnBoard.id, + const columnBoardTarget = columnBoardNodeFactory.build({ title: originalColumnBoard.title, }); const columBoardElement = columnboardBoardElementFactory.build({ target: columnBoardTarget }); @@ -258,14 +262,14 @@ describe('board copy service', () => { title: copyOfColumnBoard.title, }); - return { destinationCourse, originalBoard, user, originalColumnBoard }; + return { destinationCourse, originalBoard, user, originalColumnBoard, columnBoardTarget }; }; it('should call columnBoardCopyService with original columnBoard', async () => { - const { destinationCourse, originalBoard, user, originalColumnBoard } = setup(); + const { destinationCourse, originalBoard, user, columnBoardTarget } = setup(); const expected = { - originalColumnBoardId: originalColumnBoard.id, + originalColumnBoardId: columnBoardTarget.id, destinationExternalReference: { type: BoardExternalReferenceType.Course, id: destinationCourse.id, @@ -280,7 +284,7 @@ describe('board copy service', () => { it('should add columnBoard copy to board copy', async () => { const { destinationCourse, originalBoard, user } = setup(); const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); - const board = status.copyEntity as Board; + const board = status.copyEntity as LegacyBoard; expect(board.getElements().length).toEqual(1); }); @@ -318,8 +322,8 @@ describe('board copy service', () => { lessonCopyService.updateCopiedEmbeddedTasks = jest.fn().mockImplementation((status: CopyStatus) => status); const originalColumnBoard = columnBoardFactory.build(); - const columnBoardTarget = columnBoardTargetFactory.buildWithId({ - columnBoardId: originalColumnBoard.id, + const columnBoardTarget = columnBoardNodeFactory.buildWithId({ + id: originalColumnBoard.id, title: originalColumnBoard.title, }); const columnBoardElement = columnboardBoardElementFactory.buildWithId({ target: columnBoardTarget }); @@ -455,7 +459,7 @@ describe('board copy service', () => { const { destinationCourse, originalBoard, user } = setup(); const status = await copyService.copyBoard({ originalBoard, user, destinationCourse }); - const board = status.copyEntity as Board; + const board = status.copyEntity as LegacyBoard; expect(board.references).toHaveLength(0); }); diff --git a/apps/server/src/modules/learnroom/service/board-copy.service.ts b/apps/server/src/modules/learnroom/service/board-copy.service.ts index 2356f06a02c..d418a32e475 100644 --- a/apps/server/src/modules/learnroom/service/board-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/board-copy.service.ts @@ -7,28 +7,29 @@ import { getResolvedValues } from '@shared/common/utils/promise'; import { ColumnBoard } from '@shared/domain/domainobject'; import { BoardExternalReferenceType } from '@shared/domain/domainobject/board/types'; import { - Board, - BoardElement, - BoardElementType, - ColumnBoardTarget, ColumnboardBoardElement, + ColumnBoardNode, Course, + LegacyBoard, + LegacyBoardElement, + LegacyBoardElementType, LessonBoardElement, LessonEntity, Task, TaskBoardElement, User, - isColumnBoardTarget, isLesson, isTask, } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; -import { BoardRepo } from '@shared/repo'; +import { LegacyBoardRepo } from '@shared/repo'; import { LegacyLogger } from '@src/core/logger'; import { sortBy } from 'lodash'; +import { BoardNodeRepo } from '@modules/board/repo'; + type BoardCopyParams = { - originalBoard: Board; + originalBoard: LegacyBoard; destinationCourse: Course; user: User; }; @@ -37,21 +38,23 @@ type BoardCopyParams = { export class BoardCopyService { constructor( private readonly logger: LegacyLogger, - private readonly boardRepo: BoardRepo, + private readonly boardRepo: LegacyBoardRepo, private readonly taskCopyService: TaskCopyService, private readonly lessonCopyService: LessonCopyService, private readonly columnBoardCopyService: ColumnBoardCopyService, - private readonly copyHelperService: CopyHelperService + private readonly copyHelperService: CopyHelperService, + private readonly boardNodeRepo: BoardNodeRepo ) {} async copyBoard(params: BoardCopyParams): Promise { const { originalBoard, user, destinationCourse } = params; - const boardElements: BoardElement[] = originalBoard.getElements(); + const boardElements: LegacyBoardElement[] = originalBoard.getElements(); const elements: CopyStatus[] = await this.copyBoardElements(boardElements, user, destinationCourse); - const references: BoardElement[] = this.extractReferences(elements); - let boardCopy: Board = new Board({ references, course: destinationCourse }); + const references: LegacyBoardElement[] = await this.extractReferences(elements); + + let boardCopy: LegacyBoard = new LegacyBoard({ references, course: destinationCourse }); let status: CopyStatus = { title: 'board', type: CopyElementType.BOARD, @@ -63,7 +66,7 @@ export class BoardCopyService { status = this.updateCopiedEmbeddedTasksOfLessons(status); if (status.copyEntity) { - boardCopy = status.copyEntity as Board; + boardCopy = status.copyEntity as LegacyBoard; } status = await this.swapLinkedIdsInBoards(status); @@ -79,7 +82,7 @@ export class BoardCopyService { } private async copyBoardElements( - boardElements: BoardElement[], + boardElements: LegacyBoardElement[], user: User, destinationCourse: Course ): Promise { @@ -88,15 +91,18 @@ export class BoardCopyService { return Promise.reject(new Error('Broken boardelement - not pointing to any target entity')); } - if (element.boardElementType === BoardElementType.Task && isTask(element.target)) { + if (element.boardElementType === LegacyBoardElementType.Task && isTask(element.target)) { return this.copyTask(element.target, user, destinationCourse).then((status) => [pos, status]); } - if (element.boardElementType === BoardElementType.Lesson && isLesson(element.target)) { + if (element.boardElementType === LegacyBoardElementType.Lesson && isLesson(element.target)) { return this.copyLesson(element.target, user, destinationCourse).then((status) => [pos, status]); } - if (element.boardElementType === BoardElementType.ColumnBoard && isColumnBoardTarget(element.target)) { + if ( + element.boardElementType === LegacyBoardElementType.ColumnBoard && + element.target instanceof ColumnBoardNode + ) { return this.copyColumnBoard(element.target, user, destinationCourse).then((status) => [pos, status]); } @@ -129,12 +135,12 @@ export class BoardCopyService { } private async copyColumnBoard( - columnBoardTarget: ColumnBoardTarget, + columnBoardNode: ColumnBoardNode, user: User, destinationCourse: Course ): Promise { return this.columnBoardCopyService.copyColumnBoard({ - originalColumnBoardId: columnBoardTarget.columnBoardId, + originalColumnBoardId: columnBoardNode.id, userId: user.id, destinationExternalReference: { id: destinationCourse.id, @@ -143,9 +149,10 @@ export class BoardCopyService { }); } - private extractReferences(statuses: CopyStatus[]): BoardElement[] { - const references: BoardElement[] = []; - statuses.forEach((status) => { + private async extractReferences(statuses: CopyStatus[]): Promise { + const references: LegacyBoardElement[] = []; + for (const status of statuses) { + // statuses.forEach((status) => { if (status.copyEntity instanceof Task) { const taskElement = new TaskBoardElement({ target: status.copyEntity }); references.push(taskElement); @@ -155,12 +162,14 @@ export class BoardCopyService { references.push(lessonElement); } if (status.copyEntity instanceof ColumnBoard) { + // eslint-disable-next-line no-await-in-loop + const columnBoardNode = (await this.boardNodeRepo.findById(status.copyEntity.id)) as ColumnBoardNode; const columnBoardElement = new ColumnboardBoardElement({ - target: new ColumnBoardTarget({ columnBoardId: status.copyEntity.id, title: status.copyEntity.title }), + target: columnBoardNode, }); references.push(columnBoardElement); } - }); + } return references; } @@ -182,7 +191,7 @@ export class BoardCopyService { const copyDict = this.copyHelperService.buildCopyEntityDict(copyStatus); copyDict.forEach((value, key) => map.set(key, value.id)); - if (copyStatus.copyEntity instanceof Board && copyStatus.originalEntity instanceof Board) { + if (copyStatus.copyEntity instanceof LegacyBoard && copyStatus.originalEntity instanceof LegacyBoard) { map.set(copyStatus.originalEntity.course.id, copyStatus.copyEntity.course.id); } diff --git a/apps/server/src/modules/learnroom/service/column-board-target.service.spec.ts b/apps/server/src/modules/learnroom/service/column-board-target.service.spec.ts deleted file mode 100644 index 5463ac2eb65..00000000000 --- a/apps/server/src/modules/learnroom/service/column-board-target.service.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ColumnBoardService } from '@modules/board'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ColumnBoardTarget } from '@shared/domain/entity'; -import { cleanupCollections, columnBoardTargetFactory } from '@shared/testing'; -import { ColumnBoardTargetService } from './column-board-target.service'; - -describe(ColumnBoardTargetService.name, () => { - let module: TestingModule; - let service: ColumnBoardTargetService; - let columnBoardService: DeepMocked; - let em: EntityManager; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot()], - providers: [ - ColumnBoardTargetService, - { provide: ColumnBoardService, useValue: createMock() }, - ], - }).compile(); - service = module.get(ColumnBoardTargetService); - columnBoardService = module.get(ColumnBoardService); - em = module.get(EntityManager); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(async () => { - await cleanupCollections(em); - await em.nativeDelete(ColumnBoardTarget, {}); - }); - - describe('findOrCreateTargets', () => { - describe('when target exists for columnBoardId', () => { - const setup = async () => { - const columnBoardId = new ObjectId().toHexString(); - const target = columnBoardTargetFactory.build({ columnBoardId, title: 'board #1' }); - await em.persistAndFlush(target); - - return { target }; - }; - - it('should return the target', async () => { - const { target } = await setup(); - - const result = await service.findOrCreateTargets([target.columnBoardId]); - - expect(result).toEqual([target]); - }); - - it('should update the target name', async () => { - const { target } = await setup(); - columnBoardService.getBoardObjectTitlesById.mockResolvedValueOnce({ [target.columnBoardId]: 'board #42' }); - - const result = await service.findOrCreateTargets([target.columnBoardId]); - - expect(result[0].title).toEqual('board #42'); - }); - }); - - describe('when no target exists for columnBoardId', () => { - it('should create a target', async () => { - const id = new ObjectId().toHexString(); - columnBoardService.getBoardObjectTitlesById.mockResolvedValueOnce({ [id]: 'board #42' }); - const result = await service.findOrCreateTargets([id]); - - expect(result[0].columnBoardId).toEqual(id); - }); - }); - }); -}); diff --git a/apps/server/src/modules/learnroom/service/column-board-target.service.ts b/apps/server/src/modules/learnroom/service/column-board-target.service.ts deleted file mode 100644 index ba45415186e..00000000000 --- a/apps/server/src/modules/learnroom/service/column-board-target.service.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { FilterQuery } from '@mikro-orm/core'; -import { EntityManager } from '@mikro-orm/mongodb'; -import { ColumnBoardService } from '@modules/board'; -import { Injectable } from '@nestjs/common'; -import { ColumnBoardTarget } from '@shared/domain/entity'; -import { EntityId } from '@shared/domain/types'; - -@Injectable() -export class ColumnBoardTargetService { - constructor(private readonly columnBoardService: ColumnBoardService, private readonly em: EntityManager) {} - - async findOrCreateTargets(columnBoardIds: EntityId[]): Promise { - const existingTargets = await this.findExistingTargets(columnBoardIds); - - const titlesMap = await this.columnBoardService.getBoardObjectTitlesById(columnBoardIds); - - const columnBoardTargets = columnBoardIds.map((id) => { - const title = titlesMap[id] ?? ''; - let target = existingTargets.find((item) => item.columnBoardId === id); - if (target) { - target.title = title; - } else { - target = new ColumnBoardTarget({ columnBoardId: id, title }); - } - this.em.persist(target); - return target; - }); - - await this.em.flush(); - - return columnBoardTargets; - } - - private async findExistingTargets(columnBoardIds: EntityId[]): Promise { - const existingTargets = await this.em.find(ColumnBoardTarget, { - _columnBoardId: { $in: columnBoardIds }, - } as unknown as FilterQuery); - - return existingTargets; - } -} diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-export.service.spec.ts b/apps/server/src/modules/learnroom/service/common-cartridge-export.service.spec.ts index 9f964682836..69d93a05df9 100644 --- a/apps/server/src/modules/learnroom/service/common-cartridge-export.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/common-cartridge-export.service.spec.ts @@ -1,37 +1,106 @@ +import { faker } from '@faker-js/faker'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { CourseService } from '@modules/learnroom/service'; -import { CommonCartridgeExportService } from '@modules/learnroom/service/common-cartridge-export.service'; -import { LessonService } from '@modules/lesson/service'; -import { TaskService } from '@modules/task/service/task.service'; +import { CommonCartridgeVersion } from '@modules/common-cartridge'; +import { CommonCartridgeExportService, CourseService, LearnroomConfig } from '@modules/learnroom'; +import { LessonService } from '@modules/lesson'; +import { TaskService } from '@modules/task'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; +import { ComponentType } from '@shared/domain/entity'; import { - ComponentProperties, - ComponentTextProperties, - ComponentType, - Course, - LessonEntity, - Task, -} from '@shared/domain/entity'; -import { courseFactory, lessonFactory, setupEntities, taskFactory } from '@shared/testing'; + columnBoardFactory, + columnFactory, + cardFactory, + courseFactory, + lessonFactory, + setupEntities, + taskFactory, +} from '@shared/testing'; +import { ColumnBoardService } from '@src/modules/board'; import AdmZip from 'adm-zip'; -import { CommonCartridgeVersion } from '../common-cartridge'; +import { CommonCartridgeMapper } from '../mapper/common-cartridge.mapper'; describe('CommonCartridgeExportService', () => { let module: TestingModule; - let courseExportService: CommonCartridgeExportService; + let sut: CommonCartridgeExportService; let courseServiceMock: DeepMocked; let lessonServiceMock: DeepMocked; let taskServiceMock: DeepMocked; + let configServiceMock: DeepMocked>; + let columnBoardServiceMock: DeepMocked; - let course: Course; - let lessons: LessonEntity[]; - let tasks: Task[]; + const createXmlString = (nodeName: string, value: boolean | number | string): string => + `<${nodeName}>${value.toString()}`; + const getFileContent = (archive: AdmZip, filePath: string): string | undefined => + archive.getEntry(filePath)?.getData().toString(); + const setupParams = async (version: CommonCartridgeVersion, exportTopics: boolean, exportTasks: boolean) => { + const course = courseFactory.teachersWithId(2).buildWithId(); + const tasks = taskFactory.buildListWithId(2); + const lessons = lessonFactory.buildListWithId(1, { + contents: [ + { + title: 'text-title', + hidden: false, + component: ComponentType.TEXT, + content: { + text: 'text', + }, + }, + { + title: 'lernstore-title', + hidden: false, + component: ComponentType.LERNSTORE, + content: { + resources: [ + { + client: 'client-1', + description: 'description-1', + title: 'title-1', + url: 'url-1', + }, + { + client: 'client-2', + description: 'description-2', + title: 'title-2', + url: 'url-2', + }, + ], + }, + }, + ], + }); + const [lesson] = lessons; + const taskFromLesson = taskFactory.buildWithId({ course, lesson }); + const card = cardFactory.build(); + const column = columnFactory.build({ children: [card] }); + const columnBoard = columnBoardFactory.build({ children: [column] }); + + lessonServiceMock.findById.mockResolvedValue(lesson); + courseServiceMock.findById.mockResolvedValue(course); + lessonServiceMock.findByCourseIds.mockResolvedValue([lessons, lessons.length]); + taskServiceMock.findBySingleParent.mockResolvedValue([tasks, tasks.length]); + configServiceMock.getOrThrow.mockReturnValue(faker.internet.url()); + columnBoardServiceMock.findIdsByExternalReference.mockResolvedValue([faker.string.uuid()]); + columnBoardServiceMock.findById.mockResolvedValue(columnBoard); + + const buffer = await sut.exportCourse( + course.id, + faker.string.uuid(), + version, + exportTopics ? [lesson.id] : [], + exportTasks ? tasks.map((task) => task.id) : [] + ); + const archive = new AdmZip(buffer); + + return { archive, course, lessons, tasks, taskFromLesson, columnBoard, column, card }; + }; beforeAll(async () => { await setupEntities(); module = await Test.createTestingModule({ providers: [ CommonCartridgeExportService, + CommonCartridgeMapper, { provide: CourseService, useValue: createMock(), @@ -44,43 +113,22 @@ describe('CommonCartridgeExportService', () => { provide: TaskService, useValue: createMock(), }, + { + provide: ConfigService, + useValue: createMock>(), + }, + { + provide: ColumnBoardService, + useValue: createMock(), + }, ], }).compile(); - courseExportService = module.get(CommonCartridgeExportService); + sut = module.get(CommonCartridgeExportService); courseServiceMock = module.get(CourseService); lessonServiceMock = module.get(LessonService); taskServiceMock = module.get(TaskService); - course = courseFactory.teachersWithId(2).buildWithId(); - lessons = lessonFactory.buildListWithId(5, { - contents: [ - { - component: ComponentType.TEXT, - title: 'Text', - content: { - text: 'text', - }, - } as ComponentProperties, - { - component: ComponentType.ETHERPAD, - title: 'Etherpad', - content: { - url: 'url', - }, - } as ComponentProperties, - { - component: ComponentType.GEOGEBRA, - title: 'Geogebra', - content: { - materialId: 'materialId', - }, - } as ComponentProperties, - {} as ComponentProperties, - ], - }); - tasks = taskFactory.buildListWithId(5, { - name: 'Task of a lesson', - lesson: lessons[0], - }); + configServiceMock = module.get(ConfigService); + columnBoardServiceMock = module.get(ColumnBoardService); }); afterAll(async () => { @@ -88,138 +136,153 @@ describe('CommonCartridgeExportService', () => { }); describe('exportCourse', () => { - const setupExport = async (version: CommonCartridgeVersion) => { - const [lesson] = lessons; - const textContent = { text: 'Some random text' } as ComponentTextProperties; - const lessonContent: ComponentProperties = { - _id: 'random_id', - title: 'A random title', - hidden: false, - component: ComponentType.TEXT, - content: textContent, - }; - lesson.contents = [lessonContent]; - lessonServiceMock.findById.mockResolvedValueOnce(lesson); - courseServiceMock.findById.mockResolvedValueOnce(course); - lessonServiceMock.findByCourseIds.mockResolvedValueOnce([lessons, lessons.length]); - taskServiceMock.findBySingleParent.mockResolvedValueOnce([tasks, tasks.length]); - const archive = new AdmZip(await courseExportService.exportCourse(course.id, '', version)); - return archive; - }; + describe('when using version 1.1', () => { + const setup = async () => setupParams(CommonCartridgeVersion.V_1_1_0, true, true); - describe('When Common Cartridge version 1.1', () => { - let archive: AdmZip; + it('should use schema version 1.1.0', async () => { + const { archive } = await setup(); - beforeAll(async () => { - archive = await setupExport(CommonCartridgeVersion.V_1_1_0); + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(createXmlString('schemaversion', '1.1.0')); }); - it('should create manifest file', () => { - expect(archive.getEntry('imsmanifest.xml')).toBeDefined(); - }); + it('should add course', async () => { + const { archive, course } = await setup(); - it('should add title to manifest file', () => { - expect(archive.getEntry('imsmanifest.xml')?.getData().toString()).toContain(course.name); + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(createXmlString('mnf:string', course.name)); }); - it('should add lessons as organization items to manifest file', () => { - const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); + it('should add lessons', async () => { + const { archive, lessons } = await setup(); + lessons.forEach((lesson) => { - expect(manifest).toContain(lesson.name); + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(createXmlString('title', lesson.name)); }); }); - it('should add lesson text content to manifest file', () => { - const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); - expect(manifest).toContain(lessons[0].contents[0].title); - }); + it('should add tasks', async () => { + const { archive, tasks } = await setup(); - it('should add copyright information to manifest file', () => { - const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); - expect(manifest).toContain(course.teachers[0].firstName); - expect(manifest).toContain(course.teachers[0].lastName); - expect(manifest).toContain(course.teachers[1].firstName); - expect(manifest).toContain(course.teachers[1].lastName); - expect(manifest).toContain(course.createdAt.getFullYear().toString()); - }); - - it('should add tasks as assignments', () => { - const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); tasks.forEach((task) => { - expect(manifest).toContain(`${task.name}`); - expect(manifest).toContain(`identifier="i${task.id}" type="webcontent" intendeduse="unspecified"`); + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(` { + it('should add tasks of lesson to manifest file', async () => { + const { archive, lessons } = await setup(); const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); + lessons[0].tasks.getItems().forEach((task) => { expect(manifest).toContain(`${task.name}`); expect(manifest).toContain(`identifier="i${task.id}" type="webcontent" intendeduse="unspecified"`); }); }); - it('should add version 1 information to manifest file', () => { - const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); - expect(manifest).toContain(CommonCartridgeVersion.V_1_1_0); + it('should add column boards', async () => { + const { archive, columnBoard } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); + + expect(manifest).toContain(createXmlString('title', columnBoard.title)); }); - }); - describe('When Common Cartridge version 1.3', () => { - let archive: AdmZip; + it('should add column', async () => { + const { archive, column } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); - beforeAll(async () => { - archive = await setupExport(CommonCartridgeVersion.V_1_3_0); + expect(manifest).toContain(createXmlString('title', column.title)); }); - it('should create manifest file', () => { - expect(archive.getEntry('imsmanifest.xml')).toBeDefined(); - }); + it('should add card', async () => { + const { archive, card } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); - it('should add title to manifest file', () => { - expect(archive.getEntry('imsmanifest.xml')?.getData().toString()).toContain(course.name); + expect(manifest).toContain(createXmlString('title', card.title)); }); + }); - it('should add lessons as organization items to manifest file', () => { - const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); - lessons.forEach((lesson) => { - expect(manifest).toContain(lesson.name); - }); + describe('when using version 1.3', () => { + const setup = async () => setupParams(CommonCartridgeVersion.V_1_3_0, true, true); + + it('should use schema version 1.3.0', async () => { + const { archive } = await setup(); + + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(createXmlString('schemaversion', '1.3.0')); }); - it('should add lesson text content to manifest file', () => { - const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); - expect(manifest).toContain(lessons[0].contents[0].title); + it('should add course', async () => { + const { archive, course } = await setup(); + + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(createXmlString('mnf:string', course.name)); }); - it('should add copyright information to manifest file', () => { - const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); - expect(manifest).toContain(course.teachers[0].firstName); - expect(manifest).toContain(course.teachers[0].lastName); - expect(manifest).toContain(course.teachers[1].firstName); - expect(manifest).toContain(course.teachers[1].lastName); - expect(manifest).toContain(course.createdAt.getFullYear().toString()); + it('should add lessons', async () => { + const { archive, lessons } = await setup(); + + lessons.forEach((lesson) => { + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(createXmlString('title', lesson.name)); + }); }); - it('should add tasks as assignments', () => { - const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); + it('should add tasks', async () => { + const { archive, tasks } = await setup(); + tasks.forEach((task) => { - expect(manifest).toContain(`${task.name}`); - expect(manifest).toContain(`identifier="i${task.id}" type="webcontent" intendeduse="assignment"`); + expect(getFileContent(archive, 'imsmanifest.xml')).toContain(` { + it('should add tasks of lesson to manifest file', async () => { + const { archive, lessons } = await setup(); const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); + lessons[0].tasks.getItems().forEach((task) => { expect(manifest).toContain(`${task.name}`); expect(manifest).toContain(`identifier="i${task.id}" type="webcontent" intendeduse="assignment"`); }); }); - it('should add version 3 information to manifest file', () => { - const manifest = archive.getEntry('imsmanifest.xml')?.getData().toString(); - expect(manifest).toContain(CommonCartridgeVersion.V_1_3_0); + it('should add column boards', async () => { + const { archive, columnBoard } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); + + expect(manifest).toContain(createXmlString('title', columnBoard.title)); + }); + + it('should add column', async () => { + const { archive, column } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); + + expect(manifest).toContain(createXmlString('title', column.title)); + }); + + it('should add card', async () => { + const { archive, card } = await setup(); + const manifest = getFileContent(archive, 'imsmanifest.xml'); + + expect(manifest).toContain(createXmlString('title', card.title)); + }); + }); + + describe('When topics array is empty', () => { + const setup = async () => setupParams(CommonCartridgeVersion.V_1_1_0, false, true); + + it("shouldn't add lessons", async () => { + const { archive, lessons } = await setup(); + + lessons.forEach((lesson) => { + expect(getFileContent(archive, 'imsmanifest.xml')).not.toContain(createXmlString('title', lesson.name)); + }); + }); + }); + + describe('When tasks array is empty', () => { + const setup = async () => setupParams(CommonCartridgeVersion.V_1_1_0, true, false); + + it("shouldn't add tasks", async () => { + const { archive, tasks } = await setup(); + + tasks.forEach((task) => { + expect(getFileContent(archive, 'imsmanifest.xml')).not.toContain(` { + public async exportCourse( + courseId: EntityId, + userId: EntityId, + version: CommonCartridgeVersion, + exportedTopics: string[], + exportedTasks: string[] + ): Promise { const course = await this.courseService.findById(courseId); - const builder = new CommonCartridgeFileBuilder({ - identifier: createIdentifier(courseId), - title: course.name, - version, - copyrightOwners: this.mapCourseTeachersToCopyrightOwners(course), - creationYear: course.createdAt.getFullYear().toString(), - }); + const builder = new CommonCartridgeFileBuilder(this.commonCartridgeMapper.mapCourseToManifest(version, course)); + + builder.addMetadata(this.commonCartridgeMapper.mapCourseToMetadata(course)); - await this.addLessons(builder, version, courseId); - await this.addTasks(builder, version, courseId, userId); + await this.addLessons(builder, courseId, version, exportedTopics); + await this.addTasks(builder, courseId, userId, version, exportedTasks); + await this.addColumnBoards(builder, courseId); return builder.build(); } private async addLessons( builder: CommonCartridgeFileBuilder, + courseId: EntityId, version: CommonCartridgeVersion, - courseId: EntityId + topics: string[] ): Promise { const [lessons] = await this.lessonService.findByCourseIds([courseId]); lessons.forEach((lesson) => { - const organizationBuilder = builder.addOrganization({ - version, - identifier: createIdentifier(lesson.id), - title: lesson.name, - resources: [], - }); + if (!topics.includes(lesson.id)) { + return; + } + + const organizationBuilder = builder.addOrganization(this.commonCartridgeMapper.mapLessonToOrganization(lesson)); lesson.contents.forEach((content) => { - const resourceProps = this.mapContentToResource(lesson.id, content, version); - if (resourceProps) { - organizationBuilder.addResourceToOrganization(resourceProps); - } + this.addComponentToOrganization(organizationBuilder, content); }); - const tasks = lesson.tasks.getItems(); - tasks.forEach((task) => { - organizationBuilder.addResourceToOrganization(this.mapTaskToWebContentResource(task, version)); + lesson.getLessonLinkedTasks().forEach((task) => { + organizationBuilder.addResource(this.commonCartridgeMapper.mapTaskToResource(task, version)); }); }); } private async addTasks( builder: CommonCartridgeFileBuilder, - version: CommonCartridgeVersion, courseId: EntityId, - userId: EntityId + userId: EntityId, + version: CommonCartridgeVersion, + exportedTasks: string[] ): Promise { const [tasks] = await this.taskService.findBySingleParent(userId, courseId); - const organizationBuilder = builder.addOrganization({ - version, - identifier: createIdentifier(), - // FIXME: change the title for tasks organization + + if (tasks.length === 0) { + return; + } + + const organization = builder.addOrganization({ title: '', - resources: [], + identifier: createIdentifier(), }); tasks.forEach((task) => { - organizationBuilder.addResourceToOrganization(this.mapTaskToWebContentResource(task, version)); + if (!exportedTasks.includes(task.id)) { + return; + } + + organization.addResource(this.commonCartridgeMapper.mapTaskToResource(task, version)); }); } - private mapContentToResource( - lessonId: string, - content: ComponentProperties, - version: CommonCartridgeVersion - ): ICommonCartridgeResourceProps | undefined { - const commonProps = { - version, - identifier: createIdentifier(content._id), - href: `${createIdentifier(lessonId)}/${createIdentifier(content._id)}.html`, - title: content.title, - }; - - if (content.component === ComponentType.TEXT) { - return { - version, - identifier: createIdentifier(content._id), - href: `${createIdentifier(lessonId)}/${createIdentifier(content._id)}.html`, - title: content.title, - type: CommonCartridgeResourceType.WEB_CONTENT, - intendedUse: CommonCartridgeIntendedUseType.UNSPECIFIED, - html: `

${content.title}

${content.content.text}

`, - }; - } + private async addColumnBoards(builder: CommonCartridgeFileBuilder, courseId: EntityId): Promise { + const columnBoardIds = await this.columnBoardService.findIdsByExternalReference({ + type: BoardExternalReferenceType.Course, + id: courseId, + }); - if (content.component === ComponentType.GEOGEBRA) { - const url = `https://www.geogebra.org/m/${content.content.materialId}`; - return version === CommonCartridgeVersion.V_1_3_0 - ? { ...commonProps, type: CommonCartridgeResourceType.WEB_LINK_V3, url } - : { ...commonProps, type: CommonCartridgeResourceType.WEB_LINK_V1, url }; - } + for await (const columnBoardId of columnBoardIds) { + const columnBoard = await this.columnBoardService.findById(columnBoardId); + + const organization = builder.addOrganization({ + title: columnBoard.title, + identifier: createIdentifier(columnBoard.id), + }); - if (content.component === ComponentType.ETHERPAD) { - return version === CommonCartridgeVersion.V_1_3_0 - ? { - ...commonProps, - type: CommonCartridgeResourceType.WEB_LINK_V3, - url: content.content.url, - title: content.content.description, - } - : { - ...commonProps, - type: CommonCartridgeResourceType.WEB_LINK_V1, - url: content.content.url, - title: content.content.description, - }; + columnBoard.children + .filter((child) => child instanceof Column) + .forEach((column) => this.addColumnToOrganization(column as Column, organization)); } + } + + private addColumnToOrganization(column: Column, organizationBuilder: CommonCartridgeOrganizationBuilder): void { + const { id } = column; + const columnOrganization = organizationBuilder.addSubOrganization({ + title: column.title, + identifier: createIdentifier(id), + }); - return undefined; + column.children + .filter((child) => child instanceof Card) + .forEach((card) => this.addCardToOrganization(card as Card, columnOrganization)); } - /** - * This method gets the course as parameter and maps the contained teacher names within the teachers Collection to a string. - * @param Course - * @return string - * */ - private mapCourseTeachersToCopyrightOwners(course: Course): string { - const result = course.teachers - .toArray() - .map((teacher) => `${teacher.firstName} ${teacher.lastName}`) - .reduce((previousTeachers, currentTeacher) => `${previousTeachers}, ${currentTeacher}`); - return result; + private addCardToOrganization(card: Card, organizationBuilder: CommonCartridgeOrganizationBuilder): void { + const { id } = card; + organizationBuilder.addSubOrganization({ + title: card.title, + identifier: createIdentifier(id), + }); } - private mapTaskToWebContentResource( - task: Task, - version: CommonCartridgeVersion - ): ICommonCartridgeWebContentResourceProps { - const taskIdentifier = createIdentifier(task.id); - return { - version, - identifier: taskIdentifier, - href: `${taskIdentifier}/${taskIdentifier}.html`, - title: task.name, - type: CommonCartridgeResourceType.WEB_CONTENT, - html: `

${task.name}

${task.description}

`, - intendedUse: - version === CommonCartridgeVersion.V_1_1_0 - ? CommonCartridgeIntendedUseType.UNSPECIFIED - : CommonCartridgeIntendedUseType.ASSIGNMENT, - }; + private addComponentToOrganization( + organizationBuilder: CommonCartridgeOrganizationBuilder, + component: ComponentProperties + ): void { + const resources = this.commonCartridgeMapper.mapContentToResources(component); + + if (Array.isArray(resources)) { + const subOrganizationBuilder = organizationBuilder.addSubOrganization( + this.commonCartridgeMapper.mapContentToOrganization(component) + ); + + resources.forEach((resource) => { + subOrganizationBuilder.addResource(resource); + }); + } else { + organizationBuilder.addResource(resources); + } } } diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-import.service.spec.ts b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.spec.ts new file mode 100644 index 00000000000..45f7e64c535 --- /dev/null +++ b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.spec.ts @@ -0,0 +1,109 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { MikroORM } from '@mikro-orm/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { setupEntities, userFactory } from '@shared/testing'; +import { CardService, ColumnBoardService, ColumnService } from '@src/modules/board'; +import { readFile } from 'fs/promises'; +import { CommonCartridgeImportMapper } from '../mapper/common-cartridge-import.mapper'; +import { CommonCartridgeImportService } from './common-cartridge-import.service'; +import { CourseService } from './course.service'; + +describe('CommonCartridgeImportService', () => { + let orm: MikroORM; + let moduleRef: TestingModule; + let sut: CommonCartridgeImportService; + let courseServiceMock: DeepMocked; + let columnBoardServiceMock: DeepMocked; + let columnServiceMock: DeepMocked; + let cardServiceMock: DeepMocked; + + beforeEach(async () => { + orm = await setupEntities(); + moduleRef = await Test.createTestingModule({ + providers: [ + CommonCartridgeImportService, + CommonCartridgeImportMapper, + { + provide: CourseService, + useValue: createMock(), + }, + { + provide: ColumnBoardService, + useValue: createMock(), + }, + { + provide: ColumnService, + useValue: createMock(), + }, + { + provide: CardService, + useValue: createMock(), + }, + ], + }).compile(); + + sut = moduleRef.get(CommonCartridgeImportService); + courseServiceMock = moduleRef.get(CourseService); + columnBoardServiceMock = moduleRef.get(ColumnBoardService); + columnServiceMock = moduleRef.get(ColumnService); + cardServiceMock = moduleRef.get(CardService); + }); + + afterAll(async () => { + await moduleRef.close(); + await orm.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('importFile', () => { + describe('when the common cartridge is valid', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const buffer = await readFile( + './apps/server/src/modules/common-cartridge/testing/assets/us_history_since_1877.imscc' + ); + + return { user, buffer }; + }; + + it('should create a course', async () => { + const { user, buffer } = await setup(); + + await sut.importFile(user, buffer); + + expect(courseServiceMock.create).toHaveBeenCalledTimes(1); + }); + + it('should create a column board', async () => { + const { user, buffer } = await setup(); + + await sut.importFile(user, buffer); + + expect(columnBoardServiceMock.create).toHaveBeenCalledTimes(1); + }); + + it('should create columns', async () => { + const { user, buffer } = await setup(); + + await sut.importFile(user, buffer); + + expect(columnServiceMock.create).toBeCalledTimes(14); + }); + + it('should create cards', async () => { + const { user, buffer } = await setup(); + + await sut.importFile(user, buffer); + + expect(cardServiceMock.createMany).toBeCalledTimes(14); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts new file mode 100644 index 00000000000..62b697332d7 --- /dev/null +++ b/apps/server/src/modules/learnroom/service/common-cartridge-import.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { BoardExternalReferenceType, ColumnBoard } from '@shared/domain/domainobject'; +import { Course, User } from '@shared/domain/entity'; +import { CardService, ColumnBoardService, ColumnService } from '@src/modules/board'; +import { + CommonCartridgeFileParser, + DEFAULT_FILE_PARSER_OPTIONS, + OrganizationProps, +} from '@src/modules/common-cartridge'; +import { CommonCartridgeImportMapper } from '../mapper/common-cartridge-import.mapper'; +import { CourseService } from './course.service'; + +@Injectable() +export class CommonCartridgeImportService { + constructor( + private readonly courseService: CourseService, + private readonly columnBoardService: ColumnBoardService, + private readonly columnService: ColumnService, + private readonly cardService: CardService, + private readonly mapper: CommonCartridgeImportMapper + ) {} + + public async importFile(user: User, file: Buffer): Promise { + const parser = new CommonCartridgeFileParser(file, { + maxSearchDepth: 1, + pathSeparator: DEFAULT_FILE_PARSER_OPTIONS.pathSeparator, + }); + const course = new Course({ teachers: [user], school: user.school, name: parser.manifest.getTitle() }); + + await this.courseService.create(course); + await this.createColumnBoard(parser, course); + } + + private async createColumnBoard(parser: CommonCartridgeFileParser, course: Course): Promise { + const columnBoard = await this.columnBoardService.create( + { + type: BoardExternalReferenceType.Course, + id: course.id, + }, + parser.manifest.getTitle() + ); + + await this.createColumns(parser, columnBoard); + } + + private async createColumns(parser: CommonCartridgeFileParser, columnBoard: ColumnBoard): Promise { + const organizations = parser.manifest.getOrganizations(); + const columns = organizations.filter((organization) => organization.pathDepth === 0); + + for await (const column of columns) { + await this.createColumn(columnBoard, column, organizations); + } + } + + private async createColumn( + columnBoard: ColumnBoard, + columnProps: OrganizationProps, + organizations: OrganizationProps[] + ): Promise { + const column = await this.columnService.create(columnBoard, this.mapper.mapOrganizationToColumn(columnProps)); + const cardProps = organizations + .filter((organization) => organization.pathDepth === 1 && organization.path.startsWith(columnProps.identifier)) + .map((organization) => this.mapper.mapOrganizationToCard(organization)); + + await this.cardService.createMany(column, cardProps); + } +} diff --git a/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts b/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts index 5a5dab7c5ac..130ba69f7df 100644 --- a/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course-copy.service.spec.ts @@ -4,20 +4,20 @@ import { LessonCopyService } from '@modules/lesson/service'; import { ToolContextType } from '@modules/tool/common/enum'; import { ContextExternalTool } from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; +import { ToolFeatures } from '@modules/tool/tool-config'; import { Test, TestingModule } from '@nestjs/testing'; import { Course } from '@shared/domain/entity'; -import { BoardRepo, CourseRepo, UserRepo } from '@shared/repo'; +import { LegacyBoardRepo, CourseRepo, UserRepo } from '@shared/repo'; import { boardFactory, contextExternalToolFactory, courseFactory, courseGroupFactory, - schoolFactory, + schoolEntityFactory, setupEntities, userFactory, } from '@shared/testing'; import { IToolFeatures } from '@src/modules/tool/tool-config'; -import { ToolFeatures } from '@modules/tool/tool-config'; import { BoardCopyService } from './board-copy.service'; import { CourseCopyService } from './course-copy.service'; import { RoomsService } from './rooms.service'; @@ -26,7 +26,7 @@ describe('course copy service', () => { let module: TestingModule; let service: CourseCopyService; let courseRepo: DeepMocked; - let boardRepo: DeepMocked; + let boardRepo: DeepMocked; let roomsService: DeepMocked; let boardCopyService: DeepMocked; let lessonCopyService: DeepMocked; @@ -53,8 +53,8 @@ describe('course copy service', () => { useValue: createMock(), }, { - provide: BoardRepo, - useValue: createMock(), + provide: LegacyBoardRepo, + useValue: createMock(), }, { provide: RoomsService, @@ -91,7 +91,7 @@ describe('course copy service', () => { service = module.get(CourseCopyService); courseRepo = module.get(CourseRepo); - boardRepo = module.get(BoardRepo); + boardRepo = module.get(LegacyBoardRepo); roomsService = module.get(RoomsService); boardCopyService = module.get(BoardCopyService); lessonCopyService = module.get(LessonCopyService); @@ -119,7 +119,7 @@ describe('course copy service', () => { courseRepo.findById.mockResolvedValue(course); courseRepo.findAllByUserId.mockResolvedValue([allCourses, allCourses.length]); boardRepo.findByCourseId.mockResolvedValue(originalBoard); - roomsService.updateBoard.mockResolvedValue(originalBoard); + roomsService.updateLegacyBoard.mockResolvedValue(originalBoard); contextExternalToolService.findAllByContext.mockResolvedValue(tools); const courseCopyName = 'Copy'; @@ -200,7 +200,7 @@ describe('course copy service', () => { it('should ensure course has up to date board', async () => { const { course, user, originalBoard } = setup(); await service.copyCourse({ userId: user.id, courseId: course.id }); - expect(roomsService.updateBoard).toHaveBeenCalledWith(originalBoard, course.id, user.id); + expect(roomsService.updateLegacyBoard).toHaveBeenCalledWith(originalBoard, course.id, user.id); }); it('should use deriveCopyName from copyHelperService', async () => { @@ -292,7 +292,7 @@ describe('course copy service', () => { it('should set school of user', async () => { const { course } = setup(); - const destinationSchool = schoolFactory.buildWithId(); + const destinationSchool = schoolEntityFactory.buildWithId(); const targetUser = userFactory.build({ school: destinationSchool }); userRepo.findById.mockResolvedValue(targetUser); @@ -367,7 +367,7 @@ describe('course copy service', () => { courseRepo.findById.mockResolvedValue(course); courseRepo.findAllByUserId.mockResolvedValue([allCourses, allCourses.length]); boardRepo.findByCourseId.mockResolvedValue(originalBoard); - roomsService.updateBoard.mockResolvedValue(originalBoard); + roomsService.updateLegacyBoard.mockResolvedValue(originalBoard); const courseCopyName = 'Copy'; copyHelperService.deriveCopyName.mockReturnValue(courseCopyName); @@ -433,7 +433,7 @@ describe('course copy service', () => { describe('copy course entity', () => { it('should assign user as teacher', async () => { const { course } = setup(); - const destinationSchool = schoolFactory.buildWithId(); + const destinationSchool = schoolEntityFactory.buildWithId(); const targetUser = userFactory.build({ school: destinationSchool }); userRepo.findById.mockResolvedValue(targetUser); const status = await service.copyCourse({ userId: targetUser.id, courseId: course.id }); @@ -444,7 +444,7 @@ describe('course copy service', () => { it('should set school of user', async () => { const { course } = setup(); - const destinationSchool = schoolFactory.buildWithId(); + const destinationSchool = schoolEntityFactory.buildWithId(); const targetUser = userFactory.build({ school: destinationSchool }); userRepo.findById.mockResolvedValue(targetUser); const status = await service.copyCourse({ userId: targetUser.id, courseId: course.id }); @@ -505,7 +505,7 @@ describe('course copy service', () => { userRepo.findById.mockResolvedValue(user); boardRepo.findByCourseId.mockResolvedValue(originalBoard); - roomsService.updateBoard.mockResolvedValue(originalBoard); + roomsService.updateLegacyBoard.mockResolvedValue(originalBoard); const courseCopyName = 'Copy'; copyHelperService.deriveCopyName.mockReturnValue(courseCopyName); @@ -550,7 +550,7 @@ describe('course copy service', () => { userRepo.findById.mockResolvedValue(user); boardRepo.findByCourseId.mockResolvedValue(originalBoard); - roomsService.updateBoard.mockResolvedValue(originalBoard); + roomsService.updateLegacyBoard.mockResolvedValue(originalBoard); const boardCopy = boardFactory.build(); const boardCopyStatus = { diff --git a/apps/server/src/modules/learnroom/service/course-copy.service.ts b/apps/server/src/modules/learnroom/service/course-copy.service.ts index 15b67e8fbd6..e176ef3ea37 100644 --- a/apps/server/src/modules/learnroom/service/course-copy.service.ts +++ b/apps/server/src/modules/learnroom/service/course-copy.service.ts @@ -5,7 +5,7 @@ import { ContextExternalToolService } from '@modules/tool/context-external-tool/ import { Inject, Injectable } from '@nestjs/common'; import { Course, User } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; -import { BoardRepo, CourseRepo, UserRepo } from '@shared/repo'; +import { LegacyBoardRepo, CourseRepo, UserRepo } from '@shared/repo'; import { IToolFeatures, ToolFeatures } from '@modules/tool/tool-config'; import { BoardCopyService } from './board-copy.service'; import { RoomsService } from './rooms.service'; @@ -21,7 +21,7 @@ export class CourseCopyService { constructor( @Inject(ToolFeatures) private readonly toolFeatures: IToolFeatures, private readonly courseRepo: CourseRepo, - private readonly boardRepo: BoardRepo, + private readonly legacyBoardRepo: LegacyBoardRepo, private readonly roomsService: RoomsService, private readonly boardCopyService: BoardCopyService, private readonly copyHelperService: CopyHelperService, @@ -42,8 +42,8 @@ export class CourseCopyService { // fetch original course and board const originalCourse = await this.courseRepo.findById(courseId); - let originalBoard = await this.boardRepo.findByCourseId(courseId); - originalBoard = await this.roomsService.updateBoard(originalBoard, courseId, userId); + let originalBoard = await this.legacyBoardRepo.findByCourseId(courseId); + originalBoard = await this.roomsService.updateLegacyBoard(originalBoard, courseId, userId); // handle potential name conflict const [existingCourses] = await this.courseRepo.findAllByUserId(userId); diff --git a/apps/server/src/modules/learnroom/service/course-do.service.spec.ts b/apps/server/src/modules/learnroom/service/course-do.service.spec.ts new file mode 100644 index 00000000000..b015194b5ee --- /dev/null +++ b/apps/server/src/modules/learnroom/service/course-do.service.spec.ts @@ -0,0 +1,172 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Group } from '@modules/group'; +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { groupFactory } from '@shared/testing'; +import { Course, COURSE_REPO, CourseNotSynchronizedLoggableException, CourseRepo } from '../domain'; +import { courseFactory } from '../testing'; +import { CourseDoService } from './course-do.service'; + +describe(CourseDoService.name, () => { + let module: TestingModule; + let service: CourseDoService; + + let courseRepo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + CourseDoService, + { + provide: COURSE_REPO, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(CourseDoService); + courseRepo = module.get(COURSE_REPO); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('findById', () => { + describe('when a group with the id exists', () => { + const setup = () => { + const course: Course = courseFactory.build(); + + courseRepo.findCourseById.mockResolvedValueOnce(course); + + return { + course, + }; + }; + + it('should return the group', async () => { + const { course } = setup(); + + const result: Course = await service.findById(course.id); + + expect(result).toEqual(course); + }); + }); + + describe('when a group with the id does not exists', () => { + const setup = () => { + const course: Course = courseFactory.build(); + + courseRepo.findCourseById.mockImplementationOnce(() => { + throw new NotFoundLoggableException(Course.name, { id: course.id }); + }); + + return { + course, + }; + }; + + it('should throw NotFoundLoggableException', async () => { + const { course } = setup(); + + await expect(service.findById(course.id)).rejects.toThrow(NotFoundLoggableException); + }); + }); + }); + + describe('saveAll', () => { + const setup = () => { + const course: Course = courseFactory.build(); + + courseRepo.saveAll.mockResolvedValueOnce([course]); + + return { + course, + }; + }; + + it('should save all courses', async () => { + const { course } = setup(); + + await service.saveAll([course]); + + expect(courseRepo.saveAll).toHaveBeenCalledWith([course]); + }); + + it('should return the saved courses', async () => { + const { course } = setup(); + + const result: Course[] = await service.saveAll([course]); + + expect(result).toEqual([course]); + }); + }); + + describe('findBySyncedGroup', () => { + const setup = () => { + const course: Course = courseFactory.build(); + const group: Group = groupFactory.build(); + + courseRepo.findBySyncedGroup.mockResolvedValueOnce([course]); + + return { + course, + group, + }; + }; + + it('should return the synced courses', async () => { + const { course, group } = setup(); + + const result: Course[] = await service.findBySyncedGroup(group); + + expect(result).toEqual([course]); + }); + }); + + describe('stopSynchronization', () => { + describe('when a course is synchronized with a group', () => { + const setup = () => { + const course: Course = courseFactory.build({ syncedWithGroup: new ObjectId().toHexString() }); + + return { + course, + }; + }; + + it('should save a course without a synchronized group', async () => { + const { course } = setup(); + + await service.stopSynchronization(course); + + expect(courseRepo.save).toHaveBeenCalledWith( + new Course({ + ...course.getProps(), + syncedWithGroup: undefined, + }) + ); + }); + }); + + describe('when a course is not synchronized with a group', () => { + const setup = () => { + const course: Course = courseFactory.build(); + + return { + course, + }; + }; + + it('should throw an unprocessable entity exception', async () => { + const { course } = setup(); + + await expect(service.stopSynchronization(course)).rejects.toThrow(CourseNotSynchronizedLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/service/course-do.service.ts b/apps/server/src/modules/learnroom/service/course-do.service.ts new file mode 100644 index 00000000000..5bcba757d1f --- /dev/null +++ b/apps/server/src/modules/learnroom/service/course-do.service.ts @@ -0,0 +1,38 @@ +import { AuthorizationLoaderServiceGeneric } from '@modules/authorization'; +import { type Group } from '@modules/group'; +import { Inject, Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { type Course, COURSE_REPO, CourseNotSynchronizedLoggableException, CourseRepo } from '../domain'; + +@Injectable() +export class CourseDoService implements AuthorizationLoaderServiceGeneric { + constructor(@Inject(COURSE_REPO) private readonly courseRepo: CourseRepo) {} + + public async findById(courseId: EntityId): Promise { + const courses: Course = await this.courseRepo.findCourseById(courseId); + + return courses; + } + + public async saveAll(courses: Course[]): Promise { + const savedCourses: Course[] = await this.courseRepo.saveAll(courses); + + return savedCourses; + } + + public async findBySyncedGroup(group: Group): Promise { + const courses: Course[] = await this.courseRepo.findBySyncedGroup(group); + + return courses; + } + + public async stopSynchronization(course: Course): Promise { + if (!course.syncedWithGroup) { + throw new CourseNotSynchronizedLoggableException(course.id); + } + + course.syncedWithGroup = undefined; + + await this.courseRepo.save(course); + } +} diff --git a/apps/server/src/modules/learnroom/service/course.service.spec.ts b/apps/server/src/modules/learnroom/service/course.service.spec.ts index 379c588d518..c14f3e21b40 100644 --- a/apps/server/src/modules/learnroom/service/course.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/course.service.spec.ts @@ -1,16 +1,28 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { + DataDeletedEvent, + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; +import { EventBus } from '@nestjs/cqrs'; import { Test, TestingModule } from '@nestjs/testing'; -import { Course } from '@shared/domain/entity'; -import { CourseRepo, UserRepo } from '@shared/repo'; -import { courseFactory, setupEntities, userFactory } from '@shared/testing'; +import { Course as CourseEntity } from '@shared/domain/entity'; +import { CourseRepo as LegacyCourseRepo, UserRepo } from '@shared/repo'; +import { courseFactory as courseEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { ObjectId } from 'bson'; import { CourseService } from './course.service'; describe('CourseService', () => { let module: TestingModule; - let courseRepo: DeepMocked; let courseService: CourseService; + let userRepo: DeepMocked; + let eventBus: DeepMocked; + let legacyCourseRepo: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -22,18 +34,25 @@ describe('CourseService', () => { useValue: createMock(), }, { - provide: CourseRepo, - useValue: createMock(), + provide: LegacyCourseRepo, + useValue: createMock(), }, { provide: Logger, useValue: createMock(), }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); - courseRepo = module.get(CourseRepo); + legacyCourseRepo = module.get(LegacyCourseRepo); courseService = module.get(CourseService); userRepo = module.get(UserRepo); + eventBus = module.get(EventBus); }); afterAll(async () => { @@ -47,7 +66,7 @@ describe('CourseService', () => { describe('findById', () => { const setup = () => { const courseId = 'courseId'; - courseRepo.findById.mockResolvedValueOnce({} as Course); + legacyCourseRepo.findById.mockResolvedValueOnce({} as CourseEntity); return { courseId }; }; @@ -57,7 +76,7 @@ describe('CourseService', () => { await expect(courseService.findById(courseId)).resolves.not.toThrow(); - expect(courseRepo.findById).toBeCalledWith(courseId); + expect(legacyCourseRepo.findById).toBeCalledWith(courseId); }); }); @@ -65,13 +84,13 @@ describe('CourseService', () => { describe('when finding by userId', () => { const setup = () => { const user = userFactory.buildWithId(); - const course1 = courseFactory.buildWithId({ students: [user] }); - const course2 = courseFactory.buildWithId({ teachers: [user] }); - const course3 = courseFactory.buildWithId({ substitutionTeachers: [user] }); + const course1 = courseEntityFactory.buildWithId({ students: [user] }); + const course2 = courseEntityFactory.buildWithId({ teachers: [user] }); + const course3 = courseEntityFactory.buildWithId({ substitutionTeachers: [user] }); const allCourses = [course1, course2, course3]; userRepo.findById.mockResolvedValue(user); - courseRepo.findAllByUserId.mockResolvedValue([allCourses, allCourses.length]); + legacyCourseRepo.findAllByUserId.mockResolvedValue([allCourses, allCourses.length]); return { user, @@ -84,7 +103,7 @@ describe('CourseService', () => { await courseService.findAllCoursesByUserId(user.id); - expect(courseRepo.findAllByUserId).toBeCalledWith(user.id); + expect(legacyCourseRepo.findAllByUserId).toBeCalledWith(user.id); }); it('should return array of courses with userId', async () => { @@ -101,36 +120,41 @@ describe('CourseService', () => { describe('when deleting by userId', () => { const setup = () => { const user = userFactory.buildWithId(); - const course1 = courseFactory.buildWithId({ students: [user] }); - const course2 = courseFactory.buildWithId({ teachers: [user] }); - const course3 = courseFactory.buildWithId({ substitutionTeachers: [user] }); + const course1 = courseEntityFactory.buildWithId({ students: [user] }); + const course2 = courseEntityFactory.buildWithId({ teachers: [user] }); + const course3 = courseEntityFactory.buildWithId({ substitutionTeachers: [user] }); const allCourses = [course1, course2, course3]; userRepo.findById.mockResolvedValue(user); - courseRepo.findAllByUserId.mockResolvedValue([allCourses, allCourses.length]); + legacyCourseRepo.findAllByUserId.mockResolvedValue([allCourses, allCourses.length]); + + const expectedResult = DomainDeletionReportBuilder.build(DomainName.COURSE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 3, [course1.id, course2.id, course3.id]), + ]); return { + expectedResult, user, }; }; it('should call courseRepo.findAllByUserId', async () => { const { user } = setup(); - await courseService.deleteUserDataFromCourse(user.id); - expect(courseRepo.findAllByUserId).toBeCalledWith(user.id); + await courseService.deleteUserData(user.id); + expect(legacyCourseRepo.findAllByUserId).toBeCalledWith(user.id); }); it('should update courses without deleted user', async () => { - const { user } = setup(); - const result = await courseService.deleteUserDataFromCourse(user.id); - expect(result).toEqual(3); + const { expectedResult, user } = setup(); + const result = await courseService.deleteUserData(user.id); + expect(result).toEqual(expectedResult); }); }); describe('findAllByUserId', () => { const setup = () => { const userId = 'userId'; - courseRepo.findAllByUserId.mockResolvedValueOnce([[], 0]); + legacyCourseRepo.findAllByUserId.mockResolvedValueOnce([[], 0]); return { userId }; }; @@ -140,7 +164,68 @@ describe('CourseService', () => { await expect(courseService.findAllByUserId(userId)).resolves.not.toThrow(); - expect(courseRepo.findAllByUserId).toBeCalledWith(userId); + expect(legacyCourseRepo.findAllByUserId).toBeCalledWith(userId); + }); + }); + + describe('create', () => { + const setup = () => { + const course = courseEntityFactory.buildWithId(); + legacyCourseRepo.createCourse.mockResolvedValueOnce(); + + return { course }; + }; + + it('should call createCourse from course repository', async () => { + const { course } = setup(); + + await expect(courseService.create(course)).resolves.not.toThrow(); + + expect(legacyCourseRepo.createCourse).toBeCalledWith(course); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILERECORDS; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in courseService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(courseService, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await courseService.handle({ deletionRequestId, targetRefId }); + + expect(courseService.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(courseService, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await courseService.handle({ deletionRequestId, targetRefId }); + + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); + }); }); }); }); diff --git a/apps/server/src/modules/learnroom/service/course.service.ts b/apps/server/src/modules/learnroom/service/course.service.ts index f85526bcc83..dd10304a750 100644 --- a/apps/server/src/modules/learnroom/service/course.service.ts +++ b/apps/server/src/modules/learnroom/service/course.service.ts @@ -1,44 +1,71 @@ +import { + DataDeletedEvent, + DataDeletionDomainOperationLoggable, + DeletionService, + DomainDeletionReport, + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + StatusModel, + UserDeletedEvent, +} from '@modules/deletion'; import { Injectable } from '@nestjs/common'; -import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; -import { Course } from '@shared/domain/entity'; -import { Counted, DomainModel, EntityId, StatusModel } from '@shared/domain/types'; -import { CourseRepo } from '@shared/repo'; +import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { Course as CourseEntity } from '@shared/domain/entity'; +import { Counted, EntityId } from '@shared/domain/types'; +import { CourseRepo as LegacyCourseRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; @Injectable() -export class CourseService { - constructor(private readonly repo: CourseRepo, private readonly logger: Logger) { +@EventsHandler(UserDeletedEvent) +export class CourseService implements DeletionService, IEventHandler { + constructor( + private readonly repo: LegacyCourseRepo, + private readonly logger: Logger, + private readonly eventBus: EventBus + ) { this.logger.setContext(CourseService.name); } - async findById(courseId: EntityId): Promise { + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + + async findById(courseId: EntityId): Promise { return this.repo.findById(courseId); } - public async findAllCoursesByUserId(userId: EntityId): Promise> { + public async findAllCoursesByUserId(userId: EntityId): Promise> { const [courses, count] = await this.repo.findAllByUserId(userId); return [courses, count]; } - public async deleteUserDataFromCourse(userId: EntityId): Promise { + public async deleteUserData(userId: EntityId): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting data from Courses', - DomainModel.COURSE, + DomainName.COURSE, userId, StatusModel.PENDING ) ); const [courses, count] = await this.repo.findAllByUserId(userId); - courses.forEach((course: Course) => course.removeUser(userId)); + courses.forEach((course: CourseEntity) => course.removeUser(userId)); await this.repo.save(courses); + + const result = DomainDeletionReportBuilder.build(DomainName.COURSE, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, count, this.getCoursesId(courses)), + ]); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully removed data from Courses', - DomainModel.COURSE, + DomainName.COURSE, userId, StatusModel.FINISHED, 0, @@ -46,12 +73,25 @@ export class CourseService { ) ); - return count; + return result; } - async findAllByUserId(userId: EntityId): Promise { + async findAllByUserId(userId: EntityId): Promise { const [courses] = await this.repo.findAllByUserId(userId); return courses; } + + async create(course: CourseEntity): Promise { + await this.repo.createCourse(course); + } + + private getCoursesId(courses: CourseEntity[]): EntityId[] { + return courses.map((course) => course.id); + } + + async findOneForUser(courseId: EntityId, userId: EntityId): Promise { + const course = await this.repo.findOne(courseId, userId); + return course; + } } diff --git a/apps/server/src/modules/learnroom/service/coursegroup.service.spec.ts b/apps/server/src/modules/learnroom/service/coursegroup.service.spec.ts index 438be147390..97b4ec6c06d 100644 --- a/apps/server/src/modules/learnroom/service/coursegroup.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/coursegroup.service.spec.ts @@ -3,6 +3,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CourseGroupRepo, UserRepo } from '@shared/repo'; import { courseGroupFactory, setupEntities, userFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { EventBus } from '@nestjs/cqrs'; +import { ObjectId } from 'bson'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DataDeletedEvent, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { CourseGroupService } from './coursegroup.service'; describe('CourseGroupService', () => { @@ -10,6 +20,7 @@ describe('CourseGroupService', () => { let courseGroupRepo: DeepMocked; let courseGroupService: CourseGroupService; let userRepo: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -28,11 +39,18 @@ describe('CourseGroupService', () => { provide: Logger, useValue: createMock(), }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); courseGroupRepo = module.get(CourseGroupRepo); courseGroupService = module.get(CourseGroupService); userRepo = module.get(UserRepo); + eventBus = module.get(EventBus); }); afterAll(async () => { @@ -86,7 +104,12 @@ describe('CourseGroupService', () => { userRepo.findById.mockResolvedValue(user); courseGroupRepo.findByUserId.mockResolvedValue([[courseGroup1, courseGroup2], 2]); + const expectedResult = DomainDeletionReportBuilder.build(DomainName.COURSEGROUP, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [courseGroup1.id, courseGroup2.id]), + ]); + return { + expectedResult, user, }; }; @@ -94,17 +117,61 @@ describe('CourseGroupService', () => { it('should call courseGroupRepo.findByUserId', async () => { const { user } = setup(); - await courseGroupService.deleteUserDataFromCourseGroup(user.id); + await courseGroupService.deleteUserData(user.id); expect(courseGroupRepo.findByUserId).toBeCalledWith(user.id); }); it('should update courses without deleted user', async () => { - const { user } = setup(); + const { expectedResult, user } = setup(); + + const result = await courseGroupService.deleteUserData(user.id); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILERECORDS; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; - const result = await courseGroupService.deleteUserDataFromCourseGroup(user.id); + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in courseGroupService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); - expect(result).toEqual(2); + jest.spyOn(courseGroupService, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await courseGroupService.handle({ deletionRequestId, targetRefId }); + + expect(courseGroupService.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(courseGroupService, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await courseGroupService.handle({ deletionRequestId, targetRefId }); + + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); + }); }); }); }); diff --git a/apps/server/src/modules/learnroom/service/coursegroup.service.ts b/apps/server/src/modules/learnroom/service/coursegroup.service.ts index b9da03616d2..c5b0a2fd90a 100644 --- a/apps/server/src/modules/learnroom/service/coursegroup.service.ts +++ b/apps/server/src/modules/learnroom/service/coursegroup.service.ts @@ -1,27 +1,49 @@ import { Injectable } from '@nestjs/common'; -import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; +import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; import { CourseGroup } from '@shared/domain/entity'; -import { Counted, DomainModel, EntityId, StatusModel } from '@shared/domain/types'; +import { Counted, EntityId } from '@shared/domain/types'; import { CourseGroupRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; +import { + UserDeletedEvent, + DeletionService, + DataDeletedEvent, + DomainDeletionReport, + DataDeletionDomainOperationLoggable, + DomainName, + DomainDeletionReportBuilder, + DomainOperationReportBuilder, + OperationType, + StatusModel, +} from '@modules/deletion'; @Injectable() -export class CourseGroupService { - constructor(private readonly repo: CourseGroupRepo, private readonly logger: Logger) { +@EventsHandler(UserDeletedEvent) +export class CourseGroupService implements DeletionService, IEventHandler { + constructor( + private readonly repo: CourseGroupRepo, + private readonly logger: Logger, + private readonly eventBus: EventBus + ) { this.logger.setContext(CourseGroupService.name); } + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + public async findAllCourseGroupsByUserId(userId: EntityId): Promise> { const [courseGroups, count] = await this.repo.findByUserId(userId); return [courseGroups, count]; } - public async deleteUserDataFromCourseGroup(userId: EntityId): Promise { + public async deleteUserData(userId: EntityId): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting user data from CourseGroup', - DomainModel.COURSEGROUP, + DomainName.COURSEGROUP, userId, StatusModel.PENDING ) @@ -31,10 +53,15 @@ export class CourseGroupService { courseGroups.forEach((courseGroup) => courseGroup.removeStudent(userId)); await this.repo.save(courseGroups); + + const result = DomainDeletionReportBuilder.build(DomainName.COURSEGROUP, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, count, this.getCourseGroupsId(courseGroups)), + ]); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully deleted user data from CourseGroup', - DomainModel.COURSEGROUP, + DomainName.COURSEGROUP, userId, StatusModel.FINISHED, count, @@ -42,6 +69,10 @@ export class CourseGroupService { ) ); - return count; + return result; + } + + private getCourseGroupsId(courseGroups: CourseGroup[]): EntityId[] { + return courseGroups.map((courseGroup) => courseGroup.id); } } diff --git a/apps/server/src/modules/learnroom/service/dashboard.service.spec.ts b/apps/server/src/modules/learnroom/service/dashboard.service.spec.ts index d87e7b0a48d..e9150af68b5 100644 --- a/apps/server/src/modules/learnroom/service/dashboard.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/dashboard.service.spec.ts @@ -1,10 +1,20 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; +import { DashboardEntity, GridElement } from '@shared/domain/entity'; import { DashboardElementRepo, IDashboardRepo, UserRepo } from '@shared/repo'; import { setupEntities, userFactory } from '@shared/testing'; import { LearnroomMetadata, LearnroomTypes } from '@shared/domain/types'; -import { DashboardEntity, GridElement } from '@shared/domain/entity'; import { Logger } from '@src/core/logger'; +import { ObjectId } from 'bson'; +import { EventBus } from '@nestjs/cqrs'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DataDeletedEvent, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { DashboardService } from '.'; const learnroomMock = (id: string, name: string) => { @@ -16,6 +26,7 @@ const learnroomMock = (id: string, name: string) => { title: name, shortTitle: name.substr(0, 2), displayColor: '#ACACAC', + isSynchronized: false, }; }, }; @@ -27,6 +38,7 @@ describe(DashboardService.name, () => { let dashboardRepo: IDashboardRepo; let dashboardElementRepo: DeepMocked; let dashboardService: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -49,12 +61,19 @@ describe(DashboardService.name, () => { provide: Logger, useValue: createMock(), }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); dashboardService = module.get(DashboardService); userRepo = module.get(UserRepo); dashboardRepo = module.get('DASHBOARD_REPO'); dashboardElementRepo = module.get(DashboardElementRepo); + eventBus = module.get(EventBus); }); afterAll(async () => { @@ -65,59 +84,110 @@ describe(DashboardService.name, () => { jest.clearAllMocks(); }); - describe('when deleting by userId', () => { + describe('when deleting dashboard by userId', () => { const setup = () => { const user = userFactory.buildWithId(); + const dashboardId = new ObjectId().toHexString(); + const dashboard = new DashboardEntity(dashboardId, { + grid: [ + { + pos: { x: 1, y: 2 }, + gridElement: GridElement.FromPersistedReference('elementId', learnroomMock('referenceId', 'Mathe')), + }, + ], + userId: user.id, + }); userRepo.findById.mockResolvedValue(user); - return { user }; + const expectedResult = DomainDeletionReportBuilder.build(DomainName.DASHBOARD, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [dashboardId]), + ]); + + return { dashboard, expectedResult, user }; }; - it('should call dashboardRepo.getUsersDashboard', async () => { - const { user } = setup(); - const spy = jest.spyOn(dashboardRepo, 'getUsersDashboard'); + describe('when dashboard exist', () => { + it('should call dashboardRepo.getUsersDashboardIfExist', async () => { + const { user } = setup(); + const spy = jest.spyOn(dashboardRepo, 'getUsersDashboardIfExist'); - await dashboardService.deleteDashboardByUserId(user.id); + await dashboardService.deleteUserData(user.id); - expect(spy).toHaveBeenCalledWith(user.id); - }); + expect(spy).toHaveBeenCalledWith(user.id); + }); - it('should call dashboardElementRepo.deleteByDashboardId', async () => { - const { user } = setup(); - jest.spyOn(dashboardRepo, 'getUsersDashboard').mockResolvedValueOnce( - new DashboardEntity('dashboardId', { - grid: [ - { - pos: { x: 1, y: 2 }, - gridElement: GridElement.FromPersistedReference('elementId', learnroomMock('referenceId', 'Mathe')), - }, - ], - userId: 'userId', - }) - ); - const spy = jest.spyOn(dashboardElementRepo, 'deleteByDashboardId'); - - await dashboardService.deleteDashboardByUserId(user.id); - - expect(spy).toHaveBeenCalledWith('dashboardId'); - }); + it('should call dashboardElementRepo.deleteByDashboardId', async () => { + const { dashboard, user } = setup(); + jest.spyOn(dashboardRepo, 'getUsersDashboardIfExist').mockResolvedValueOnce(dashboard); + const spy = jest.spyOn(dashboardElementRepo, 'deleteByDashboardId'); + + await dashboardService.deleteUserData(user.id); + + expect(spy).toHaveBeenCalledWith(dashboard.id); + }); + + it('should call dashboardRepo.deleteDashboardByUserId', async () => { + const { user } = setup(); + const spy = jest.spyOn(dashboardRepo, 'deleteDashboardByUserId'); + + await dashboardService.deleteUserData(user.id); + + expect(spy).toHaveBeenCalledWith(user.id); + }); - it('should call dashboardRepo.deleteDashboardByUserId', async () => { - const { user } = setup(); - const spy = jest.spyOn(dashboardRepo, 'deleteDashboardByUserId'); + it('should delete users dashboard', async () => { + const { dashboard, expectedResult, user } = setup(); + jest.spyOn(dashboardRepo, 'getUsersDashboardIfExist').mockResolvedValueOnce(dashboard); + jest.spyOn(dashboardRepo, 'deleteDashboardByUserId').mockImplementation(() => Promise.resolve(1)); - await dashboardService.deleteDashboardByUserId(user.id); + const result = await dashboardService.deleteUserData(user.id); - expect(spy).toHaveBeenCalledWith(user.id); + expect(result).toEqual(expectedResult); + }); }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILERECORDS; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in dashboardService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(dashboardService, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await dashboardService.handle({ deletionRequestId, targetRefId }); + + expect(dashboardService.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); - it('should delete users dashboard', async () => { - const { user } = setup(); - jest.spyOn(dashboardRepo, 'deleteDashboardByUserId').mockImplementation(() => Promise.resolve(1)); + jest.spyOn(dashboardService, 'deleteUserData').mockResolvedValueOnce(expectedData); - const result = await dashboardService.deleteDashboardByUserId(user.id); + await dashboardService.handle({ deletionRequestId, targetRefId }); - expect(result).toEqual(1); + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); + }); }); }); }); diff --git a/apps/server/src/modules/learnroom/service/dashboard.service.ts b/apps/server/src/modules/learnroom/service/dashboard.service.ts index b60400de5fa..c48a8bef3a1 100644 --- a/apps/server/src/modules/learnroom/service/dashboard.service.ts +++ b/apps/server/src/modules/learnroom/service/dashboard.service.ts @@ -1,39 +1,68 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; -import { DomainModel, EntityId, StatusModel } from '@shared/domain/types'; +import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { EntityId } from '@shared/domain/types'; import { IDashboardRepo, DashboardElementRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; +import { + UserDeletedEvent, + DeletionService, + DataDeletedEvent, + DomainDeletionReport, + DataDeletionDomainOperationLoggable, + DomainName, + DomainDeletionReportBuilder, + DomainOperationReportBuilder, + OperationType, + StatusModel, +} from '@modules/deletion'; @Injectable() -export class DashboardService { +@EventsHandler(UserDeletedEvent) +export class DashboardService implements DeletionService, IEventHandler { constructor( @Inject('DASHBOARD_REPO') private readonly dashboardRepo: IDashboardRepo, private readonly dashboardElementRepo: DashboardElementRepo, - private readonly logger: Logger + private readonly logger: Logger, + private readonly eventBus: EventBus ) { this.logger.setContext(DashboardService.name); } - async deleteDashboardByUserId(userId: EntityId): Promise { + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + + public async deleteUserData(userId: EntityId): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting user data from Dashboard', - DomainModel.DASHBOARD, + DomainName.DASHBOARD, userId, StatusModel.PENDING ) ); - const usersDashboard = await this.dashboardRepo.getUsersDashboard(userId); - await this.dashboardElementRepo.deleteByDashboardId(usersDashboard.id); - const result = await this.dashboardRepo.deleteDashboardByUserId(userId); + let deletedDashboard = 0; + const refs: string[] = []; + const usersDashboard = await this.dashboardRepo.getUsersDashboardIfExist(userId); + if (usersDashboard !== null) { + await this.dashboardElementRepo.deleteByDashboardId(usersDashboard.id); + deletedDashboard = await this.dashboardRepo.deleteDashboardByUserId(userId); + refs.push(usersDashboard.id); + } + + const result = DomainDeletionReportBuilder.build(DomainName.DASHBOARD, [ + DomainOperationReportBuilder.build(OperationType.DELETE, deletedDashboard, refs), + ]); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully deleted user data from Dashboard', - DomainModel.DASHBOARD, + DomainName.DASHBOARD, userId, StatusModel.FINISHED, 0, - result + deletedDashboard ) ); diff --git a/apps/server/src/modules/learnroom/service/group-deleted-handler.service.spec.ts b/apps/server/src/modules/learnroom/service/group-deleted-handler.service.spec.ts new file mode 100644 index 00000000000..8f4fe0f9044 --- /dev/null +++ b/apps/server/src/modules/learnroom/service/group-deleted-handler.service.spec.ts @@ -0,0 +1,78 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { GroupDeletedEvent } from '@modules/group'; +import { Test, TestingModule } from '@nestjs/testing'; +import { groupFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { Course } from '../domain'; +import { courseFactory } from '../testing'; +import { CourseDoService } from './course-do.service'; +import { GroupDeletedHandlerService } from './group-deleted-handler.service'; + +describe(GroupDeletedHandlerService.name, () => { + let module: TestingModule; + let service: GroupDeletedHandlerService; + + let courseService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + GroupDeletedHandlerService, + { + provide: CourseDoService, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(GroupDeletedHandlerService); + courseService = module.get(CourseDoService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('handle', () => { + describe('when deleting a group', () => { + const setup = () => { + const group = groupFactory.build(); + const course: Course = courseFactory.build({ + syncedWithGroup: group.id, + teacherIds: [new ObjectId().toHexString()], + studentIds: [new ObjectId().toHexString()], + }); + + courseService.findBySyncedGroup.mockResolvedValueOnce([course]); + + return { + group, + course, + }; + }; + + it('should remove all sync references from courses', async () => { + const { group, course } = setup(); + + await service.handle(new GroupDeletedEvent(group)); + + expect(courseService.saveAll).toHaveBeenCalledWith<[Course[]]>([ + new Course({ + ...course.getProps(), + syncedWithGroup: undefined, + studentIds: [], + }), + ]); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/service/group-deleted-handler.service.ts b/apps/server/src/modules/learnroom/service/group-deleted-handler.service.ts new file mode 100644 index 00000000000..beb1d8205f5 --- /dev/null +++ b/apps/server/src/modules/learnroom/service/group-deleted-handler.service.ts @@ -0,0 +1,29 @@ +import { Group, GroupDeletedEvent } from '@modules/group'; +import { Injectable } from '@nestjs/common'; +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { Logger } from '@src/core/logger'; +import { Course, CourseSynchronizationStoppedLoggable } from '../domain'; +import { CourseDoService } from './course-do.service'; + +@Injectable() +@EventsHandler(GroupDeletedEvent) +export class GroupDeletedHandlerService implements IEventHandler { + constructor(private readonly courseService: CourseDoService, private readonly logger: Logger) {} + + public async handle(event: GroupDeletedEvent): Promise { + await this.removeCourseSyncReference(event.target); + } + + private async removeCourseSyncReference(group: Group): Promise { + const courses: Course[] = await this.courseService.findBySyncedGroup(group); + + courses.forEach((course: Course): void => { + course.students = []; + course.syncedWithGroup = undefined; + }); + + await this.courseService.saveAll(courses); + + this.logger.info(new CourseSynchronizationStoppedLoggable(courses, group)); + } +} diff --git a/apps/server/src/modules/learnroom/service/index.ts b/apps/server/src/modules/learnroom/service/index.ts index f5e9336abcf..49b39023c4c 100644 --- a/apps/server/src/modules/learnroom/service/index.ts +++ b/apps/server/src/modules/learnroom/service/index.ts @@ -1,8 +1,10 @@ export * from './board-copy.service'; -export * from './course-copy.service'; -export * from './column-board-target.service'; export * from './common-cartridge-export.service'; +export * from './common-cartridge-import.service'; +export * from './course-copy.service'; export * from './course.service'; -export * from './rooms.service'; +export { CourseDoService } from './course-do.service'; export * from './coursegroup.service'; export * from './dashboard.service'; +export * from './rooms.service'; +export { GroupDeletedHandlerService } from './group-deleted-handler.service'; diff --git a/apps/server/src/modules/learnroom/service/rooms.service.spec.ts b/apps/server/src/modules/learnroom/service/rooms.service.spec.ts index a720c83a1ba..359d25649eb 100644 --- a/apps/server/src/modules/learnroom/service/rooms.service.spec.ts +++ b/apps/server/src/modules/learnroom/service/rooms.service.spec.ts @@ -1,16 +1,22 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; -import { ObjectId } from '@mikro-orm/mongodb'; import { CardService, ColumnBoardService, ColumnService, ContentElementService } from '@modules/board'; import { LessonService } from '@modules/lesson'; import { TaskService } from '@modules/task'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardExternalReference, BoardExternalReferenceType } from '@shared/domain/domainobject'; -import { EntityId } from '@shared/domain/types'; -import { BoardRepo } from '@shared/repo'; -import { boardFactory, courseFactory, lessonFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; -import { ColumnBoardTargetService } from './column-board-target.service'; +import { LegacyBoardRepo } from '@shared/repo'; +import { + boardFactory, + columnBoardNodeFactory, + courseFactory, + lessonFactory, + setupEntities, + taskFactory, + userFactory, +} from '@shared/testing'; +import { ColumnBoardNode } from '@shared/domain/entity'; +import { BoardNodeRepo } from '@modules/board/repo'; import { RoomsService } from './rooms.service'; describe('rooms service', () => { @@ -18,9 +24,9 @@ describe('rooms service', () => { let roomsService: RoomsService; let lessonService: DeepMocked; let taskService: DeepMocked; - let boardRepo: DeepMocked; + let legacyBoardRepo: DeepMocked; let columnBoardService: DeepMocked; - let columnBoardTargetService: DeepMocked; + let boardNodeRepo: DeepMocked; let configBefore: IConfig; afterAll(async () => { @@ -42,8 +48,8 @@ describe('rooms service', () => { useValue: createMock(), }, { - provide: BoardRepo, - useValue: createMock(), + provide: LegacyBoardRepo, + useValue: createMock(), }, { provide: ColumnBoardService, @@ -62,17 +68,21 @@ describe('rooms service', () => { useValue: createMock(), }, { - provide: ColumnBoardTargetService, - useValue: createMock(), + provide: ColumnBoardNode, + useValue: createMock(), + }, + { + provide: BoardNodeRepo, + useValue: createMock(), }, ], }).compile(); roomsService = module.get(RoomsService); lessonService = module.get(LessonService); taskService = module.get(TaskService); - boardRepo = module.get(BoardRepo); + legacyBoardRepo = module.get(LegacyBoardRepo); columnBoardService = module.get(ColumnBoardService); - columnBoardTargetService = module.get(ColumnBoardTargetService); + boardNodeRepo = module.get(BoardNodeRepo); }); afterEach(() => { @@ -80,25 +90,32 @@ describe('rooms service', () => { Configuration.reset(configBefore); }); - describe('updateBoard', () => { - describe('for lessons and tasks', () => { + describe('updateLegacyBoard', () => { + describe('for lessons, tasks and column boards', () => { const setup = () => { const user = userFactory.buildWithId(); const room = courseFactory.buildWithId({ students: [user] }); const tasks = taskFactory.buildList(3, { course: room }); const lessons = lessonFactory.buildList(3, { course: room }); - const board = boardFactory.buildWithId({ course: room }); + const legacyBoard = boardFactory.buildWithId({ course: room }); - board.syncBoardElementReferences([...tasks, ...lessons]); + const columnBoardNode = columnBoardNodeFactory.build(); + + // TODO what is this doing here? + legacyBoard.syncBoardElementReferences([...tasks, ...lessons, columnBoardNode]); const tasksSpy = taskService.findBySingleParent.mockResolvedValue([tasks, 3]); const lessonsSpy = lessonService.findByCourseIds.mockResolvedValue([lessons, 3]); - const syncBoardElementReferencesSpy = jest.spyOn(board, 'syncBoardElementReferences'); - const saveSpy = boardRepo.save.mockResolvedValue(); + + columnBoardService.findIdsByExternalReference.mockResolvedValue([columnBoardNode.id]); + boardNodeRepo.findById.mockResolvedValue(columnBoardNode); + + const syncBoardElementReferencesSpy = jest.spyOn(legacyBoard, 'syncBoardElementReferences'); + const saveSpy = legacyBoardRepo.save.mockResolvedValue(); return { user, - board, + board: legacyBoard, room, tasks, lessons, @@ -106,170 +123,49 @@ describe('rooms service', () => { tasksSpy, syncBoardElementReferencesSpy, saveSpy, + + columnBoardNode, }; }; it('should fetch all lessons of room', async () => { const { board, room, user, lessonsSpy } = setup(); - await roomsService.updateBoard(board, room.id, user.id); + await roomsService.updateLegacyBoard(board, room.id, user.id); expect(lessonsSpy).toHaveBeenCalledWith([room.id]); }); it('should fetch all tasks of room', async () => { const { board, room, user, tasksSpy } = setup(); - await roomsService.updateBoard(board, room.id, user.id); + await roomsService.updateLegacyBoard(board, room.id, user.id); expect(tasksSpy).toHaveBeenCalledWith(user.id, room.id); }); - it('should sync boards lessons with fetched tasks and lessons', async () => { - const { board, room, user, tasks, lessons, syncBoardElementReferencesSpy } = setup(); - await roomsService.updateBoard(board, room.id, user.id); - expect(syncBoardElementReferencesSpy).toHaveBeenCalledWith([...lessons, ...tasks]); - }); - - it('should persist board', async () => { - const { board, room, user, saveSpy } = setup(); - await roomsService.updateBoard(board, room.id, user.id); - expect(saveSpy).toHaveBeenCalledWith(board); - }); - }); - - describe('for column boards', () => { - const setup = () => { - lessonService.findByCourseIds.mockResolvedValueOnce([[], 0]); - taskService.findBySingleParent.mockResolvedValueOnce([[], 0]); - - const user = userFactory.buildWithId(); - const course1 = courseFactory.buildWithId({ students: [user] }); - const course2 = courseFactory.buildWithId({ students: [user] }); - const boardWithoutColumnBoard = boardFactory.build({ course: course1 }); - const boardWithColumnBoard = boardFactory.build({ course: course2 }); - const columnBoardId = new ObjectId().toHexString(); - - jest.spyOn(boardWithoutColumnBoard, 'syncBoardElementReferences').mockImplementation(); - jest.spyOn(boardWithColumnBoard, 'syncBoardElementReferences').mockImplementation(); - - columnBoardService.findIdsByExternalReference.mockImplementation( - async (courseReference: BoardExternalReference): Promise => { - if (courseReference.id === boardWithColumnBoard.course.id) { - return Promise.resolve([columnBoardId]); - } - return Promise.resolve([]); - } - ); + it('should fetch all column boardIds for course', async () => { + const { board, room, user } = setup(); - return { user, boardWithoutColumnBoard, boardWithColumnBoard, columnBoardId }; - }; - - describe('when ColumnBoard-feature is enabled', () => { - const setupWithEnvVariables = () => { - Configuration.set('FEATURE_COLUMN_BOARD_ENABLED', 'true'); - - return setup(); - }; + await roomsService.updateLegacyBoard(board, room.id, user.id); - describe('when no column board exists for the board', () => { - it('should create one', async () => { - const { user, boardWithoutColumnBoard: board } = setupWithEnvVariables(); - - await roomsService.updateBoard(board, board.course.id, user.id); - - expect(columnBoardService.createWelcomeColumnBoard).toBeCalledWith({ - type: BoardExternalReferenceType.Course, - id: board.course.id, - }); - }); - }); - - describe('when a colum board exists for the board', () => { - it('should not create one', async () => { - const { user, boardWithColumnBoard: board } = setupWithEnvVariables(); - - await roomsService.updateBoard(board, board.course.id, user.id); - - expect(columnBoardService.createWelcomeColumnBoard).not.toBeCalledWith( - expect.objectContaining({ id: board.course.id }) - ); - }); - }); - - it('should use the service to find or create targets', async () => { - const { user, boardWithColumnBoard: board, columnBoardId } = setupWithEnvVariables(); - - await roomsService.updateBoard(board, board.course.id, user.id); - - expect(columnBoardTargetService.findOrCreateTargets).toBeCalledWith([columnBoardId]); + expect(columnBoardService.findIdsByExternalReference).toHaveBeenCalledWith({ + type: 'course', + id: room.id, }); }); - - describe('when ColumnBoard-feature and COLUMN_BOARD_HELP_LINK is enabled', () => { - const setupWithEnvVariables = () => { - Configuration.set('FEATURE_COLUMN_BOARD_ENABLED', 'true'); - Configuration.set('COLUMN_BOARD_HELP_LINK', 'www.google.com'); - - return setup(); - }; - - describe('when no column board exists for the board', () => { - it('should create one', async () => { - const { user, boardWithoutColumnBoard: board } = setupWithEnvVariables(); - - await roomsService.updateBoard(board, board.course.id, user.id); - - expect(columnBoardService.createWelcomeColumnBoard).toBeCalledWith({ - type: BoardExternalReferenceType.Course, - id: board.course.id, - }); - }); - }); + it('should fetch all column boards', async () => { + const { board, room, user, columnBoardNode } = setup(); + await roomsService.updateLegacyBoard(board, room.id, user.id); + expect(boardNodeRepo.findById).toHaveBeenCalledWith(columnBoardNode.id); }); - describe('when ColumnBoard-feature and COLUMN_BOARD_FEEDBACK_LINK is enabled', () => { - const setupWithEnvVariables = () => { - Configuration.set('FEATURE_COLUMN_BOARD_ENABLED', 'true'); - Configuration.set('COLUMN_BOARD_FEEDBACK_LINK', 'www.twitter.com'); - - return setup(); - }; - - describe('when no column board exists for the board', () => { - it('should create one', async () => { - const { user, boardWithoutColumnBoard: board } = setupWithEnvVariables(); - - await roomsService.updateBoard(board, board.course.id, user.id); - - expect(columnBoardService.createWelcomeColumnBoard).toBeCalledWith({ - type: BoardExternalReferenceType.Course, - id: board.course.id, - }); - }); - }); + it('should sync legacy boards lessons with fetched tasks and lessons and columnBoards', async () => { + const { board, room, user, tasks, lessons, columnBoardNode, syncBoardElementReferencesSpy } = setup(); + await roomsService.updateLegacyBoard(board, room.id, user.id); + expect(syncBoardElementReferencesSpy).toHaveBeenCalledWith([...lessons, ...tasks, columnBoardNode]); }); - describe('when ColumnBoard-feature is disabled', () => { - const setupWithEnvVariables = () => { - Configuration.set('FEATURE_COLUMN_BOARD_ENABLED', 'false'); - - return setup(); - }; - - describe('when no column board exists for the board', () => { - it('should NOT create one', async () => { - const { user, boardWithoutColumnBoard: board } = setupWithEnvVariables(); - - await roomsService.updateBoard(board, board.course.id, user.id); - - expect(columnBoardService.createWelcomeColumnBoard).not.toBeCalled(); - }); - }); - - it('should NOT use the service to find or create targets', async () => { - const { user, boardWithColumnBoard: board } = setupWithEnvVariables(); - - await roomsService.updateBoard(board, board.course.id, user.id); - - expect(columnBoardTargetService.findOrCreateTargets).not.toBeCalled(); - }); + it('should persist board', async () => { + const { board, room, user, saveSpy } = setup(); + await roomsService.updateLegacyBoard(board, room.id, user.id); + expect(saveSpy).toHaveBeenCalledWith(board); }); }); }); diff --git a/apps/server/src/modules/learnroom/service/rooms.service.ts b/apps/server/src/modules/learnroom/service/rooms.service.ts index 28671d0e526..0bc0c8cf7d4 100644 --- a/apps/server/src/modules/learnroom/service/rooms.service.ts +++ b/apps/server/src/modules/learnroom/service/rooms.service.ts @@ -1,55 +1,42 @@ -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ColumnBoardService } from '@modules/board'; import { LessonService } from '@modules/lesson'; import { TaskService } from '@modules/task'; import { Injectable } from '@nestjs/common'; import { BoardExternalReferenceType } from '@shared/domain/domainobject'; -import { Board, ColumnBoardTarget } from '@shared/domain/entity'; +import { LegacyBoard, ColumnBoardNode } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; -import { BoardRepo } from '@shared/repo'; -import { ColumnBoardTargetService } from './column-board-target.service'; +import { LegacyBoardRepo } from '@shared/repo'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { BoardNodeRepo } from '@modules/board/repo'; @Injectable() export class RoomsService { constructor( private readonly taskService: TaskService, private readonly lessonService: LessonService, - private readonly boardRepo: BoardRepo, + private readonly boardRepo: LegacyBoardRepo, private readonly columnBoardService: ColumnBoardService, - private readonly columnBoardTargetService: ColumnBoardTargetService + private readonly boardNodeRepo: BoardNodeRepo ) {} - async updateBoard(board: Board, roomId: EntityId, userId: EntityId): Promise { + async updateLegacyBoard(board: LegacyBoard, roomId: EntityId, userId: EntityId): Promise { const [courseLessons] = await this.lessonService.findByCourseIds([roomId]); const [courseTasks] = await this.taskService.findBySingleParent(userId, roomId); - const courseColumnBoardTargets = await this.handleColumnBoardIntegration(roomId); + const columnBoardIds = await this.columnBoardService.findIdsByExternalReference({ + type: BoardExternalReferenceType.Course, + id: roomId, + }); - const boardElementTargets = [...courseLessons, ...courseTasks, ...courseColumnBoardTargets]; + const columnBoards = await Promise.all( + columnBoardIds.map(async (id) => (await this.boardNodeRepo.findById(id)) as ColumnBoardNode) + ); + + const boardElementTargets = [...courseLessons, ...courseTasks, ...columnBoards]; board.syncBoardElementReferences(boardElementTargets); await this.boardRepo.save(board); return board; } - - private async handleColumnBoardIntegration(roomId: EntityId): Promise { - let courseColumnBoardTargets: ColumnBoardTarget[] = []; - - if ((Configuration.get('FEATURE_COLUMN_BOARD_ENABLED') as boolean) === true) { - const courseReference = { - type: BoardExternalReferenceType.Course, - id: roomId, - }; - - const columnBoardIds = await this.columnBoardService.findIdsByExternalReference(courseReference); - if (columnBoardIds.length === 0) { - const columnBoard = await this.columnBoardService.createWelcomeColumnBoard(courseReference); - columnBoardIds.push(columnBoard.id); - } - - courseColumnBoardTargets = await this.columnBoardTargetService.findOrCreateTargets(columnBoardIds); - } - return courseColumnBoardTargets; - } } diff --git a/apps/server/src/modules/learnroom/testing/courseFactory.ts b/apps/server/src/modules/learnroom/testing/courseFactory.ts new file mode 100644 index 00000000000..8f1f35e3dbe --- /dev/null +++ b/apps/server/src/modules/learnroom/testing/courseFactory.ts @@ -0,0 +1,20 @@ +import { DomainObjectFactory } from '@shared/testing'; +import { ObjectId } from 'bson'; +import { Course, CourseProps } from '../domain'; + +export const courseFactory = DomainObjectFactory.define(Course, ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + name: `course #${sequence}`, + features: new Set(), + schoolId: new ObjectId().toHexString(), + studentIds: [], + teacherIds: [], + substitutionTeacherIds: [], + groupIds: [], + classIds: [], + courseGroupIds: [], + description: 'description', + color: '#ACACAC', + }; +}); diff --git a/apps/server/src/modules/learnroom/testing/index.ts b/apps/server/src/modules/learnroom/testing/index.ts new file mode 100644 index 00000000000..7f58d75774f --- /dev/null +++ b/apps/server/src/modules/learnroom/testing/index.ts @@ -0,0 +1 @@ +export { courseFactory } from './courseFactory'; diff --git a/apps/server/src/modules/learnroom/types/room-board.types.ts b/apps/server/src/modules/learnroom/types/room-board.types.ts index 6ec8632c294..5c0297c6ff8 100644 --- a/apps/server/src/modules/learnroom/types/room-board.types.ts +++ b/apps/server/src/modules/learnroom/types/room-board.types.ts @@ -7,6 +7,7 @@ export type RoomBoardDTO = { title: string; elements: RoomBoardElementDTO[]; isArchived: boolean; + isSynchronized: boolean; }; export enum RoomBoardElementTypes { diff --git a/apps/server/src/modules/learnroom/uc/course-export.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course-export.uc.spec.ts index 8ca36158f5d..661f04a5e10 100644 --- a/apps/server/src/modules/learnroom/uc/course-export.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/course-export.uc.spec.ts @@ -1,17 +1,21 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { CommonCartridgeVersion } from '@modules/common-cartridge'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { CommonCartridgeExportService } from '@modules/learnroom/service/common-cartridge-export.service'; -import { AuthorizationReferenceService } from '@modules/authorization/domain'; -import { ObjectId } from 'bson'; -import { ForbiddenException } from '@nestjs/common'; +import { faker } from '@faker-js/faker'; +import { AuthorizationReferenceService } from '../../authorization/domain'; +import { LearnroomConfig } from '../learnroom.config'; +import { CommonCartridgeExportService } from '../service/common-cartridge-export.service'; import { CourseExportUc } from './course-export.uc'; -import { CommonCartridgeVersion } from '../common-cartridge'; describe('CourseExportUc', () => { let module: TestingModule; let courseExportUc: CourseExportUc; let courseExportServiceMock: DeepMocked; let authorizationServiceMock: DeepMocked; + let configServiceMock: DeepMocked>; beforeAll(async () => { module = await Test.createTestingModule({ @@ -25,11 +29,16 @@ describe('CourseExportUc', () => { provide: AuthorizationReferenceService, useValue: createMock(), }, + { + provide: ConfigService, + useValue: createMock>(), + }, ], }).compile(); courseExportUc = module.get(CourseExportUc); courseExportServiceMock = module.get(CommonCartridgeExportService); authorizationServiceMock = module.get(AuthorizationReferenceService); + configServiceMock = module.get(ConfigService); }); afterAll(async () => { @@ -46,22 +55,25 @@ describe('CourseExportUc', () => { const courseId = new ObjectId().toHexString(); const userId = new ObjectId().toHexString(); const version: CommonCartridgeVersion = CommonCartridgeVersion.V_1_1_0; + const topics: string[] = [faker.string.uuid()]; + const tasks: string[] = [faker.string.uuid()]; - return { version, userId, courseId }; + return { version, userId, courseId, topics, tasks }; }; describe('when authorization throw a error', () => { const setup = () => { authorizationServiceMock.checkPermissionByReferences.mockRejectedValueOnce(new ForbiddenException()); courseExportServiceMock.exportCourse.mockResolvedValueOnce(Buffer.from('')); + configServiceMock.get.mockReturnValueOnce(true); return setupParams(); }; it('should pass this error', async () => { - const { courseId, userId, version } = setup(); + const { courseId, userId, version, topics, tasks } = setup(); - await expect(courseExportUc.exportCourse(courseId, userId, version)).rejects.toThrowError( + await expect(courseExportUc.exportCourse(courseId, userId, version, topics, tasks)).rejects.toThrowError( new ForbiddenException() ); }); @@ -71,14 +83,17 @@ describe('CourseExportUc', () => { const setup = () => { authorizationServiceMock.checkPermissionByReferences.mockResolvedValueOnce(); courseExportServiceMock.exportCourse.mockRejectedValueOnce(new Error()); + configServiceMock.get.mockReturnValueOnce(true); return setupParams(); }; it('should pass this error', async () => { - const { courseId, userId, version } = setup(); + const { courseId, userId, version, topics, tasks } = setup(); - await expect(courseExportUc.exportCourse(courseId, userId, version)).rejects.toThrowError(new Error()); + await expect(courseExportUc.exportCourse(courseId, userId, version, topics, tasks)).rejects.toThrowError( + new Error() + ); }); }); @@ -86,21 +101,42 @@ describe('CourseExportUc', () => { const setup = () => { authorizationServiceMock.checkPermissionByReferences.mockResolvedValueOnce(); courseExportServiceMock.exportCourse.mockResolvedValueOnce(Buffer.from('')); + configServiceMock.get.mockReturnValueOnce(true); return setupParams(); }; it('should check for permissions', async () => { - const { courseId, userId, version } = setup(); + const { courseId, userId, version, topics, tasks } = setup(); - await expect(courseExportUc.exportCourse(courseId, userId, version)).resolves.not.toThrow(); + await expect(courseExportUc.exportCourse(courseId, userId, version, topics, tasks)).resolves.not.toThrow(); expect(authorizationServiceMock.checkPermissionByReferences).toBeCalledTimes(1); }); it('should return a binary file as buffer', async () => { - const { courseId, userId, version } = setup(); + const { courseId, userId, version, topics, tasks } = setup(); + + await expect(courseExportUc.exportCourse(courseId, userId, version, topics, tasks)).resolves.toBeInstanceOf( + Buffer + ); + }); + }); + + describe('when feature is disabled', () => { + const setup = () => { + authorizationServiceMock.checkPermissionByReferences.mockResolvedValueOnce(); + courseExportServiceMock.exportCourse.mockResolvedValueOnce(Buffer.from('')); + configServiceMock.get.mockReturnValueOnce(false); + + return setupParams(); + }; - await expect(courseExportUc.exportCourse(courseId, userId, version)).resolves.toBeInstanceOf(Buffer); + it('should throw a NotFoundException', async () => { + const { courseId, userId, version, topics, tasks } = setup(); + + await expect(courseExportUc.exportCourse(courseId, userId, version, topics, tasks)).rejects.toThrowError( + new NotFoundException() + ); }); }); }); diff --git a/apps/server/src/modules/learnroom/uc/course-export.uc.ts b/apps/server/src/modules/learnroom/uc/course-export.uc.ts index bbb4a388157..5c6c386c144 100644 --- a/apps/server/src/modules/learnroom/uc/course-export.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course-export.uc.ts @@ -1,19 +1,29 @@ import { AuthorizationContextBuilder } from '@modules/authorization'; import { AuthorizableReferenceType, AuthorizationReferenceService } from '@modules/authorization/domain'; -import { Injectable } from '@nestjs/common'; +import { CommonCartridgeVersion } from '@modules/common-cartridge'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { CommonCartridgeVersion } from '../common-cartridge'; +import { LearnroomConfig } from '../learnroom.config'; import { CommonCartridgeExportService } from '../service/common-cartridge-export.service'; @Injectable() export class CourseExportUc { constructor( + private readonly configService: ConfigService, private readonly courseExportService: CommonCartridgeExportService, private readonly authorizationService: AuthorizationReferenceService ) {} - async exportCourse(courseId: EntityId, userId: EntityId, version: CommonCartridgeVersion): Promise { + public async exportCourse( + courseId: EntityId, + userId: EntityId, + version: CommonCartridgeVersion, + topics: string[], + tasks: string[] + ): Promise { + this.checkFeatureEnabled(); const context = AuthorizationContextBuilder.read([Permission.COURSE_EDIT]); await this.authorizationService.checkPermissionByReferences( userId, @@ -22,6 +32,12 @@ export class CourseExportUc { context ); - return this.courseExportService.exportCourse(courseId, userId, version); + return this.courseExportService.exportCourse(courseId, userId, version, topics, tasks); + } + + private checkFeatureEnabled(): void { + if (!this.configService.get('FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED')) { + throw new NotFoundException(); + } } } diff --git a/apps/server/src/modules/learnroom/uc/course-import.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course-import.uc.spec.ts new file mode 100644 index 00000000000..eedae71a09f --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/course-import.uc.spec.ts @@ -0,0 +1,121 @@ +import { faker } from '@faker-js/faker'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { MikroORM } from '@mikro-orm/core'; +import { NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission } from '@shared/domain/interface'; +import { courseFactory, setupEntities, userFactory } from '@shared/testing'; +import { AuthorizationService } from '@src/modules/authorization'; +import { LearnroomConfig } from '../learnroom.config'; +import { CommonCartridgeImportService } from '../service'; +import { CourseImportUc } from './course-import.uc'; + +describe('CourseImportUc', () => { + let module: TestingModule; + let sut: CourseImportUc; + let orm: MikroORM; + let configServiceMock: DeepMocked>; + let authorizationServiceMock: DeepMocked; + let courseImportServiceMock: DeepMocked; + + beforeAll(async () => { + orm = await setupEntities(); + module = await Test.createTestingModule({ + providers: [ + CourseImportUc, + { + provide: ConfigService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: CommonCartridgeImportService, + useValue: createMock(), + }, + ], + }).compile(); + + sut = module.get(CourseImportUc); + configServiceMock = module.get(ConfigService); + authorizationServiceMock = module.get(AuthorizationService); + courseImportServiceMock = module.get(CommonCartridgeImportService); + }); + + afterAll(async () => { + await module.close(); + await orm.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('importFromCommonCartridge', () => { + describe('when the feature is enabled', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const course = courseFactory.buildWithId(); + const file = Buffer.from(''); + + configServiceMock.getOrThrow.mockReturnValue(true); + authorizationServiceMock.getUserWithPermissions.mockResolvedValue(user); + courseImportServiceMock.importFile.mockResolvedValue(); + + return { user, course, file }; + }; + + it('should check the permissions', async () => { + const { user, file } = setup(); + + await sut.importFromCommonCartridge(user.id, file); + + expect(authorizationServiceMock.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.COURSE_CREATE]); + }); + + it('should call import service', async () => { + const { user, file } = setup(); + + await sut.importFromCommonCartridge(user.id, file); + + expect(courseImportServiceMock.importFile).toHaveBeenCalledTimes(1); + }); + }); + + describe('when user has insufficient permissions', () => { + const setup = () => { + configServiceMock.getOrThrow.mockReturnValue(true); + authorizationServiceMock.checkAllPermissions.mockImplementation(() => { + throw new Error(); + }); + }; + + it('should throw', async () => { + setup(); + + await expect(sut.importFromCommonCartridge(faker.string.uuid(), Buffer.from(''))).rejects.toThrow(); + }); + }); + + describe('when the feature is disabled', () => { + const setup = () => { + configServiceMock.getOrThrow.mockReturnValue(false); + }; + + it('should throw', async () => { + setup(); + + await expect(sut.importFromCommonCartridge(faker.string.uuid(), Buffer.from(''))).rejects.toThrow( + NotFoundException + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/uc/course-import.uc.ts b/apps/server/src/modules/learnroom/uc/course-import.uc.ts new file mode 100644 index 00000000000..4eccabe8132 --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/course-import.uc.ts @@ -0,0 +1,28 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Permission } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { AuthorizationService } from '@src/modules/authorization'; +import { LearnroomConfig } from '../learnroom.config'; +import { CommonCartridgeImportService } from '../service'; + +@Injectable() +export class CourseImportUc { + public constructor( + private readonly configService: ConfigService, + private readonly authorizationService: AuthorizationService, + private readonly courseImportService: CommonCartridgeImportService + ) {} + + public async importFromCommonCartridge(userId: EntityId, file: Buffer): Promise { + if (!this.configService.getOrThrow('FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED')) { + throw new NotFoundException(); + } + + const user = await this.authorizationService.getUserWithPermissions(userId); + + this.authorizationService.checkAllPermissions(user, [Permission.COURSE_CREATE]); + + await this.courseImportService.importFile(user, file); + } +} diff --git a/apps/server/src/modules/learnroom/uc/course-sync.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course-sync.uc.spec.ts new file mode 100644 index 00000000000..f9171895b9d --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/course-sync.uc.spec.ts @@ -0,0 +1,82 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Permission } from '@shared/domain/interface'; +import { setupEntities, userFactory } from '@shared/testing'; +import { CourseDoService } from '../service'; +import { courseFactory } from '../testing'; +import { CourseSyncUc } from './course-sync.uc'; + +describe(CourseSyncUc.name, () => { + let module: TestingModule; + let uc: CourseSyncUc; + + let authorizationService: DeepMocked; + let courseService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + CourseSyncUc, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: CourseDoService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(CourseSyncUc); + authorizationService = module.get(AuthorizationService); + courseService = module.get(CourseDoService); + await setupEntities(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('stopSynchronization', () => { + describe('when a user stops a synchronization of a course with a group', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const course = courseFactory.build(); + + courseService.findById.mockResolvedValueOnce(course); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + return { + user, + course, + }; + }; + + it('should check the users permission', async () => { + const { user, course } = setup(); + + await uc.stopSynchronization(user.id, course.id); + + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + course, + AuthorizationContextBuilder.write([Permission.COURSE_EDIT]) + ); + }); + + it('should stop the synchronization', async () => { + const { user, course } = setup(); + + await uc.stopSynchronization(user.id, course.id); + + expect(courseService.stopSynchronization).toHaveBeenCalledWith(course); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/uc/course-sync.uc.ts b/apps/server/src/modules/learnroom/uc/course-sync.uc.ts new file mode 100644 index 00000000000..5ffe55abe3e --- /dev/null +++ b/apps/server/src/modules/learnroom/uc/course-sync.uc.ts @@ -0,0 +1,28 @@ +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { Injectable } from '@nestjs/common'; +import { type User as UserEntity } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { Course } from '../domain'; +import { CourseDoService } from '../service'; + +@Injectable() +export class CourseSyncUc { + constructor( + private readonly authorizationService: AuthorizationService, + private readonly courseService: CourseDoService + ) {} + + public async stopSynchronization(userId: EntityId, courseId: EntityId): Promise { + const course: Course = await this.courseService.findById(courseId); + + const user: UserEntity = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkPermission( + user, + course, + AuthorizationContextBuilder.write([Permission.COURSE_EDIT]) + ); + + await this.courseService.stopSynchronization(course); + } +} diff --git a/apps/server/src/modules/learnroom/uc/course.uc.spec.ts b/apps/server/src/modules/learnroom/uc/course.uc.spec.ts index c12c148a3f6..29b0a5be59b 100644 --- a/apps/server/src/modules/learnroom/uc/course.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/course.uc.spec.ts @@ -1,14 +1,20 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { SortOrder } from '@shared/domain/interface'; +import { Permission, RoleName, SortOrder } from '@shared/domain/interface'; import { CourseRepo } from '@shared/repo'; -import { courseFactory, setupEntities } from '@shared/testing'; +import { courseFactory, setupEntities, UserAndAccountTestFactory } from '@shared/testing'; +import { AuthorizationService } from '@src/modules/authorization'; +import { RoleDto, RoleService } from '@src/modules/role'; import { CourseUc } from './course.uc'; +import { CourseService } from '../service'; describe('CourseUc', () => { let module: TestingModule; let uc: CourseUc; let courseRepo: DeepMocked; + let courseService: DeepMocked; + let authorizationService: DeepMocked; + let roleService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -19,11 +25,26 @@ describe('CourseUc', () => { provide: CourseRepo, useValue: createMock(), }, + { + provide: CourseService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: RoleService, + useValue: createMock(), + }, ], }).compile(); uc = module.get(CourseUc); courseRepo = module.get(CourseRepo); + courseService = module.get(CourseService); + authorizationService = module.get(AuthorizationService); + roleService = module.get(RoleService); }); afterAll(async () => { @@ -55,4 +76,31 @@ describe('CourseUc', () => { expect(courseRepo.findAllByUserId).toHaveBeenCalledWith('someUserId', {}, resultingOptions); }); }); + + describe('getUserPermissionByCourseId', () => { + const setup = () => { + const { teacherUser } = UserAndAccountTestFactory.buildTeacher({}, []); + const course = courseFactory.buildWithId({ + teachers: [teacherUser], + }); + + return { course, teacherUser }; + }; + it('should return permissions for user', async () => { + const { course, teacherUser } = setup(); + courseService.findById.mockResolvedValue(course); + authorizationService.getUserWithPermissions.mockResolvedValue(teacherUser); + const mockRoleDto: RoleDto = { + name: RoleName.TEACHER, + permissions: [Permission.COURSE_DELETE], + }; + roleService.findByName.mockResolvedValue(mockRoleDto); + const permissions = await uc.getUserPermissionByCourseId(teacherUser.id, course.id); + + expect(permissions.length).toBeGreaterThan(0); + expect(courseService.findById).toHaveBeenCalledWith(course.id); + expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(teacherUser.id); + expect(roleService.findByName).toHaveBeenCalledWith(RoleName.TEACHER); + }); + }); }); diff --git a/apps/server/src/modules/learnroom/uc/course.uc.ts b/apps/server/src/modules/learnroom/uc/course.uc.ts index fb901bb8c10..a60676803d9 100644 --- a/apps/server/src/modules/learnroom/uc/course.uc.ts +++ b/apps/server/src/modules/learnroom/uc/course.uc.ts @@ -4,12 +4,30 @@ import { Course } from '@shared/domain/entity'; import { SortOrder } from '@shared/domain/interface'; import { Counted, EntityId } from '@shared/domain/types'; import { CourseRepo } from '@shared/repo'; +import { AuthorizationService } from '@src/modules/authorization'; +import { RoleService } from '@src/modules/role'; +import { RoleNameMapper } from '../mapper/rolename.mapper'; +import { CourseService } from '../service'; @Injectable() export class CourseUc { - constructor(private readonly courseRepo: CourseRepo) {} + public constructor( + private readonly courseRepo: CourseRepo, + private readonly courseService: CourseService, + private readonly authService: AuthorizationService, + private readonly roleService: RoleService + ) {} - findAllByUser(userId: EntityId, options?: PaginationParams): Promise> { + public findAllByUser(userId: EntityId, options?: PaginationParams): Promise> { return this.courseRepo.findAllByUserId(userId, {}, { pagination: options, order: { updatedAt: SortOrder.desc } }); } + + public async getUserPermissionByCourseId(userId: EntityId, courseId: EntityId): Promise { + const course = await this.courseService.findById(courseId); + const user = await this.authService.getUserWithPermissions(userId); + const userRole = RoleNameMapper.mapToRoleName(user, course); + const role = await this.roleService.findByName(userRole); + + return role.permissions ?? []; + } } diff --git a/apps/server/src/modules/learnroom/uc/dashboard.uc.spec.ts b/apps/server/src/modules/learnroom/uc/dashboard.uc.spec.ts index deca332c1f0..098482c143e 100644 --- a/apps/server/src/modules/learnroom/uc/dashboard.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/dashboard.uc.spec.ts @@ -17,6 +17,7 @@ const learnroomMock = (id: string, name: string) => { title: name, shortTitle: name.substr(0, 2), displayColor: '#ACACAC', + isSynchronized: false, }; }, }; diff --git a/apps/server/src/modules/learnroom/uc/index.ts b/apps/server/src/modules/learnroom/uc/index.ts index 68311bc9268..2ef52519ad0 100644 --- a/apps/server/src/modules/learnroom/uc/index.ts +++ b/apps/server/src/modules/learnroom/uc/index.ts @@ -1,8 +1,10 @@ +export * from './course-copy.uc'; +export * from './course-export.uc'; +export * from './course-import.uc'; +export * from './course-sync.uc'; export * from './course.uc'; -export * from './rooms.uc'; export * from './dashboard.uc'; -export * from './course-copy.uc'; export * from './lesson-copy.uc'; -export * from './course-export.uc'; export * from './room-board-dto.factory'; export * from './rooms.authorisation.service'; +export * from './rooms.uc'; diff --git a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.spec.ts b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.spec.ts index c63826163cb..b0a98727efa 100644 --- a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.spec.ts +++ b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.spec.ts @@ -3,7 +3,7 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; import { AuthorizationService } from '@modules/authorization'; import { Test, TestingModule } from '@nestjs/testing'; -import { Board, Course, LessonEntity, Task, TaskWithStatusVo, User } from '@shared/domain/entity'; +import { LegacyBoard, Course, LessonEntity, Task, TaskWithStatusVo, User } from '@shared/domain/entity'; import { boardFactory, columnboardBoardElementFactory, @@ -91,7 +91,7 @@ describe(RoomBoardDTOFactory.name, () => { let teacher: User; let student: User; let substitutionTeacher: User; - let board: Board; + let board: LegacyBoard; let room: Course; let tasks: Task[]; @@ -163,7 +163,7 @@ describe(RoomBoardDTOFactory.name, () => { let teacher: User; let student: User; let substitutionTeacher: User; - let board: Board; + let board: LegacyBoard; let room: Course; let tasks: Task[]; @@ -202,7 +202,7 @@ describe(RoomBoardDTOFactory.name, () => { let teacher: User; let student: User; let substitutionTeacher: User; - let board: Board; + let board: LegacyBoard; let room: Course; let lessons: LessonEntity[]; @@ -241,7 +241,7 @@ describe(RoomBoardDTOFactory.name, () => { let teacher: User; let student: User; let substitutionTeacher: User; - let board: Board; + let board: LegacyBoard; let room: Course; let lesson: LessonEntity; const inOneDay = new Date(Date.now() + 8.64e7); @@ -323,7 +323,7 @@ describe(RoomBoardDTOFactory.name, () => { let teacher: User; let student: User; let substitutionTeacher: User; - let board: Board; + let board: LegacyBoard; let room: Course; let lessons: LessonEntity[]; diff --git a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts index 785f513ba25..2670bf81f84 100644 --- a/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts +++ b/apps/server/src/modules/learnroom/uc/room-board-dto.factory.ts @@ -2,12 +2,12 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Action, AuthorizationService } from '@modules/authorization'; import { Injectable } from '@nestjs/common'; import { - Board, - BoardElement, - BoardElementType, - ColumnBoardTarget, ColumnboardBoardElement, + ColumnBoardNode, Course, + LegacyBoard, + LegacyBoardElement, + LegacyBoardElementType, LessonEntity, Task, TaskWithStatusVo, @@ -27,7 +27,7 @@ import { RoomsAuthorisationService } from './rooms.authorisation.service'; class DtoCreator { room: Course; - board: Board; + board: LegacyBoard; user: User; @@ -43,7 +43,7 @@ class DtoCreator { roomsAuthorisationService, }: { room: Course; - board: Board; + board: LegacyBoard; user: User; authorisationService: AuthorizationService; roomsAuthorisationService: RoomsAuthorisationService; @@ -64,14 +64,14 @@ class DtoCreator { return dto; } - private filterByPermission(elements: BoardElement[]) { + private filterByPermission(elements: LegacyBoardElement[]) { const filtered = elements.filter((element) => { let result = false; - if (element.boardElementType === BoardElementType.Task) { + if (element.boardElementType === LegacyBoardElementType.Task) { result = this.roomsAuthorisationService.hasTaskReadPermission(this.user, element.target as Task); } - if (element.boardElementType === BoardElementType.Lesson) { + if (element.boardElementType === LegacyBoardElementType.Lesson) { result = this.roomsAuthorisationService.hasLessonReadPermission(this.user, element.target as LessonEntity); } @@ -99,18 +99,18 @@ class DtoCreator { return false; } - private mapToElementDTOs(elements: BoardElement[]) { + private mapToElementDTOs(elements: LegacyBoardElement[]) { const results: RoomBoardElementDTO[] = []; elements.forEach((element) => { - if (element.boardElementType === BoardElementType.Task) { + if (element.boardElementType === LegacyBoardElementType.Task) { const mapped = this.mapTaskElement(element); results.push(mapped); } - if (element.boardElementType === BoardElementType.Lesson) { + if (element.boardElementType === LegacyBoardElementType.Lesson) { const mapped = this.mapLessonElement(element); results.push(mapped); } - if (element.boardElementType === BoardElementType.ColumnBoard) { + if (element.boardElementType === LegacyBoardElementType.ColumnBoard) { const mapped = this.mapColumnBoardElement(element); results.push(mapped); } @@ -118,7 +118,7 @@ class DtoCreator { return results; } - private mapTaskElement(element: BoardElement): RoomBoardElementDTO { + private mapTaskElement(element: LegacyBoardElement): RoomBoardElementDTO { const task = element.target as Task; const status = this.createTaskStatus(task); @@ -136,7 +136,7 @@ class DtoCreator { return status; } - private mapLessonElement(element: BoardElement): RoomBoardElementDTO { + private mapLessonElement(element: LegacyBoardElement): RoomBoardElementDTO { const type = RoomBoardElementTypes.LESSON; const lesson = element.target as LessonEntity; const content: LessonMetaData = { @@ -155,28 +155,29 @@ class DtoCreator { return { type, content }; } - private mapColumnBoardElement(element: BoardElement): RoomBoardElementDTO { + private mapColumnBoardElement(element: LegacyBoardElement): RoomBoardElementDTO { const type = RoomBoardElementTypes.COLUMN_BOARD; - const columnBoardTarget = element.target as ColumnBoardTarget; + const columnBoardNode = element.target as ColumnBoardNode; const content: ColumnBoardMetaData = { - id: columnBoardTarget.id, - columnBoardId: columnBoardTarget.columnBoardId, - title: columnBoardTarget.title, - createdAt: columnBoardTarget.createdAt, - updatedAt: columnBoardTarget.updatedAt, - published: columnBoardTarget.published, + id: columnBoardNode.id, + columnBoardId: columnBoardNode.id, + title: columnBoardNode.title || '', + createdAt: columnBoardNode.createdAt, + updatedAt: columnBoardNode.updatedAt, + published: columnBoardNode.isVisible, }; return { type, content }; } private buildDTOWithElements(elements: RoomBoardElementDTO[]): RoomBoardDTO { - const dto = { + const dto: RoomBoardDTO = { roomId: this.room.id, displayColor: this.room.color, title: this.room.name, elements, isArchived: this.room.isFinished(), + isSynchronized: !!this.room.syncedWithGroup, }; return dto; } @@ -189,7 +190,7 @@ export class RoomBoardDTOFactory { private readonly roomsAuthorisationService: RoomsAuthorisationService ) {} - createDTO({ room, board, user }: { room: Course; board: Board; user: User }): RoomBoardDTO { + createDTO({ room, board, user }: { room: Course; board: LegacyBoard; user: User }): RoomBoardDTO { const worker = new DtoCreator({ room, board, diff --git a/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts b/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts index 69faa5c8fd7..0d3fee07c6b 100644 --- a/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts +++ b/apps/server/src/modules/learnroom/uc/rooms.uc.spec.ts @@ -1,9 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardRepo, CourseRepo, TaskRepo, UserRepo } from '@shared/repo'; +import { CourseRepo, LegacyBoardRepo, TaskRepo, UserRepo } from '@shared/repo'; import { boardFactory, courseFactory, lessonFactory, setupEntities, taskFactory, userFactory } from '@shared/testing'; import { RoomsService } from '../service/rooms.service'; +import { RoomBoardDTO } from '../types'; import { RoomBoardDTOFactory } from './room-board-dto.factory'; import { RoomsAuthorisationService } from './rooms.authorisation.service'; import { RoomsUc } from './rooms.uc'; @@ -13,7 +14,7 @@ describe('rooms usecase', () => { let courseRepo: DeepMocked; let taskRepo: DeepMocked; let userRepo: DeepMocked; - let boardRepo: DeepMocked; + let legacyBoardRepo: DeepMocked; let factory: DeepMocked; let authorisation: DeepMocked; let roomsService: DeepMocked; @@ -41,8 +42,8 @@ describe('rooms usecase', () => { useValue: createMock(), }, { - provide: BoardRepo, - useValue: createMock(), + provide: LegacyBoardRepo, + useValue: createMock(), }, { provide: RoomBoardDTOFactory, @@ -63,7 +64,7 @@ describe('rooms usecase', () => { courseRepo = module.get(CourseRepo); taskRepo = module.get(TaskRepo); userRepo = module.get(UserRepo); - boardRepo = module.get(BoardRepo); + legacyBoardRepo = module.get(LegacyBoardRepo); factory = module.get(RoomBoardDTOFactory); authorisation = module.get(RoomsAuthorisationService); roomsService = module.get(RoomsService); @@ -77,24 +78,25 @@ describe('rooms usecase', () => { const tasks = taskFactory.buildList(3, { course: room }); const lessons = lessonFactory.buildList(3, { course: room }); const board = boardFactory.buildWithId({ course: room }); - const roomBoardDTO = { + const roomBoardDTO: RoomBoardDTO = { roomId: room.id, displayColor: room.color, title: room.name, elements: [], isArchived: room.isFinished(), + isSynchronized: !!room.syncedWithGroup, }; board.syncBoardElementReferences([...lessons, ...tasks]); const userSpy = userRepo.findById.mockResolvedValue(user); const roomSpy = courseRepo.findOne.mockResolvedValue(room); - const boardSpy = boardRepo.findByCourseId.mockResolvedValue(board); + const boardSpy = legacyBoardRepo.findByCourseId.mockResolvedValue(board); const tasksSpy = taskRepo.findBySingleParent.mockResolvedValue([tasks, 3]); const syncBoardElementReferencesSpy = jest.spyOn(board, 'syncBoardElementReferences'); const mapperSpy = factory.createDTO.mockReturnValue(roomBoardDTO); - const saveSpy = boardRepo.save.mockResolvedValue(); - roomsService.updateBoard.mockResolvedValue(board); + const saveSpy = legacyBoardRepo.save.mockResolvedValue(); + roomsService.updateLegacyBoard.mockResolvedValue(board); return { user, @@ -146,7 +148,7 @@ describe('rooms usecase', () => { it('should ensure course has uptodate board', async () => { const { board, room, user } = setup(); await uc.getBoard(room.id, user.id); - expect(roomsService.updateBoard).toHaveBeenCalledWith(board, room.id, user.id); + expect(roomsService.updateLegacyBoard).toHaveBeenCalledWith(board, room.id, user.id); }); }); @@ -160,16 +162,16 @@ describe('rooms usecase', () => { board.syncBoardElementReferences([hiddenTask, visibleTask]); const userSpy = userRepo.findById.mockResolvedValue(user); const roomSpy = courseRepo.findOne.mockResolvedValue(room); - const boardSpy = boardRepo.findByCourseId.mockResolvedValue(board); + const boardSpy = legacyBoardRepo.findByCourseId.mockResolvedValue(board); const authorisationSpy = authorisation.hasCourseWritePermission.mockReturnValue(shouldAuthorize); - const saveSpy = boardRepo.save.mockResolvedValue(); + const saveSpy = legacyBoardRepo.save.mockResolvedValue(); return { user, room, hiddenTask, visibleTask, board, userSpy, roomSpy, boardSpy, authorisationSpy, saveSpy }; }; describe('when user does not have write permission on course', () => { it('should throw forbidden', async () => { const { user, room, hiddenTask } = setup(false); - const call = () => uc.updateVisibilityOfBoardElement(room.id, hiddenTask.id, user.id, true); + const call = () => uc.updateVisibilityOfLegacyBoardElement(room.id, hiddenTask.id, user.id, true); await expect(call).rejects.toThrow(ForbiddenException); }); }); @@ -177,7 +179,7 @@ describe('rooms usecase', () => { describe('when visibility is true', () => { it('should publish element', async () => { const { user, room, hiddenTask } = setup(true); - await uc.updateVisibilityOfBoardElement(room.id, hiddenTask.id, user.id, true); + await uc.updateVisibilityOfLegacyBoardElement(room.id, hiddenTask.id, user.id, true); expect(hiddenTask.isDraft()).toEqual(false); }); }); @@ -185,32 +187,32 @@ describe('rooms usecase', () => { describe('when visibility is false', () => { it('should unpublish element', async () => { const { user, room, visibleTask } = setup(true); - await uc.updateVisibilityOfBoardElement(room.id, visibleTask.id, user.id, false); + await uc.updateVisibilityOfLegacyBoardElement(room.id, visibleTask.id, user.id, false); expect(visibleTask.isDraft()).toEqual(true); }); }); it('should fetch user', async () => { const { user, room, hiddenTask, userSpy } = setup(true); - await uc.updateVisibilityOfBoardElement(room.id, hiddenTask.id, user.id, true); + await uc.updateVisibilityOfLegacyBoardElement(room.id, hiddenTask.id, user.id, true); expect(userSpy).toHaveBeenCalledWith(user.id); }); it('should fetch course with userid', async () => { const { user, room, hiddenTask, roomSpy } = setup(true); - await uc.updateVisibilityOfBoardElement(room.id, hiddenTask.id, user.id, true); + await uc.updateVisibilityOfLegacyBoardElement(room.id, hiddenTask.id, user.id, true); expect(roomSpy).toHaveBeenCalledWith(room.id, user.id); }); it('should fetch course with userid', async () => { const { user, room, hiddenTask, roomSpy } = setup(true); - await uc.updateVisibilityOfBoardElement(room.id, hiddenTask.id, user.id, true); + await uc.updateVisibilityOfLegacyBoardElement(room.id, hiddenTask.id, user.id, true); expect(roomSpy).toHaveBeenCalledWith(room.id, user.id); }); it('should persist board after changes', async () => { const { user, room, hiddenTask, board, saveSpy } = setup(true); - await uc.updateVisibilityOfBoardElement(room.id, hiddenTask.id, user.id, true); + await uc.updateVisibilityOfLegacyBoardElement(room.id, hiddenTask.id, user.id, true); expect(saveSpy).toHaveBeenCalledWith(board); }); }); @@ -225,9 +227,9 @@ describe('rooms usecase', () => { const reorderSpy = jest.spyOn(board, 'reorderElements'); const userSpy = userRepo.findById.mockResolvedValue(user); const roomSpy = courseRepo.findOne.mockResolvedValue(room); - const boardSpy = boardRepo.findByCourseId.mockResolvedValue(board); + const boardSpy = legacyBoardRepo.findByCourseId.mockResolvedValue(board); const authorisationSpy = authorisation.hasCourseWritePermission.mockReturnValue(shouldAuthorize); - const saveSpy = boardRepo.save.mockResolvedValue(); + const saveSpy = legacyBoardRepo.save.mockResolvedValue(); return { user, room, tasks, board, reorderSpy, userSpy, roomSpy, boardSpy, authorisationSpy, saveSpy }; }; diff --git a/apps/server/src/modules/learnroom/uc/rooms.uc.ts b/apps/server/src/modules/learnroom/uc/rooms.uc.ts index cc513f3b6f7..a9710c128f6 100644 --- a/apps/server/src/modules/learnroom/uc/rooms.uc.ts +++ b/apps/server/src/modules/learnroom/uc/rooms.uc.ts @@ -1,6 +1,6 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; -import { BoardRepo, CourseRepo, UserRepo } from '@shared/repo'; +import { LegacyBoardRepo, CourseRepo, UserRepo } from '@shared/repo'; import { RoomsService } from '../service/rooms.service'; import { RoomBoardDTO } from '../types'; import { RoomBoardDTOFactory } from './room-board-dto.factory'; @@ -11,7 +11,7 @@ export class RoomsUc { constructor( private readonly courseRepo: CourseRepo, private readonly userRepo: UserRepo, - private readonly boardRepo: BoardRepo, + private readonly legacyBoardRepo: LegacyBoardRepo, private readonly factory: RoomBoardDTOFactory, private readonly authorisationService: RoomsAuthorisationService, private readonly roomsService: RoomsService @@ -19,16 +19,18 @@ export class RoomsUc { async getBoard(roomId: EntityId, userId: EntityId): Promise { const user = await this.userRepo.findById(userId, true); + // TODO no authorisation check here? const course = await this.courseRepo.findOne(roomId, userId); - const board = await this.boardRepo.findByCourseId(roomId); + const legacyBoard = await this.legacyBoardRepo.findByCourseId(roomId); - await this.roomsService.updateBoard(board, roomId, userId); + // TODO this must be rewritten. Board auto-creation must be treated separately + await this.roomsService.updateLegacyBoard(legacyBoard, roomId, userId); - const roomBoardDTO = this.factory.createDTO({ room: course, board, user }); + const roomBoardDTO = this.factory.createDTO({ room: course, board: legacyBoard, user }); return roomBoardDTO; } - async updateVisibilityOfBoardElement( + async updateVisibilityOfLegacyBoardElement( roomId: EntityId, elementId: EntityId, userId: EntityId, @@ -39,24 +41,37 @@ export class RoomsUc { if (!this.authorisationService.hasCourseWritePermission(user, course)) { throw new ForbiddenException('you are not allowed to edit this'); } - const board = await this.boardRepo.findByCourseId(course.id); - const element = board.getByTargetId(elementId); + const legacyBoard = await this.legacyBoardRepo.findByCourseId(course.id); + const element = legacyBoard.getByTargetId(elementId); if (visibility) { element.publish(); } else { element.unpublish(); } - await this.boardRepo.save(board); + + await this.legacyBoardRepo.save(legacyBoard); + // TODO if the element is a columnboard, then the visibility must be in sync with it + // TODO call columnBoard service to update the visibility of the columnboard, based on reference + + // if (element instanceof ColumnboardBoardElement) { + // await this.updateColumnBoardVisibility(element.target._columnBoardId, visibility); + // } } + /* + private async updateColumnBoardVisibility(columbBoardId: EntityId, visibility: boolean) { + // TODO + // await this.columnBoardService.updateBoardVisibility(columbBoardId, visibility); + } +*/ async reorderBoardElements(roomId: EntityId, userId: EntityId, orderedList: EntityId[]): Promise { const user = await this.userRepo.findById(userId); const course = await this.courseRepo.findOne(roomId, userId); if (!this.authorisationService.hasCourseWritePermission(user, course)) { throw new ForbiddenException('you are not allowed to edit this'); } - const board = await this.boardRepo.findByCourseId(course.id); - board.reorderElements(orderedList); - await this.boardRepo.save(board); + const legacyBoard = await this.legacyBoardRepo.findByCourseId(course.id); + legacyBoard.reorderElements(orderedList); + await this.legacyBoardRepo.save(legacyBoard); } } diff --git a/apps/server/src/modules/learnroom/utils/common-cartridge-file-validator.pipe.spec.ts b/apps/server/src/modules/learnroom/utils/common-cartridge-file-validator.pipe.spec.ts new file mode 100644 index 00000000000..94b9ba79bff --- /dev/null +++ b/apps/server/src/modules/learnroom/utils/common-cartridge-file-validator.pipe.spec.ts @@ -0,0 +1,120 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import AdmZip from 'adm-zip'; +import { readFile } from 'node:fs/promises'; +import { CommonCartridgeFileValidatorPipe } from './common-cartridge-file-validator.pipe'; + +describe('CommonCartridgeFileValidatorPipe', () => { + let module: TestingModule; + let sut: CommonCartridgeFileValidatorPipe; + let configServiceMock: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + CommonCartridgeFileValidatorPipe, + { + provide: ConfigService, + useValue: createMock(), + }, + ], + }).compile(); + sut = module.get(CommonCartridgeFileValidatorPipe); + configServiceMock = module.get(ConfigService); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('transform', () => { + describe('when no file is provided', () => { + const setup = () => { + return { file: undefined as unknown as Express.Multer.File }; + }; + + it('should throw', () => { + const { file } = setup(); + + expect(() => sut.transform(file)).toThrow('No file uploaded'); + }); + }); + + describe('when the file is too big', () => { + const setup = () => { + configServiceMock.getOrThrow.mockReturnValue(1000); + + return { file: { size: 1001 } as unknown as Express.Multer.File }; + }; + + it('should throw', () => { + const { file } = setup(); + + expect(() => sut.transform(file)).toThrow('File is too large'); + }); + }); + + describe('when the file is not a zip archive', () => { + const setup = () => { + configServiceMock.getOrThrow.mockReturnValue(1000); + + return { + file: { size: 1000, buffer: Buffer.from('') } as unknown as Express.Multer.File, + }; + }; + + it('should throw', () => { + const { file } = setup(); + + expect(() => sut.transform(file)).toThrow('Invalid or unsupported zip format. No END header found'); + }); + }); + + describe('when the file does not contain a manifest file', () => { + const setup = () => { + const buffer = new AdmZip().toBuffer(); + + configServiceMock.get.mockReturnValue(1000); + + return { + file: { size: 1000, buffer } as unknown as Express.Multer.File, + }; + }; + + it('should throw', () => { + const { file } = setup(); + + expect(() => sut.transform(file)).toThrow('No manifest file found in the archive'); + }); + }); + + describe('when the file is valid', () => { + const setup = async () => { + const buffer = await readFile( + './apps/server/src/modules/common-cartridge/testing/assets/us_history_since_1877.imscc' + ); + + configServiceMock.getOrThrow.mockReturnValue(1000); + + return { + file: { size: 1000, buffer } as unknown as Express.Multer.File, + }; + }; + + it('should return the file', async () => { + const { file } = await setup(); + + expect(sut.transform(file)).toBe(file); + }); + }); + }); +}); diff --git a/apps/server/src/modules/learnroom/utils/common-cartridge-file-validator.pipe.ts b/apps/server/src/modules/learnroom/utils/common-cartridge-file-validator.pipe.ts new file mode 100644 index 00000000000..09cbe069ccb --- /dev/null +++ b/apps/server/src/modules/learnroom/utils/common-cartridge-file-validator.pipe.ts @@ -0,0 +1,47 @@ +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import AdmZip from 'adm-zip'; +import { LearnroomConfig } from '../learnroom.config'; + +@Injectable() +export class CommonCartridgeFileValidatorPipe implements PipeTransform { + constructor(private readonly configService: ConfigService) {} + + public transform(value: Express.Multer.File): Express.Multer.File { + this.checkValue(value); + this.checkSize(value); + this.checkFileType(value); + this.checkForManifestFile(new AdmZip(value.buffer)); + + return value; + } + + private checkValue(value: Express.Multer.File): void { + if (!value) { + throw new BadRequestException('No file uploaded'); + } + } + + private checkSize(value: Express.Multer.File): void { + if (value.size > this.configService.getOrThrow('FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE')) { + throw new BadRequestException('File is too large'); + } + } + + private checkFileType(value: Express.Multer.File): void { + try { + // checks if the file is a valid zip file + // eslint-disable-next-line no-new + new AdmZip(value.buffer); + } catch (error) { + throw new BadRequestException(error); + } + } + + private checkForManifestFile(archive: AdmZip): void { + const manifest = archive.getEntry('imsmanifest.xml') || archive.getEntry('manifest.xml'); + if (!manifest) { + throw new BadRequestException('No manifest file found in the archive'); + } + } +} diff --git a/apps/server/src/modules/learnroom/utils/index.ts b/apps/server/src/modules/learnroom/utils/index.ts new file mode 100644 index 00000000000..b31e3aa0710 --- /dev/null +++ b/apps/server/src/modules/learnroom/utils/index.ts @@ -0,0 +1 @@ +export * from './common-cartridge-file-validator.pipe'; diff --git a/apps/server/src/modules/legacy-school/controller/admin-api-schools.controller.ts b/apps/server/src/modules/legacy-school/controller/admin-api-schools.controller.ts new file mode 100644 index 00000000000..c8baef308b5 --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/admin-api-schools.controller.ts @@ -0,0 +1,25 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AdminApiSchoolUc } from '../uc/admin-api-schools.uc'; +import { AdminApiSchoolMapper } from './admin-api-schools.mapper'; +import { AdminApiSchoolCreateBodyParams } from './dto/request/admin-api-school-create.body.params'; +import { AdminApiSchoolCreateResponseDto } from './dto/response/admin-api-school-create.response.dto'; + +@ApiTags('AdminSchool') +@UseGuards(AuthGuard('api-key')) +@Controller('admin/schools') +export class AdminApiSchoolsController { + constructor(private readonly uc: AdminApiSchoolUc) {} + + @Post('') + @ApiOperation({ + summary: 'create an empty school', + }) + async createSchool(@Body() body: AdminApiSchoolCreateBodyParams): Promise { + const school = await this.uc.createSchool(body); + const mapped = AdminApiSchoolMapper.mapSchoolDoToSchoolCreatedResponse(school); + + return Promise.resolve(new AdminApiSchoolCreateResponseDto(mapped)); + } +} diff --git a/apps/server/src/modules/legacy-school/controller/admin-api-schools.mapper.ts b/apps/server/src/modules/legacy-school/controller/admin-api-schools.mapper.ts new file mode 100644 index 00000000000..0f3fe699b4a --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/admin-api-schools.mapper.ts @@ -0,0 +1,16 @@ +import { LegacySchoolDo } from '@shared/domain/domainobject'; +import { AdminApiSchoolCreateNoIdErrorLoggable } from '../loggable/admin-api-school-create-no-id-error.loggable'; +import { AdminApiSchoolCreateResponseDto } from './dto/response/admin-api-school-create.response.dto'; + +export class AdminApiSchoolMapper { + static mapSchoolDoToSchoolCreatedResponse(school: LegacySchoolDo) { + if (school.id === undefined) { + /* istanbul ignore next */ + throw new AdminApiSchoolCreateNoIdErrorLoggable(); + } + + const dto = new AdminApiSchoolCreateResponseDto({ id: school.id, name: school.name }); + + return dto; + } +} diff --git a/apps/server/src/modules/legacy-school/controller/api-test/school.administration.api.spec.ts b/apps/server/src/modules/legacy-school/controller/api-test/school.administration.api.spec.ts new file mode 100644 index 00000000000..0734be043bb --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/api-test/school.administration.api.spec.ts @@ -0,0 +1,72 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SchoolEntity } from '@shared/domain/entity'; +import { TestApiClient, TestXApiKeyClient, federalStateFactory } from '@shared/testing'; +import { AdminApiServerTestModule } from '@src/modules/server/admin-api.server.module'; +import { AdminApiSchoolCreateResponseDto } from '../dto/response/admin-api-school-create.response.dto'; + +const baseRouteName = '/admin/schools'; + +describe('Admin API - Schools (API)', () => { + let app: INestApplication; + let testXApiKeyClient: TestXApiKeyClient; + let testApiClient: TestApiClient; + let em: EntityManager; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [AdminApiServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testXApiKeyClient = new TestXApiKeyClient(app, baseRouteName); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('create a school', () => { + describe('without token', () => { + it('should refuse with wrong token', async () => { + const client = new TestXApiKeyClient(app, baseRouteName, 'thisisaninvalidapikey'); + const response = await client.post(''); + expect(response.status).toEqual(401); + }); + it('should refuse without token', async () => { + const response = await testApiClient.post(''); + expect(response.status).toEqual(401); + }); + }); + + describe('with api token', () => { + const setup = async () => { + const federalState = federalStateFactory.build({ name: 'niedersachsen' }); + await em.persistAndFlush(federalState); + return { federalState }; + }; + + it('should return school', async () => { + const { federalState } = await setup(); + const response = await testXApiKeyClient.post('', { name: 'schoolname', federalStateName: federalState.name }); + expect(response.status).toEqual(201); + const result = response.body as AdminApiSchoolCreateResponseDto; + expect(result.id).toBeDefined(); + }); + + it('should have persisted the school', async () => { + const { federalState } = await setup(); + + const response = await testXApiKeyClient.post('', { name: 'schoolname', federalStateName: federalState.name }); + + const { id } = response.body as AdminApiSchoolCreateResponseDto; + const loaded = await em.findOneOrFail(SchoolEntity, id); + expect(loaded).toBeDefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/legacy-school/controller/api-test/school.api.spec.ts b/apps/server/src/modules/legacy-school/controller/api-test/school.api.spec.ts index c023b94aa0f..74aaa0ef8b9 100644 --- a/apps/server/src/modules/legacy-school/controller/api-test/school.api.spec.ts +++ b/apps/server/src/modules/legacy-school/controller/api-test/school.api.spec.ts @@ -5,12 +5,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { - schoolFactory, schoolSystemOptionsEntityFactory, systemEntityFactory, TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; +import { schoolEntityFactory } from '@shared/testing/factory/school-entity.factory'; import { SchoolSystemOptionsEntity } from '../../entity'; import { SchulConneXProvisioningOptionsResponse } from '../dto'; @@ -42,7 +42,7 @@ describe('School (API)', () => { const system: SystemEntity = systemEntityFactory.buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS, }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [system], }); const schoolSystemOptions: SchoolSystemOptionsEntity = schoolSystemOptionsEntityFactory.buildWithId({ @@ -87,7 +87,7 @@ describe('School (API)', () => { const system: SystemEntity = systemEntityFactory.buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS, }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [system], }); const schoolSystemOptions: SchoolSystemOptionsEntity = schoolSystemOptionsEntityFactory.buildWithId({ @@ -131,7 +131,7 @@ describe('School (API)', () => { const system: SystemEntity = systemEntityFactory.buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS, }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [system], }); const schoolSystemOptions: SchoolSystemOptionsEntity = schoolSystemOptionsEntityFactory.buildWithId({ @@ -201,7 +201,7 @@ describe('School (API)', () => { const system: SystemEntity = systemEntityFactory.buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS, }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [system], }); diff --git a/apps/server/src/modules/legacy-school/controller/dto/request/admin-api-school-create.body.params.ts b/apps/server/src/modules/legacy-school/controller/dto/request/admin-api-school-create.body.params.ts new file mode 100644 index 00000000000..c2bdf93f865 --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/dto/request/admin-api-school-create.body.params.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class AdminApiSchoolCreateBodyParams { + @IsString() + name!: string; + + @IsString() + federalStateName!: string; +} diff --git a/apps/server/src/modules/legacy-school/controller/dto/response/admin-api-school-create.response.dto.ts b/apps/server/src/modules/legacy-school/controller/dto/response/admin-api-school-create.response.dto.ts new file mode 100644 index 00000000000..74454dbee09 --- /dev/null +++ b/apps/server/src/modules/legacy-school/controller/dto/response/admin-api-school-create.response.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AdminApiSchoolCreateResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + constructor(props: AdminApiSchoolCreateResponseDto) { + this.id = props.id; + this.name = props.name; + } +} diff --git a/apps/server/src/modules/legacy-school/legacy-school-admin.api-module.ts b/apps/server/src/modules/legacy-school/legacy-school-admin.api-module.ts new file mode 100644 index 00000000000..02c8cd071ec --- /dev/null +++ b/apps/server/src/modules/legacy-school/legacy-school-admin.api-module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AdminApiSchoolsController } from './controller/admin-api-schools.controller'; +import { LegacySchoolModule } from './legacy-school.module'; +import { AdminApiSchoolUc } from './uc/admin-api-schools.uc'; + +@Module({ + imports: [LegacySchoolModule], + controllers: [AdminApiSchoolsController], + providers: [AdminApiSchoolUc], +}) +export class LegacySchoolAdminApiModule {} diff --git a/apps/server/src/modules/legacy-school/legacy-school.module.ts b/apps/server/src/modules/legacy-school/legacy-school.module.ts index 92d028044d2..52f2d15cb43 100644 --- a/apps/server/src/modules/legacy-school/legacy-school.module.ts +++ b/apps/server/src/modules/legacy-school/legacy-school.module.ts @@ -1,5 +1,5 @@ import { GroupModule } from '@modules/group'; -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { FederalStateRepo, LegacySchoolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { SchoolSystemOptionsRepo, SchoolYearRepo } from './repo'; @@ -17,7 +17,7 @@ import { * @deprecated because it uses the deprecated LegacySchoolDo. */ @Module({ - imports: [LoggerModule, GroupModule], + imports: [LoggerModule, forwardRef(() => GroupModule)], providers: [ LegacySchoolRepo, LegacySchoolService, diff --git a/apps/server/src/modules/legacy-school/loggable/admin-api-school-create-no-id-error.loggable.ts b/apps/server/src/modules/legacy-school/loggable/admin-api-school-create-no-id-error.loggable.ts new file mode 100644 index 00000000000..f8227a205d7 --- /dev/null +++ b/apps/server/src/modules/legacy-school/loggable/admin-api-school-create-no-id-error.loggable.ts @@ -0,0 +1,14 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class AdminApiSchoolCreateNoIdErrorLoggable extends InternalServerErrorException implements Loggable { + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + /* istanbul ignore next */ + return { + type: 'ADMIN_API_CREATED_SCHOOL_HAS_NO_ID', + message: + 'A newly created school has been returned without an id. This should never happen, since an id is assigned when an entity is created. Check if created schools are always persisted.', + stack: this.stack, + }; + } +} diff --git a/apps/server/src/modules/legacy-school/repo/school-system-options.repo.spec.ts b/apps/server/src/modules/legacy-school/repo/school-system-options.repo.spec.ts index 602448b022d..22116d1c056 100644 --- a/apps/server/src/modules/legacy-school/repo/school-system-options.repo.spec.ts +++ b/apps/server/src/modules/legacy-school/repo/school-system-options.repo.spec.ts @@ -4,7 +4,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { - schoolFactory, + schoolEntityFactory, schoolSystemOptionsEntityFactory, schoolSystemOptionsFactory, systemEntityFactory, @@ -123,7 +123,7 @@ describe(SchoolSystemOptionsRepo.name, () => { const systemEntity: SystemEntity = systemEntityFactory.buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS, }); - const schoolEntity: SchoolEntity = schoolFactory.buildWithId({ systems: [systemEntity] }); + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity] }); const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ systemId: systemEntity.id, @@ -169,7 +169,7 @@ describe(SchoolSystemOptionsRepo.name, () => { const systemEntity: SystemEntity = systemEntityFactory.buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS, }); - const schoolEntity: SchoolEntity = schoolFactory.buildWithId({ systems: [systemEntity] }); + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity] }); const schoolSystemOptionsEntity: SchoolSystemOptionsEntity = schoolSystemOptionsEntityFactory.buildWithId({ school: schoolEntity, system: systemEntity, @@ -238,7 +238,7 @@ describe(SchoolSystemOptionsRepo.name, () => { const systemEntity: SystemEntity = systemEntityFactory.buildWithId({ provisioningStrategy: undefined, }); - const schoolEntity: SchoolEntity = schoolFactory.buildWithId({ systems: [systemEntity] }); + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [systemEntity] }); const schoolSystemOptions: SchoolSystemOptions = schoolSystemOptionsFactory.build({ systemId: systemEntity.id, diff --git a/apps/server/src/modules/legacy-school/service/legacy-school.service.spec.ts b/apps/server/src/modules/legacy-school/service/legacy-school.service.spec.ts index c3f51ecfa9d..c51a405eb52 100644 --- a/apps/server/src/modules/legacy-school/service/legacy-school.service.spec.ts +++ b/apps/server/src/modules/legacy-school/service/legacy-school.service.spec.ts @@ -3,7 +3,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo } from '@shared/domain/domainobject'; import { SchoolFeature } from '@shared/domain/types'; import { LegacySchoolRepo } from '@shared/repo'; -import { legacySchoolDoFactory, setupEntities } from '@shared/testing'; +import { federalStateFactory, legacySchoolDoFactory, setupEntities } from '@shared/testing'; +import { FederalStateService } from './federal-state.service'; import { LegacySchoolService } from './legacy-school.service'; import { SchoolValidationService } from './validation/school-validation.service'; @@ -13,6 +14,7 @@ describe('LegacySchoolService', () => { let schoolRepo: DeepMocked; let schoolValidationService: DeepMocked; + let federalStateService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -26,12 +28,17 @@ describe('LegacySchoolService', () => { provide: SchoolValidationService, useValue: createMock(), }, + { + provide: FederalStateService, + useValue: createMock(), + }, ], }).compile(); schoolRepo = module.get(LegacySchoolRepo); schoolService = module.get(LegacySchoolService); schoolValidationService = module.get(SchoolValidationService); + federalStateService = module.get(FederalStateService); await setupEntities(); }); @@ -344,4 +351,27 @@ describe('LegacySchoolService', () => { }); }); }); + + describe('create school', () => { + it('should return school', async () => { + const name = 'Hogwarts'; + const federalStateName = 'maybescottland?'; + const federalState = federalStateFactory.build({ name: federalStateName }); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + const school = await schoolService.createSchool({ name, federalStateName }); + expect(school.name).toEqual(name); + expect(school.federalState).toEqual(federalState); + }); + + it('should persist school', async () => { + const name = 'Hogwarts'; + const federalStateName = 'maybescottland?'; + const federalState = federalStateFactory.build({ name: federalStateName }); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + const school = await schoolService.createSchool({ name, federalStateName }); + expect(schoolRepo.save).toHaveBeenCalledWith(school); + }); + }); }); diff --git a/apps/server/src/modules/legacy-school/service/legacy-school.service.ts b/apps/server/src/modules/legacy-school/service/legacy-school.service.ts index cdbd2a1ea6c..db39ba7159b 100644 --- a/apps/server/src/modules/legacy-school/service/legacy-school.service.ts +++ b/apps/server/src/modules/legacy-school/service/legacy-school.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { LegacySchoolDo } from '@shared/domain/domainobject'; import { EntityId, SchoolFeature } from '@shared/domain/types'; import { LegacySchoolRepo } from '@shared/repo'; +import { FederalStateService } from './federal-state.service'; import { SchoolValidationService } from './validation'; /** @@ -11,7 +12,8 @@ import { SchoolValidationService } from './validation'; export class LegacySchoolService { constructor( private readonly schoolRepo: LegacySchoolRepo, - private readonly schoolValidationService: SchoolValidationService + private readonly schoolValidationService: SchoolValidationService, + private readonly federalStateService: FederalStateService ) {} async hasFeature(schoolId: EntityId, feature: SchoolFeature): Promise { @@ -54,4 +56,11 @@ export class LegacySchoolService { return ret; } + + async createSchool(props: { name: string; federalStateName: string }): Promise { + const federalState = await this.federalStateService.findFederalStateByName(props.federalStateName); + const school = new LegacySchoolDo({ name: props.name, federalState }); + await this.schoolRepo.save(school); + return school; + } } diff --git a/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts index f6494fdd951..580e38baffe 100644 --- a/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts +++ b/apps/server/src/modules/legacy-school/service/schulconnex-provisioning-options-update.service.ts @@ -1,5 +1,5 @@ import { Group, GroupService, GroupTypes } from '@modules/group'; -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; import { SchulConneXProvisioningOptions } from '../domain'; import { ProvisioningOptionsUpdateHandler } from './provisioning-options-update-handler'; @@ -8,7 +8,7 @@ import { ProvisioningOptionsUpdateHandler } from './provisioning-options-update- export class SchulconnexProvisioningOptionsUpdateService implements ProvisioningOptionsUpdateHandler { - constructor(private readonly groupService: GroupService) {} + constructor(@Inject(forwardRef(() => GroupService)) private readonly groupService: GroupService) {} public async handleUpdate( schoolId: EntityId, diff --git a/apps/server/src/modules/legacy-school/uc/admin-api-schools.uc.ts b/apps/server/src/modules/legacy-school/uc/admin-api-schools.uc.ts new file mode 100644 index 00000000000..f5cff895fe9 --- /dev/null +++ b/apps/server/src/modules/legacy-school/uc/admin-api-schools.uc.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { LegacySchoolDo } from '@shared/domain/domainobject'; +import { LegacySchoolService } from '../service'; + +@Injectable() +export class AdminApiSchoolUc { + constructor(private readonly schoolService: LegacySchoolService) {} + + public async createSchool(props: { name: string; federalStateName: string }): Promise { + const school = await this.schoolService.createSchool(props); + return school; + } +} diff --git a/apps/server/src/modules/lesson/controller/api-test/lesson-delete.api.spec.ts b/apps/server/src/modules/lesson/controller/api-test/lesson-delete.api.spec.ts index 01480d90a62..0dc0a82d606 100644 --- a/apps/server/src/modules/lesson/controller/api-test/lesson-delete.api.spec.ts +++ b/apps/server/src/modules/lesson/controller/api-test/lesson-delete.api.spec.ts @@ -12,7 +12,7 @@ import { courseGroupFactory, lessonFactory, } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; describe('Lesson Controller (API) - delete', () => { let app: INestApplication; diff --git a/apps/server/src/modules/lesson/controller/api-test/lesson-get.api.spec.ts b/apps/server/src/modules/lesson/controller/api-test/lesson-get.api.spec.ts new file mode 100644 index 00000000000..632a2e6fc65 --- /dev/null +++ b/apps/server/src/modules/lesson/controller/api-test/lesson-get.api.spec.ts @@ -0,0 +1,325 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + TestApiClient, + UserAndAccountTestFactory, + courseFactory, + courseGroupFactory, + lessonFactory, + materialFactory, +} from '@shared/testing'; +import { + ComponentEtherpadProperties, + ComponentGeogebraProperties, + ComponentInternalProperties, + ComponentLernstoreProperties, + ComponentNexboardProperties, + ComponentProperties, + ComponentTextProperties, + ComponentType, +} from '@shared/domain/entity'; +import { LessonResponse } from '../dto'; + +describe('Lesson Controller (API) - GET /lessons/:lessonId', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, '/lessons'); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('when user is a valid teacher', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const course = courseFactory.buildWithId({ teachers: [teacherUser] }); + const material = materialFactory.buildWithId(); + const lesson = lessonFactory.build({ course, materials: [material] }); + const hiddenLesson = lessonFactory.build({ course, hidden: true }); + await em.persistAndFlush([teacherAccount, teacherUser, course, material, lesson, hiddenLesson]); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, lesson, hiddenLesson }; + }; + it('should return the lesson', async () => { + const { loggedInClient, lesson } = await setup(); + const response = await loggedInClient.get(`${lesson.id}`); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonResponse; + expect(body._id).toEqual(lesson.id); + expect(body.name).toEqual(lesson.name); + }); + it('should return a hidden lessons', async () => { + const { loggedInClient, hiddenLesson } = await setup(); + const response = await loggedInClient.get(hiddenLesson.id); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonResponse; + expect(body._id).toEqual(hiddenLesson.id); + expect(body.name).toEqual(hiddenLesson.name); + }); + it('should return lesson with materials', async () => { + const { loggedInClient, lesson } = await setup(); + const response = await loggedInClient.get(`${lesson.id}`); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonResponse; + expect(body.materials).toHaveLength(1); + expect(body.materials[0]._id).toEqual(lesson.materials[0].id); + }); + }); + + describe('when lesson has contents', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const course = courseFactory.buildWithId({ teachers: [teacherUser] }); + + const userId = new ObjectId().toHexString(); + const contents = { title: 'title', hidden: false, user: userId }; + + const contentText: ComponentTextProperties = { text: 'text (ck4) content' }; + const lessonWithText = lessonFactory.build({ + course, + contents: [{ ...contents, component: ComponentType.TEXT, content: contentText }], + }); + + const contentGeogebra: ComponentGeogebraProperties = { materialId: 'geogebraId' }; + const lessonWithGeogebra = lessonFactory.build({ + course, + contents: [{ ...contents, component: ComponentType.GEOGEBRA, content: contentGeogebra }], + }); + + const contentEtherpad: ComponentEtherpadProperties = { + title: 'etherpad', + description: 'etherpad description', + url: 'etherpadUrl', + }; + const lessonWithEtherpad = lessonFactory.build({ + course, + contents: [{ ...contents, component: ComponentType.ETHERPAD, content: contentEtherpad }], + }); + + const contentInternal: ComponentInternalProperties = { url: 'internalUrl' }; + const lessonWithInternal = lessonFactory.build({ + course, + contents: [{ ...contents, component: ComponentType.INTERNAL, content: contentInternal }], + }); + + const contentLernstore: ComponentLernstoreProperties = { + resources: [ + { + client: 'client', + description: 'lernstore description', + title: 'lernstore title', + url: 'lernstoreUrl', + }, + ], + }; + const lessonWithLernstore = lessonFactory.build({ + course, + contents: [{ ...contents, component: ComponentType.LERNSTORE, content: contentLernstore }], + }); + + const contentNexboard: ComponentNexboardProperties = { + title: 'nexboard content', + url: 'nexboardUrl', + board: 'nexboard', + description: 'nexboard description', + }; + const lessonWithNexboard = lessonFactory.build({ + course, + contents: [{ ...contents, component: ComponentType.NEXBOARD, content: contentNexboard }], + }); + + await em.persistAndFlush([ + teacherAccount, + teacherUser, + course, + lessonWithText, + lessonWithGeogebra, + lessonWithEtherpad, + lessonWithInternal, + lessonWithLernstore, + lessonWithNexboard, + ]); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { + loggedInClient, + lessonWithText, + lessonWithGeogebra, + lessonWithEtherpad, + lessonWithInternal, + lessonWithLernstore, + lessonWithNexboard, + contentText, + contentGeogebra, + contentEtherpad, + contentInternal, + contentLernstore, + contentNexboard, + }; + }; + + it('should not return user Id', async () => { + const { loggedInClient, lessonWithText } = await setup(); + const response = await loggedInClient.get(`${lessonWithText.id}`); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonResponse; + expect((body.contents[0] as ComponentProperties).user).toBeUndefined(); + }); + + it('should return lesson with text contents', async () => { + const { loggedInClient, lessonWithText, contentText } = await setup(); + const response = await loggedInClient.get(`${lessonWithText.id}`); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonResponse; + const componentProps = body.contents[0] as ComponentProperties; + expect(componentProps.component).toEqual(ComponentType.TEXT); + expect(componentProps.content).toEqual(contentText); + }); + it('should return lesson with Geogebra contents', async () => { + const { loggedInClient, lessonWithGeogebra, contentGeogebra } = await setup(); + + const response = await loggedInClient.get(`${lessonWithGeogebra.id}`); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonResponse; + const componentProps = body.contents[0] as ComponentProperties; + expect(componentProps.component).toEqual(ComponentType.GEOGEBRA); + expect(componentProps.content).toEqual(contentGeogebra); + }); + it('should return lesson with Etherpad contents', async () => { + const { loggedInClient, lessonWithEtherpad, contentEtherpad } = await setup(); + + const response = await loggedInClient.get(`${lessonWithEtherpad.id}`); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonResponse; + const componentProps = body.contents[0] as ComponentProperties; + expect(componentProps.component).toEqual(ComponentType.ETHERPAD); + expect(componentProps.content).toEqual(contentEtherpad); + }); + it('should return lesson with Internal contents', async () => { + const { loggedInClient, lessonWithInternal, contentInternal } = await setup(); + + const response = await loggedInClient.get(`${lessonWithInternal.id}`); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonResponse; + const componentProps = body.contents[0] as ComponentProperties; + expect(componentProps.component).toEqual(ComponentType.INTERNAL); + expect(componentProps.content).toEqual(contentInternal); + }); + it('should return lesson with Lernstore contents', async () => { + const { loggedInClient, lessonWithLernstore, contentLernstore } = await setup(); + + const response = await loggedInClient.get(`${lessonWithLernstore.id}`); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonResponse; + const componentProps = body.contents[0] as ComponentProperties; + expect(componentProps.component).toEqual(ComponentType.LERNSTORE); + expect(componentProps.content).toEqual(contentLernstore); + }); + it('should return lesson with Nexboard contents', async () => { + const { loggedInClient, lessonWithNexboard, contentNexboard } = await setup(); + + const response = await loggedInClient.get(`${lessonWithNexboard.id}`); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonResponse; + const componentProps = body.contents[0] as ComponentProperties; + expect(componentProps.component).toEqual(ComponentType.NEXBOARD); + expect(componentProps.content).toEqual(contentNexboard); + }); + }); + + describe('when user is a valid student', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.buildWithId({ students: [studentUser] }); + const lesson = lessonFactory.build({ course }); + const hiddenLesson = lessonFactory.build({ course, hidden: true }); + + await em.persistAndFlush([studentAccount, studentUser, course, lesson, hiddenLesson]); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, lesson, hiddenLesson }; + }; + it('should return lesson', async () => { + const { loggedInClient, lesson } = await setup(); + const response = await loggedInClient.get(lesson.id); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonResponse; + + expect(body._id).toEqual(lesson.id); + expect(body.name).toEqual(lesson.name); + }); + it('should not return hidden lesson', async () => { + const { loggedInClient, hiddenLesson } = await setup(); + + const response = await loggedInClient.get(hiddenLesson.id); + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user is not authorized', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.buildWithId({ students: [] }); + const lesson = lessonFactory.build({ course }); + await em.persistAndFlush([studentAccount, studentUser, course, lesson]); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, course, lesson }; + }; + it('should return status 404', async () => { + const { loggedInClient, lesson } = await setup(); + const response = await loggedInClient.get(lesson.id); + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); + + describe('when lesson belongs to a courseGroup', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const course = courseFactory.buildWithId({ teachers: [teacherUser] }); + const courseGroup = courseGroupFactory.buildWithId({ course }); + const lesson = lessonFactory.build({ courseGroup }); + await em.persistAndFlush([teacherAccount, teacherUser, course, courseGroup, lesson]); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, lesson, courseGroup }; + }; + it('should return lesson with courseGroup id', async () => { + const { loggedInClient, lesson, courseGroup } = await setup(); + const response = await loggedInClient.get(lesson.id); + expect(response.status).toBe(HttpStatus.OK); + const body = response.body as LessonResponse; + expect(body.courseGroupId).toEqual(courseGroup.id); + }); + }); +}); diff --git a/apps/server/src/modules/lesson/controller/api-test/lesson-list.api.spec.ts b/apps/server/src/modules/lesson/controller/api-test/lesson-list.api.spec.ts new file mode 100644 index 00000000000..0caf82fe949 --- /dev/null +++ b/apps/server/src/modules/lesson/controller/api-test/lesson-list.api.spec.ts @@ -0,0 +1,115 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TestApiClient, UserAndAccountTestFactory, courseFactory, lessonFactory } from '@shared/testing'; +import { LessonMetadataListResponse } from '../dto'; + +describe('Lesson Controller (API) - GET list of lessons from course /lessons/course/:courseId', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testApiClient = new TestApiClient(app, '/lessons'); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('when user is a valid teacher', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const course = courseFactory.buildWithId({ teachers: [teacherUser] }); + const lesson = lessonFactory.build({ course }); + const hiddenLesson = lessonFactory.build({ course, hidden: true }); + await em.persistAndFlush([teacherAccount, teacherUser, course, lesson, hiddenLesson]); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, course, lesson, hiddenLesson }; + }; + + it('should return a list of all lessons', async () => { + const { loggedInClient, course, lesson } = await setup(); + const response = await loggedInClient.get(`/course/${course.id}`); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonMetadataListResponse; + expect(body.data.length).toEqual(2); + expect(body.data[0]._id).toEqual(lesson.id); + expect(body.data[0].name).toEqual(lesson.name); + }); + it('should return hidden lessons', async () => { + const { loggedInClient, course, hiddenLesson } = await setup(); + const response = await loggedInClient.get(`/course/${course.id}`); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonMetadataListResponse; + expect(body.data.length).toEqual(2); + expect(body.data[1]._id).toEqual(hiddenLesson.id); + expect(body.data[1].name).toEqual(hiddenLesson.name); + }); + }); + + describe('when user is a valid student', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.buildWithId({ students: [studentUser] }); + const lesson = lessonFactory.build({ course }); + const hiddenLesson = lessonFactory.build({ course, hidden: true }); + + await em.persistAndFlush([studentAccount, studentUser, course, lesson, hiddenLesson]); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, course, lesson, hiddenLesson }; + }; + it('should return a list of lessons', async () => { + const { loggedInClient, course, lesson } = await setup(); + const response = await loggedInClient.get(`/course/${course.id}`); + expect(response.status).toBe(HttpStatus.OK); + + const body = response.body as LessonMetadataListResponse; + + expect(body.data.length).toEqual(1); + expect(body.data[0]._id).toEqual(lesson.id); + expect(body.data[0].name).toEqual(lesson.name); + }); + it('should not return hidden lessons', async () => { + const { loggedInClient, course, hiddenLesson } = await setup(); + + const response = await loggedInClient.get(`/course/${course.id}`); + expect(response.status).toBe(HttpStatus.OK); + const body = response.body as LessonMetadataListResponse; + + expect(body.data.find((lesson) => lesson._id === hiddenLesson.id)).toBeUndefined(); + }); + }); + + describe('when user is not authorized', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + const course = courseFactory.buildWithId({ students: [] }); + const lesson = lessonFactory.build({ course }); + await em.persistAndFlush([studentAccount, studentUser, course, lesson]); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, course, lesson }; + }; + it('should return status 404', async () => { + const { loggedInClient, course } = await setup(); + const response = await loggedInClient.get(`/course/${course.id}`); + expect(response.status).toBe(HttpStatus.NOT_FOUND); + }); + }); +}); diff --git a/apps/server/src/modules/lesson/controller/dto/index.ts b/apps/server/src/modules/lesson/controller/dto/index.ts index 7f8b6803c69..af82f2cc84b 100644 --- a/apps/server/src/modules/lesson/controller/dto/index.ts +++ b/apps/server/src/modules/lesson/controller/dto/index.ts @@ -1 +1,5 @@ export * from './lesson.url.params'; +export { LessonsUrlParams } from './lessons.url.params'; +export * from './lesson-content.response'; +export * from './lesson.response'; +export * from './material.response'; diff --git a/apps/server/src/modules/lesson/controller/dto/lesson-content.response.ts b/apps/server/src/modules/lesson/controller/dto/lesson-content.response.ts new file mode 100644 index 00000000000..0af0d4006ba --- /dev/null +++ b/apps/server/src/modules/lesson/controller/dto/lesson-content.response.ts @@ -0,0 +1,57 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain/types'; +import { + ComponentEtherpadProperties, + ComponentGeogebraProperties, + ComponentInternalProperties, + ComponentLernstoreProperties, + ComponentNexboardProperties, + ComponentProperties, + ComponentTextProperties, + ComponentType, +} from '@shared/domain/entity/lesson.entity'; + +export class LessonContentResponse { + constructor(lessonContent: ComponentProperties) { + this.id = lessonContent._id; + // @deprecated _id used in legacy client + this._id = lessonContent._id; + this.title = lessonContent.title; + this.hidden = lessonContent.hidden; + this.component = lessonContent.component; + this.content = lessonContent.content; + } + + @ApiProperty() + content?: + | ComponentTextProperties + | ComponentEtherpadProperties + | ComponentGeogebraProperties + | ComponentInternalProperties + | ComponentLernstoreProperties + | ComponentNexboardProperties; + + @ApiProperty({ + description: 'The id of the Material entity', + pattern: '[a-f0-9]{24}', + deprecated: true, + }) + _id?: EntityId; + + @ApiProperty({ + description: 'The id of the Material entity', + pattern: '[a-f0-9]{24}', + }) + id?: EntityId; + + @ApiProperty({ + description: 'Title of the Material entity', + }) + title: string; + + @ApiProperty({ enum: ComponentType }) + component: ComponentType; + + @ApiProperty() + hidden: boolean; +} diff --git a/apps/server/src/modules/lesson/controller/dto/lesson.response.ts b/apps/server/src/modules/lesson/controller/dto/lesson.response.ts new file mode 100644 index 00000000000..65c7d37a630 --- /dev/null +++ b/apps/server/src/modules/lesson/controller/dto/lesson.response.ts @@ -0,0 +1,103 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain/types'; +import { PaginationResponse } from '@shared/controller'; +import { ComponentProperties, LessonEntity } from '@shared/domain/entity'; +import { MaterialResponse } from './material.response'; +import { LessonContentResponse } from './lesson-content.response'; + +export class LessonMetadataResponse { + constructor({ _id, name }: LessonMetadataResponse) { + this._id = _id; + this.name = name; + } + + @ApiProperty({ + description: 'The id of the Lesson entity', + pattern: '[a-f0-9]{24}', + }) + _id: EntityId; + + @ApiProperty({ + description: 'Name of the Lesson entity', + }) + name: string; +} + +export class LessonMetadataListResponse extends PaginationResponse { + constructor(data: LessonMetadataResponse[], total: number, skip?: number, limit?: number) { + super(total, skip, limit); + this.data = data; + } + + @ApiProperty({ type: [LessonMetadataResponse] }) + data: LessonMetadataResponse[]; +} + +export class LessonResponse { + constructor(lesson: LessonEntity) { + this.id = lesson.id; + // @deprecated _id used in legacy client + this._id = lesson.id; + this.name = lesson.name; + this.courseId = lesson.course?.id; + this.courseGroupId = lesson.courseGroup?.id; + this.hidden = lesson.hidden; + this.contents = lesson + .getLessonComponents() + .map((content: ComponentProperties) => new LessonContentResponse(content)); + this.materials = lesson.getLessonMaterials().map((material) => new MaterialResponse(material)); + this.position = lesson.position; + } + + @ApiProperty({ + description: 'The id of the Lesson entity', + pattern: '[a-f0-9]{24}', + deprecated: true, + }) + _id: EntityId; + + @ApiProperty({ + description: 'The id of the Lesson entity', + pattern: '[a-f0-9]{24}', + }) + id: EntityId; + + @ApiProperty({ + description: 'Name of the Lesson entity', + }) + name: string; + + @ApiPropertyOptional({ + description: 'The id of the Course entity', + pattern: '[a-f0-9]{24}', + }) + courseId?: EntityId; + + @ApiPropertyOptional({ + description: 'The id of the Course-group entity', + pattern: '[a-f0-9]{24}', + }) + courseGroupId?: EntityId; + + @ApiProperty({ + description: 'Hidden status of the Lesson entity', + }) + hidden: boolean; + + @ApiProperty({ + description: 'Position of the Lesson entity', + }) + position: number; + + @ApiProperty({ + description: 'Contents of the Lesson entity', + type: [LessonContentResponse], + }) + contents: LessonContentResponse[] | []; + + @ApiProperty({ + description: 'Materials of the Lesson entity', + type: [MaterialResponse], + }) + materials: MaterialResponse[] | []; +} diff --git a/apps/server/src/modules/lesson/controller/dto/lessons.url.params.ts b/apps/server/src/modules/lesson/controller/dto/lessons.url.params.ts new file mode 100644 index 00000000000..b3b167808db --- /dev/null +++ b/apps/server/src/modules/lesson/controller/dto/lessons.url.params.ts @@ -0,0 +1,13 @@ +import { IsMongoId } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain/types'; + +export class LessonsUrlParams { + @IsMongoId() + @ApiProperty({ + description: 'The id of the course the lesson belongs to.', + required: true, + nullable: false, + }) + courseId!: EntityId; +} diff --git a/apps/server/src/modules/lesson/controller/dto/material.response.ts b/apps/server/src/modules/lesson/controller/dto/material.response.ts new file mode 100644 index 00000000000..037669f30e4 --- /dev/null +++ b/apps/server/src/modules/lesson/controller/dto/material.response.ts @@ -0,0 +1,58 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { EntityId } from '@shared/domain/types'; +import { Material, RelatedResourceProperties } from '@shared/domain/entity'; + +export class MaterialResponse { + constructor(material: Material) { + this.id = material.id; + this._id = material.id; + this.client = material.client; + this.license = material.license; + this.relatedResources = material.relatedResources; + this.title = material.title; + this.url = material.url; + this.merlinReference = material.merlinReference; + } + + @ApiProperty({ + description: 'The id of the Material entity', + pattern: '[a-f0-9]{24}', + }) + _id: EntityId; + + @ApiProperty({ + description: 'The id of the Material entity', + pattern: '[a-f0-9]{24}', + }) + id: EntityId; + + @ApiProperty({ + description: 'Title of the Material entity', + }) + title: string; + + @ApiProperty({ + description: '?', + }) + relatedResources: RelatedResourceProperties[]; + + @ApiProperty({ + description: 'Url of the material', + }) + url?: string; + + @ApiProperty({ + description: 'Position of the Lesson entity', + }) + client: string; + + @ApiProperty({ + description: 'Description of the material license', + }) + license: string[]; + + @ApiProperty({ + description: 'For material from Merlin, the Merlin reference', + }) + merlinReference?: string; +} diff --git a/apps/server/src/modules/lesson/controller/lesson.controller.ts b/apps/server/src/modules/lesson/controller/lesson.controller.ts index 66e52b06478..1cc28c71e2e 100644 --- a/apps/server/src/modules/lesson/controller/lesson.controller.ts +++ b/apps/server/src/modules/lesson/controller/lesson.controller.ts @@ -1,8 +1,9 @@ import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; -import { Controller, Delete, Param } from '@nestjs/common'; +import { Controller, Delete, Get, Param } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { LessonUC } from '../uc'; -import { LessonUrlParams } from './dto'; +import { LessonUrlParams, LessonsUrlParams, LessonMetadataListResponse, LessonResponse } from './dto'; +import { LessonMapper } from './mapper/lesson.mapper'; @ApiTags('Lesson') @Authenticate('jwt') @@ -16,4 +17,20 @@ export class LessonController { return result; } + + @Get('course/:courseId') + async getCourseLessons(@Param() urlParams: LessonsUrlParams, @CurrentUser() currentUser: ICurrentUser) { + const lessons = await this.lessonUC.getLessons(currentUser.userId, urlParams.courseId); + + const dtoList = lessons.map((lesson) => LessonMapper.mapToMetadataResponse(lesson)); + const response = new LessonMetadataListResponse(dtoList, dtoList.length); + return response; + } + + @Get(':lessonId') + async getLesson(@Param() urlParams: LessonUrlParams, @CurrentUser() currentUser: ICurrentUser) { + const lesson = await this.lessonUC.getLesson(currentUser.userId, urlParams.lessonId); + const response = new LessonResponse(lesson); + return response; + } } diff --git a/apps/server/src/modules/lesson/controller/mapper/lesson.mapper.ts b/apps/server/src/modules/lesson/controller/mapper/lesson.mapper.ts new file mode 100644 index 00000000000..d2a804e18fc --- /dev/null +++ b/apps/server/src/modules/lesson/controller/mapper/lesson.mapper.ts @@ -0,0 +1,9 @@ +import { LessonEntity } from '@shared/domain/entity'; +import { LessonMetadataResponse } from '../dto'; + +export class LessonMapper { + static mapToMetadataResponse(lesson: LessonEntity): LessonMetadataResponse { + const dto = new LessonMetadataResponse({ _id: lesson.id, name: lesson.name }); + return dto; + } +} diff --git a/apps/server/src/modules/lesson/index.ts b/apps/server/src/modules/lesson/index.ts index b031de2b421..d178a4def80 100644 --- a/apps/server/src/modules/lesson/index.ts +++ b/apps/server/src/modules/lesson/index.ts @@ -2,3 +2,4 @@ export * from './lesson.module'; export * from './types/lesson-copy-parent.params'; export * from './types/lesson-copy.params'; export { NexboardService, LessonService, LessonCopyService, EtherpadService } from './service'; +export { LessonConfig } from './lesson.config'; diff --git a/apps/server/src/modules/lesson/lesson-api.module.ts b/apps/server/src/modules/lesson/lesson-api.module.ts index 1f17893f582..1408b3f6066 100644 --- a/apps/server/src/modules/lesson/lesson-api.module.ts +++ b/apps/server/src/modules/lesson/lesson-api.module.ts @@ -3,9 +3,10 @@ import { AuthorizationModule } from '@modules/authorization'; import { LessonController } from './controller'; import { LessonModule } from './lesson.module'; import { LessonUC } from './uc'; +import { LearnroomModule } from '../learnroom'; @Module({ - imports: [LessonModule, AuthorizationModule], + imports: [LessonModule, AuthorizationModule, LearnroomModule], controllers: [LessonController], providers: [LessonUC], }) diff --git a/apps/server/src/modules/lesson/lesson.config.ts b/apps/server/src/modules/lesson/lesson.config.ts new file mode 100644 index 00000000000..033a8682e19 --- /dev/null +++ b/apps/server/src/modules/lesson/lesson.config.ts @@ -0,0 +1,5 @@ +export interface LessonConfig { + FEATURE_NEXBOARD_COPY_ENABLED: boolean; + FEATURE_ETHERPAD_ENABLED: boolean; + ETHERPAD__PAD_URI?: string; +} diff --git a/apps/server/src/modules/lesson/lesson.module.ts b/apps/server/src/modules/lesson/lesson.module.ts index 3a009550010..b4d72b321f5 100644 --- a/apps/server/src/modules/lesson/lesson.module.ts +++ b/apps/server/src/modules/lesson/lesson.module.ts @@ -4,11 +4,12 @@ import { FilesStorageClientModule } from '@modules/files-storage-client'; import { TaskModule } from '@modules/task'; import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; +import { CqrsModule } from '@nestjs/cqrs'; import { LessonRepo } from './repository'; import { EtherpadService, LessonCopyService, LessonService, NexboardService } from './service'; @Module({ - imports: [FilesStorageClientModule, LoggerModule, CopyHelperModule, TaskModule], + imports: [FilesStorageClientModule, LoggerModule, CopyHelperModule, TaskModule, CqrsModule], providers: [LessonRepo, LessonService, EtherpadService, NexboardService, LessonCopyService, FeathersServiceProvider], exports: [LessonService, LessonCopyService], }) diff --git a/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts b/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts index a3e7fdab366..0804e123d4b 100644 --- a/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts +++ b/apps/server/src/modules/lesson/service/lesson-copy.service.spec.ts @@ -1043,24 +1043,6 @@ describe('lesson copy service', () => { return { user, originalCourse, destinationCourse, originalLesson }; }; - it('should not call neXboard service, if feature flag is false', async () => { - const { user, destinationCourse, originalLesson } = setup(); - configurationSpy = jest.spyOn(Configuration, 'get').mockReturnValue(false); - - const status = await copyService.copyLesson({ - originalLessonId: originalLesson.id, - destinationCourse, - user, - }); - - const lessonContents = (status.copyEntity as LessonEntity).contents as ComponentProperties[]; - expect(configurationSpy).toHaveBeenCalledWith('FEATURE_NEXBOARD_ENABLED'); - expect(nexboardService.createNexboard).not.toHaveBeenCalled(); - expect(lessonContents).toEqual([]); - - configurationSpy = jest.spyOn(Configuration, 'get').mockReturnValue(true); - }); - it('should not call neXboard service, if copy feature flag is false', async () => { const { user, destinationCourse, originalLesson } = setup(); configurationSpy = jest.spyOn(Configuration, 'get').mockImplementation((config: string) => { diff --git a/apps/server/src/modules/lesson/service/lesson-copy.service.ts b/apps/server/src/modules/lesson/service/lesson-copy.service.ts index c80665db26e..2a92f3c6b79 100644 --- a/apps/server/src/modules/lesson/service/lesson-copy.service.ts +++ b/apps/server/src/modules/lesson/service/lesson-copy.service.ts @@ -1,7 +1,7 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { CopyDictionary, CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { CopyFilesService, FileUrlReplacement } from '@modules/files-storage-client'; -import { TaskCopyService } from '@modules/task/service/task-copy.service'; +import { TaskCopyService } from '@modules/task'; import { Injectable } from '@nestjs/common'; import { ComponentEtherpadProperties, @@ -166,7 +166,6 @@ export class LessonCopyService { contentStatus: CopyStatus[]; }> { const etherpadEnabled = Configuration.get('FEATURE_ETHERPAD_ENABLED') as boolean; - const nexboardEnabled = Configuration.get('FEATURE_NEXBOARD_ENABLED') as boolean; const nexboardCopyEnabled = Configuration.get('FEATURE_NEXBOARD_COPY_ENABLED') as boolean; const copiedContent: ComponentProperties[] = []; const copiedContentStatus: CopyStatus[] = []; @@ -224,7 +223,7 @@ export class LessonCopyService { copiedContent.push(linkContent); copiedContentStatus.push(embeddedTaskStatus); } - if (element.component === ComponentType.NEXBOARD && nexboardEnabled) { + if (element.component === ComponentType.NEXBOARD) { const nexboardStatus = { title: element.title, type: CopyElementType.LESSON_CONTENT_NEXBOARD, diff --git a/apps/server/src/modules/lesson/service/lesson.service.spec.ts b/apps/server/src/modules/lesson/service/lesson.service.spec.ts index 9eba963ad8a..365ae0977fc 100644 --- a/apps/server/src/modules/lesson/service/lesson.service.spec.ts +++ b/apps/server/src/modules/lesson/service/lesson.service.spec.ts @@ -5,8 +5,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ComponentProperties, ComponentType } from '@shared/domain/entity'; import { lessonFactory, setupEntities } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { LessonRepo } from '../repository'; +import { EventBus } from '@nestjs/cqrs'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DataDeletedEvent, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { LessonService } from './lesson.service'; +import { LessonRepo } from '../repository'; describe('LessonService', () => { let lessonService: LessonService; @@ -14,6 +23,7 @@ describe('LessonService', () => { let lessonRepo: DeepMocked; let filesStorageClientAdapterService: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -31,12 +41,19 @@ describe('LessonService', () => { provide: Logger, useValue: createMock(), }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); lessonService = module.get(LessonService); lessonRepo = module.get(LessonRepo); filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); + eventBus = module.get(EventBus); await setupEntities(); }); @@ -149,7 +166,12 @@ describe('LessonService', () => { lessonRepo.findByUserId.mockResolvedValue([lesson1, lesson2]); + const expectedResult = DomainDeletionReportBuilder.build(DomainName.LESSONS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [lesson1.id, lesson2.id]), + ]); + return { + expectedResult, userId, }; }; @@ -157,17 +179,61 @@ describe('LessonService', () => { it('should call lessonRepo.findByUserId', async () => { const { userId } = setup(); - await lessonService.deleteUserDataFromLessons(userId); + await lessonService.deleteUserData(userId); expect(lessonRepo.findByUserId).toBeCalledWith(userId); }); it('should update lessons without deleted user', async () => { - const { userId } = setup(); + const { expectedResult, userId } = setup(); + + const result = await lessonService.deleteUserData(userId); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILERECORDS; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in lessonService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(lessonService, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await lessonService.handle({ deletionRequestId, targetRefId }); + + expect(lessonService.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(lessonService, 'deleteUserData').mockResolvedValueOnce(expectedData); - const result = await lessonService.deleteUserDataFromLessons(userId); + await lessonService.handle({ deletionRequestId, targetRefId }); - expect(result).toEqual(2); + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); }); }); }); diff --git a/apps/server/src/modules/lesson/service/lesson.service.ts b/apps/server/src/modules/lesson/service/lesson.service.ts index af38c26df8d..82ff335fa1e 100644 --- a/apps/server/src/modules/lesson/service/lesson.service.ts +++ b/apps/server/src/modules/lesson/service/lesson.service.ts @@ -1,22 +1,41 @@ import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { Injectable } from '@nestjs/common'; import { ComponentProperties, LessonEntity } from '@shared/domain/entity'; -import { Counted, DomainModel, EntityId, StatusModel } from '@shared/domain/types'; +import { Counted, EntityId } from '@shared/domain/types'; import { AuthorizationLoaderService } from '@src/modules/authorization'; import { Logger } from '@src/core/logger'; -import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; +import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + UserDeletedEvent, + DeletionService, + DataDeletedEvent, + DomainDeletionReport, + DataDeletionDomainOperationLoggable, + DomainName, + DomainDeletionReportBuilder, + DomainOperationReportBuilder, + OperationType, + StatusModel, +} from '@modules/deletion'; import { LessonRepo } from '../repository'; @Injectable() -export class LessonService implements AuthorizationLoaderService { +@EventsHandler(UserDeletedEvent) +export class LessonService implements AuthorizationLoaderService, DeletionService, IEventHandler { constructor( private readonly lessonRepo: LessonRepo, private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, - private readonly logger: Logger + private readonly logger: Logger, + private readonly eventBus: EventBus ) { this.logger.setContext(LessonService.name); } + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + async deleteLesson(lesson: LessonEntity): Promise { await this.filesStorageClientAdapterService.deleteFilesOfParent(lesson.id); @@ -37,11 +56,11 @@ export class LessonService implements AuthorizationLoaderService { return lessons; } - async deleteUserDataFromLessons(userId: EntityId): Promise { + public async deleteUserData(userId: EntityId): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting user data from Lessons', - DomainModel.LESSONS, + DomainName.LESSONS, userId, StatusModel.PENDING ) @@ -62,10 +81,18 @@ export class LessonService implements AuthorizationLoaderService { const numberOfUpdatedLessons = updatedLessons.length; + const result = DomainDeletionReportBuilder.build(DomainName.LESSONS, [ + DomainOperationReportBuilder.build( + OperationType.UPDATE, + numberOfUpdatedLessons, + this.getLessonsId(updatedLessons) + ), + ]); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully removed user data from Classes', - DomainModel.LESSONS, + DomainName.LESSONS, userId, StatusModel.FINISHED, numberOfUpdatedLessons, @@ -73,6 +100,10 @@ export class LessonService implements AuthorizationLoaderService { ) ); - return numberOfUpdatedLessons; + return result; + } + + private getLessonsId(lessons: LessonEntity[]): EntityId[] { + return lessons.map((lesson) => lesson.id); } } diff --git a/apps/server/src/modules/lesson/uc/lesson.uc.spec.ts b/apps/server/src/modules/lesson/uc/lesson.uc.spec.ts index 5a1da45fdd4..12083aa3291 100644 --- a/apps/server/src/modules/lesson/uc/lesson.uc.spec.ts +++ b/apps/server/src/modules/lesson/uc/lesson.uc.spec.ts @@ -2,7 +2,8 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; -import { lessonFactory, setupEntities, userFactory } from '@shared/testing'; +import { courseFactory, lessonFactory, setupEntities, userFactory } from '@shared/testing'; +import { CourseService } from '@modules/learnroom/service'; import { LessonService } from '../service'; import { LessonUC } from './lesson.uc'; @@ -11,6 +12,7 @@ describe('LessonUC', () => { let module: TestingModule; let lessonService: DeepMocked; + let courseService: DeepMocked; let authorizationService: DeepMocked; beforeAll(async () => { @@ -25,11 +27,16 @@ describe('LessonUC', () => { provide: AuthorizationService, useValue: createMock(), }, + { + provide: CourseService, + useValue: createMock(), + }, ], }).compile(); lessonUC = module.get(LessonUC); lessonService = module.get(LessonService); + courseService = module.get(CourseService); authorizationService = module.get(AuthorizationService); await setupEntities(); @@ -68,4 +75,122 @@ describe('LessonUC', () => { expect(result).toBe(true); }); + + describe('getLesons', () => { + describe('when user is a valid teacher', () => { + const setup = () => { + const user = userFactory.buildWithId(); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const course = courseFactory.buildWithId(); + courseService.findOneForUser.mockResolvedValueOnce(course); + + const lesson = lessonFactory.buildWithId({ course }); + const hiddenLesson = lessonFactory.buildWithId({ course, hidden: true }); + lessonService.findByCourseIds.mockResolvedValueOnce([[lesson, hiddenLesson], 2]); + authorizationService.hasPermission.mockReturnValue(true); + + return { user, course, lesson, hiddenLesson }; + }; + it('should get user with permissions from authorizationService', async () => { + const { user } = setup(); + await lessonUC.getLessons(user.id, 'courseId'); + expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(user.id); + }); + it('should get course from courseService', async () => { + const { user, course } = setup(); + await lessonUC.getLessons(user.id, course.id); + expect(courseService.findOneForUser).toHaveBeenCalledWith(course.id, user.id); + }); + it('should check user course permission', async () => { + const { user, course } = setup(); + await lessonUC.getLessons(user.id, course.id); + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + expect.objectContaining({ ...user }), + expect.objectContaining({ ...course }), + AuthorizationContextBuilder.read([Permission.COURSE_VIEW]) + ); + }); + it('should call lessonService', async () => { + const { user, course } = setup(); + await lessonUC.getLessons(user.id, course.id); + expect(lessonService.findByCourseIds).toHaveBeenCalledWith([course.id]); + }); + it('should check permission', async () => { + const { user, course, lesson, hiddenLesson } = setup(); + await lessonUC.getLessons(user.id, course.id); + expect(authorizationService.hasPermission.mock.calls).toEqual([ + [user, lesson, AuthorizationContextBuilder.read([Permission.TOPIC_VIEW])], + [user, hiddenLesson, AuthorizationContextBuilder.read([Permission.TOPIC_VIEW])], + ]); + }); + it('should return all lessons', async () => { + const { user, course, lesson, hiddenLesson } = setup(); + const result = await lessonUC.getLessons(user.id, course.id); + expect(result).toEqual([lesson, hiddenLesson]); + }); + }); + describe('when user is a valid student', () => { + const setup = () => { + const user = userFactory.buildWithId(); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const course = courseFactory.buildWithId(); + courseService.findOneForUser.mockResolvedValueOnce(course); + + const lesson = lessonFactory.buildWithId({ course }); + const hiddenLesson = lessonFactory.buildWithId({ course, hidden: true }); + lessonService.findByCourseIds.mockResolvedValueOnce([[lesson, hiddenLesson], 2]); + + authorizationService.hasPermission.mockReturnValueOnce(true).mockReturnValueOnce(false); + + return { user, course, lesson, hiddenLesson }; + }; + it('should filter out hidden lessons', async () => { + const { user, course, lesson } = setup(); + const result = await lessonUC.getLessons(user.id, course.id); + expect(result).toEqual([lesson]); + }); + }); + }); + + describe('getLesson', () => { + describe('when user is a valid teacher', () => { + const setup = () => { + const user = userFactory.buildWithId(); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const lesson = lessonFactory.buildWithId(); + lessonService.findById.mockResolvedValueOnce(lesson); + + authorizationService.hasPermission.mockReturnValueOnce(true); + + return { user, lesson }; + }; + it('should get user with permissions from authorizationService', async () => { + const { user } = setup(); + await lessonUC.getLesson(user.id, 'lessonId'); + expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(user.id); + }); + it('should get lesson from lessonService', async () => { + const { user, lesson } = setup(); + await lessonUC.getLesson(user.id, lesson.id); + expect(lessonService.findById).toHaveBeenCalledWith(lesson.id); + }); + it('should return check permission', async () => { + const { user, lesson } = setup(); + await lessonUC.getLesson(user.id, lesson.id); + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + expect.objectContaining({ ...user }), + expect.objectContaining({ ...lesson }), + AuthorizationContextBuilder.read([Permission.TOPIC_VIEW]) + ); + }); + it('should return lesson', async () => { + const { user, lesson } = setup(); + const result = await lessonUC.getLesson(user.id, lesson.id); + expect(result).toEqual(lesson); + }); + }); + }); }); diff --git a/apps/server/src/modules/lesson/uc/lesson.uc.ts b/apps/server/src/modules/lesson/uc/lesson.uc.ts index 0e670a6b342..ec618a37841 100644 --- a/apps/server/src/modules/lesson/uc/lesson.uc.ts +++ b/apps/server/src/modules/lesson/uc/lesson.uc.ts @@ -2,16 +2,19 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/auth import { Injectable } from '@nestjs/common'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; +import { LessonEntity } from '@shared/domain/entity'; +import { CourseService } from '@modules/learnroom/service/course.service'; import { LessonService } from '../service'; @Injectable() export class LessonUC { constructor( private readonly authorizationService: AuthorizationService, - private readonly lessonService: LessonService + private readonly lessonService: LessonService, + private readonly courseService: CourseService ) {} - async delete(userId: EntityId, lessonId: EntityId) { + async delete(userId: EntityId, lessonId: EntityId): Promise { const [user, lesson] = await Promise.all([ this.authorizationService.getUserWithPermissions(userId), this.lessonService.findById(lessonId), @@ -25,4 +28,27 @@ export class LessonUC { return true; } + + async getLessons(userId: EntityId, courseId: EntityId): Promise { + const user = await this.authorizationService.getUserWithPermissions(userId); + const course = await this.courseService.findOneForUser(courseId, userId); + + this.authorizationService.checkPermission(user, course, AuthorizationContextBuilder.read([Permission.COURSE_VIEW])); + + const [lessons] = await this.lessonService.findByCourseIds([courseId]); + const filteredLessons = lessons.filter((lesson) => + this.authorizationService.hasPermission(user, lesson, AuthorizationContextBuilder.read([Permission.TOPIC_VIEW])) + ); + + return filteredLessons; + } + + async getLesson(userId: EntityId, lessonId: EntityId): Promise { + const user = await this.authorizationService.getUserWithPermissions(userId); + const lesson = await this.lessonService.findById(lessonId); + + this.authorizationService.checkPermission(user, lesson, AuthorizationContextBuilder.read([Permission.TOPIC_VIEW])); + + return lesson; + } } diff --git a/apps/server/src/modules/management/console/board-management.console.spec.ts b/apps/server/src/modules/management/console/board-management.console.spec.ts deleted file mode 100644 index 4fe62db18a5..00000000000 --- a/apps/server/src/modules/management/console/board-management.console.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ConsoleWriterService } from '@infra/console'; -import { ObjectId } from 'bson'; -import { BoardManagementUc } from '../uc/board-management.uc'; -import { BoardManagementConsole } from './board-management.console'; - -describe(BoardManagementConsole.name, () => { - let service: BoardManagementConsole; - let module: TestingModule; - let consoleWriter: DeepMocked; - let boarManagementUc: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - BoardManagementConsole, - { - provide: BoardManagementUc, - useValue: createMock(), - }, - { - provide: ConsoleWriterService, - useValue: createMock(), - }, - ], - }).compile(); - - service = module.get(BoardManagementConsole); - consoleWriter = module.get(ConsoleWriterService); - boarManagementUc = module.get(BoardManagementUc); - boarManagementUc.createBoard.mockResolvedValue(new ObjectId().toHexString()); - }); - - afterAll(async () => { - await module.close(); - }); - - describe('createBoard', () => { - it('should call the board use case', async () => { - const fakeEntityId = new ObjectId().toHexString(); - await service.createBoard(fakeEntityId); - - expect(boarManagementUc.createBoard).toHaveBeenCalled(); - }); - - it('should log a report to the console', async () => { - const fakeEntityId = new ObjectId().toHexString(); - await service.createBoard(fakeEntityId); - - expect(consoleWriter.info).toHaveBeenCalled(); - }); - - it('should return the report', async () => { - const fakeEntityId = new ObjectId().toHexString(); - await service.createBoard(fakeEntityId); - - expect(consoleWriter.info).toHaveBeenCalledWith(expect.stringContaining('Success')); - }); - - it('should fail if no valid courseId was provided', async () => { - await service.createBoard('123'); - - expect(consoleWriter.info).toHaveBeenCalledWith( - expect.stringContaining('Error: please provide a valid courseId') - ); - }); - }); -}); diff --git a/apps/server/src/modules/management/console/board-management.console.ts b/apps/server/src/modules/management/console/board-management.console.ts deleted file mode 100644 index 83e5d4d7961..00000000000 --- a/apps/server/src/modules/management/console/board-management.console.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ConsoleWriterService } from '@infra/console'; -import { ObjectId } from 'bson'; -import { Command, Console } from 'nestjs-console'; -import { BoardManagementUc } from '../uc/board-management.uc'; - -@Console({ command: 'board', description: 'board setup console' }) -export class BoardManagementConsole { - constructor(private consoleWriter: ConsoleWriterService, private boardManagementUc: BoardManagementUc) {} - - @Command({ - command: 'create-board [courseId]', - description: 'create a multi-column-board', - }) - async createBoard(courseId = ''): Promise { - if (!ObjectId.isValid(courseId)) { - this.consoleWriter.info('Error: please provide a valid courseId this board should be assigned to.'); - return; - } - - const boardId = await this.boardManagementUc.createBoard(courseId); - if (boardId) { - this.consoleWriter.info(`Success: board creation is completed! The new boardId is "${boardId ?? ''}"`); - } - } -} diff --git a/apps/server/src/modules/management/console/database-management.console.spec.ts b/apps/server/src/modules/management/console/database-management.console.spec.ts index 44517e19396..5e08af4edd5 100644 --- a/apps/server/src/modules/management/console/database-management.console.spec.ts +++ b/apps/server/src/modules/management/console/database-management.console.spec.ts @@ -34,6 +34,8 @@ describe('DatabaseManagementConsole', () => { }); describe('database', () => { + let consoleInfoSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; beforeAll(() => { databaseManagementUc.seedDatabaseCollectionsFromFileSystem.mockImplementation((collections?: string[]) => { if (collections === undefined) { @@ -49,48 +51,71 @@ describe('DatabaseManagementConsole', () => { }); }); - it('should export existing collections', async () => { - const consoleInfoSpy = jest.spyOn(consoleWriter, 'info'); - await service.exportCollections({}); - const result = JSON.stringify(['someCollection:1', 'otherCollection:2']); - expect(consoleInfoSpy).toHaveBeenCalledWith(result); - consoleInfoSpy.mockReset(); + beforeEach(() => { + consoleInfoSpy = jest.spyOn(consoleWriter, 'info'); + consoleErrorSpy = jest.spyOn(consoleWriter, 'error'); }); - it('should export specific collection', async () => { - const consoleInfoSpy = jest.spyOn(consoleWriter, 'info'); - await service.exportCollections({ collection: 'singleCollection' }); - const result = JSON.stringify(['singleCollection:4']); - expect(consoleInfoSpy).toHaveBeenCalledWith(result); - consoleInfoSpy.mockReset(); + afterEach(() => { + jest.clearAllMocks(); }); - it('should pass override flag to uc', async () => { - const consoleInfoSpy = jest.spyOn(consoleWriter, 'info'); - await service.exportCollections({ collection: 'singleCollection', override: true }); - const result = JSON.stringify(['singleCollection:4']); - expect(consoleInfoSpy).toHaveBeenCalledWith(result); - consoleInfoSpy.mockReset(); - }); - it('should seed existing collections', async () => { - const consoleInfoSpy = jest.spyOn(consoleWriter, 'info'); - await service.seedCollections({}); - const result = JSON.stringify(['someCollection:1', 'otherCollection:2']); - expect(consoleInfoSpy).toHaveBeenCalledWith(result); - consoleInfoSpy.mockReset(); + + describe('when exporting collections', () => { + it('should export existing collections', async () => { + await service.exportCollections({}); + const result = JSON.stringify(['someCollection:1', 'otherCollection:2']); + expect(consoleInfoSpy).toHaveBeenCalledWith(result); + }); + it('should export specific collection', async () => { + await service.exportCollections({ collection: 'singleCollection' }); + const result = JSON.stringify(['singleCollection:4']); + expect(consoleInfoSpy).toHaveBeenCalledWith(result); + }); + it('should pass override flag to uc', async () => { + await service.exportCollections({ collection: 'singleCollection', override: true }); + const result = JSON.stringify(['singleCollection:4']); + expect(consoleInfoSpy).toHaveBeenCalledWith(result); + }); }); - it('should seed specific collection', async () => { - const consoleInfoSpy = jest.spyOn(consoleWriter, 'info'); - const retValue = await service.seedCollections({ collection: 'singleCollection' }); - expect(retValue).toEqual(['singleCollection:4']); - const result = JSON.stringify(['singleCollection:4']); - expect(consoleInfoSpy).toHaveBeenCalledWith(result); - consoleInfoSpy.mockReset(); + describe('When seeding collections', () => { + it('should seed existing collections', async () => { + await service.seedCollections({}); + const result = JSON.stringify(['someCollection:1', 'otherCollection:2']); + expect(consoleInfoSpy).toHaveBeenCalledWith(result); + }); + it('should seed specific collection', async () => { + const retValue = await service.seedCollections({ collection: 'singleCollection' }); + expect(retValue).toEqual(['singleCollection:4']); + const result = JSON.stringify(['singleCollection:4']); + expect(consoleInfoSpy).toHaveBeenCalledWith(result); + }); }); it('should sync indexes', async () => { - const consoleInfoSpy = jest.spyOn(consoleWriter, 'info'); await service.syncIndexes(); expect(consoleInfoSpy).toHaveBeenCalledWith('sync of indexes is completed'); expect(databaseManagementUc.syncIndexes).toHaveBeenCalled(); - consoleInfoSpy.mockReset(); + }); + describe('When calling migration', () => { + it('should migrate up', async () => { + await service.migration({ up: true }); + expect(consoleInfoSpy).toHaveBeenCalledWith('migration up is completed'); + expect(databaseManagementUc.migrationUp).toHaveBeenCalled(); + }); + it('should migrate down', async () => { + await service.migration({ down: true }); + expect(consoleInfoSpy).toHaveBeenCalledWith('migration down is completed'); + expect(databaseManagementUc.migrationDown).toHaveBeenCalled(); + }); + it('should check pending migrations', async () => { + await service.migration({ pending: true }); + expect(consoleInfoSpy).toHaveBeenCalledWith(expect.stringContaining('Pending:')); + expect(databaseManagementUc.migrationPending).toHaveBeenCalled(); + }); + it('should no migrate if no param specified', async () => { + await service.migration({}); + expect(consoleErrorSpy).toHaveBeenCalledWith('no migration option was given'); + expect(databaseManagementUc.migrationUp).not.toHaveBeenCalled(); + expect(databaseManagementUc.migrationDown).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/apps/server/src/modules/management/console/database-management.console.ts b/apps/server/src/modules/management/console/database-management.console.ts index 780072aa837..1b14fb37bd5 100644 --- a/apps/server/src/modules/management/console/database-management.console.ts +++ b/apps/server/src/modules/management/console/database-management.console.ts @@ -8,6 +8,15 @@ interface Options { onlyfactories?: boolean; } +interface MigrationOptions { + up?: boolean; + down?: boolean; + from?: string; + to?: string; + only?: string; + pending?: boolean; +} + @Console({ command: 'database', description: 'database setup console' }) export class DatabaseManagementConsole { constructor(private consoleWriter: ConsoleWriterService, private databaseManagementUc: DatabaseManagementUc) {} @@ -75,4 +84,63 @@ export class DatabaseManagementConsole { this.consoleWriter.info(report); return report; } + + @Command({ + command: 'migration', + options: [ + { + flags: '--up', + required: false, + description: 'execute migration up', + }, + { + flags: '--down', + required: false, + description: 'rollback migration (down)', + }, + { + flags: '-f, --from', + required: false, + description: 'run migration up/down from specified name', + }, + { + flags: '-t, --to', + required: false, + description: 'run migration up/down to specified name', + }, + { + flags: '-o, --only', + required: false, + description: 'run a single migration', + }, + { + flags: '--pending', + required: false, + description: 'list pending migrations', + }, + ], + description: 'Execute MikroOrm migration up/down', + }) + async migration(migrationOptions: MigrationOptions): Promise { + let report = 'no migration option was given'; + if (!migrationOptions.up && !migrationOptions.down && !migrationOptions.pending) { + this.consoleWriter.error(report); + return report; + } + if (migrationOptions.up) { + await this.databaseManagementUc.migrationUp(migrationOptions.from, migrationOptions.to, migrationOptions.only); + report = 'migration up is completed'; + } + if (migrationOptions.down) { + await this.databaseManagementUc.migrationDown(migrationOptions.from, migrationOptions.to, migrationOptions.only); + report = 'migration down is completed'; + } + if (migrationOptions.pending) { + const pendingMigrations = await this.databaseManagementUc.migrationPending(); + report = `Pending: ${JSON.stringify(pendingMigrations.map((migration) => migration.name))}`; + } + + this.consoleWriter.info(report); + return report; + } } diff --git a/apps/server/src/modules/management/controller/api-test/database-management.api.spec.ts b/apps/server/src/modules/management/controller/api-test/database-management.api.spec.ts index 012ee8f3cf2..070d6ad1de8 100644 --- a/apps/server/src/modules/management/controller/api-test/database-management.api.spec.ts +++ b/apps/server/src/modules/management/controller/api-test/database-management.api.spec.ts @@ -1,7 +1,7 @@ import { MikroORM } from '@mikro-orm/core'; +import { ManagementServerTestModule } from '@modules/management/management-server.module'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ManagementServerTestModule } from '@modules/management/management-server.module'; import request from 'supertest'; describe('Database Management Controller (API)', () => { diff --git a/apps/server/src/modules/management/converter/bson.converter.spec.ts b/apps/server/src/modules/management/converter/bson.converter.spec.ts index dd9d09e0b7b..de84aa4f7e9 100644 --- a/apps/server/src/modules/management/converter/bson.converter.spec.ts +++ b/apps/server/src/modules/management/converter/bson.converter.spec.ts @@ -1,5 +1,5 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { ObjectId } from 'bson'; import { BsonConverter } from './bson.converter'; describe('BsonConverter', () => { @@ -40,8 +40,13 @@ describe('BsonConverter', () => { describe('When deserialize from bson', () => { it('should convert dates and object ids', () => { - const result = converter.deserialize([bson]); - expect(result).toEqual([pojo]); + const [result] = converter.deserialize([bson]) as { + _id: ObjectId; + dueDate: Date; + }[]; + + expect(result._id.toString()).toEqual(pojo._id.toString()); + expect(result.dueDate).toEqual(pojo.dueDate); }); }); }); diff --git a/apps/server/src/modules/management/management.module.ts b/apps/server/src/modules/management/management.module.ts index c1ed6aed227..caa49da5688 100644 --- a/apps/server/src/modules/management/management.module.ts +++ b/apps/server/src/modules/management/management.module.ts @@ -1,19 +1,17 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; import { ConsoleWriterService } from '@infra/console'; import { DatabaseManagementModule, DatabaseManagementService } from '@infra/database'; import { EncryptionModule } from '@infra/encryption'; import { FileSystemModule } from '@infra/file-system'; import { KeycloakConfigurationModule } from '@infra/identity-management/keycloak-configuration/keycloak-configuration.module'; +import { serverConfig } from '@modules/server'; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; import { LoggerModule } from '@src/core/logger'; -import { serverConfig } from '@modules/server'; -import { BoardManagementConsole } from './console/board-management.console'; import { DatabaseManagementConsole } from './console/database-management.console'; import { DatabaseManagementController } from './controller/database-management.controller'; import { BsonConverter } from './converter/bson.converter'; -import { BoardManagementUc } from './uc/board-management.uc'; import { DatabaseManagementUc } from './uc/database-management.uc'; const baseImports = [ @@ -36,8 +34,6 @@ const providers = [ DatabaseManagementConsole, // infra services ConsoleWriterService, - BoardManagementConsole, - BoardManagementUc, ]; const controllers = [DatabaseManagementController]; diff --git a/apps/server/src/modules/management/seed-data/federalstates.ts b/apps/server/src/modules/management/seed-data/federalstates.ts index 1f0cbe54e9f..403814e9c0e 100644 --- a/apps/server/src/modules/management/seed-data/federalstates.ts +++ b/apps/server/src/modules/management/seed-data/federalstates.ts @@ -1,6 +1,6 @@ import { CountyEmbeddable, FederalStateProperties } from '@shared/domain/entity/federal-state.entity'; import { federalStateFactory } from '@shared/testing/factory/federal-state.factory'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DeepPartial } from 'fishery'; type SeedFederalStateProperties = Omit & { diff --git a/apps/server/src/modules/management/seed-data/roles.ts b/apps/server/src/modules/management/seed-data/roles.ts index b8205b6a897..1484c9d8d58 100644 --- a/apps/server/src/modules/management/seed-data/roles.ts +++ b/apps/server/src/modules/management/seed-data/roles.ts @@ -127,6 +127,7 @@ const roleSeedData: { [key: string | RoleName]: SeedRoleProperties } = { Permission.SYSTEM_CREATE, Permission.SYSTEM_VIEW, Permission.SCHOOL_TOOL_ADMIN, + Permission.USER_CHANGE_OWN_NAME, ], }, superhero: { @@ -136,6 +137,8 @@ const roleSeedData: { [key: string | RoleName]: SeedRoleProperties } = { name: RoleName.SUPERHERO, roles: [RoleName.USER], permissions: [ + Permission.ACCOUNT_DELETE, + Permission.ACCOUNT_VIEW, Permission.ADMIN_EDIT, Permission.CLASS_LIST, Permission.CREATE_SUPPORT_JWT, @@ -171,6 +174,7 @@ const roleSeedData: { [key: string | RoleName]: SeedRoleProperties } = { Permission.TEAM_EDIT, Permission.TOOL_CREATE, Permission.TOOL_EDIT, + Permission.USER_CHANGE_OWN_NAME, Permission.YEARS_EDIT, ], }, @@ -209,6 +213,7 @@ const roleSeedData: { [key: string | RoleName]: SeedRoleProperties } = { Permission.TOPIC_EDIT, Permission.USERGROUP_CREATE, Permission.USERGROUP_EDIT, + Permission.USER_CHANGE_OWN_NAME, Permission.USER_CREATE, Permission.TASK_DASHBOARD_TEACHER_VIEW_V3, Permission.TEAM_CREATE, diff --git a/apps/server/src/modules/management/seed-data/schools.ts b/apps/server/src/modules/management/seed-data/schools.ts index b1cf893366a..42f74aa90ac 100644 --- a/apps/server/src/modules/management/seed-data/schools.ts +++ b/apps/server/src/modules/management/seed-data/schools.ts @@ -6,10 +6,11 @@ import { SchoolYearEntity, SystemEntity, } from '@shared/domain/entity'; +import { LanguageType } from '@shared/domain/interface'; import { SchoolFeature, SchoolPurpose } from '@shared/domain/types'; -import { federalStateFactory, schoolFactory } from '@shared/testing'; +import { federalStateFactory, schoolEntityFactory } from '@shared/testing'; import { FileStorageType } from '@src/modules/school/domain/type/file-storage-type.enum'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DeepPartial } from 'fishery'; import { EFederalState } from './federalstates'; import { SeedSchoolYearEnum } from './schoolyears'; @@ -152,7 +153,7 @@ const seedSchools: SeedSchoolProperties[] = [ documentBaseDirType: '', experimental: false, pilot: false, - language: 'de', + language: LanguageType.DE, logo_dataUrl: '', officialSchoolNumber: '', }, @@ -181,7 +182,7 @@ const seedSchools: SeedSchoolProperties[] = [ documentBaseDirType: '', experimental: false, pilot: false, - language: 'de', + language: LanguageType.DE, logo_dataUrl: '', logo_name: '', officialSchoolNumber: '', @@ -206,7 +207,7 @@ const seedSchools: SeedSchoolProperties[] = [ experimental: false, pilot: false, timezone: 'America/Belem', - language: 'en', + language: LanguageType.EN, logo_dataUrl: '', logo_name: '', officialSchoolNumber: '', @@ -303,7 +304,7 @@ export function generateSchools(entities: { systems, federalState, }; - const schoolEntity = schoolFactory.buildWithId(params, partial.id); + const schoolEntity = schoolEntityFactory.buildWithId(params, partial.id); schoolEntity.permissions = partial.permissions; diff --git a/apps/server/src/modules/management/uc/board-management.uc.ts b/apps/server/src/modules/management/uc/board-management.uc.ts deleted file mode 100644 index 1068df45a0b..00000000000 --- a/apps/server/src/modules/management/uc/board-management.uc.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { ConsoleWriterService } from '@infra/console'; -import { EntityManager } from '@mikro-orm/mongodb'; -import { Injectable } from '@nestjs/common'; -import { BoardExternalReferenceType } from '@shared/domain/domainobject'; -import { BoardNode, Course } from '@shared/domain/entity'; -import { EntityId, InputFormat } from '@shared/domain/types'; -import { - cardNodeFactory, - columnBoardNodeFactory, - columnNodeFactory, - richTextElementNodeFactory, -} from '@shared/testing'; - -@Injectable() -export class BoardManagementUc { - constructor(private consoleWriter: ConsoleWriterService, private em: EntityManager) {} - - async createBoard(courseId: EntityId): Promise { - if (!(await this.doesCourseExist(courseId))) { - return undefined; - } - - const context = { type: BoardExternalReferenceType.Course, id: courseId }; - const board = columnBoardNodeFactory.build({ context }); - await this.em.persistAndFlush(board); - - const columns = this.createColumns(3, board); - await this.em.persistAndFlush(columns); - - const cardsPerColumn = columns.map((column) => this.createCards(this.random(1, 3), column)); - const cards = cardsPerColumn.flat(); - await this.em.persistAndFlush(cards); - - const elementsPerCard = cards.map((card) => this.createElements(1, card)); - const elements = elementsPerCard.flat(); - await this.em.persistAndFlush(elements); - - return board.id; - } - - private createColumns(amount: number, parent: BoardNode): BoardNode[] { - return this.generateArray(amount, (i) => - columnNodeFactory.build({ - parent, - title: `Column ${i + 1}`, - position: i, - }) - ); - } - - private createCards(amount: number, parent: BoardNode): BoardNode[] { - return this.generateArray(amount, (i) => - cardNodeFactory.build({ - parent, - title: `Card ${i + 1}`, - height: this.random(50, 250), - position: i, - }) - ); - } - - private createElements(amount: number, parent: BoardNode): BoardNode[] { - return this.generateArray(amount, (i) => - richTextElementNodeFactory.build({ - parent, - text: `

Text ${i + 1}

`, - inputFormat: InputFormat.RICH_TEXT_CK5, - position: i, - }) - ); - } - - private generateArray(length: number, fn: (i: number) => T) { - return [...Array(length).keys()].map((_, i) => fn(i)); - } - - private random(min: number, max: number): number { - return Math.floor(Math.random() * (max + min - 1) + min); - } - - private async doesCourseExist(courseId: EntityId = ''): Promise { - try { - await this.em.findOneOrFail(Course, courseId); - return true; - } catch (err) { - this.consoleWriter.info(`Error: course does not exist (courseId: "${courseId}")`); - } - return false; - } -} diff --git a/apps/server/src/modules/management/uc/database-management.uc.spec.ts b/apps/server/src/modules/management/uc/database-management.uc.spec.ts index 251c34e0dc3..01f4b6955b5 100644 --- a/apps/server/src/modules/management/uc/database-management.uc.spec.ts +++ b/apps/server/src/modules/management/uc/database-management.uc.spec.ts @@ -3,13 +3,12 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { DatabaseManagementService } from '@infra/database'; import { DefaultEncryptionService, LdapEncryptionService, SymetricKeyEncryptionService } from '@infra/encryption'; import { FileSystemAdapter } from '@infra/file-system'; -import { EntityManager } from '@mikro-orm/mongodb'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { StorageProviderEntity, SystemEntity } from '@shared/domain/entity'; import { setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { ObjectId } from 'mongodb'; import { BsonConverter } from '../converter/bson.converter'; import { generateSeedData } from '../seed-data/generateSeedData'; import { DatabaseManagementUc } from './database-management.uc'; @@ -657,4 +656,32 @@ describe('DatabaseManagementService', () => { expect(collectionsSeeded).toStrictEqual(expectedCollectionsWithLength); }); }); + + describe('migration', () => { + it('should call migrationUp', async () => { + dbService.migrationUp = jest.fn(); + await uc.migrationUp(); + expect(dbService.migrationUp).toHaveBeenCalled(); + }); + it('should call migrationUp with params', async () => { + dbService.migrationUp = jest.fn(); + await uc.migrationUp('foo', 'bar', 'baz'); + expect(dbService.migrationUp).toHaveBeenCalledWith('foo', 'bar', 'baz'); + }); + it('should call migrationDown', async () => { + dbService.migrationDown = jest.fn(); + await uc.migrationDown(); + expect(dbService.migrationDown).toHaveBeenCalled(); + }); + it('should call migrationDown with params', async () => { + dbService.migrationDown = jest.fn(); + await uc.migrationDown('foo', 'bar', 'baz'); + expect(dbService.migrationDown).toHaveBeenCalledWith('foo', 'bar', 'baz'); + }); + it('should call migrationPending', async () => { + dbService.migrationDown = jest.fn(); + await uc.migrationPending(); + expect(dbService.migrationPending).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/server/src/modules/management/uc/database-management.uc.ts b/apps/server/src/modules/management/uc/database-management.uc.ts index 57da050fa15..8ec0dfcb436 100644 --- a/apps/server/src/modules/management/uc/database-management.uc.ts +++ b/apps/server/src/modules/management/uc/database-management.uc.ts @@ -8,6 +8,7 @@ import { ConfigService } from '@nestjs/config'; import { StorageProviderEntity, SystemEntity } from '@shared/domain/entity'; import { LegacyLogger } from '@src/core/logger'; import { orderBy } from 'lodash'; +import { UmzugMigration } from '@mikro-orm/migrations-mongodb'; import { BsonConverter } from '../converter/bson.converter'; import { generateSeedData } from '../seed-data/generateSeedData'; @@ -404,4 +405,16 @@ export class DatabaseManagementUc { }); return systems; } + + public async migrationUp(from?: string, to?: string, only?: string): Promise { + return this.databaseManagementService.migrationUp(from, to, only); + } + + public async migrationDown(from?: string, to?: string, only?: string): Promise { + return this.databaseManagementService.migrationDown(from, to, only); + } + + public async migrationPending(): Promise { + return this.databaseManagementService.migrationPending(); + } } diff --git a/apps/server/src/modules/me/api/dto/index.ts b/apps/server/src/modules/me/api/dto/index.ts new file mode 100644 index 00000000000..3979b0169ba --- /dev/null +++ b/apps/server/src/modules/me/api/dto/index.ts @@ -0,0 +1 @@ +export * from './me.response'; diff --git a/apps/server/src/modules/me/api/dto/me.response.ts b/apps/server/src/modules/me/api/dto/me.response.ts new file mode 100644 index 00000000000..907101df9f0 --- /dev/null +++ b/apps/server/src/modules/me/api/dto/me.response.ts @@ -0,0 +1,107 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { LanguageType } from '@shared/domain/interface'; + +export class MeAccountResponse { + @ApiProperty() + id: string; + + constructor(props: MeAccountResponse) { + this.id = props.id; + } +} + +export class MeSchoolLogoResponse { + @ApiPropertyOptional() + url?: string; + + @ApiPropertyOptional() + name?: string; + + constructor(props: MeSchoolLogoResponse) { + this.url = props.url; + this.name = props.name; + } +} + +export class MeSchoolResponse { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty() + logo: MeSchoolLogoResponse; + + constructor(props: MeSchoolResponse) { + this.id = props.id; + this.name = props.name; + this.logo = props.logo; + } +} + +export class MeUserResponse { + @ApiProperty() + id: string; + + @ApiProperty() + firstName: string; + + @ApiProperty() + lastName: string; + + @ApiPropertyOptional() + customAvatarBackgroundColor?: string; + + constructor(props: MeUserResponse) { + this.id = props.id; + this.firstName = props.firstName; + this.lastName = props.lastName; + this.customAvatarBackgroundColor = props.customAvatarBackgroundColor; + } +} + +export class MeRoleResponse { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + constructor(props: MeRoleResponse) { + this.id = props.id; + this.name = props.name; + } +} + +export class MeResponse { + @ApiProperty() + school: MeSchoolResponse; + + @ApiProperty() + user: MeUserResponse; + + @ApiProperty({ type: [MeRoleResponse] }) + roles: MeRoleResponse[]; + + @ApiProperty() + permissions: string[]; + + @ApiProperty({ + enum: LanguageType, + enumName: 'LanguageType', + }) + language?: LanguageType; + + @ApiProperty() + account: MeAccountResponse; + + constructor(props: MeResponse) { + this.school = props.school; + this.user = props.user; + this.roles = props.roles; + this.permissions = props.permissions; + this.language = props.language; + this.account = props.account; + } +} diff --git a/apps/server/src/modules/me/api/index.ts b/apps/server/src/modules/me/api/index.ts new file mode 100644 index 00000000000..2eb46d70790 --- /dev/null +++ b/apps/server/src/modules/me/api/index.ts @@ -0,0 +1,2 @@ +export * from './me.controller'; +export * from './me.uc'; diff --git a/apps/server/src/modules/me/api/mapper/index.ts b/apps/server/src/modules/me/api/mapper/index.ts new file mode 100644 index 00000000000..372771fbe5f --- /dev/null +++ b/apps/server/src/modules/me/api/mapper/index.ts @@ -0,0 +1 @@ +export * from './me.response.mapper'; diff --git a/apps/server/src/modules/me/api/mapper/me.response.mapper.ts b/apps/server/src/modules/me/api/mapper/me.response.mapper.ts new file mode 100644 index 00000000000..85979195110 --- /dev/null +++ b/apps/server/src/modules/me/api/mapper/me.response.mapper.ts @@ -0,0 +1,94 @@ +import { Role, User } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; +import { School } from '@src/modules/school'; +import { + MeAccountResponse, + MeResponse, + MeRoleResponse, + MeSchoolLogoResponse, + MeSchoolResponse, + MeUserResponse, +} from '../dto'; + +export class MeResponseMapper { + public static mapToResponse(school: School, user: User, accountId: EntityId, permissions: Set): MeResponse { + const schoolResponse = MeResponseMapper.mapSchool(school); + const userResponse = MeResponseMapper.mapUser(user); + const rolesResponse = MeResponseMapper.mapUserRoles(user); + const permissionsResponse = MeResponseMapper.mapPermissions(permissions); + const language = user.getInfo().language || school.getInfo().language; + const accountResponse = MeResponseMapper.mapAccount(accountId); + + const res = new MeResponse({ + school: schoolResponse, + user: userResponse, + roles: rolesResponse, + permissions: permissionsResponse, + language, + account: accountResponse, + }); + + return res; + } + + private static mapSchool(school: School): MeSchoolResponse { + const schoolInfoProps = school.getInfo(); + const { dataUrl: url, name } = schoolInfoProps.logo || {}; + + const logo = new MeSchoolLogoResponse({ + url, + name, + }); + + const schoolResponse = new MeSchoolResponse({ + id: schoolInfoProps.id, + name: schoolInfoProps.name, + logo, + }); + + return schoolResponse; + } + + private static mapUser(user: User): MeUserResponse { + const userInfo = user.getInfo(); + + const userResponse = new MeUserResponse({ + id: userInfo.id, + firstName: userInfo.firstName, + lastName: userInfo.lastName, + customAvatarBackgroundColor: userInfo.customAvatarBackgroundColor, + }); + + return userResponse; + } + + private static mapUserRoles(user: User): MeRoleResponse[] { + const roles = user.getRoles(); + const rolesResponse = roles.map((role) => MeResponseMapper.mapRole(role)); + + return rolesResponse; + } + + private static mapRole(role: Role): MeRoleResponse { + const roleResponse = new MeRoleResponse({ + id: role.id, + name: role.name, + }); + + return roleResponse; + } + + private static mapPermissions(permissions: Set): string[] { + const permissionsResponse = Array.from(permissions); + + return permissionsResponse; + } + + private static mapAccount(accountId: EntityId): MeAccountResponse { + const accountResponse = new MeAccountResponse({ + id: accountId, + }); + + return accountResponse; + } +} diff --git a/apps/server/src/modules/me/api/me.controller.ts b/apps/server/src/modules/me/api/me.controller.ts new file mode 100644 index 00000000000..f9cf53b0ba9 --- /dev/null +++ b/apps/server/src/modules/me/api/me.controller.ts @@ -0,0 +1,21 @@ +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { MeResponse } from './dto'; +import { MeUc } from './me.uc'; + +@ApiTags('Me') +@Authenticate('jwt') +@Controller('me') +export class MeController { + constructor(private readonly meUc: MeUc) {} + + @ApiOperation({ summary: 'Resolve jwt and response informations about the owner of the jwt.' }) + @ApiResponse({ status: 200, type: MeResponse }) + @Get() + public async me(@CurrentUser() currentUser: ICurrentUser): Promise { + const res = await this.meUc.getMe(currentUser.userId, currentUser.schoolId, currentUser.accountId); + + return res; + } +} diff --git a/apps/server/src/modules/me/api/me.uc.ts b/apps/server/src/modules/me/api/me.uc.ts new file mode 100644 index 00000000000..a98d828a068 --- /dev/null +++ b/apps/server/src/modules/me/api/me.uc.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { SchoolService } from '@src/modules/school'; +import { UserService } from '@src/modules/user'; +import { SchoolPermissionService } from '../domain/school-permission.service'; +import { MeResponse } from './dto'; +import { MeResponseMapper } from './mapper'; + +@Injectable() +export class MeUc { + constructor( + private readonly schoolService: SchoolService, + private readonly userService: UserService, + private readonly schoolPermissionService: SchoolPermissionService + ) {} + + public async getMe(userId: EntityId, schoolId: EntityId, accountId: EntityId): Promise { + const [school, user] = await Promise.all([ + this.schoolService.getSchoolById(schoolId), + this.userService.getUserEntityWithRoles(userId), // TODO: replace when user domain object is available + ]); + + const permissions = this.schoolPermissionService.resolvePermissions(user, school); + + const dto = MeResponseMapper.mapToResponse(school, user, accountId, permissions); + + return dto; + } +} diff --git a/apps/server/src/modules/me/api/test/me.controller.api.spec.ts b/apps/server/src/modules/me/api/test/me.controller.api.spec.ts new file mode 100644 index 00000000000..afc55cd7d43 --- /dev/null +++ b/apps/server/src/modules/me/api/test/me.controller.api.spec.ts @@ -0,0 +1,157 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import type { User } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; +import { TestApiClient, UserAndAccountTestFactory, schoolEntityFactory } from '@shared/testing'; +import { AccountEntity } from '@modules/account/entity/account.entity'; +import { ServerTestModule } from '@src/modules/server'; +import { MeResponse } from '../dto'; + +const mapToMeResponseObject = (user: User, account: AccountEntity, permissions: string[]): MeResponse => { + const roles = user.getRoles(); + const role = roles[0]; + const { school } = user; + + const meResponseObject: MeResponse = { + school: { + id: school.id, + name: school.name, + logo: {}, + }, + user: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + }, + roles: [ + { + id: role.id, + name: role.name, + }, + ], + permissions, + account: { + id: account.id, + }, + }; + + return meResponseObject; +}; + +describe('Me Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'me'); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('me', () => { + describe('when no jwt is passed', () => { + it('should respond with unauthorized exception', async () => { + const response = await testApiClient.get(); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + expect(response.body).toEqual({ + type: 'UNAUTHORIZED', + title: 'Unauthorized', + message: 'Unauthorized', + code: 401, + }); + }); + }); + + describe('when valid jwt is passed', () => { + describe('when user is a student', () => { + const setup = async () => { + // The LERNSTORE_VIEW permission on the school is set here as an example. See the unit tests for all variations. + const school = schoolEntityFactory.build({ permissions: { student: { LERNSTORE_VIEW: true } } }); + const { studentAccount: account, studentUser: user } = UserAndAccountTestFactory.buildStudent({ school }); + + await em.persistAndFlush([account, user]); + em.clear(); + + const loggedInClient = await testApiClient.login(account); + const expectedPermissions = user.resolvePermissions(); + const expectedResponse = mapToMeResponseObject(user, account, expectedPermissions); + + return { loggedInClient, expectedResponse }; + }; + + it('should respond with "me" information and status code 200', async () => { + const { loggedInClient, expectedResponse } = await setup(); + + const response = await loggedInClient.get(); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual(expectedResponse); + }); + }); + + describe('when user is a teacher', () => { + const setup = async () => { + const { teacherAccount: account, teacherUser: user } = UserAndAccountTestFactory.buildTeacher(); + + await em.persistAndFlush([account, user]); + em.clear(); + + const loggedInClient = await testApiClient.login(account); + const expectedPermissions = user + .resolvePermissions() + // In this test the STUDENT_LIST permission is not set on the school and thus filtered out here. + // This is just an example. See the unit tests for all variations. + .filter((permission) => permission !== Permission.STUDENT_LIST); + const expectedResponse = mapToMeResponseObject(user, account, expectedPermissions); + + return { loggedInClient, expectedResponse }; + }; + + it('should respond with "me" information and status code 200', async () => { + const { loggedInClient, expectedResponse } = await setup(); + + const response = await loggedInClient.get(); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual(expectedResponse); + }); + }); + + describe('when user is an admin', () => { + const setup = async () => { + const { adminAccount: account, adminUser: user } = UserAndAccountTestFactory.buildAdmin(); + + await em.persistAndFlush([account, user]); + em.clear(); + + const loggedInClient = await testApiClient.login(account); + const expectedPermissions = user.resolvePermissions(); + const expectedResponse = mapToMeResponseObject(user, account, expectedPermissions); + + return { loggedInClient, expectedResponse }; + }; + + it('should respond with "me" information and status code 200', async () => { + const { loggedInClient, expectedResponse } = await setup(); + + const response = await loggedInClient.get(); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.body).toEqual(expectedResponse); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/me/domain/school-permission.service.spec.ts b/apps/server/src/modules/me/domain/school-permission.service.spec.ts new file mode 100644 index 00000000000..bfc7c6b0021 --- /dev/null +++ b/apps/server/src/modules/me/domain/school-permission.service.spec.ts @@ -0,0 +1,260 @@ +import { Test } from '@nestjs/testing'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { roleFactory, setupEntities, userFactory } from '@shared/testing'; +import { schoolFactory } from '@src/modules/school/testing'; +import { SchoolPermissionService } from './school-permission.service'; + +describe('SchoolPermissionService', () => { + let service: SchoolPermissionService; + + beforeAll(async () => { + await setupEntities(); + + const module = await Test.createTestingModule({ providers: [SchoolPermissionService] }).compile(); + + service = module.get(SchoolPermissionService); + }); + + describe('resolvePermissions', () => { + describe('when the user is neither teacher nor student', () => { + const setup = () => { + const somePermission = 'somePermission' as Permission; + const someRole = roleFactory.build({ name: 'someRole' as RoleName, permissions: [somePermission] }); + const someUser = userFactory.build({ roles: [someRole] }); + const school = schoolFactory.build(); + + return { someUser, school, somePermission }; + }; + + it('should return permissions from user', () => { + const { someUser, school, somePermission } = setup(); + + const permissions = service.resolvePermissions(someUser, school); + + expect(permissions).toEqual(new Set([somePermission])); + }); + }); + + describe('when the user is an admin', () => { + const setup = () => { + const somePermission = 'somePermission' as Permission; + const someRole = roleFactory.build({ name: RoleName.ADMINISTRATOR, permissions: [somePermission] }); + const someUser = userFactory.build({ roles: [someRole] }); + const school = schoolFactory.build(); + + return { someUser, school, somePermission }; + }; + + it('should return permissions from user', () => { + const { someUser, school, somePermission } = setup(); + + const permissions = service.resolvePermissions(someUser, school); + + expect(permissions).toEqual(new Set([somePermission])); + }); + }); + + describe('when the user is a student', () => { + describe('when student role has LERNSTORE_VIEW permission by default', () => { + const setupStudent = () => { + const studentRole = roleFactory.build({ name: RoleName.STUDENT, permissions: [Permission.LERNSTORE_VIEW] }); + const student = userFactory.build({ roles: [studentRole] }); + + return { student }; + }; + + describe('when school permission for LERNSTORE_VIEW is true', () => { + const setup = () => { + const school = schoolFactory.build({ permissions: { student: { LERNSTORE_VIEW: true } } }); + const { student } = setupStudent(); + + return { school, student }; + }; + + it('should return permissions including LERNSTORE_VIEW', () => { + const { student, school } = setup(); + + const permissions = service.resolvePermissions(student, school); + + expect(permissions).toContain(Permission.LERNSTORE_VIEW); + }); + }); + + describe('when school permission for LERNSTORE_VIEW is false', () => { + const setup = () => { + const school = schoolFactory.build({ permissions: { student: { LERNSTORE_VIEW: false } } }); + const { student } = setupStudent(); + + return { school, student }; + }; + + it('should return permissions not including LERNSTORE_VIEW', () => { + const { student, school } = setup(); + + const permissions = service.resolvePermissions(student, school); + + expect(permissions).not.toContain(Permission.LERNSTORE_VIEW); + }); + }); + + describe('when school permission for LERNSTORE_VIEW is not set', () => { + const setup = () => { + const school = schoolFactory.build(); + const { student } = setupStudent(); + + return { school, student }; + }; + + it('should return permissions not including LERNSTORE_VIEW', () => { + const { student, school } = setup(); + + const permissions = service.resolvePermissions(student, school); + + expect(permissions).not.toContain(Permission.LERNSTORE_VIEW); + }); + }); + }); + + describe('when student role does not have LERNSTORE_VIEW permission by default', () => { + const setupStudent = () => { + const studentRole = roleFactory.build({ name: RoleName.STUDENT }); + const student = userFactory.build({ roles: [studentRole] }); + + return { student }; + }; + + describe('when school permission for LERNSTORE_VIEW is true', () => { + const setup = () => { + const school = schoolFactory.build({ permissions: { student: { LERNSTORE_VIEW: true } } }); + const { student } = setupStudent(); + + return { school, student }; + }; + + it('should return permissions including LERNSTORE_VIEW', () => { + const { student, school } = setup(); + + const permissions = service.resolvePermissions(student, school); + + expect(permissions).toContain(Permission.LERNSTORE_VIEW); + }); + }); + + describe('when school permission for LERNSTORE_VIEW is false', () => { + const setup = () => { + const school = schoolFactory.build({ permissions: { student: { LERNSTORE_VIEW: false } } }); + const { student } = setupStudent(); + + return { school, student }; + }; + + it('should return permissions not including LERNSTORE_VIEW', () => { + const { student, school } = setup(); + + const permissions = service.resolvePermissions(student, school); + + expect(permissions).not.toContain(Permission.LERNSTORE_VIEW); + }); + }); + + describe('when school permission for LERNSTORE_VIEW is not set', () => { + const setup = () => { + const school = schoolFactory.build(); + const { student } = setupStudent(); + + return { school, student }; + }; + + it('should return permissions not including LERNSTORE_VIEW', () => { + const { student, school } = setup(); + + const permissions = service.resolvePermissions(student, school); + + expect(permissions).not.toContain(Permission.LERNSTORE_VIEW); + }); + }); + }); + }); + + describe('when the user is a teacher', () => { + // We could have the same distinction regarding default permissions here as above for students, but we leave it out for brevity. + const setupTeacher = () => { + const teacherRole = roleFactory.build({ name: RoleName.TEACHER }); + const teacher = userFactory.build({ roles: [teacherRole] }); + + return { teacher }; + }; + + describe('when school permission for STUDENT_LIST is true', () => { + const setup = () => { + const school = schoolFactory.build({ permissions: { teacher: { STUDENT_LIST: true } } }); + const { teacher } = setupTeacher(); + + return { school, teacher }; + }; + + it('should return permissions including STUDENT_LIST', () => { + const { teacher, school } = setup(); + + const permissions = service.resolvePermissions(teacher, school); + + expect(permissions).toContain(Permission.STUDENT_LIST); + }); + }); + + describe('when school permission for STUDENT_LIST is false', () => { + const setup = () => { + const school = schoolFactory.build({ permissions: { teacher: { STUDENT_LIST: false } } }); + const { teacher } = setupTeacher(); + + return { school, teacher }; + }; + + it('should return permissions not including STUDENT_LIST', () => { + const { teacher, school } = setup(); + + const permissions = service.resolvePermissions(teacher, school); + + expect(permissions).not.toContain(Permission.STUDENT_LIST); + }); + }); + + describe('when school permission for STUDENT_LIST is not set', () => { + const setup = () => { + const school = schoolFactory.build(); + const { teacher } = setupTeacher(); + + return { school, teacher }; + }; + + it('should return permissions not including STUDENT_LIST', () => { + const { teacher, school } = setup(); + + const permissions = service.resolvePermissions(teacher, school); + + expect(permissions).not.toContain(Permission.STUDENT_LIST); + }); + }); + }); + + // There are more variations for users with multiple roles, but we restrict the tests to this most relevant one. + describe('when the user is both teacher and admin and school permission for STUDENT_LIST is falsy', () => { + const setup = () => { + const teacherRole = roleFactory.build({ name: RoleName.TEACHER }); + const adminRole = roleFactory.build({ name: RoleName.ADMINISTRATOR, permissions: [Permission.STUDENT_LIST] }); + const user = userFactory.build({ roles: [teacherRole, adminRole] }); + const school = schoolFactory.build(); + + return { user, school }; + }; + + it('should not withdraw STUDENT_LIST permissions', () => { + const { user, school } = setup(); + + const permissions = service.resolvePermissions(user, school); + + expect(permissions).toContain(Permission.STUDENT_LIST); + }); + }); + }); +}); diff --git a/apps/server/src/modules/me/domain/school-permission.service.ts b/apps/server/src/modules/me/domain/school-permission.service.ts new file mode 100644 index 00000000000..32fb1eab97f --- /dev/null +++ b/apps/server/src/modules/me/domain/school-permission.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { User } from '@shared/domain/entity'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { School } from '@src/modules/school'; + +@Injectable() +export class SchoolPermissionService { + // TODO: refactor it in https://ticketsystem.dbildungscloud.de/browse/BC-7021 + public resolvePermissions(user: User, school: School): Set { + const userPermissions = user.resolvePermissions(); + const schoolPermissions = school.getPermissions(); + + const permissions = new Set(userPermissions); + + if (user.getRoles().some((role) => role.name === RoleName.ADMINISTRATOR)) { + return permissions; + } + + if (user.getRoles().some((role) => role.name === RoleName.STUDENT)) { + if (schoolPermissions?.student?.LERNSTORE_VIEW) { + permissions.add(Permission.LERNSTORE_VIEW); + } else { + permissions.delete(Permission.LERNSTORE_VIEW); + } + } + + if (user.getRoles().some((role) => role.name === RoleName.TEACHER)) { + if (schoolPermissions?.teacher?.STUDENT_LIST) { + permissions.add(Permission.STUDENT_LIST); + } else { + permissions.delete(Permission.STUDENT_LIST); + } + } + + return permissions; + } +} diff --git a/apps/server/src/modules/me/index.ts b/apps/server/src/modules/me/index.ts new file mode 100644 index 00000000000..5075fc591e9 --- /dev/null +++ b/apps/server/src/modules/me/index.ts @@ -0,0 +1 @@ +// Nothing to export diff --git a/apps/server/src/modules/me/me-api.module.ts b/apps/server/src/modules/me/me-api.module.ts new file mode 100644 index 00000000000..633e7921d11 --- /dev/null +++ b/apps/server/src/modules/me/me-api.module.ts @@ -0,0 +1,13 @@ +import { AuthenticationModule } from '@modules/authentication'; +import { SchoolModule } from '@modules/school'; +import { UserModule } from '@modules/user'; +import { Module } from '@nestjs/common'; +import { MeController, MeUc } from './api'; +import { SchoolPermissionService } from './domain/school-permission.service'; + +@Module({ + imports: [SchoolModule, UserModule, AuthenticationModule], + controllers: [MeController], + providers: [MeUc, SchoolPermissionService], +}) +export class MeApiModule {} diff --git a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts index d7a1e0faeff..b49efcc5cd3 100644 --- a/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts +++ b/apps/server/src/modules/meta-tag-extractor/meta-tag-extractor.module.ts @@ -1,15 +1,15 @@ +import { ConsoleWriterModule } from '@infra/console'; +import { AuthenticationModule } from '@modules/authentication/authentication.module'; +import { BoardModule } from '@modules/board'; +import { LearnroomModule } from '@modules/learnroom'; +import { LessonModule } from '@modules/lesson'; +import { TaskModule } from '@modules/task'; +import { UserModule } from '@modules/user'; import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { ConsoleWriterModule } from '@infra/console'; import { createConfigModuleOptions } from '@src/config'; import { LoggerModule } from '@src/core/logger'; -import { AuthenticationModule } from '../authentication/authentication.module'; -import { BoardModule } from '../board'; -import { LearnroomModule } from '../learnroom'; -import { LessonModule } from '../lesson'; -import { TaskModule } from '../task'; -import { UserModule } from '../user'; import metaTagExtractorConfig from './meta-tag-extractor.config'; import { MetaTagExtractorService } from './service'; import { MetaTagInternalUrlService } from './service/meta-tag-internal-url.service'; diff --git a/apps/server/src/modules/news/controller/dto/news.response.ts b/apps/server/src/modules/news/controller/dto/news.response.ts index 766f40e19c2..0d9fe9e13b2 100644 --- a/apps/server/src/modules/news/controller/dto/news.response.ts +++ b/apps/server/src/modules/news/controller/dto/news.response.ts @@ -103,7 +103,7 @@ export class NewsResponse { @ApiProperty({ description: 'Reference to the User that created the News entity', }) - creator: UserInfoResponse; + creator?: UserInfoResponse; @ApiPropertyOptional({ description: 'Reference to the User that updated the News entity', diff --git a/apps/server/src/modules/news/mapper/news.mapper.spec.ts b/apps/server/src/modules/news/mapper/news.mapper.spec.ts index 4ffa8228045..de81fae7dcd 100644 --- a/apps/server/src/modules/news/mapper/news.mapper.spec.ts +++ b/apps/server/src/modules/news/mapper/news.mapper.spec.ts @@ -10,7 +10,7 @@ import { User, } from '@shared/domain/entity'; import { CreateNews, INewsScope, IUpdateNews, NewsTarget, NewsTargetModel } from '@shared/domain/types'; -import { courseFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { courseFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { CreateNewsParams, FilterNewsParams, @@ -117,7 +117,7 @@ describe('NewsMapper', () => { describe('mapToResponse', () => { it('should correctly map school news to Dto', () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const creator = userFactory.build(); const newsProps = { title: 'test title', content: 'test content' }; const schoolNews = createNews(newsProps, SchoolNews, school, creator, school); @@ -127,7 +127,7 @@ describe('NewsMapper', () => { expect(result).toStrictEqual(expected); }); it('should correctly map course news to dto', () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const creator = userFactory.build(); const course = courseFactory.build({ school }); const newsProps = { title: 'test title', content: 'test content' }; @@ -139,7 +139,7 @@ describe('NewsMapper', () => { expect(result).toStrictEqual(expected); }); it('should correctly map team news to dto', () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const team = new TeamEntity({ name: 'team #1' }); const creator = userFactory.build(); const newsProps = { title: 'test title', content: 'test content' }; diff --git a/apps/server/src/modules/news/mapper/news.mapper.ts b/apps/server/src/modules/news/mapper/news.mapper.ts index 30bf13e6540..f410e1183fe 100644 --- a/apps/server/src/modules/news/mapper/news.mapper.ts +++ b/apps/server/src/modules/news/mapper/news.mapper.ts @@ -10,7 +10,6 @@ export class NewsMapper { static mapToResponse(news: News): NewsResponse { const target = TargetInfoMapper.mapToResponse(news.target); const school = SchoolInfoMapper.mapToResponse(news.school); - const creator = UserInfoMapper.mapToResponse(news.creator); const dto = new NewsResponse({ id: news.id, @@ -23,12 +22,14 @@ export class NewsMapper { targetModel: news.targetModel, target, school, - creator, createdAt: news.createdAt, updatedAt: news.updatedAt, permissions: news.permissions, }); + if (news.creator) { + dto.creator = UserInfoMapper.mapToResponse(news.creator); + } if (news.updater) { dto.updater = UserInfoMapper.mapToResponse(news.updater); } diff --git a/apps/server/src/modules/news/news.module.ts b/apps/server/src/modules/news/news.module.ts index 3766b26052e..0ae71e0ca54 100644 --- a/apps/server/src/modules/news/news.module.ts +++ b/apps/server/src/modules/news/news.module.ts @@ -2,14 +2,16 @@ import { Module } from '@nestjs/common'; import { NewsRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@modules/authorization'; +import { CqrsModule } from '@nestjs/cqrs'; import { NewsController } from './controller/news.controller'; import { TeamNewsController } from './controller/team-news.controller'; import { NewsUc } from './uc/news.uc'; +import { NewsService } from './service/news.service'; @Module({ - imports: [AuthorizationModule, LoggerModule], + imports: [AuthorizationModule, CqrsModule, LoggerModule], controllers: [NewsController, TeamNewsController], - providers: [NewsUc, NewsRepo], - exports: [NewsUc], + providers: [NewsUc, NewsRepo, NewsService], + exports: [NewsUc, NewsService], }) export class NewsModule {} diff --git a/apps/server/src/modules/news/service/news.service.spec.ts b/apps/server/src/modules/news/service/news.service.spec.ts new file mode 100644 index 00000000000..4e5fe11c67a --- /dev/null +++ b/apps/server/src/modules/news/service/news.service.spec.ts @@ -0,0 +1,205 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { setupEntities, teamNewsFactory, userFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { NewsRepo } from '@shared/repo'; +import { EventBus } from '@nestjs/cqrs'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DataDeletedEvent, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; +import { NewsService } from './news.service'; + +describe(NewsService.name, () => { + let module: TestingModule; + let service: NewsService; + let repo: DeepMocked; + let eventBus: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + NewsService, + { + provide: NewsRepo, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(NewsService); + repo = module.get(NewsRepo); + eventBus = module.get(EventBus); + + await setupEntities(); + }); + + afterEach(() => { + repo.findByCreatorOrUpdaterId.mockClear(); + repo.save.mockClear(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('deleteCreatorReference', () => { + const setup = () => { + const user1 = userFactory.build(); + const user2 = userFactory.build(); + const anotherUserId = new ObjectId().toHexString(); + + const news1 = teamNewsFactory.buildWithId({ + creator: user1, + }); + const news2 = teamNewsFactory.buildWithId({ + updater: user2, + }); + const news3 = teamNewsFactory.buildWithId({ + creator: user1, + updater: user2, + }); + + const expectedResultWithDeletedCreator = DomainDeletionReportBuilder.build(DomainName.NEWS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [news1.id, news3.id]), + ]); + + const expectedResultWithDeletedUpdater = DomainDeletionReportBuilder.build(DomainName.NEWS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [news2.id, news3.id]), + ]); + + const expectedResultWithoutUpdatedNews = DomainDeletionReportBuilder.build(DomainName.NEWS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 0, []), + ]); + + return { + anotherUserId, + expectedResultWithDeletedCreator, + expectedResultWithDeletedUpdater, + expectedResultWithoutUpdatedNews, + user1, + user2, + news1, + news2, + news3, + }; + }; + + describe('when user is creator of news', () => { + it('it should be removed from news', async () => { + const { user1, news1, news3 } = setup(); + + repo.findByCreatorOrUpdaterId.mockResolvedValueOnce([[news1, news3], 2]); + + await service.deleteUserData(user1.id); + + expect(news1.creator).toBeUndefined(); + expect(news3.creator).toBeUndefined(); + }); + + it('it should return response for 2 news updated', async () => { + const { expectedResultWithDeletedCreator, user1, news1, news3 } = setup(); + + repo.findByCreatorOrUpdaterId.mockResolvedValueOnce([[news1, news3], 2]); + + const result = await service.deleteUserData(user1.id); + + expect(result).toEqual(expectedResultWithDeletedCreator); + }); + }); + + describe('when user is updater of news', () => { + it('user should be removed from updater', async () => { + const { user2, news2, news3 } = setup(); + + repo.findByCreatorOrUpdaterId.mockResolvedValueOnce([[news2, news3], 2]); + + await service.deleteUserData(user2.id); + + expect(news2.updater).toBeUndefined(); + expect(news3.updater).toBeUndefined(); + }); + + it('it should return response for 2 news updated', async () => { + const { expectedResultWithDeletedUpdater, user2, news2, news3 } = setup(); + + repo.findByCreatorOrUpdaterId.mockResolvedValueOnce([[news2, news3], 2]); + + const result = await service.deleteUserData(user2.id); + + expect(result).toEqual(expectedResultWithDeletedUpdater); + }); + }); + + describe('when user is neither creator nor updater', () => { + it('should return response with 0 updated news', async () => { + const { anotherUserId, expectedResultWithoutUpdatedNews } = setup(); + + repo.findByCreatorOrUpdaterId.mockResolvedValueOnce([[], 0]); + + const result = await service.deleteUserData(anotherUserId); + + expect(result).toEqual(expectedResultWithoutUpdatedNews); + }); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILERECORDS; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in classService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(service.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); + }); + }); + }); +}); diff --git a/apps/server/src/modules/news/service/news.service.ts b/apps/server/src/modules/news/service/news.service.ts new file mode 100644 index 00000000000..a7d871cf21a --- /dev/null +++ b/apps/server/src/modules/news/service/news.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { Logger } from '@src/core/logger'; +import { NewsRepo } from '@shared/repo'; +import { News } from '@shared/domain/entity'; +import { IEventHandler, EventBus, EventsHandler } from '@nestjs/cqrs'; +import { + UserDeletedEvent, + DeletionService, + DataDeletedEvent, + DomainDeletionReport, + DomainName, + DomainDeletionReportBuilder, + DomainOperationReportBuilder, + OperationType, + DataDeletionDomainOperationLoggable, + StatusModel, +} from '@modules/deletion'; + +@Injectable() +@EventsHandler(UserDeletedEvent) +export class NewsService implements DeletionService, IEventHandler { + constructor( + private readonly newsRepo: NewsRepo, + private readonly logger: Logger, + private readonly eventBus: EventBus + ) { + this.logger.setContext(NewsService.name); + } + + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + + public async deleteUserData(userId: EntityId): Promise { + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'Deleting user data from News', + DomainName.NEWS, + userId, + StatusModel.PENDING + ) + ); + + const [newsWithUserData, counterOfNews] = await this.newsRepo.findByCreatorOrUpdaterId(userId); + + newsWithUserData.forEach((newsEntity) => { + newsEntity.removeCreatorReference(userId); + newsEntity.removeUpdaterReference(userId); + }); + + await this.newsRepo.save(newsWithUserData); + + const result = DomainDeletionReportBuilder.build(DomainName.NEWS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, counterOfNews, this.getNewsId(newsWithUserData)), + ]); + + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'Successfully removed user data from News', + DomainName.NEWS, + userId, + StatusModel.FINISHED, + counterOfNews, + 0 + ) + ); + return result; + } + + private getNewsId(news: News[]): EntityId[] { + return news.map((item) => item.id); + } +} diff --git a/apps/server/src/modules/news/uc/news.uc.spec.ts b/apps/server/src/modules/news/uc/news.uc.spec.ts index 45250a28935..c55a01d754c 100644 --- a/apps/server/src/modules/news/uc/news.uc.spec.ts +++ b/apps/server/src/modules/news/uc/news.uc.spec.ts @@ -27,6 +27,7 @@ describe('NewsUc', () => { const exampleCourseNews = { _id: newsId, displayAt, + updater: undefined, targetModel: NewsTargetModel.Course, target: { id: courseTargetId, @@ -281,6 +282,7 @@ describe('NewsUc', () => { const updatedNews = await service.update(newsId, userId, params); expect(updatedNews.title).toBe(params.title); expect(updatedNews.content).toBe(params.content); + expect(updatedNews.updater).toBe(userId); }); it('should throw Unauthorized exception if user has no NEWS_EDIT permissions', async () => { diff --git a/apps/server/src/modules/news/uc/news.uc.ts b/apps/server/src/modules/news/uc/news.uc.ts index 2983010fcb6..7949fe3859e 100644 --- a/apps/server/src/modules/news/uc/news.uc.ts +++ b/apps/server/src/modules/news/uc/news.uc.ts @@ -122,6 +122,7 @@ export class NewsUc { news[key] = value; } } + Object.assign(news, { updater: userId }); await this.newsRepo.save(news); diff --git a/apps/server/src/modules/oauth-provider/oauth-provider-api.module.ts b/apps/server/src/modules/oauth-provider/oauth-provider-api.module.ts index bf131e22eee..0d9801d19e3 100644 --- a/apps/server/src/modules/oauth-provider/oauth-provider-api.module.ts +++ b/apps/server/src/modules/oauth-provider/oauth-provider-api.module.ts @@ -1,9 +1,9 @@ -import { Module } from '@nestjs/common'; import { OauthProviderServiceModule } from '@infra/oauth-provider'; -import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@modules/authorization'; import { PseudonymModule } from '@modules/pseudonym'; import { UserModule } from '@modules/user'; +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; import { OauthProviderController } from './controller/oauth-provider.controller'; import { OauthProviderResponseMapper } from './mapper/oauth-provider-response.mapper'; import { OauthProviderModule } from './oauth-provider.module'; diff --git a/apps/server/src/modules/oauth-provider/oauth-provider.module.ts b/apps/server/src/modules/oauth-provider/oauth-provider.module.ts index 7483ad140e5..35a1b9ece82 100644 --- a/apps/server/src/modules/oauth-provider/oauth-provider.module.ts +++ b/apps/server/src/modules/oauth-provider/oauth-provider.module.ts @@ -1,12 +1,12 @@ -import { Module } from '@nestjs/common'; import { OauthProviderServiceModule } from '@infra/oauth-provider'; -import { TeamsRepo } from '@shared/repo'; -import { LoggerModule } from '@src/core/logger'; import { LtiToolModule } from '@modules/lti-tool'; import { PseudonymModule } from '@modules/pseudonym'; import { ToolModule } from '@modules/tool'; import { ToolConfigModule } from '@modules/tool/tool-config.module'; import { UserModule } from '@modules/user'; +import { Module } from '@nestjs/common'; +import { TeamsRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; import { IdTokenService } from './service/id-token.service'; import { OauthProviderLoginFlowService } from './service/oauth-provider.login-flow.service'; diff --git a/apps/server/src/modules/oauth/loggable/auth-code-failure-loggable-exception.ts b/apps/server/src/modules/oauth/loggable/auth-code-failure-loggable-exception.ts index 3798d8d8b73..ebe1ce78454 100644 --- a/apps/server/src/modules/oauth/loggable/auth-code-failure-loggable-exception.ts +++ b/apps/server/src/modules/oauth/loggable/auth-code-failure-loggable-exception.ts @@ -1,12 +1,12 @@ -import { ErrorLogMessage, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -import { OauthSsoErrorLoggableException } from './oauth-sso-error-loggable-exception'; +import { InternalServerErrorException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -export class AuthCodeFailureLoggableException extends OauthSsoErrorLoggableException { +export class AuthCodeFailureLoggableException extends InternalServerErrorException implements Loggable { constructor(private readonly errorCode?: string) { super(errorCode ?? 'sso_auth_code_step', 'Authorization Query Object has no authorization code or error'); } - override getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { return { type: 'SSO_AUTH_CODE_STEP', message: 'Authorization Query Object has no authorization code or error', diff --git a/apps/server/src/modules/oauth/loggable/id-token-extraction-failure-loggable-exception.spec.ts b/apps/server/src/modules/oauth/loggable/id-token-extraction-failure-loggable-exception.spec.ts index 2c0ed9d0e02..584e58069e3 100644 --- a/apps/server/src/modules/oauth/loggable/id-token-extraction-failure-loggable-exception.spec.ts +++ b/apps/server/src/modules/oauth/loggable/id-token-extraction-failure-loggable-exception.spec.ts @@ -4,7 +4,9 @@ describe(IdTokenExtractionFailureLoggableException.name, () => { describe('getLogMessage', () => { const setup = () => { const fieldName = 'id_token'; + const exception = new IdTokenExtractionFailureLoggableException(fieldName); + return { exception, fieldName }; }; @@ -14,7 +16,7 @@ describe(IdTokenExtractionFailureLoggableException.name, () => { const logMessage = exception.getLogMessage(); expect(logMessage).toEqual({ - type: 'SSO_JWT_PROBLEM', + type: 'ID_TOKEN_EXTRACTION_FAILURE', message: 'Failed to extract field', stack: exception.stack, data: { diff --git a/apps/server/src/modules/oauth/loggable/id-token-extraction-failure-loggable-exception.ts b/apps/server/src/modules/oauth/loggable/id-token-extraction-failure-loggable-exception.ts index 57c5d354d74..f7c0cd8e5a5 100644 --- a/apps/server/src/modules/oauth/loggable/id-token-extraction-failure-loggable-exception.ts +++ b/apps/server/src/modules/oauth/loggable/id-token-extraction-failure-loggable-exception.ts @@ -1,15 +1,23 @@ -import { ErrorLogMessage, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -import { OauthSsoErrorLoggableException } from './oauth-sso-error-loggable-exception'; +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -export class IdTokenExtractionFailureLoggableException extends OauthSsoErrorLoggableException { +export class IdTokenExtractionFailureLoggableException extends BusinessError implements Loggable { constructor(private readonly fieldName: string) { - super(); + super( + { + type: 'ID_TOKEN_EXTRACTION_FAILURE', + title: 'Id token extraction failure', + defaultMessage: 'Failed to extract field', + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); } - override getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { return { - type: 'SSO_JWT_PROBLEM', - message: 'Failed to extract field', + type: this.type, + message: this.message, stack: this.stack, data: { fieldName: this.fieldName, diff --git a/apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.spec.ts b/apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.spec.ts index 754779962bc..1fe18e735cb 100644 --- a/apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.spec.ts +++ b/apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.spec.ts @@ -13,7 +13,7 @@ describe(IdTokenInvalidLoggableException.name, () => { const logMessage = exception.getLogMessage(); expect(logMessage).toEqual({ - type: 'SSO_JWT_PROBLEM', + type: 'ID_TOKEN_INVALID', message: 'Failed to validate idToken', stack: expect.any(String), }); diff --git a/apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.ts b/apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.ts index 20a24b28ca1..be76b5e58d8 100644 --- a/apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.ts +++ b/apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.ts @@ -1,11 +1,23 @@ -import { ErrorLogMessage, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -import { OauthSsoErrorLoggableException } from './oauth-sso-error-loggable-exception'; +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -export class IdTokenInvalidLoggableException extends OauthSsoErrorLoggableException { - override getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { +export class IdTokenInvalidLoggableException extends BusinessError implements Loggable { + constructor() { + super( + { + type: 'ID_TOKEN_INVALID', + title: 'Id token invalid', + defaultMessage: 'Failed to validate idToken', + }, + HttpStatus.UNAUTHORIZED + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { return { - type: 'SSO_JWT_PROBLEM', - message: 'Failed to validate idToken', + type: this.type, + message: this.message, stack: this.stack, }; } diff --git a/apps/server/src/modules/oauth/loggable/id-token-user-not-found-loggable-exception.spec.ts b/apps/server/src/modules/oauth/loggable/id-token-user-not-found-loggable-exception.spec.ts index 65af754db8d..d6aa2a3f4e7 100644 --- a/apps/server/src/modules/oauth/loggable/id-token-user-not-found-loggable-exception.spec.ts +++ b/apps/server/src/modules/oauth/loggable/id-token-user-not-found-loggable-exception.spec.ts @@ -21,8 +21,8 @@ describe(IdTokenUserNotFoundLoggableException.name, () => { const logMessage = exception.getLogMessage(); expect(logMessage).toEqual({ - type: 'SSO_USER_NOTFOUND', - message: 'Failed to find user with uuid from id token', + type: 'USER_NOT_FOUND', + message: 'Failed to find user with uuid from id token.', stack: exception.stack, data: { uuid, diff --git a/apps/server/src/modules/oauth/loggable/id-token-user-not-found-loggable-exception.ts b/apps/server/src/modules/oauth/loggable/id-token-user-not-found-loggable-exception.ts index 70c6f56ea96..50c4069950b 100644 --- a/apps/server/src/modules/oauth/loggable/id-token-user-not-found-loggable-exception.ts +++ b/apps/server/src/modules/oauth/loggable/id-token-user-not-found-loggable-exception.ts @@ -1,15 +1,23 @@ -import { ErrorLogMessage, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -import { OauthSsoErrorLoggableException } from './oauth-sso-error-loggable-exception'; +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -export class IdTokenUserNotFoundLoggableException extends OauthSsoErrorLoggableException { +export class IdTokenUserNotFoundLoggableException extends BusinessError implements Loggable { constructor(private readonly uuid: string, private readonly additionalInfo?: string) { - super(); + super( + { + type: 'USER_NOT_FOUND', + title: 'User not found', + defaultMessage: 'Failed to find user with uuid from id token.', + }, + HttpStatus.NOT_FOUND + ); } - override getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { return { - type: 'SSO_USER_NOTFOUND', - message: 'Failed to find user with uuid from id token', + type: this.type, + message: this.message, stack: this.stack, data: { uuid: this.uuid, diff --git a/apps/server/src/modules/oauth/loggable/index.ts b/apps/server/src/modules/oauth/loggable/index.ts index ebd14914f4f..53234c5f3a9 100644 --- a/apps/server/src/modules/oauth/loggable/index.ts +++ b/apps/server/src/modules/oauth/loggable/index.ts @@ -1,7 +1,6 @@ -export * from './user-not-found-after-provisioning.loggable-exception'; -export * from './token-request-loggable-exception'; -export { OauthSsoErrorLoggableException } from './oauth-sso-error-loggable-exception'; export { AuthCodeFailureLoggableException } from './auth-code-failure-loggable-exception'; +export { UserNotFoundAfterProvisioningLoggableException } from './user-not-found-after-provisioning.loggable-exception'; +export { TokenRequestLoggableException } from './token-request-loggable-exception'; export { IdTokenInvalidLoggableException } from './id-token-invalid-loggable-exception'; export { OauthConfigMissingLoggableException } from './oauth-config-missing-loggable-exception'; export { IdTokenExtractionFailureLoggableException } from './id-token-extraction-failure-loggable-exception'; diff --git a/apps/server/src/modules/oauth/loggable/oauth-config-missing-loggable-exception.spec.ts b/apps/server/src/modules/oauth/loggable/oauth-config-missing-loggable-exception.spec.ts index 9fa1c8c576a..cf3bd4d9440 100644 --- a/apps/server/src/modules/oauth/loggable/oauth-config-missing-loggable-exception.spec.ts +++ b/apps/server/src/modules/oauth/loggable/oauth-config-missing-loggable-exception.spec.ts @@ -1,10 +1,11 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { OauthConfigMissingLoggableException } from './oauth-config-missing-loggable-exception'; describe(OauthConfigMissingLoggableException.name, () => { describe('getLogMessage', () => { const setup = () => { const systemId = new ObjectId().toHexString(); + const exception = new OauthConfigMissingLoggableException(systemId); return { @@ -19,7 +20,7 @@ describe(OauthConfigMissingLoggableException.name, () => { const logMessage = exception.getLogMessage(); expect(logMessage).toEqual({ - type: 'SSO_INTERNAL_ERROR', + type: 'OAUTH_CONFIG_MISSING', message: 'Requested system has no oauth configured', stack: exception.stack, data: { diff --git a/apps/server/src/modules/oauth/loggable/oauth-config-missing-loggable-exception.ts b/apps/server/src/modules/oauth/loggable/oauth-config-missing-loggable-exception.ts index cfff342ff51..d36550ac44c 100644 --- a/apps/server/src/modules/oauth/loggable/oauth-config-missing-loggable-exception.ts +++ b/apps/server/src/modules/oauth/loggable/oauth-config-missing-loggable-exception.ts @@ -1,15 +1,23 @@ -import { ErrorLogMessage, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -import { OauthSsoErrorLoggableException } from './oauth-sso-error-loggable-exception'; +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -export class OauthConfigMissingLoggableException extends OauthSsoErrorLoggableException { +export class OauthConfigMissingLoggableException extends BusinessError implements Loggable { constructor(private readonly systemId: string) { - super(); + super( + { + type: 'OAUTH_CONFIG_MISSING', + title: 'Oauth config missing', + defaultMessage: 'Requested system has no oauth configured', + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); } - override getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { return { - type: 'SSO_INTERNAL_ERROR', - message: 'Requested system has no oauth configured', + type: this.type, + message: this.message, stack: this.stack, data: { systemId: this.systemId, diff --git a/apps/server/src/modules/oauth/loggable/oauth-sso-error-loggable-exception.spec.ts b/apps/server/src/modules/oauth/loggable/oauth-sso-error-loggable-exception.spec.ts deleted file mode 100644 index 79776582f25..00000000000 --- a/apps/server/src/modules/oauth/loggable/oauth-sso-error-loggable-exception.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { OauthSsoErrorLoggableException } from './oauth-sso-error-loggable-exception'; - -describe(OauthSsoErrorLoggableException.name, () => { - describe('getLogMessage', () => { - const setup = () => { - const exception = new OauthSsoErrorLoggableException(); - - return { - exception, - }; - }; - - it('should return a LogMessage', () => { - const { exception } = setup(); - - const result = exception.getLogMessage(); - - expect(result).toEqual({ - type: 'SSO_LOGIN_FAILED', - message: 'Internal Server Error', - stack: expect.any(String), - }); - }); - }); -}); diff --git a/apps/server/src/modules/oauth/loggable/oauth-sso-error-loggable-exception.ts b/apps/server/src/modules/oauth/loggable/oauth-sso-error-loggable-exception.ts deleted file mode 100644 index 12593554163..00000000000 --- a/apps/server/src/modules/oauth/loggable/oauth-sso-error-loggable-exception.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { InternalServerErrorException } from '@nestjs/common'; -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; - -export class OauthSsoErrorLoggableException extends InternalServerErrorException implements Loggable { - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { - return { - type: 'SSO_LOGIN_FAILED', - message: this.message, - stack: this.stack, - }; - } -} diff --git a/apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.spec.ts b/apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.spec.ts index a2fd8158e08..fa8542473ff 100644 --- a/apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.spec.ts +++ b/apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.spec.ts @@ -27,6 +27,7 @@ describe('UserNotFoundAfterProvisioningLoggableException', () => { const message = exception.getLogMessage(); expect(message).toEqual({ + type: 'USER_NOT_FOUND_AFTER_PROVISIONING', message: 'Unable to find user after provisioning. The feature for OAuth2 provisioning might be disabled for this school.', stack: expect.any(String), diff --git a/apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.ts b/apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.ts index b1f7d243d3f..1ec225a0e4b 100644 --- a/apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.ts +++ b/apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.ts @@ -1,21 +1,28 @@ +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; import { EntityId } from '@shared/domain/types'; import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -import { OauthSsoErrorLoggableException } from './oauth-sso-error-loggable-exception'; -export class UserNotFoundAfterProvisioningLoggableException extends OauthSsoErrorLoggableException implements Loggable { +export class UserNotFoundAfterProvisioningLoggableException extends BusinessError implements Loggable { constructor( private readonly externalUserId: string, private readonly systemId: EntityId, private readonly officialSchoolNumber?: string ) { super( - 'Unable to find user after provisioning. The feature for OAuth2 provisioning might be disabled for this school.', - 'sso_user_not_found_after_provisioning' + { + type: 'USER_NOT_FOUND_AFTER_PROVISIONING', + title: 'User not found after provisioning', + defaultMessage: + 'Unable to find user after provisioning. The feature for OAuth2 provisioning might be disabled for this school.', + }, + HttpStatus.INTERNAL_SERVER_ERROR ); } - override getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { return { + type: this.type, message: this.message, stack: this.stack, data: { diff --git a/apps/server/src/modules/oauth/oauth.module.ts b/apps/server/src/modules/oauth/oauth.module.ts index 3898a2e8547..d9dc4927a49 100644 --- a/apps/server/src/modules/oauth/oauth.module.ts +++ b/apps/server/src/modules/oauth/oauth.module.ts @@ -10,9 +10,7 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { LtiToolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { HydraSsoService } from './service/hydra.service'; -import { OauthAdapterService } from './service/oauth-adapter.service'; -import { OAuthService } from './service/oauth.service'; +import { HydraSsoService, OauthAdapterService, OAuthService } from './service'; @Module({ imports: [ @@ -28,6 +26,6 @@ import { OAuthService } from './service/oauth.service'; LegacySchoolModule, ], providers: [OAuthService, OauthAdapterService, HydraSsoService, LtiToolRepo], - exports: [OAuthService, HydraSsoService], + exports: [OAuthService, HydraSsoService, OauthAdapterService], }) export class OauthModule {} diff --git a/apps/server/src/modules/oauth/service/dto/client-credentials-grant-token-request.ts b/apps/server/src/modules/oauth/service/dto/client-credentials-grant-token-request.ts new file mode 100644 index 00000000000..5963a0f4af9 --- /dev/null +++ b/apps/server/src/modules/oauth/service/dto/client-credentials-grant-token-request.ts @@ -0,0 +1,18 @@ +import { OAuthGrantType } from '../../interface/oauth-grant-type.enum'; + +export class ClientCredentialsGrantTokenRequest { + client_id: string; + + client_secret: string; + + scope?: string; + + grant_type: OAuthGrantType.CLIENT_CREDENTIALS_GRANT; + + constructor(props: ClientCredentialsGrantTokenRequest) { + this.client_id = props.client_id; + this.client_secret = props.client_secret; + this.scope = props.scope; + this.grant_type = props.grant_type; + } +} diff --git a/apps/server/src/modules/oauth/service/dto/index.ts b/apps/server/src/modules/oauth/service/dto/index.ts index dbe924b094c..fa158fe08bc 100644 --- a/apps/server/src/modules/oauth/service/dto/index.ts +++ b/apps/server/src/modules/oauth/service/dto/index.ts @@ -3,3 +3,4 @@ export * from './oauth-token.response'; export * from './oauth-process.dto'; export * from './cookies.dto'; export * from './hydra.redirect.dto'; +export { ClientCredentialsGrantTokenRequest } from './client-credentials-grant-token-request'; diff --git a/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts b/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts index af03a6fdda2..63319b859d4 100644 --- a/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts @@ -5,6 +5,7 @@ import { axiosResponseFactory } from '@shared/testing'; import { axiosErrorFactory } from '@shared/testing/factory'; import { AxiosError } from 'axios'; import { of, throwError } from 'rxjs'; +import { OAuthTokenDto } from '../interface'; import { OAuthGrantType } from '../interface/oauth-grant-type.enum'; import { TokenRequestLoggableException } from '../loggable'; import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; @@ -65,7 +66,7 @@ describe('OauthAdapterServive', () => { }); }); - describe('sendRequestToken', () => { + describe('sendTokenRequest', () => { const tokenResponse: OauthTokenResponse = { access_token: 'accessToken', refresh_token: 'refreshToken', @@ -85,12 +86,13 @@ describe('OauthAdapterServive', () => { describe('when it requests a token', () => { it('should get token from the external server', async () => { - const responseToken: OauthTokenResponse = await service.sendAuthenticationCodeTokenRequest( - 'tokenEndpoint', - testPayload - ); + const responseToken: OAuthTokenDto = await service.sendTokenRequest('tokenEndpoint', testPayload); - expect(responseToken).toStrictEqual(tokenResponse); + expect(responseToken).toEqual({ + idToken: tokenResponse.id_token, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + }); }); }); @@ -107,7 +109,7 @@ describe('OauthAdapterServive', () => { it('should throw an error', async () => { const { error } = setup(); - const resp = service.sendAuthenticationCodeTokenRequest('tokenEndpoint', testPayload); + const resp = service.sendTokenRequest('tokenEndpoint', testPayload); await expect(resp).rejects.toEqual(error); }); @@ -127,7 +129,7 @@ describe('OauthAdapterServive', () => { it('should throw the default sso error', async () => { const { error } = setup(); - const resp = service.sendAuthenticationCodeTokenRequest('tokenEndpoint', testPayload); + const resp = service.sendTokenRequest('tokenEndpoint', testPayload); await expect(resp).rejects.toEqual(error); }); @@ -150,7 +152,7 @@ describe('OauthAdapterServive', () => { it('should throw an error', async () => { const { axiosError } = setup(); - const resp = service.sendAuthenticationCodeTokenRequest('tokenEndpoint', testPayload); + const resp = service.sendTokenRequest('tokenEndpoint', testPayload); await expect(resp).rejects.toEqual(new TokenRequestLoggableException(axiosError)); }); diff --git a/apps/server/src/modules/oauth/service/oauth-adapter.service.ts b/apps/server/src/modules/oauth/service/oauth-adapter.service.ts index 4ab048b84c4..177aa98eed8 100644 --- a/apps/server/src/modules/oauth/service/oauth-adapter.service.ts +++ b/apps/server/src/modules/oauth/service/oauth-adapter.service.ts @@ -4,8 +4,10 @@ import { AxiosResponse, isAxiosError } from 'axios'; import JwksRsa from 'jwks-rsa'; import QueryString from 'qs'; import { lastValueFrom, Observable } from 'rxjs'; +import { OAuthTokenDto } from '../interface'; import { TokenRequestLoggableException } from '../loggable'; -import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; +import { TokenRequestMapper } from '../mapper/token-request.mapper'; +import { AuthenticationCodeGrantTokenRequest, ClientCredentialsGrantTokenRequest, OauthTokenResponse } from './dto'; @Injectable() export class OauthAdapterService { @@ -16,28 +18,34 @@ export class OauthAdapterService { cache: true, jwksUri, }); + const key: JwksRsa.SigningKey = await client.getSigningKey(); + return key.getPublicKey(); } - public sendAuthenticationCodeTokenRequest( + public async sendTokenRequest( tokenEndpoint: string, - payload: AuthenticationCodeGrantTokenRequest - ): Promise { + payload: AuthenticationCodeGrantTokenRequest | ClientCredentialsGrantTokenRequest + ): Promise { const urlEncodedPayload: string = QueryString.stringify(payload); + const responseTokenObservable = this.httpService.post(tokenEndpoint, urlEncodedPayload, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); - const responseData: Promise = this.resolveTokenRequest(responseTokenObservable); - return responseData; + + const tokenDto: OAuthTokenDto = await this.resolveTokenRequest(responseTokenObservable); + + return tokenDto; } private async resolveTokenRequest( observable: Observable> - ): Promise { + ): Promise { let responseToken: AxiosResponse; + try { responseToken = await lastValueFrom(observable); } catch (error: unknown) { @@ -47,6 +55,8 @@ export class OauthAdapterService { throw error; } - return responseToken.data; + const tokenDto: OAuthTokenDto = TokenRequestMapper.mapTokenResponseToDto(responseToken.data); + + return tokenDto; } } diff --git a/apps/server/src/modules/oauth/service/oauth.service.spec.ts b/apps/server/src/modules/oauth/service/oauth.service.spec.ts index 4f65b85005e..de9246db5e7 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -20,12 +20,10 @@ import { LegacySystemService } from '@src/modules/system'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { OAuthTokenDto } from '../interface'; import { - AuthCodeFailureLoggableException, IdTokenInvalidLoggableException, OauthConfigMissingLoggableException, UserNotFoundAfterProvisioningLoggableException, } from '../loggable'; -import { OauthTokenResponse } from './dto'; import { OauthAdapterService } from './oauth-adapter.service'; import { OAuthService } from './oauth.service'; @@ -138,34 +136,34 @@ describe('OAuthService', () => { describe('requestToken', () => { const setupRequest = () => { const code = '43534543jnj543342jn2'; - const tokenResponse: OauthTokenResponse = { - access_token: 'accessToken', - refresh_token: 'refreshToken', - id_token: 'idToken', + const oauthToken: OAuthTokenDto = { + accessToken: 'accessToken', + idToken: 'idToken', + refreshToken: 'refreshToken', }; return { code, - tokenResponse, + oauthToken, }; }; beforeEach(() => { - const { tokenResponse } = setupRequest(); + const { oauthToken } = setupRequest(); oAuthEncryptionService.decrypt.mockReturnValue('decryptedSecret'); - oauthAdapterService.sendAuthenticationCodeTokenRequest.mockResolvedValue(tokenResponse); + oauthAdapterService.sendTokenRequest.mockResolvedValue(oauthToken); }); describe('when it requests a token', () => { it('should get token from the external server', async () => { - const { code, tokenResponse } = setupRequest(); + const { code, oauthToken } = setupRequest(); const result: OAuthTokenDto = await service.requestToken(code, testOauthConfig, 'redirectUri'); expect(result).toEqual({ - idToken: tokenResponse.id_token, - accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token, + idToken: oauthToken.idToken, + accessToken: oauthToken.accessToken, + refreshToken: oauthToken.refreshToken, }); }); }); @@ -222,34 +220,34 @@ describe('OAuthService', () => { oauthConfig, }); - const oauthTokenResponse: OauthTokenResponse = { - access_token: 'accessToken', - refresh_token: 'refreshToken', - id_token: 'idToken', + const oauthToken: OAuthTokenDto = { + accessToken: 'accessToken', + idToken: 'idToken', + refreshToken: 'refreshToken', }; return { authCode, system, - oauthTokenResponse, + oauthToken, oauthConfig, }; }; describe('when system does not have oauth config', () => { it('should authenticate a user', async () => { - const { authCode, system, oauthTokenResponse } = setup(); + const { authCode, system, oauthToken } = setup(); systemService.findById.mockResolvedValue(testSystem); oAuthEncryptionService.decrypt.mockReturnValue('decryptedSecret'); oauthAdapterService.getPublicKey.mockResolvedValue('publicKey'); - oauthAdapterService.sendAuthenticationCodeTokenRequest.mockResolvedValue(oauthTokenResponse); + oauthAdapterService.sendTokenRequest.mockResolvedValue(oauthToken); const result: OAuthTokenDto = await service.authenticateUser(system.id!, 'redirectUri', authCode); expect(result).toEqual({ - accessToken: oauthTokenResponse.access_token, - idToken: oauthTokenResponse.id_token, - refreshToken: oauthTokenResponse.refresh_token, + accessToken: oauthToken.accessToken, + idToken: oauthToken.idToken, + refreshToken: oauthToken.refreshToken, }); }); }); @@ -266,22 +264,6 @@ describe('OAuthService', () => { await expect(func).rejects.toThrow(new OauthConfigMissingLoggableException(testSystem.id)); }); }); - - describe('when query has an error code', () => { - it('should throw an error', async () => { - const func = () => service.authenticateUser('systemId', 'redirectUri', undefined, 'errorCode'); - - await expect(func).rejects.toThrow(new AuthCodeFailureLoggableException('errorCode')); - }); - }); - - describe('when query has no code and no error', () => { - it('should throw an error', async () => { - const func = () => service.authenticateUser('systemId', 'redirectUri'); - - await expect(func).rejects.toThrow(new AuthCodeFailureLoggableException()); - }); - }); }); describe('provisionUser', () => { diff --git a/apps/server/src/modules/oauth/service/oauth.service.ts b/apps/server/src/modules/oauth/service/oauth.service.ts index 52a8f6c07dd..ebba4c64e66 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -1,6 +1,7 @@ import { DefaultEncryptionService, EncryptionService } from '@infra/encryption'; import { LegacySchoolService } from '@modules/legacy-school'; -import { OauthDataDto, ProvisioningService } from '@modules/provisioning'; +import { OauthDataDto } from '@modules/provisioning/dto/oauth-data.dto'; +import { ProvisioningService } from '@modules/provisioning/service/provisioning.service'; import { LegacySystemService } from '@modules/system'; import { SystemDto } from '@modules/system/service'; import { UserService } from '@modules/user'; @@ -14,13 +15,12 @@ import { LegacyLogger } from '@src/core/logger'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { OAuthTokenDto } from '../interface'; import { - AuthCodeFailureLoggableException, IdTokenInvalidLoggableException, OauthConfigMissingLoggableException, UserNotFoundAfterProvisioningLoggableException, } from '../loggable'; import { TokenRequestMapper } from '../mapper/token-request.mapper'; -import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; +import { AuthenticationCodeGrantTokenRequest } from './dto'; import { OauthAdapterService } from './oauth-adapter.service'; @Injectable() @@ -38,23 +38,15 @@ export class OAuthService { this.logger.setContext(OAuthService.name); } - async authenticateUser( - systemId: string, - redirectUri: string, - authCode?: string, - errorCode?: string - ): Promise { - if (errorCode || !authCode) { - throw new AuthCodeFailureLoggableException(errorCode); - } - + async authenticateUser(systemId: string, redirectUri: string, code: string): Promise { const system: SystemDto = await this.systemService.findById(systemId); + if (!system.oauthConfig) { throw new OauthConfigMissingLoggableException(systemId); } const { oauthConfig } = system; - const oauthTokens: OAuthTokenDto = await this.requestToken(authCode, oauthConfig, redirectUri); + const oauthTokens: OAuthTokenDto = await this.requestToken(code, oauthConfig, redirectUri); await this.validateToken(oauthTokens.idToken, oauthConfig); @@ -125,12 +117,8 @@ export class OAuthService { async requestToken(code: string, oauthConfig: OauthConfigEntity, redirectUri: string): Promise { const payload: AuthenticationCodeGrantTokenRequest = this.buildTokenRequestPayload(code, oauthConfig, redirectUri); - const responseToken: OauthTokenResponse = await this.oauthAdapterService.sendAuthenticationCodeTokenRequest( - oauthConfig.tokenEndpoint, - payload - ); + const tokenDto: OAuthTokenDto = await this.oauthAdapterService.sendTokenRequest(oauthConfig.tokenEndpoint, payload); - const tokenDto: OAuthTokenDto = TokenRequestMapper.mapTokenResponseToDto(responseToken); return tokenDto; } diff --git a/apps/server/src/modules/provisioning/config/provisioning-config.ts b/apps/server/src/modules/provisioning/config/provisioning-config.ts index b36e52ea050..07894262770 100644 --- a/apps/server/src/modules/provisioning/config/provisioning-config.ts +++ b/apps/server/src/modules/provisioning/config/provisioning-config.ts @@ -4,10 +4,12 @@ export const ProvisioningFeatures = Symbol('ProvisioningFeatures'); export interface IProvisioningFeatures { schulconnexGroupProvisioningEnabled: boolean; + schulconnexCourseSyncEnabled: boolean; } export class ProvisioningConfiguration { static provisioningFeatures: IProvisioningFeatures = { schulconnexGroupProvisioningEnabled: Configuration.get('FEATURE_SANIS_GROUP_PROVISIONING_ENABLED') as boolean, + schulconnexCourseSyncEnabled: Configuration.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED') as boolean, }; } diff --git a/apps/server/src/modules/provisioning/index.ts b/apps/server/src/modules/provisioning/index.ts index caf7d1f3483..caded3ec616 100644 --- a/apps/server/src/modules/provisioning/index.ts +++ b/apps/server/src/modules/provisioning/index.ts @@ -2,3 +2,4 @@ export * from './provisioning.module'; export * from './dto'; export * from './service/provisioning.service'; export * from './strategy'; +export { ProvisioningConfig } from './provisioning.config'; diff --git a/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.spec.ts b/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.spec.ts index 47447c99103..9c0c9560f8a 100644 --- a/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.spec.ts +++ b/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.spec.ts @@ -1,4 +1,4 @@ -import { SanisGroupRole, SanisSonstigeGruppenzugehoerigeResponse } from '../strategy'; +import { SanisGroupRole, SanisSonstigeGruppenzugehoerigeResponse } from '@infra/schulconnex-client'; import { GroupRoleUnknownLoggable } from './group-role-unknown.loggable'; describe('GroupRoleUnknownLoggable', () => { diff --git a/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.ts b/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.ts index 0eb43237060..dd44dd8b7e7 100644 --- a/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.ts +++ b/apps/server/src/modules/provisioning/loggable/group-role-unknown.loggable.ts @@ -1,5 +1,5 @@ import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -import { SanisSonstigeGruppenzugehoerigeResponse } from '../strategy/sanis/response'; +import { SanisSonstigeGruppenzugehoerigeResponse } from '@infra/schulconnex-client'; export class GroupRoleUnknownLoggable implements Loggable { constructor(private readonly relation: SanisSonstigeGruppenzugehoerigeResponse) {} diff --git a/apps/server/src/modules/provisioning/provisioning.config.ts b/apps/server/src/modules/provisioning/provisioning.config.ts new file mode 100644 index 00000000000..fa6280d61ee --- /dev/null +++ b/apps/server/src/modules/provisioning/provisioning.config.ts @@ -0,0 +1,3 @@ +export interface ProvisioningConfig { + FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED: boolean; +} diff --git a/apps/server/src/modules/provisioning/provisioning.module.ts b/apps/server/src/modules/provisioning/provisioning.module.ts index 3ae6e61ba50..5567fa99e75 100644 --- a/apps/server/src/modules/provisioning/provisioning.module.ts +++ b/apps/server/src/modules/provisioning/provisioning.module.ts @@ -1,5 +1,6 @@ -import { AccountModule } from '@modules/account/account.module'; +import { AccountModule } from '@modules/account'; import { GroupModule } from '@modules/group'; +import { LearnroomModule } from '@modules/learnroom'; import { LegacySchoolModule } from '@modules/legacy-school'; import { RoleModule } from '@modules/role'; import { SystemModule } from '@modules/system/system.module'; @@ -15,7 +16,12 @@ import { SanisProvisioningStrategy, SanisResponseMapper, } from './strategy'; -import { OidcProvisioningService } from './strategy/oidc/service/oidc-provisioning.service'; +import { + SchulconnexCourseSyncService, + SchulconnexGroupProvisioningService, + SchulconnexSchoolProvisioningService, + SchulconnexUserProvisioningService, +} from './strategy/oidc/service'; @Module({ imports: [ @@ -28,11 +34,15 @@ import { OidcProvisioningService } from './strategy/oidc/service/oidc-provisioni HttpModule, LoggerModule, GroupModule, + LearnroomModule, ], providers: [ ProvisioningService, SanisResponseMapper, - OidcProvisioningService, + SchulconnexSchoolProvisioningService, + SchulconnexUserProvisioningService, + SchulconnexGroupProvisioningService, + SchulconnexCourseSyncService, SanisProvisioningStrategy, IservProvisioningStrategy, OidcMockProvisioningStrategy, diff --git a/apps/server/src/modules/provisioning/strategy/index.ts b/apps/server/src/modules/provisioning/strategy/index.ts index e0eeeae1776..632463a04fa 100644 --- a/apps/server/src/modules/provisioning/strategy/index.ts +++ b/apps/server/src/modules/provisioning/strategy/index.ts @@ -1,5 +1,5 @@ export * from './base.strategy'; export * from './iserv/iserv.strategy'; -export * from './oidc/oidc.strategy'; +export * from './oidc'; export * from './oidc-mock/oidc-mock.strategy'; export * from './sanis'; diff --git a/apps/server/src/modules/provisioning/strategy/oidc/index.ts b/apps/server/src/modules/provisioning/strategy/oidc/index.ts new file mode 100644 index 00000000000..a35eb285666 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/oidc/index.ts @@ -0,0 +1 @@ +export { SchulconnexProvisioningStrategy } from './schulconnex.strategy'; diff --git a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts deleted file mode 100644 index 7b6722f735c..00000000000 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.spec.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ObjectId } from '@mikro-orm/mongodb'; -import { NotImplementedException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; -import { RoleName } from '@shared/domain/interface'; -import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { - externalGroupDtoFactory, - externalSchoolDtoFactory, - legacySchoolDoFactory, - userDoFactory, -} from '@shared/testing'; -import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; -import { - ExternalGroupDto, - ExternalSchoolDto, - ExternalUserDto, - OauthDataDto, - OauthDataStrategyInputDto, - ProvisioningDto, - ProvisioningSystemDto, -} from '../../dto'; -import { OidcProvisioningStrategy } from './oidc.strategy'; -import { OidcProvisioningService } from './service/oidc-provisioning.service'; - -class TestOidcStrategy extends OidcProvisioningStrategy { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getData(input: OauthDataStrategyInputDto): Promise { - throw new NotImplementedException(); - } - - getType(): SystemProvisioningStrategy { - throw new NotImplementedException(); - } -} - -describe('OidcStrategy', () => { - let module: TestingModule; - let strategy: TestOidcStrategy; - - let oidcProvisioningService: DeepMocked; - let provisioningFeatures: IProvisioningFeatures; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - TestOidcStrategy, - { - provide: OidcProvisioningService, - useValue: createMock(), - }, - { - provide: ProvisioningFeatures, - useValue: {}, - }, - ], - }).compile(); - - strategy = module.get(TestOidcStrategy); - oidcProvisioningService = module.get(OidcProvisioningService); - provisioningFeatures = module.get(ProvisioningFeatures); - }); - - beforeEach(() => { - Object.assign>(provisioningFeatures, { - schulconnexGroupProvisioningEnabled: false, - }); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('apply is called', () => { - describe('when school data is provided', () => { - const setup = () => { - const externalUserId = 'externalUserId'; - const externalSchoolId = 'externalSchoolId'; - const schoolId = 'schoolId'; - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.OIDC, - }), - externalSchool: new ExternalSchoolDto({ - externalId: externalSchoolId, - name: 'schoolName', - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - }); - const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ - firstName: 'firstName', - lastName: 'lastName', - email: 'email', - schoolId: 'schoolId', - externalId: externalUserId, - }); - const school: LegacySchoolDo = legacySchoolDoFactory.build({ - id: schoolId, - name: 'schoolName', - externalId: externalSchoolId, - }); - - oidcProvisioningService.provisionExternalSchool.mockResolvedValue(school); - oidcProvisioningService.provisionExternalUser.mockResolvedValue(user); - - return { - oauthData, - schoolId, - }; - }; - - it('should provision school', async () => { - const { oauthData } = setup(); - - await strategy.apply(oauthData); - - expect(oidcProvisioningService.provisionExternalSchool).toHaveBeenCalledWith( - oauthData.externalSchool, - oauthData.system.systemId - ); - }); - }); - - describe('when user data is provided', () => { - const setup = () => { - const externalUserId = 'externalUserId'; - const externalSchoolId = 'externalSchoolId'; - const schoolId = 'schoolId'; - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.OIDC, - }), - externalSchool: new ExternalSchoolDto({ - externalId: externalSchoolId, - name: 'schoolName', - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - }); - const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ - firstName: 'firstName', - lastName: 'lastName', - email: 'email', - schoolId: 'schoolId', - externalId: externalUserId, - }); - const school: LegacySchoolDo = legacySchoolDoFactory.build({ - id: schoolId, - name: 'schoolName', - externalId: externalSchoolId, - }); - - oidcProvisioningService.provisionExternalSchool.mockResolvedValue(school); - oidcProvisioningService.provisionExternalUser.mockResolvedValue(user); - - return { - oauthData, - schoolId, - }; - }; - - it('should provision external user', async () => { - const { oauthData, schoolId } = setup(); - - await strategy.apply(oauthData); - - expect(oidcProvisioningService.provisionExternalUser).toHaveBeenCalledWith( - oauthData.externalUser, - oauthData.system.systemId, - schoolId - ); - }); - - it('should return the users external id', async () => { - const { oauthData } = setup(); - - const result: ProvisioningDto = await strategy.apply(oauthData); - - expect(result).toEqual(new ProvisioningDto({ externalUserId: oauthData.externalUser.externalId })); - }); - }); - - describe('when group data is provided and the feature is enabled', () => { - const setup = () => { - provisioningFeatures.schulconnexGroupProvisioningEnabled = true; - - const externalUserId = 'externalUserId'; - const externalGroups: ExternalGroupDto[] = externalGroupDtoFactory.buildList(2); - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: new ObjectId().toHexString(), - provisioningStrategy: SystemProvisioningStrategy.OIDC, - }), - externalSchool: externalSchoolDtoFactory.build(), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - externalGroups, - }); - - const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ - externalId: externalUserId, - }); - - oidcProvisioningService.provisionExternalUser.mockResolvedValueOnce(user); - oidcProvisioningService.filterExternalGroups.mockResolvedValueOnce(externalGroups); - - return { - oauthData, - }; - }; - - it('should remove external groups and affiliation', async () => { - const { oauthData } = setup(); - - await strategy.apply(oauthData); - - expect(oidcProvisioningService.removeExternalGroupsAndAffiliation).toHaveBeenCalledWith( - oauthData.externalUser.externalId, - oauthData.externalGroups, - oauthData.system.systemId - ); - }); - - it('should provision every external group', async () => { - const { oauthData } = setup(); - - await strategy.apply(oauthData); - - expect(oidcProvisioningService.provisionExternalGroup).toHaveBeenCalledWith( - oauthData.externalGroups?.[0], - oauthData.externalSchool, - oauthData.system.systemId - ); - expect(oidcProvisioningService.provisionExternalGroup).toHaveBeenCalledWith( - oauthData.externalGroups?.[1], - oauthData.externalSchool, - oauthData.system.systemId - ); - }); - }); - - describe('when group data is provided, but the feature is disabled', () => { - const setup = () => { - provisioningFeatures.schulconnexGroupProvisioningEnabled = false; - - const externalUserId = 'externalUserId'; - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.OIDC, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - externalGroups: externalGroupDtoFactory.buildList(2), - }); - - const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ - externalId: externalUserId, - }); - - oidcProvisioningService.provisionExternalUser.mockResolvedValue(user); - - return { - oauthData, - }; - }; - - it('should not remove external groups and affiliation', async () => { - const { oauthData } = setup(); - - await strategy.apply(oauthData); - - expect(oidcProvisioningService.removeExternalGroupsAndAffiliation).not.toHaveBeenCalled(); - }); - - it('should not provision groups', async () => { - const { oauthData } = setup(); - - await strategy.apply(oauthData); - - expect(oidcProvisioningService.provisionExternalGroup).not.toHaveBeenCalled(); - }); - }); - - describe('when group data is not provided', () => { - const setup = () => { - provisioningFeatures.schulconnexGroupProvisioningEnabled = true; - - const externalUserId = 'externalUserId'; - const oauthData: OauthDataDto = new OauthDataDto({ - system: new ProvisioningSystemDto({ - systemId: 'systemId', - provisioningStrategy: SystemProvisioningStrategy.OIDC, - }), - externalUser: new ExternalUserDto({ - externalId: externalUserId, - }), - externalGroups: undefined, - }); - - const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ - externalId: externalUserId, - }); - - oidcProvisioningService.provisionExternalUser.mockResolvedValue(user); - - return { - externalUserId, - oauthData, - }; - }; - - it('should remove external groups and affiliation', async () => { - const { externalUserId, oauthData } = setup(); - - await strategy.apply(oauthData); - - expect(oidcProvisioningService.removeExternalGroupsAndAffiliation).toHaveBeenCalledWith( - externalUserId, - [], - oauthData.system.systemId - ); - }); - }); - }); -}); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts b/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts deleted file mode 100644 index e1e2f33e2f1..00000000000 --- a/apps/server/src/modules/provisioning/strategy/oidc/oidc.strategy.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; -import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; -import { ExternalGroupDto, OauthDataDto, ProvisioningDto } from '../../dto'; -import { ProvisioningStrategy } from '../base.strategy'; -import { OidcProvisioningService } from './service/oidc-provisioning.service'; - -@Injectable() -export abstract class OidcProvisioningStrategy extends ProvisioningStrategy { - constructor( - @Inject(ProvisioningFeatures) protected readonly provisioningFeatures: IProvisioningFeatures, - protected readonly oidcProvisioningService: OidcProvisioningService - ) { - super(); - } - - override async apply(data: OauthDataDto): Promise { - let school: LegacySchoolDo | undefined; - if (data.externalSchool) { - school = await this.oidcProvisioningService.provisionExternalSchool(data.externalSchool, data.system.systemId); - } - - const user: UserDO = await this.oidcProvisioningService.provisionExternalUser( - data.externalUser, - data.system.systemId, - school?.id - ); - - if (this.provisioningFeatures.schulconnexGroupProvisioningEnabled) { - await this.oidcProvisioningService.removeExternalGroupsAndAffiliation( - data.externalUser.externalId, - data.externalGroups ?? [], - data.system.systemId - ); - - if (data.externalGroups) { - let groups: ExternalGroupDto[] = data.externalGroups; - - groups = await this.oidcProvisioningService.filterExternalGroups(groups, school?.id, data.system.systemId); - - await Promise.all( - groups.map((group: ExternalGroupDto) => - this.oidcProvisioningService.provisionExternalGroup(group, data.externalSchool, data.system.systemId) - ) - ); - } - } - - return new ProvisioningDto({ externalUserId: user.externalId || data.externalUser.externalId }); - } -} diff --git a/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.spec.ts new file mode 100644 index 00000000000..ee6bafdeaff --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.spec.ts @@ -0,0 +1,509 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Group, GroupService } from '@modules/group'; +import { NotImplementedException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; +import { RoleName } from '@shared/domain/interface'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { + externalGroupDtoFactory, + externalSchoolDtoFactory, + groupFactory, + legacySchoolDoFactory, + userDoFactory, +} from '@shared/testing'; +import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; +import { + ExternalGroupDto, + ExternalSchoolDto, + ExternalUserDto, + OauthDataDto, + OauthDataStrategyInputDto, + ProvisioningDto, + ProvisioningSystemDto, +} from '../../dto'; +import { SchulconnexProvisioningStrategy } from './schulconnex.strategy'; +import { + SchulconnexCourseSyncService, + SchulconnexGroupProvisioningService, + SchulconnexSchoolProvisioningService, + SchulconnexUserProvisioningService, +} from './service'; + +class TestSchulconnexStrategy extends SchulconnexProvisioningStrategy { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getData(input: OauthDataStrategyInputDto): Promise { + throw new NotImplementedException(); + } + + getType(): SystemProvisioningStrategy { + throw new NotImplementedException(); + } +} + +describe(SchulconnexProvisioningStrategy.name, () => { + let module: TestingModule; + let strategy: TestSchulconnexStrategy; + + let schulconnexSchoolProvisioningService: DeepMocked; + let schulconnexUserProvisioningService: DeepMocked; + let schulconnexGroupProvisioningService: DeepMocked; + let schulconnexCourseSyncService: DeepMocked; + let groupService: DeepMocked; + let provisioningFeatures: IProvisioningFeatures; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + TestSchulconnexStrategy, + { + provide: SchulconnexSchoolProvisioningService, + useValue: createMock(), + }, + { + provide: SchulconnexUserProvisioningService, + useValue: createMock(), + }, + { + provide: SchulconnexGroupProvisioningService, + useValue: createMock(), + }, + { + provide: SchulconnexCourseSyncService, + useValue: createMock(), + }, + { + provide: GroupService, + useValue: createMock(), + }, + { + provide: ProvisioningFeatures, + useValue: {}, + }, + ], + }).compile(); + + strategy = module.get(TestSchulconnexStrategy); + schulconnexSchoolProvisioningService = module.get(SchulconnexSchoolProvisioningService); + schulconnexUserProvisioningService = module.get(SchulconnexUserProvisioningService); + schulconnexGroupProvisioningService = module.get(SchulconnexGroupProvisioningService); + schulconnexCourseSyncService = module.get(SchulconnexCourseSyncService); + groupService = module.get(GroupService); + provisioningFeatures = module.get(ProvisioningFeatures); + }); + + beforeEach(() => { + Object.assign>(provisioningFeatures, { + schulconnexGroupProvisioningEnabled: false, + schulconnexCourseSyncEnabled: false, + }); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('apply is called', () => { + describe('when school data is provided', () => { + const setup = () => { + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + const schoolId = 'schoolId'; + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalSchool: new ExternalSchoolDto({ + externalId: externalSchoolId, + name: 'schoolName', + }), + externalUser: new ExternalUserDto({ + externalId: externalUserId, + }), + }); + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + schoolId: 'schoolId', + externalId: externalUserId, + }); + const school: LegacySchoolDo = legacySchoolDoFactory.build({ + id: schoolId, + name: 'schoolName', + externalId: externalSchoolId, + }); + + schulconnexSchoolProvisioningService.provisionExternalSchool.mockResolvedValue(school); + schulconnexUserProvisioningService.provisionExternalUser.mockResolvedValue(user); + + return { + oauthData, + schoolId, + }; + }; + + it('should provision school', async () => { + const { oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(schulconnexSchoolProvisioningService.provisionExternalSchool).toHaveBeenCalledWith( + oauthData.externalSchool, + oauthData.system.systemId + ); + }); + }); + + describe('when user data is provided', () => { + const setup = () => { + const externalUserId = 'externalUserId'; + const externalSchoolId = 'externalSchoolId'; + const schoolId = 'schoolId'; + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalSchool: new ExternalSchoolDto({ + externalId: externalSchoolId, + name: 'schoolName', + }), + externalUser: new ExternalUserDto({ + externalId: externalUserId, + }), + }); + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + schoolId: 'schoolId', + externalId: externalUserId, + }); + const school: LegacySchoolDo = legacySchoolDoFactory.build({ + id: schoolId, + name: 'schoolName', + externalId: externalSchoolId, + }); + + schulconnexSchoolProvisioningService.provisionExternalSchool.mockResolvedValue(school); + schulconnexUserProvisioningService.provisionExternalUser.mockResolvedValue(user); + + return { + oauthData, + schoolId, + }; + }; + + it('should provision external user', async () => { + const { oauthData, schoolId } = setup(); + + await strategy.apply(oauthData); + + expect(schulconnexUserProvisioningService.provisionExternalUser).toHaveBeenCalledWith( + oauthData.externalUser, + oauthData.system.systemId, + schoolId + ); + }); + + it('should return the users external id', async () => { + const { oauthData } = setup(); + + const result: ProvisioningDto = await strategy.apply(oauthData); + + expect(result).toEqual(new ProvisioningDto({ externalUserId: oauthData.externalUser.externalId })); + }); + }); + + describe('when group data is provided and the feature is enabled', () => { + const setup = () => { + provisioningFeatures.schulconnexGroupProvisioningEnabled = true; + + const externalUserId = 'externalUserId'; + const externalGroups: ExternalGroupDto[] = externalGroupDtoFactory.buildList(2); + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: new ObjectId().toHexString(), + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalSchool: externalSchoolDtoFactory.build(), + externalUser: new ExternalUserDto({ + externalId: externalUserId, + }), + externalGroups, + }); + + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + externalId: externalUserId, + }); + + schulconnexUserProvisioningService.provisionExternalUser.mockResolvedValueOnce(user); + schulconnexGroupProvisioningService.filterExternalGroups.mockResolvedValueOnce(externalGroups); + + return { + oauthData, + }; + }; + + it('should remove external groups and affiliation', async () => { + const { oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(schulconnexGroupProvisioningService.removeExternalGroupsAndAffiliation).toHaveBeenCalledWith( + oauthData.externalUser.externalId, + oauthData.externalGroups, + oauthData.system.systemId + ); + }); + + it('should provision every external group', async () => { + const { oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(schulconnexGroupProvisioningService.provisionExternalGroup).toHaveBeenCalledWith( + oauthData.externalGroups?.[0], + oauthData.externalSchool, + oauthData.system.systemId + ); + expect(schulconnexGroupProvisioningService.provisionExternalGroup).toHaveBeenCalledWith( + oauthData.externalGroups?.[1], + oauthData.externalSchool, + oauthData.system.systemId + ); + }); + }); + + describe('when group data is provided, but the feature is disabled', () => { + const setup = () => { + provisioningFeatures.schulconnexGroupProvisioningEnabled = false; + + const externalUserId = 'externalUserId'; + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalUser: new ExternalUserDto({ + externalId: externalUserId, + }), + externalGroups: externalGroupDtoFactory.buildList(2), + }); + + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + externalId: externalUserId, + }); + + schulconnexUserProvisioningService.provisionExternalUser.mockResolvedValue(user); + + return { + oauthData, + }; + }; + + it('should not remove external groups and affiliation', async () => { + const { oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(schulconnexGroupProvisioningService.removeExternalGroupsAndAffiliation).not.toHaveBeenCalled(); + }); + + it('should not provision groups', async () => { + const { oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(schulconnexGroupProvisioningService.provisionExternalGroup).not.toHaveBeenCalled(); + }); + }); + + describe('when group data is not provided', () => { + const setup = () => { + provisioningFeatures.schulconnexGroupProvisioningEnabled = true; + + const externalUserId = 'externalUserId'; + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: 'systemId', + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalUser: new ExternalUserDto({ + externalId: externalUserId, + }), + externalGroups: undefined, + }); + + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + externalId: externalUserId, + }); + + schulconnexUserProvisioningService.provisionExternalUser.mockResolvedValue(user); + + return { + externalUserId, + oauthData, + }; + }; + + it('should remove external groups and affiliation', async () => { + const { externalUserId, oauthData } = setup(); + + await strategy.apply(oauthData); + + expect(schulconnexGroupProvisioningService.removeExternalGroupsAndAffiliation).toHaveBeenCalledWith( + externalUserId, + [], + oauthData.system.systemId + ); + }); + }); + + describe('when an existing group gets provisioned', () => { + const setup = () => { + provisioningFeatures.schulconnexGroupProvisioningEnabled = true; + provisioningFeatures.schulconnexCourseSyncEnabled = true; + + const externalUserId = 'externalUserId'; + const externalGroups: ExternalGroupDto[] = externalGroupDtoFactory.buildList(2); + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: new ObjectId().toHexString(), + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalSchool: externalSchoolDtoFactory.build(), + externalUser: new ExternalUserDto({ + externalId: externalUserId, + }), + externalGroups, + }); + + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + externalId: externalUserId, + }); + const existingGroup: Group = groupFactory.build(); + const updatedGroup: Group = groupFactory.build(); + + schulconnexUserProvisioningService.provisionExternalUser.mockResolvedValueOnce(user); + schulconnexGroupProvisioningService.removeExternalGroupsAndAffiliation.mockResolvedValueOnce([]); + schulconnexGroupProvisioningService.filterExternalGroups.mockResolvedValueOnce(externalGroups); + groupService.findByExternalSource.mockResolvedValueOnce(existingGroup); + schulconnexGroupProvisioningService.provisionExternalGroup.mockResolvedValueOnce(updatedGroup); + + return { + oauthData, + existingGroup, + updatedGroup, + }; + }; + + it('should synchronize all linked courses with the group', async () => { + const { oauthData, updatedGroup, existingGroup } = setup(); + + await strategy.apply(oauthData); + + expect(schulconnexCourseSyncService.synchronizeCourseWithGroup).toHaveBeenCalledWith( + updatedGroup, + existingGroup + ); + }); + }); + + describe('when a new group is provisioned', () => { + const setup = () => { + provisioningFeatures.schulconnexGroupProvisioningEnabled = true; + provisioningFeatures.schulconnexCourseSyncEnabled = true; + + const externalUserId = 'externalUserId'; + const externalGroups: ExternalGroupDto[] = externalGroupDtoFactory.buildList(2); + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: new ObjectId().toHexString(), + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalSchool: externalSchoolDtoFactory.build(), + externalUser: new ExternalUserDto({ + externalId: externalUserId, + }), + externalGroups, + }); + + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + externalId: externalUserId, + }); + const updatedGroup: Group = groupFactory.build(); + + schulconnexUserProvisioningService.provisionExternalUser.mockResolvedValueOnce(user); + schulconnexGroupProvisioningService.removeExternalGroupsAndAffiliation.mockResolvedValueOnce([]); + schulconnexGroupProvisioningService.filterExternalGroups.mockResolvedValueOnce(externalGroups); + groupService.findByExternalSource.mockResolvedValueOnce(null); + schulconnexGroupProvisioningService.provisionExternalGroup.mockResolvedValueOnce(updatedGroup); + + return { + oauthData, + updatedGroup, + }; + }; + + it('should synchronize all linked courses with the group', async () => { + const { oauthData, updatedGroup } = setup(); + + await strategy.apply(oauthData); + + expect(schulconnexCourseSyncService.synchronizeCourseWithGroup).toHaveBeenCalledWith(updatedGroup, undefined); + }); + }); + + describe('when a user was removed from a group', () => { + const setup = () => { + provisioningFeatures.schulconnexGroupProvisioningEnabled = true; + provisioningFeatures.schulconnexCourseSyncEnabled = true; + + const externalUserId = 'externalUserId'; + const oauthData: OauthDataDto = new OauthDataDto({ + system: new ProvisioningSystemDto({ + systemId: new ObjectId().toHexString(), + provisioningStrategy: SystemProvisioningStrategy.OIDC, + }), + externalSchool: externalSchoolDtoFactory.build(), + externalUser: new ExternalUserDto({ + externalId: externalUserId, + }), + externalGroups: [], + }); + + const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).build({ + externalId: externalUserId, + }); + const group: Group = groupFactory.build(); + + schulconnexUserProvisioningService.provisionExternalUser.mockResolvedValueOnce(user); + schulconnexGroupProvisioningService.removeExternalGroupsAndAffiliation.mockResolvedValueOnce([group]); + schulconnexGroupProvisioningService.filterExternalGroups.mockResolvedValueOnce([]); + groupService.findByExternalSource.mockResolvedValueOnce(null); + schulconnexGroupProvisioningService.provisionExternalGroup.mockResolvedValueOnce(null); + + return { + oauthData, + group, + }; + }; + + it('should synchronize a course with a group', async () => { + const { oauthData, group } = setup(); + + await strategy.apply(oauthData); + + expect(schulconnexCourseSyncService.synchronizeCourseWithGroup).toHaveBeenCalledWith(group, group); + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.ts b/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.ts new file mode 100644 index 00000000000..07c087aed8c --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/oidc/schulconnex.strategy.ts @@ -0,0 +1,105 @@ +import { Group, GroupService } from '@modules/group'; +import { Inject, Injectable } from '@nestjs/common'; +import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; +import { IProvisioningFeatures, ProvisioningFeatures } from '../../config'; +import { ExternalGroupDto, OauthDataDto, ProvisioningDto } from '../../dto'; +import { ProvisioningStrategy } from '../base.strategy'; +import { + SchulconnexCourseSyncService, + SchulconnexGroupProvisioningService, + SchulconnexSchoolProvisioningService, + SchulconnexUserProvisioningService, +} from './service'; + +@Injectable() +export abstract class SchulconnexProvisioningStrategy extends ProvisioningStrategy { + constructor( + @Inject(ProvisioningFeatures) protected readonly provisioningFeatures: IProvisioningFeatures, + protected readonly schulconnexSchoolProvisioningService: SchulconnexSchoolProvisioningService, + protected readonly schulconnexUserProvisioningService: SchulconnexUserProvisioningService, + protected readonly schulconnexGroupProvisioningService: SchulconnexGroupProvisioningService, + protected readonly schulconnexCourseSyncService: SchulconnexCourseSyncService, + protected readonly groupService: GroupService + ) { + super(); + } + + override async apply(data: OauthDataDto): Promise { + let school: LegacySchoolDo | undefined; + if (data.externalSchool) { + school = await this.schulconnexSchoolProvisioningService.provisionExternalSchool( + data.externalSchool, + data.system.systemId + ); + } + + const user: UserDO = await this.schulconnexUserProvisioningService.provisionExternalUser( + data.externalUser, + data.system.systemId, + school?.id + ); + + if (this.provisioningFeatures.schulconnexGroupProvisioningEnabled) { + await this.provisionGroups(data, school); + } + + return new ProvisioningDto({ externalUserId: user.externalId || data.externalUser.externalId }); + } + + private async provisionGroups(data: OauthDataDto, school?: LegacySchoolDo): Promise { + await this.removeUserFromGroups(data); + + if (data.externalGroups) { + let groups: ExternalGroupDto[] = data.externalGroups; + + groups = await this.schulconnexGroupProvisioningService.filterExternalGroups( + groups, + school?.id, + data.system.systemId + ); + + const groupProvisioningPromises: Promise[] = groups.map( + async (externalGroup: ExternalGroupDto): Promise => { + const existingGroup: Group | null = await this.groupService.findByExternalSource( + externalGroup.externalId, + data.system.systemId + ); + + const provisionedGroup: Group | null = await this.schulconnexGroupProvisioningService.provisionExternalGroup( + externalGroup, + data.externalSchool, + data.system.systemId + ); + + if (this.provisioningFeatures.schulconnexCourseSyncEnabled && provisionedGroup) { + await this.schulconnexCourseSyncService.synchronizeCourseWithGroup( + provisionedGroup, + existingGroup ?? undefined + ); + } + } + ); + + await Promise.all(groupProvisioningPromises); + } + } + + private async removeUserFromGroups(data: OauthDataDto): Promise { + const removedFromGroups: Group[] = + await this.schulconnexGroupProvisioningService.removeExternalGroupsAndAffiliation( + data.externalUser.externalId, + data.externalGroups ?? [], + data.system.systemId + ); + + if (this.provisioningFeatures.schulconnexCourseSyncEnabled) { + const courseSyncPromises: Promise[] = removedFromGroups.map( + async (removedFromGroup: Group): Promise => { + await this.schulconnexCourseSyncService.synchronizeCourseWithGroup(removedFromGroup, removedFromGroup); + } + ); + + await Promise.all(courseSyncPromises); + } + } +} diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/index.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/index.ts new file mode 100644 index 00000000000..fb2f2a5c5d4 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/index.ts @@ -0,0 +1,4 @@ +export { SchulconnexSchoolProvisioningService } from './schulconnex-school-provisioning.service'; +export { SchulconnexUserProvisioningService } from './schulconnex-user-provisioning.service'; +export { SchulconnexGroupProvisioningService } from './schulconnex-group-provisioning.service'; +export { SchulconnexCourseSyncService } from './schulconnex-course-sync.service'; diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts new file mode 100644 index 00000000000..881822cdea6 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.spec.ts @@ -0,0 +1,227 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Group, GroupUser } from '@modules/group'; +import { Course } from '@modules/learnroom/domain'; +import { CourseDoService } from '@modules/learnroom/service/course-do.service'; +import { courseFactory } from '@modules/learnroom/testing'; +import { RoleDto, RoleService } from '@modules/role'; +import { Test, TestingModule } from '@nestjs/testing'; +import { groupFactory, roleDtoFactory } from '@shared/testing'; +import { SchulconnexCourseSyncService } from './schulconnex-course-sync.service'; + +describe(SchulconnexCourseSyncService.name, () => { + let module: TestingModule; + let service: SchulconnexCourseSyncService; + + let courseService: DeepMocked; + let roleService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SchulconnexCourseSyncService, + { + provide: CourseDoService, + useValue: createMock(), + }, + { + provide: RoleService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(SchulconnexCourseSyncService); + courseService = module.get(CourseDoService); + roleService = module.get(RoleService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('synchronizeCourseWithGroup', () => { + describe('when synchronizing with a new group', () => { + const setup = () => { + const course: Course = courseFactory.build(); + const studentId: string = new ObjectId().toHexString(); + const teacherId: string = new ObjectId().toHexString(); + const studentRoleId: string = new ObjectId().toHexString(); + const teacherRoleId: string = new ObjectId().toHexString(); + const studentRole: RoleDto = roleDtoFactory.build({ id: studentRoleId }); + const teacherRole: RoleDto = roleDtoFactory.build({ id: teacherRoleId }); + const newGroup: Group = groupFactory.build({ + users: [ + { + userId: studentId, + roleId: studentRoleId, + }, + { + userId: teacherId, + roleId: teacherRoleId, + }, + ], + }); + + courseService.findBySyncedGroup.mockResolvedValueOnce([new Course(course.getProps())]); + roleService.findByName.mockResolvedValueOnce(studentRole); + roleService.findByName.mockResolvedValueOnce(teacherRole); + + return { + course, + newGroup, + studentId, + teacherId, + }; + }; + + it('should synchronize with the group', async () => { + const { course, newGroup, studentId, teacherId } = setup(); + + await service.synchronizeCourseWithGroup(newGroup); + + expect(courseService.saveAll).toHaveBeenCalledWith<[Course[]]>([ + new Course({ + ...course.getProps(), + name: newGroup.name, + startDate: newGroup.validFrom, + untilDate: newGroup.validUntil, + studentIds: [studentId], + teacherIds: [teacherId], + }), + ]); + }); + }); + + describe('when the course name is the same as the old group name', () => { + const setup = () => { + const course: Course = courseFactory.build({ name: 'Course Name' }); + const studentRole: RoleDto = roleDtoFactory.build(); + const teacherRole: RoleDto = roleDtoFactory.build(); + const oldGroup: Group = groupFactory.build({ name: 'Course Name' }); + const newGroup: Group = groupFactory.build({ + name: 'New Group Name', + users: [], + }); + + courseService.findBySyncedGroup.mockResolvedValueOnce([new Course(course.getProps())]); + roleService.findByName.mockResolvedValueOnce(studentRole); + roleService.findByName.mockResolvedValueOnce(teacherRole); + + return { + course, + newGroup, + oldGroup, + }; + }; + + it('should synchronize the group name', async () => { + const { course, newGroup, oldGroup } = setup(); + + await service.synchronizeCourseWithGroup(newGroup, oldGroup); + + expect(courseService.saveAll).toHaveBeenCalledWith<[Course[]]>([ + new Course({ + ...course.getProps(), + name: newGroup.name, + startDate: newGroup.validFrom, + untilDate: newGroup.validUntil, + studentIds: [], + teacherIds: [], + }), + ]); + }); + }); + + describe('when the course name is different from the old group name', () => { + const setup = () => { + const course: Course = courseFactory.build({ name: 'Custom Course Name' }); + const studentRole: RoleDto = roleDtoFactory.build(); + const teacherRole: RoleDto = roleDtoFactory.build(); + const oldGroup: Group = groupFactory.build({ name: 'Course Name' }); + const newGroup: Group = groupFactory.build({ + name: 'New Group Name', + users: [], + }); + + courseService.findBySyncedGroup.mockResolvedValueOnce([new Course(course.getProps())]); + roleService.findByName.mockResolvedValueOnce(studentRole); + roleService.findByName.mockResolvedValueOnce(teacherRole); + + return { + course, + newGroup, + oldGroup, + }; + }; + + it('should keep the old course name', async () => { + const { course, newGroup, oldGroup } = setup(); + + await service.synchronizeCourseWithGroup(newGroup, oldGroup); + + expect(courseService.saveAll).toHaveBeenCalledWith<[Course[]]>([ + new Course({ + ...course.getProps(), + name: course.name, + startDate: newGroup.validFrom, + untilDate: newGroup.validUntil, + studentIds: [], + teacherIds: [], + }), + ]); + }); + }); + + describe('when the last teacher gets removed from a synced group', () => { + const setup = () => { + const studentUserId = new ObjectId().toHexString(); + const teacherUserId = new ObjectId().toHexString(); + const course: Course = courseFactory.build({ teacherIds: [teacherUserId], studentIds: [studentUserId] }); + const studentRoleId: string = new ObjectId().toHexString(); + const studentRole: RoleDto = roleDtoFactory.build({ id: studentRoleId }); + const teacherRole: RoleDto = roleDtoFactory.build(); + const newGroup: Group = groupFactory.build({ + users: [ + new GroupUser({ + userId: studentUserId, + roleId: studentRoleId, + }), + ], + }); + + courseService.findBySyncedGroup.mockResolvedValueOnce([new Course(course.getProps())]); + roleService.findByName.mockResolvedValueOnce(studentRole); + roleService.findByName.mockResolvedValueOnce(teacherRole); + + return { + course, + newGroup, + teacherUserId, + }; + }; + + it('should keep the last teacher, remove all students and break the sync with the group', async () => { + const { course, newGroup, teacherUserId } = setup(); + + await service.synchronizeCourseWithGroup(newGroup, newGroup); + + expect(courseService.saveAll).toHaveBeenCalledWith<[Course[]]>([ + new Course({ + ...course.getProps(), + name: course.name, + startDate: newGroup.validFrom, + untilDate: newGroup.validUntil, + studentIds: [], + teacherIds: [teacherUserId], + syncedWithGroup: undefined, + }), + ]); + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts new file mode 100644 index 00000000000..04049de68aa --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-course-sync.service.ts @@ -0,0 +1,48 @@ +import { Group, GroupUser } from '@modules/group'; +import { CourseDoService } from '@modules/learnroom/service/course-do.service'; +import { RoleDto, RoleService } from '@modules/role'; +import { Injectable } from '@nestjs/common'; +import { RoleName } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { Course } from '@src/modules/learnroom/domain'; + +@Injectable() +export class SchulconnexCourseSyncService { + constructor(private readonly courseService: CourseDoService, private readonly roleService: RoleService) {} + + async synchronizeCourseWithGroup(newGroup: Group, oldGroup?: Group): Promise { + const courses: Course[] = await this.courseService.findBySyncedGroup(newGroup); + + if (courses.length) { + const studentRole: RoleDto = await this.roleService.findByName(RoleName.STUDENT); + const teacherRole: RoleDto = await this.roleService.findByName(RoleName.TEACHER); + + courses.forEach((course: Course): void => { + if (!oldGroup || oldGroup.name === course.name) { + course.name = newGroup.name; + } + + course.startDate = newGroup.validFrom; + course.untilDate = newGroup.validUntil; + + const students: GroupUser[] = newGroup.users.filter( + (user: GroupUser): boolean => user.roleId === studentRole.id + ); + const teachers: GroupUser[] = newGroup.users.filter( + (user: GroupUser): boolean => user.roleId === teacherRole.id + ); + + if (teachers.length >= 1) { + course.students = students.map((user: GroupUser): EntityId => user.userId); + course.teachers = teachers.map((user: GroupUser): EntityId => user.userId); + } else { + // Remove all remaining students and break the link, when the last teacher of the group should be removed + course.students = []; + course.syncedWithGroup = undefined; + } + }); + + await this.courseService.saveAll(courses); + } + } +} diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts similarity index 55% rename from apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts rename to apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts index 40d1f159573..a027c40ef36 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.spec.ts @@ -1,53 +1,39 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountSaveDto } from '@modules/account/services/dto'; import { Group, GroupService, GroupTypes } from '@modules/group'; import { - FederalStateService, LegacySchoolService, SchoolSystemOptionsService, - SchoolYearService, SchulConneXProvisioningOptions, } from '@modules/legacy-school'; -import { RoleService } from '@modules/role'; -import { RoleDto } from '@modules/role/service/dto/role.dto'; +import { RoleService, RoleDto } from '@modules/role'; import { UserService } from '@modules/user'; -import { UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { ExternalSource, LegacySchoolDo, RoleReference, UserDO } from '@shared/domain/domainobject'; +import { ExternalSource, LegacySchoolDo, Page, RoleReference, UserDO } from '@shared/domain/domainobject'; import { RoleName } from '@shared/domain/interface'; -import { EntityId, SchoolFeature } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; import { externalGroupDtoFactory, externalSchoolDtoFactory, - federalStateFactory, groupFactory, legacySchoolDoFactory, roleDtoFactory, roleFactory, - schoolYearFactory, userDoFactory, } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import CryptoJS from 'crypto-js'; -import { ExternalGroupDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; +import { ExternalGroupDto, ExternalSchoolDto } from '../../../dto'; import { SchoolForGroupNotFoundLoggable, UserForGroupNotFoundLoggable } from '../../../loggable'; -import { OidcProvisioningService } from './oidc-provisioning.service'; +import { SchulconnexGroupProvisioningService } from './schulconnex-group-provisioning.service'; -jest.mock('crypto-js'); - -describe('OidcProvisioningService', () => { +describe(SchulconnexGroupProvisioningService.name, () => { let module: TestingModule; - let service: OidcProvisioningService; + let service: SchulconnexGroupProvisioningService; let userService: DeepMocked; let schoolService: DeepMocked; let roleService: DeepMocked; - let accountService: DeepMocked; - let schoolYearService: DeepMocked; - let federalStateService: DeepMocked; let groupService: DeepMocked; let schoolSystemOptionsService: DeepMocked; let logger: DeepMocked; @@ -55,7 +41,7 @@ describe('OidcProvisioningService', () => { beforeAll(async () => { module = await Test.createTestingModule({ providers: [ - OidcProvisioningService, + SchulconnexGroupProvisioningService, { provide: UserService, useValue: createMock(), @@ -68,18 +54,6 @@ describe('OidcProvisioningService', () => { provide: RoleService, useValue: createMock(), }, - { - provide: AccountService, - useValue: createMock(), - }, - { - provide: SchoolYearService, - useValue: createMock(), - }, - { - provide: FederalStateService, - useValue: createMock(), - }, { provide: GroupService, useValue: createMock(), @@ -95,13 +69,10 @@ describe('OidcProvisioningService', () => { ], }).compile(); - service = module.get(OidcProvisioningService); + service = module.get(SchulconnexGroupProvisioningService); userService = module.get(UserService); schoolService = module.get(LegacySchoolService); roleService = module.get(RoleService); - accountService = module.get(AccountService); - schoolYearService = module.get(SchoolYearService); - federalStateService = module.get(FederalStateService); groupService = module.get(GroupService); schoolSystemOptionsService = module.get(SchoolSystemOptionsService); logger = module.get(Logger); @@ -115,573 +86,6 @@ describe('OidcProvisioningService', () => { jest.resetAllMocks(); }); - describe('provisionExternalSchool', () => { - describe('when systemId is given and external school does not exist', () => { - describe('when successful', () => { - const setup = () => { - const systemId = new ObjectId().toHexString(); - const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - }); - - const schoolYear = schoolYearFactory.build(); - const federalState = federalStateFactory.build(); - const savedSchoolDO = new LegacySchoolDo({ - id: 'schoolId', - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - systems: [systemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - schoolYear, - federalState, - }); - - schoolService.save.mockResolvedValue(savedSchoolDO); - schoolService.getSchoolByExternalId.mockResolvedValue(null); - schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); - federalStateService.findFederalStateByName.mockResolvedValue(federalState); - - return { - systemId, - externalSchoolDto, - savedSchoolDO, - }; - }; - - it('should save the correct data', async () => { - const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - - await service.provisionExternalSchool(externalSchoolDto, systemId); - - expect(schoolService.save).toHaveBeenCalledWith({ ...savedSchoolDO, id: undefined }, true); - }); - - it('should save the new school', async () => { - const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - - const result: LegacySchoolDo = await service.provisionExternalSchool(externalSchoolDto, systemId); - - expect(result).toEqual(savedSchoolDO); - }); - }); - - describe('when the external system provides a location for the school', () => { - const setup = () => { - const systemId = new ObjectId().toHexString(); - const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - location: 'Hannover', - }); - - const schoolYear = schoolYearFactory.build(); - const federalState = federalStateFactory.build(); - const savedSchoolDO = new LegacySchoolDo({ - id: 'schoolId', - externalId: 'externalId', - name: 'name (Hannover)', - officialSchoolNumber: 'officialSchoolNumber', - systems: [systemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - schoolYear, - federalState, - }); - - schoolService.save.mockResolvedValue(savedSchoolDO); - schoolService.getSchoolByExternalId.mockResolvedValue(null); - schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); - federalStateService.findFederalStateByName.mockResolvedValue(federalState); - - return { - systemId, - externalSchoolDto, - savedSchoolDO, - }; - }; - - it('should append it to the school name', async () => { - const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - - await service.provisionExternalSchool(externalSchoolDto, systemId); - - expect(schoolService.save).toHaveBeenCalledWith({ ...savedSchoolDO, id: undefined }, true); - }); - }); - - describe('when the external system does not provide a location for the school', () => { - const setup = () => { - const systemId = new ObjectId().toHexString(); - const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - }); - - const schoolYear = schoolYearFactory.build(); - const federalState = federalStateFactory.build(); - const savedSchoolDO = new LegacySchoolDo({ - id: 'schoolId', - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - systems: [systemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - schoolYear, - federalState, - }); - - schoolService.save.mockResolvedValue(savedSchoolDO); - schoolService.getSchoolByExternalId.mockResolvedValue(null); - schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); - federalStateService.findFederalStateByName.mockResolvedValue(federalState); - - return { - systemId, - externalSchoolDto, - savedSchoolDO, - }; - }; - - it('should only use the school name', async () => { - const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - - await service.provisionExternalSchool(externalSchoolDto, systemId); - - expect(schoolService.save).toHaveBeenCalledWith({ ...savedSchoolDO, id: undefined }, true); - }); - }); - }); - - describe('when external school already exists', () => { - describe('when successful', () => { - const setup = () => { - const systemId = new ObjectId().toHexString(); - const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - }); - - const schoolYear = schoolYearFactory.build(); - const federalState = federalStateFactory.build(); - const savedSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - systems: [systemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - schoolYear, - federalState, - }); - const existingSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'existingName', - officialSchoolNumber: 'existingOfficialSchoolNumber', - systems: [systemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - }); - - schoolService.save.mockResolvedValue(savedSchoolDO); - schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); - schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); - federalStateService.findFederalStateByName.mockResolvedValue(federalState); - - return { - systemId, - externalSchoolDto, - savedSchoolDO, - existingSchoolDO, - }; - }; - - it('should update the existing school', async () => { - const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - - const result: LegacySchoolDo = await service.provisionExternalSchool(externalSchoolDto, systemId); - - expect(result).toEqual(savedSchoolDO); - }); - }); - - describe('when the external system provides a location for the school', () => { - const setup = () => { - const systemId = new ObjectId().toHexString(); - const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - location: 'Hannover', - }); - - const schoolYear = schoolYearFactory.build(); - const federalState = federalStateFactory.build(); - const savedSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'name (Hannover)', - officialSchoolNumber: 'officialSchoolNumber', - systems: [systemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - schoolYear, - federalState, - }); - const existingSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'existingName', - officialSchoolNumber: 'existingOfficialSchoolNumber', - systems: [systemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - schoolYear, - federalState, - }); - - schoolService.save.mockResolvedValue(savedSchoolDO); - schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); - schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); - federalStateService.findFederalStateByName.mockResolvedValue(federalState); - - return { - systemId, - externalSchoolDto, - savedSchoolDO, - existingSchoolDO, - }; - }; - - it('should append it to the school name', async () => { - const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - - await service.provisionExternalSchool(externalSchoolDto, systemId); - - expect(schoolService.save).toHaveBeenCalledWith(savedSchoolDO, true); - }); - }); - - describe('when the external system does not provide a location for the school', () => { - const setup = () => { - const systemId = new ObjectId().toHexString(); - const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - }); - - const schoolYear = schoolYearFactory.build(); - const federalState = federalStateFactory.build(); - const savedSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - systems: [systemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - schoolYear, - federalState, - }); - const existingSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'existingName', - officialSchoolNumber: 'existingOfficialSchoolNumber', - systems: [systemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - schoolYear, - federalState, - }); - - schoolService.save.mockResolvedValue(savedSchoolDO); - schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); - schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); - federalStateService.findFederalStateByName.mockResolvedValue(federalState); - - return { - systemId, - externalSchoolDto, - savedSchoolDO, - existingSchoolDO, - }; - }; - - it('should only use the school name', async () => { - const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - - await service.provisionExternalSchool(externalSchoolDto, systemId); - - expect(schoolService.save).toHaveBeenCalledWith(savedSchoolDO, true); - }); - }); - - describe('when there is a system at the school', () => { - const setup = () => { - const systemId = new ObjectId().toHexString(); - const otherSystemId = new ObjectId().toHexString(); - const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - }); - - const schoolYear = schoolYearFactory.build(); - const federalState = federalStateFactory.build(); - const savedSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - systems: [otherSystemId, systemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - schoolYear, - federalState, - }); - const existingSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'existingName', - officialSchoolNumber: 'existingOfficialSchoolNumber', - systems: [otherSystemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - schoolYear, - federalState, - }); - - schoolService.save.mockResolvedValue(savedSchoolDO); - schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); - schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); - federalStateService.findFederalStateByName.mockResolvedValue(federalState); - - return { - systemId, - otherSystemId, - externalSchoolDto, - savedSchoolDO, - existingSchoolDO, - }; - }; - - it('should append the new system', async () => { - const { systemId, otherSystemId, externalSchoolDto, savedSchoolDO } = setup(); - - await service.provisionExternalSchool(externalSchoolDto, systemId); - - expect(schoolService.save).toHaveBeenCalledWith( - { - ...savedSchoolDO, - systems: [otherSystemId, systemId], - }, - true - ); - }); - }); - - describe('when there is no system at the school yet', () => { - const setup = () => { - const systemId = new ObjectId().toHexString(); - const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - }); - - const schoolYear = schoolYearFactory.build(); - const federalState = federalStateFactory.build(); - const savedSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'name', - officialSchoolNumber: 'officialSchoolNumber', - systems: [systemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - schoolYear, - federalState, - }); - const existingSchoolDO = legacySchoolDoFactory.build({ - id: 'schoolId', - externalId: 'externalId', - name: 'existingName', - officialSchoolNumber: 'existingOfficialSchoolNumber', - systems: undefined, - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - schoolYear, - federalState, - }); - - schoolService.save.mockResolvedValue(savedSchoolDO); - schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); - schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); - federalStateService.findFederalStateByName.mockResolvedValue(federalState); - - return { - systemId, - externalSchoolDto, - savedSchoolDO, - existingSchoolDO, - }; - }; - - it('should create a new system list', async () => { - const { systemId, externalSchoolDto, savedSchoolDO } = setup(); - - await service.provisionExternalSchool(externalSchoolDto, systemId); - - expect(schoolService.save).toHaveBeenCalledWith(savedSchoolDO, true); - }); - }); - }); - }); - - describe('provisionExternalUser', () => { - const setupUser = () => { - const systemId = 'systemId'; - const schoolId = 'schoolId'; - const birthday = new Date('2023-11-17'); - const existingUser: UserDO = userDoFactory.withRoles([{ id: 'existingRoleId', name: RoleName.USER }]).buildWithId( - { - firstName: 'existingFirstName', - lastName: 'existingLastName', - email: 'existingEmail', - schoolId: 'existingSchoolId', - externalId: 'externalUserId', - birthday: new Date('2023-11-16'), - }, - 'userId' - ); - const savedUser: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).buildWithId( - { - firstName: 'firstName', - lastName: 'lastName', - email: 'email', - schoolId, - externalId: 'externalUserId', - birthday, - }, - 'userId' - ); - const externalUser: ExternalUserDto = new ExternalUserDto({ - externalId: 'externalUserId', - firstName: 'firstName', - lastName: 'lastName', - email: 'email', - roles: [RoleName.USER], - birthday, - }); - const userRole: RoleDto = new RoleDto({ - id: 'roleId', - name: RoleName.USER, - }); - const hash = 'hash'; - - roleService.findByNames.mockResolvedValue([userRole]); - userService.save.mockResolvedValue(savedUser); - jest.spyOn(CryptoJS, 'SHA256').mockReturnValue({ - toString: jest.fn().mockReturnValue(hash), - words: [], - sigBytes: 0, - concat: jest.fn(), - clamp: jest.fn(), - clone: jest.fn(), - }); - - return { - existingUser, - savedUser, - externalUser, - userRole, - schoolId, - systemId, - hash, - }; - }; - - describe('when the user does not exist yet', () => { - it('should call the user service to save the user', async () => { - const { externalUser, schoolId, savedUser, systemId } = setupUser(); - - userService.findByExternalId.mockResolvedValue(null); - - await service.provisionExternalUser(externalUser, systemId, schoolId); - - expect(userService.save).toHaveBeenCalledWith(new UserDO({ ...savedUser, id: undefined })); - }); - - it('should return the saved user', async () => { - const { externalUser, schoolId, savedUser, systemId } = setupUser(); - - userService.findByExternalId.mockResolvedValue(null); - - const result: UserDO = await service.provisionExternalUser(externalUser, systemId, schoolId); - - expect(result).toEqual(savedUser); - }); - - it('should create a new account', async () => { - const { externalUser, schoolId, systemId, hash } = setupUser(); - const account: AccountSaveDto = new AccountSaveDto({ - userId: 'userId', - username: hash, - systemId, - activated: true, - }); - - userService.findByExternalId.mockResolvedValue(null); - - await service.provisionExternalUser(externalUser, systemId, schoolId); - - expect(accountService.saveWithValidation).toHaveBeenCalledWith(account); - }); - - describe('when no schoolId is provided', () => { - it('should throw UnprocessableEntityException', async () => { - const { externalUser } = setupUser(); - - userService.findByExternalId.mockResolvedValue(null); - - const promise: Promise = service.provisionExternalUser(externalUser, 'systemId', undefined); - - await expect(promise).rejects.toThrow(UnprocessableEntityException); - }); - }); - }); - - describe('when the user already exists', () => { - it('should call the user service to save the user', async () => { - const { externalUser, schoolId, existingUser, systemId } = setupUser(); - - userService.findByExternalId.mockResolvedValue(existingUser); - - await service.provisionExternalUser(externalUser, systemId, schoolId); - - expect(userService.save).toHaveBeenCalledWith(existingUser); - }); - - it('should return the updated user', async () => { - const { externalUser, schoolId, existingUser, savedUser, systemId } = setupUser(); - - userService.findByExternalId.mockResolvedValue(existingUser); - - const result: UserDO = await service.provisionExternalUser(externalUser, systemId, schoolId); - - expect(result).toEqual(savedUser); - }); - - it('should not create a new account', async () => { - const { externalUser, schoolId, systemId, existingUser } = setupUser(); - - userService.findByExternalId.mockResolvedValue(existingUser); - - await service.provisionExternalUser(externalUser, systemId, schoolId); - - expect(accountService.saveWithValidation).not.toHaveBeenCalled(); - }); - }); - }); - describe('filterExternalGroups', () => { describe('when all options are on', () => { const setup = () => { @@ -923,6 +327,18 @@ describe('OidcProvisioningService', () => { expect(groupService.save).not.toHaveBeenCalled(); }); + + it('should return null', async () => { + const { externalGroupDto, externalSchoolDto, systemId } = setup(); + + const result: Group | null = await service.provisionExternalGroup( + externalGroupDto, + externalSchoolDto, + systemId + ); + + expect(result).toBeNull(); + }); }); describe('when the user cannot be found', () => { @@ -989,6 +405,7 @@ describe('OidcProvisioningService', () => { roleService.findByNames.mockResolvedValueOnce([studentRole]); userService.findByExternalId.mockResolvedValueOnce(teacher); roleService.findByNames.mockResolvedValueOnce([teacherRole]); + groupService.save.mockImplementationOnce((group) => Promise.resolve(group)); return { externalSchoolDto, @@ -1041,6 +458,40 @@ describe('OidcProvisioningService', () => { }, }); }); + + it('should return the saved group', async () => { + const { externalGroupDto, externalSchoolDto, school, student, studentRole, teacher, teacherRole, systemId } = + setup(); + + const result: Group | null = await service.provisionExternalGroup( + externalGroupDto, + externalSchoolDto, + systemId + ); + + expect(result?.getProps()).toEqual({ + id: expect.any(String), + name: externalGroupDto.name, + externalSource: { + externalId: externalGroupDto.externalId, + systemId, + }, + type: externalGroupDto.type, + organizationId: school.id, + validFrom: externalGroupDto.from, + validUntil: externalGroupDto.until, + users: [ + { + userId: student.id, + roleId: studentRole.id, + }, + { + userId: teacher.id, + roleId: teacherRole.id, + }, + ], + }); + }); }); describe('when provisioning an existing group without other group members', () => { @@ -1201,7 +652,7 @@ describe('OidcProvisioningService', () => { const externalGroups: ExternalGroupDto[] = [firstExternalGroup, secondExternalGroup]; userService.findByExternalId.mockResolvedValue(user); - groupService.findGroupsByUserAndGroupTypes.mockResolvedValue(existingGroups); + groupService.findGroupsByUserAndGroupTypes.mockResolvedValue(new Page(existingGroups, 2)); return { externalGroups, @@ -1258,7 +709,7 @@ describe('OidcProvisioningService', () => { const externalGroups: ExternalGroupDto[] = [firstExternalGroup]; userService.findByExternalId.mockResolvedValue(user); - groupService.findGroupsByUserAndGroupTypes.mockResolvedValue(existingGroups); + groupService.findGroupsByUserAndGroupTypes.mockResolvedValue(new Page(existingGroups, 2)); return { externalGroups, @@ -1283,6 +734,18 @@ describe('OidcProvisioningService', () => { expect(groupService.save).not.toHaveBeenCalled(); }); + + it('should not return a group', async () => { + const { externalGroups, systemId, externalUserId } = setup(); + + const result: Group[] = await service.removeExternalGroupsAndAffiliation( + externalUserId, + externalGroups, + systemId + ); + + expect(result).toHaveLength(0); + }); }); describe('when group is not empty after removal of the User', () => { @@ -1324,14 +787,16 @@ describe('OidcProvisioningService', () => { }); const externalGroups: ExternalGroupDto[] = [firstExternalGroup]; - userService.findByExternalId.mockResolvedValue(user); - groupService.findGroupsByUserAndGroupTypes.mockResolvedValue(existingGroups); + userService.findByExternalId.mockResolvedValueOnce(user); + groupService.findGroupsByUserAndGroupTypes.mockResolvedValueOnce(new Page(existingGroups, 2)); + groupService.save.mockResolvedValueOnce(secondExistingGroup); return { externalGroups, systemId, externalUserId, existingGroups, + secondExistingGroup, }; }; @@ -1350,6 +815,18 @@ describe('OidcProvisioningService', () => { expect(groupService.delete).not.toHaveBeenCalled(); }); + + it('should return the modified groups', async () => { + const { externalGroups, systemId, externalUserId, secondExistingGroup } = setup(); + + const result: Group[] = await service.removeExternalGroupsAndAffiliation( + externalUserId, + externalGroups, + systemId + ); + + expect(result).toEqual([secondExistingGroup]); + }); }); }); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts similarity index 54% rename from apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts rename to apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts index e6a73385029..e84ea0c72d5 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc/service/oidc-provisioning.service.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-group-provisioning.service.ts @@ -1,145 +1,31 @@ -import { AccountSaveDto, AccountService } from '@modules/account'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Group, GroupService, GroupTypes, GroupUser } from '@modules/group'; import { - FederalStateService, LegacySchoolService, SchoolSystemOptionsService, - SchoolYearService, SchulConneXProvisioningOptions, } from '@modules/legacy-school'; -import { FederalStateNames } from '@modules/legacy-school/types'; -import { RoleService } from '@modules/role'; -import { RoleDto } from '@modules/role/service/dto/role.dto'; +import { RoleDto, RoleService } from '@modules/role'; import { UserService } from '@modules/user'; -import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; -import { ExternalSource, LegacySchoolDo, RoleReference, UserDO } from '@shared/domain/domainobject'; -import { FederalStateEntity, SchoolYearEntity } from '@shared/domain/entity'; -import { EntityId, SchoolFeature } from '@shared/domain/types'; +import { ExternalSource, LegacySchoolDo, Page, UserDO } from '@shared/domain/domainobject'; +import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; -import { ObjectId } from 'bson'; -import CryptoJS from 'crypto-js'; -import { ExternalGroupDto, ExternalGroupUserDto, ExternalSchoolDto, ExternalUserDto } from '../../../dto'; +import { ExternalGroupDto, ExternalGroupUserDto, ExternalSchoolDto } from '../../../dto'; import { SchoolForGroupNotFoundLoggable, UserForGroupNotFoundLoggable } from '../../../loggable'; @Injectable() -export class OidcProvisioningService { +export class SchulconnexGroupProvisioningService { constructor( private readonly userService: UserService, private readonly schoolService: LegacySchoolService, - private readonly groupService: GroupService, private readonly roleService: RoleService, - private readonly accountService: AccountService, - private readonly schoolYearService: SchoolYearService, - private readonly federalStateService: FederalStateService, + private readonly groupService: GroupService, private readonly schoolSystemOptionsService: SchoolSystemOptionsService, private readonly logger: Logger ) {} - public async provisionExternalSchool(externalSchool: ExternalSchoolDto, systemId: EntityId): Promise { - const existingSchool: LegacySchoolDo | null = await this.schoolService.getSchoolByExternalId( - externalSchool.externalId, - systemId - ); - let school: LegacySchoolDo; - if (existingSchool) { - school = existingSchool; - school.name = this.getSchoolName(externalSchool); - school.officialSchoolNumber = externalSchool.officialSchoolNumber ?? existingSchool.officialSchoolNumber; - if (!school.systems) { - school.systems = [systemId]; - } else if (!school.systems.includes(systemId)) { - school.systems.push(systemId); - } - } else { - const schoolYear: SchoolYearEntity = await this.schoolYearService.getCurrentSchoolYear(); - const federalState: FederalStateEntity = await this.federalStateService.findFederalStateByName( - FederalStateNames.NIEDERSACHEN - ); - - school = new LegacySchoolDo({ - externalId: externalSchool.externalId, - name: this.getSchoolName(externalSchool), - officialSchoolNumber: externalSchool.officialSchoolNumber, - systems: [systemId], - features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], - // TODO: N21-990 Refactoring: Create domain objects for schoolYear and federalState - schoolYear, - federalState, - }); - } - - const savedSchool: LegacySchoolDo = await this.schoolService.save(school, true); - - return savedSchool; - } - - private getSchoolName(externalSchool: ExternalSchoolDto): string { - const schoolName: string = externalSchool.location - ? `${externalSchool.name} (${externalSchool.location})` - : externalSchool.name; - - return schoolName; - } - - public async provisionExternalUser( - externalUser: ExternalUserDto, - systemId: EntityId, - schoolId?: string - ): Promise { - let roleRefs: RoleReference[] | undefined; - if (externalUser.roles) { - const roles: RoleDto[] = await this.roleService.findByNames(externalUser.roles); - roleRefs = roles.map((role: RoleDto): RoleReference => new RoleReference({ id: role.id || '', name: role.name })); - } - - const existingUser: UserDO | null = await this.userService.findByExternalId(externalUser.externalId, systemId); - let user: UserDO; - let createNewAccount = false; - if (existingUser) { - user = existingUser; - user.firstName = externalUser.firstName ?? existingUser.firstName; - user.lastName = externalUser.lastName ?? existingUser.lastName; - user.email = externalUser.email ?? existingUser.email; - user.roles = roleRefs ?? existingUser.roles; - user.schoolId = schoolId ?? existingUser.schoolId; - user.birthday = externalUser.birthday ?? existingUser.birthday; - } else { - createNewAccount = true; - - if (!schoolId) { - throw new UnprocessableEntityException( - `Unable to create new external user ${externalUser.externalId} without a school` - ); - } - - user = new UserDO({ - externalId: externalUser.externalId, - firstName: externalUser.firstName ?? '', - lastName: externalUser.lastName ?? '', - email: externalUser.email ?? '', - roles: roleRefs ?? [], - schoolId, - birthday: externalUser.birthday, - }); - } - - const savedUser: UserDO = await this.userService.save(user); - - if (createNewAccount) { - await this.accountService.saveWithValidation( - new AccountSaveDto({ - userId: savedUser.id, - username: CryptoJS.SHA256(savedUser.id as string).toString(CryptoJS.enc.Base64), - systemId, - activated: true, - }) - ); - } - - return savedUser; - } - public async filterExternalGroups( externalGroups: ExternalGroupDto[], schoolId: EntityId | undefined, @@ -190,7 +76,7 @@ export class OidcProvisioningService { externalGroup: ExternalGroupDto, externalSchool: ExternalSchoolDto | undefined, systemId: EntityId - ): Promise { + ): Promise { let organizationId: string | undefined; if (externalSchool) { const existingSchool: LegacySchoolDo | null = await this.schoolService.getSchoolByExternalId( @@ -200,7 +86,7 @@ export class OidcProvisioningService { if (!existingSchool || !existingSchool.id) { this.logger.info(new SchoolForGroupNotFoundLoggable(externalGroup, externalSchool)); - return; + return null; } organizationId = existingSchool.id; @@ -239,7 +125,9 @@ export class OidcProvisioningService { group.addUser(self); - await this.groupService.save(group); + const savedGroup: Group = await this.groupService.save(group); + + return savedGroup; } private async getFilteredGroupUsers(externalGroup: ExternalGroupDto, systemId: string): Promise { @@ -280,16 +168,16 @@ export class OidcProvisioningService { externalUserId: string, externalGroups: ExternalGroupDto[], systemId: EntityId - ): Promise { + ): Promise { const user: UserDO | null = await this.userService.findByExternalId(externalUserId, systemId); if (!user) { throw new NotFoundLoggableException(UserDO.name, { externalId: externalUserId }); } - const existingGroupsOfUser: Group[] = await this.groupService.findGroupsByUserAndGroupTypes(user); + const existingGroupsOfUser: Page = await this.groupService.findGroupsByUserAndGroupTypes(user); - const groupsFromSystem: Group[] = existingGroupsOfUser.filter( + const groupsFromSystem: Group[] = existingGroupsOfUser.data.filter( (existingGroup: Group) => existingGroup.externalSource?.systemId === systemId ); @@ -302,16 +190,23 @@ export class OidcProvisioningService { return !isUserInGroup; }); - await Promise.all( - groupsWithoutUser.map(async (group: Group) => { + const groupRemovePromises: Promise[] = groupsWithoutUser.map( + async (group: Group): Promise => { group.removeUser(user); if (group.isEmpty()) { await this.groupService.delete(group); - } else { - await this.groupService.save(group); + return null; } - }) + + return this.groupService.save(group); + } ); + + const deletedAndModifiedGroups: (Group | null)[] = await Promise.all(groupRemovePromises); + + const remainingGroups: Group[] = deletedAndModifiedGroups.filter((group: Group | null): group is Group => !!group); + + return remainingGroups; } } diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.spec.ts new file mode 100644 index 00000000000..31c41b8ee1a --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.spec.ts @@ -0,0 +1,472 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { FederalStateService, LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LegacySchoolDo } from '@shared/domain/domainobject'; +import { SchoolFeature } from '@shared/domain/types'; +import { federalStateFactory, legacySchoolDoFactory, schoolYearFactory } from '@shared/testing'; +import { ExternalSchoolDto } from '../../../dto'; +import { SchulconnexSchoolProvisioningService } from './schulconnex-school-provisioning.service'; + +describe(SchulconnexSchoolProvisioningService.name, () => { + let module: TestingModule; + let service: SchulconnexSchoolProvisioningService; + + let schoolService: DeepMocked; + let schoolYearService: DeepMocked; + let federalStateService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SchulconnexSchoolProvisioningService, + { + provide: LegacySchoolService, + useValue: createMock(), + }, + { + provide: SchoolYearService, + useValue: createMock(), + }, + { + provide: FederalStateService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(SchulconnexSchoolProvisioningService); + schoolService = module.get(LegacySchoolService); + schoolYearService = module.get(SchoolYearService); + federalStateService = module.get(FederalStateService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('provisionExternalSchool', () => { + describe('when systemId is given and external school does not exist', () => { + describe('when successful', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = new LegacySchoolDo({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(null); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + }; + }; + + it('should save the correct data', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith({ ...savedSchoolDO, id: undefined }, true); + }); + + it('should save the new school', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + const result: LegacySchoolDo = await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(result).toEqual(savedSchoolDO); + }); + }); + + describe('when the external system provides a location for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + location: 'Hannover', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = new LegacySchoolDo({ + id: 'schoolId', + externalId: 'externalId', + name: 'name (Hannover)', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(null); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + }; + }; + + it('should append it to the school name', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith({ ...savedSchoolDO, id: undefined }, true); + }); + }); + + describe('when the external system does not provide a location for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = new LegacySchoolDo({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(null); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + }; + }; + + it('should only use the school name', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith({ ...savedSchoolDO, id: undefined }, true); + }); + }); + }); + + describe('when external school already exists', () => { + describe('when successful', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should update the existing school', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + const result: LegacySchoolDo = await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(result).toEqual(savedSchoolDO); + }); + }); + + describe('when the external system provides a location for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + location: 'Hannover', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name (Hannover)', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should append it to the school name', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith(savedSchoolDO, true); + }); + }); + + describe('when the external system does not provide a location for the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should only use the school name', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith(savedSchoolDO, true); + }); + }); + + describe('when there is a system at the school', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const otherSystemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [otherSystemId, systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: [otherSystemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + otherSystemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should append the new system', async () => { + const { systemId, otherSystemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith( + { + ...savedSchoolDO, + systems: [otherSystemId, systemId], + }, + true + ); + }); + }); + + describe('when there is no system at the school yet', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const externalSchoolDto: ExternalSchoolDto = new ExternalSchoolDto({ + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + }); + + const schoolYear = schoolYearFactory.build(); + const federalState = federalStateFactory.build(); + const savedSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'name', + officialSchoolNumber: 'officialSchoolNumber', + systems: [systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + const existingSchoolDO = legacySchoolDoFactory.build({ + id: 'schoolId', + externalId: 'externalId', + name: 'existingName', + officialSchoolNumber: 'existingOfficialSchoolNumber', + systems: undefined, + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + schoolYear, + federalState, + }); + + schoolService.save.mockResolvedValue(savedSchoolDO); + schoolService.getSchoolByExternalId.mockResolvedValue(existingSchoolDO); + schoolYearService.getCurrentSchoolYear.mockResolvedValue(schoolYear); + federalStateService.findFederalStateByName.mockResolvedValue(federalState); + + return { + systemId, + externalSchoolDto, + savedSchoolDO, + existingSchoolDO, + }; + }; + + it('should create a new system list', async () => { + const { systemId, externalSchoolDto, savedSchoolDO } = setup(); + + await service.provisionExternalSchool(externalSchoolDto, systemId); + + expect(schoolService.save).toHaveBeenCalledWith(savedSchoolDO, true); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.ts new file mode 100644 index 00000000000..4483b324b78 --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-school-provisioning.service.ts @@ -0,0 +1,62 @@ +import { FederalStateService, LegacySchoolService, SchoolYearService } from '@modules/legacy-school'; +import { FederalStateNames } from '@modules/legacy-school/types'; +import { Injectable } from '@nestjs/common'; +import { LegacySchoolDo } from '@shared/domain/domainobject'; +import { FederalStateEntity, SchoolYearEntity } from '@shared/domain/entity'; +import { EntityId, SchoolFeature } from '@shared/domain/types'; +import { ExternalSchoolDto } from '../../../dto'; + +@Injectable() +export class SchulconnexSchoolProvisioningService { + constructor( + private readonly schoolService: LegacySchoolService, + private readonly schoolYearService: SchoolYearService, + private readonly federalStateService: FederalStateService + ) {} + + public async provisionExternalSchool(externalSchool: ExternalSchoolDto, systemId: EntityId): Promise { + const existingSchool: LegacySchoolDo | null = await this.schoolService.getSchoolByExternalId( + externalSchool.externalId, + systemId + ); + let school: LegacySchoolDo; + if (existingSchool) { + school = existingSchool; + school.name = this.getSchoolName(externalSchool); + school.officialSchoolNumber = externalSchool.officialSchoolNumber ?? existingSchool.officialSchoolNumber; + if (!school.systems) { + school.systems = [systemId]; + } else if (!school.systems.includes(systemId)) { + school.systems.push(systemId); + } + } else { + const schoolYear: SchoolYearEntity = await this.schoolYearService.getCurrentSchoolYear(); + const federalState: FederalStateEntity = await this.federalStateService.findFederalStateByName( + FederalStateNames.NIEDERSACHEN + ); + + school = new LegacySchoolDo({ + externalId: externalSchool.externalId, + name: this.getSchoolName(externalSchool), + officialSchoolNumber: externalSchool.officialSchoolNumber, + systems: [systemId], + features: [SchoolFeature.OAUTH_PROVISIONING_ENABLED], + // TODO: N21-990 Refactoring: Create domain objects for schoolYear and federalState + schoolYear, + federalState, + }); + } + + const savedSchool: LegacySchoolDo = await this.schoolService.save(school, true); + + return savedSchool; + } + + private getSchoolName(externalSchool: ExternalSchoolDto): string { + const schoolName: string = externalSchool.location + ? `${externalSchool.name} (${externalSchool.location})` + : externalSchool.name; + + return schoolName; + } +} diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.spec.ts new file mode 100644 index 00000000000..a9cd98abe0e --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.spec.ts @@ -0,0 +1,203 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AccountService, AccountSave } from '@modules/account'; +import { RoleService } from '@modules/role'; +import { RoleDto } from '@modules/role/service/dto/role.dto'; +import { UserService } from '@modules/user'; +import { UnprocessableEntityException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UserDO } from '@shared/domain/domainobject'; +import { RoleName } from '@shared/domain/interface'; +import { userDoFactory } from '@shared/testing'; +import CryptoJS from 'crypto-js'; +import { ExternalUserDto } from '../../../dto'; +import { SchulconnexUserProvisioningService } from './schulconnex-user-provisioning.service'; + +jest.mock('crypto-js'); + +describe(SchulconnexUserProvisioningService.name, () => { + let module: TestingModule; + let service: SchulconnexUserProvisioningService; + + let userService: DeepMocked; + let roleService: DeepMocked; + let accountService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SchulconnexUserProvisioningService, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: RoleService, + useValue: createMock(), + }, + { + provide: AccountService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(SchulconnexUserProvisioningService); + userService = module.get(UserService); + roleService = module.get(RoleService); + accountService = module.get(AccountService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('provisionExternalUser', () => { + const setupUser = () => { + const systemId = 'systemId'; + const schoolId = 'schoolId'; + const birthday = new Date('2023-11-17'); + const existingUser: UserDO = userDoFactory.withRoles([{ id: 'existingRoleId', name: RoleName.USER }]).buildWithId( + { + firstName: 'existingFirstName', + lastName: 'existingLastName', + email: 'existingEmail', + schoolId: 'existingSchoolId', + externalId: 'externalUserId', + birthday: new Date('2023-11-16'), + }, + 'userId' + ); + const savedUser: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).buildWithId( + { + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + schoolId, + externalId: 'externalUserId', + birthday, + }, + 'userId' + ); + const externalUser: ExternalUserDto = new ExternalUserDto({ + externalId: 'externalUserId', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + roles: [RoleName.USER], + birthday, + }); + const userRole: RoleDto = new RoleDto({ + id: 'roleId', + name: RoleName.USER, + }); + const hash = 'hash'; + + roleService.findByNames.mockResolvedValue([userRole]); + userService.save.mockResolvedValue(savedUser); + jest.spyOn(CryptoJS, 'SHA256').mockReturnValue({ + toString: jest.fn().mockReturnValue(hash), + words: [], + sigBytes: 0, + concat: jest.fn(), + clamp: jest.fn(), + clone: jest.fn(), + }); + + return { + existingUser, + savedUser, + externalUser, + userRole, + schoolId, + systemId, + hash, + }; + }; + + describe('when the user does not exist yet', () => { + it('should call the user service to save the user', async () => { + const { externalUser, schoolId, savedUser, systemId } = setupUser(); + + userService.findByExternalId.mockResolvedValue(null); + + await service.provisionExternalUser(externalUser, systemId, schoolId); + + expect(userService.save).toHaveBeenCalledWith(new UserDO({ ...savedUser, id: undefined })); + }); + + it('should return the saved user', async () => { + const { externalUser, schoolId, savedUser, systemId } = setupUser(); + + userService.findByExternalId.mockResolvedValue(null); + + const result: UserDO = await service.provisionExternalUser(externalUser, systemId, schoolId); + + expect(result).toEqual(savedUser); + }); + + it('should create a new account', async () => { + const { externalUser, schoolId, systemId, hash } = setupUser(); + const account: AccountSave = { + userId: 'userId', + username: hash, + systemId, + activated: true, + } as AccountSave; + + userService.findByExternalId.mockResolvedValue(null); + + await service.provisionExternalUser(externalUser, systemId, schoolId); + + expect(accountService.saveWithValidation).toHaveBeenCalledWith(account); + }); + + describe('when no schoolId is provided', () => { + it('should throw UnprocessableEntityException', async () => { + const { externalUser } = setupUser(); + + userService.findByExternalId.mockResolvedValue(null); + + const promise: Promise = service.provisionExternalUser(externalUser, 'systemId', undefined); + + await expect(promise).rejects.toThrow(UnprocessableEntityException); + }); + }); + }); + + describe('when the user already exists', () => { + it('should call the user service to save the user', async () => { + const { externalUser, schoolId, existingUser, systemId } = setupUser(); + + userService.findByExternalId.mockResolvedValue(existingUser); + + await service.provisionExternalUser(externalUser, systemId, schoolId); + + expect(userService.save).toHaveBeenCalledWith(existingUser); + }); + + it('should return the updated user', async () => { + const { externalUser, schoolId, existingUser, savedUser, systemId } = setupUser(); + + userService.findByExternalId.mockResolvedValue(existingUser); + + const result: UserDO = await service.provisionExternalUser(externalUser, systemId, schoolId); + + expect(result).toEqual(savedUser); + }); + + it('should not create a new account', async () => { + const { externalUser, schoolId, systemId, existingUser } = setupUser(); + + userService.findByExternalId.mockResolvedValue(existingUser); + + await service.provisionExternalUser(externalUser, systemId, schoolId); + + expect(accountService.saveWithValidation).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.ts b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.ts new file mode 100644 index 00000000000..7352ab7323f --- /dev/null +++ b/apps/server/src/modules/provisioning/strategy/oidc/service/schulconnex-user-provisioning.service.ts @@ -0,0 +1,73 @@ +import { AccountService, AccountSave } from '@modules/account'; +import { RoleDto, RoleService } from '@modules/role'; +import { UserService } from '@modules/user'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { RoleReference, UserDO } from '@shared/domain/domainobject'; +import { EntityId } from '@shared/domain/types'; +import CryptoJS from 'crypto-js'; +import { ExternalUserDto } from '../../../dto'; + +@Injectable() +export class SchulconnexUserProvisioningService { + constructor( + private readonly userService: UserService, + private readonly roleService: RoleService, + private readonly accountService: AccountService + ) {} + + public async provisionExternalUser( + externalUser: ExternalUserDto, + systemId: EntityId, + schoolId?: string + ): Promise { + let roleRefs: RoleReference[] | undefined; + if (externalUser.roles) { + const roles: RoleDto[] = await this.roleService.findByNames(externalUser.roles); + roleRefs = roles.map((role: RoleDto): RoleReference => new RoleReference({ id: role.id || '', name: role.name })); + } + + const existingUser: UserDO | null = await this.userService.findByExternalId(externalUser.externalId, systemId); + let user: UserDO; + let createNewAccount = false; + if (existingUser) { + user = existingUser; + user.firstName = externalUser.firstName ?? existingUser.firstName; + user.lastName = externalUser.lastName ?? existingUser.lastName; + user.email = externalUser.email ?? existingUser.email; + user.roles = roleRefs ?? existingUser.roles; + user.schoolId = schoolId ?? existingUser.schoolId; + user.birthday = externalUser.birthday ?? existingUser.birthday; + } else { + createNewAccount = true; + + if (!schoolId) { + throw new UnprocessableEntityException( + `Unable to create new external user ${externalUser.externalId} without a school` + ); + } + + user = new UserDO({ + externalId: externalUser.externalId, + firstName: externalUser.firstName ?? '', + lastName: externalUser.lastName ?? '', + email: externalUser.email ?? '', + roles: roleRefs ?? [], + schoolId, + birthday: externalUser.birthday, + }); + } + + const savedUser: UserDO = await this.userService.save(user); + + if (createNewAccount) { + await this.accountService.saveWithValidation({ + userId: savedUser.id, + username: CryptoJS.SHA256(savedUser.id as string).toString(CryptoJS.enc.Base64), + systemId, + activated: true, + } as AccountSave); + } + + return savedUser; + } +} diff --git a/apps/server/src/modules/provisioning/strategy/sanis/index.ts b/apps/server/src/modules/provisioning/strategy/sanis/index.ts index 4f98cbd73e9..39c4c4272ac 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/index.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/index.ts @@ -1,3 +1,2 @@ -export * from './response'; export * from './sanis.strategy'; export * from './sanis-response.mapper'; diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts index c3b70c695d6..42b6d6b97e4 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.spec.ts @@ -1,19 +1,18 @@ import { createMock } from '@golevelup/ts-jest'; -import { GroupTypes } from '@modules/group'; -import { Test, TestingModule } from '@nestjs/testing'; -import { RoleName } from '@shared/domain/interface'; -import { Logger } from '@src/core/logger'; -import { UUID } from 'bson'; -import { ExternalGroupDto, ExternalSchoolDto, ExternalUserDto } from '../../dto'; import { SanisGroupRole, SanisGroupType, SanisGruppenResponse, SanisPersonenkontextResponse, SanisResponse, - SanisRole, SanisSonstigeGruppenzugehoerigeResponse, -} from './response'; + schulconnexResponseFactory, +} from '@infra/schulconnex-client'; +import { GroupTypes } from '@modules/group'; +import { Test, TestingModule } from '@nestjs/testing'; +import { RoleName } from '@shared/domain/interface'; +import { Logger } from '@src/core/logger'; +import { ExternalGroupDto, ExternalSchoolDto, ExternalUserDto } from '../../dto'; import { SanisResponseMapper } from './sanis-response.mapper'; describe('SanisResponseMapper', () => { @@ -38,50 +37,7 @@ describe('SanisResponseMapper', () => { const externalUserId = 'aef1f4fd-c323-466e-962b-a84354c0e713'; const externalSchoolId = 'df66c8e6-cfac-40f7-b35b-0da5d8ee680e'; - const sanisResponse: SanisResponse = { - pid: externalUserId, - person: { - name: { - vorname: 'firstName', - familienname: 'lastName', - }, - geburt: { - datum: '2023-11-17', - }, - }, - personenkontexte: [ - { - id: new UUID().toString(), - rolle: SanisRole.LERN, - organisation: { - id: new UUID(externalSchoolId).toString(), - name: 'schoolName', - kennung: 'NI_123456_NI_ashd3838', - anschrift: { - ort: 'Hannover', - }, - }, - gruppen: [ - { - gruppe: { - id: new UUID().toString(), - bezeichnung: 'bezeichnung', - typ: SanisGroupType.CLASS, - }, - gruppenzugehoerigkeit: { - rollen: [SanisGroupRole.TEACHER], - }, - sonstige_gruppenzugehoerige: [ - { - rollen: [SanisGroupRole.STUDENT], - ktid: 'ktid', - }, - ], - }, - ], - }, - ], - }; + const sanisResponse: SanisResponse = schulconnexResponseFactory.build(); return { externalUserId, @@ -100,7 +56,7 @@ describe('SanisResponseMapper', () => { expect(result).toEqual({ externalId: externalSchoolId, name: 'schoolName', - officialSchoolNumber: '123456_NI_ashd3838', + officialSchoolNumber: 'Kennung', location: 'Hannover', }); }); @@ -116,9 +72,10 @@ describe('SanisResponseMapper', () => { expect(result).toEqual({ externalId: externalUserId, - firstName: 'firstName', - lastName: 'lastName', - roles: [RoleName.STUDENT], + firstName: 'Hans', + lastName: 'Peter', + email: 'hans.peter@muster-schule.de', + roles: [RoleName.ADMINISTRATOR], birthday: new Date('2023-11-17'), }); }); diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts index 4ec69d54149..c4a846a67b5 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis-response.mapper.ts @@ -1,17 +1,18 @@ +import { + SanisGruppenResponse, + SanisResponse, + SanisSonstigeGruppenzugehoerigeResponse, +} from '@infra/schulconnex-client'; +import { SanisErreichbarkeitenResponse, SchulconnexCommunicationType } from '@infra/schulconnex-client/response'; +import { SanisGroupRole } from '@infra/schulconnex-client/response/sanis-group-role'; +import { SanisGroupType } from '@infra/schulconnex-client/response/sanis-group-type'; +import { SanisRole } from '@infra/schulconnex-client/response/sanis-role'; import { GroupTypes } from '@modules/group'; import { Injectable } from '@nestjs/common'; import { RoleName } from '@shared/domain/interface'; import { Logger } from '@src/core/logger'; import { ExternalGroupDto, ExternalGroupUserDto, ExternalSchoolDto, ExternalUserDto } from '../../dto'; import { GroupRoleUnknownLoggable } from '../../loggable'; -import { - SanisGroupRole, - SanisGroupType, - SanisGruppenResponse, - SanisResponse, - SanisRole, - SanisSonstigeGruppenzugehoerigeResponse, -} from './response'; const RoleMapping: Record = { [SanisRole.LEHR]: RoleName.TEACHER, @@ -54,18 +55,27 @@ export class SanisResponseMapper { } mapToExternalUserDto(source: SanisResponse): ExternalUserDto { + let email: string | undefined; + if (source.personenkontexte[0].erreichbarkeiten?.length) { + const emailContact: SanisErreichbarkeitenResponse | undefined = source.personenkontexte[0].erreichbarkeiten.find( + (contact: SanisErreichbarkeitenResponse): boolean => contact.typ === SchulconnexCommunicationType.EMAIL + ); + email = emailContact?.kennung; + } + const mapped = new ExternalUserDto({ firstName: source.person.name.vorname, lastName: source.person.name.familienname, - roles: [this.mapSanisRoleToRoleName(source)], + roles: [SanisResponseMapper.mapSanisRoleToRoleName(source)], externalId: source.pid, birthday: source.person.geburt?.datum ? new Date(source.person.geburt?.datum) : undefined, + email, }); return mapped; } - private mapSanisRoleToRoleName(source: SanisResponse): RoleName { + public static mapSanisRoleToRoleName(source: SanisResponse): RoleName { return RoleMapping[source.personenkontexte[0].rolle]; } diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts index f7b9a4fbb0e..528f6dc2374 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.spec.ts @@ -1,4 +1,12 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { + SanisGruppenResponse, + SanisResponse, + SanisResponseValidationGroups, + SanisRole, + schulconnexResponseFactory, +} from '@infra/schulconnex-client'; +import { GroupService } from '@modules/group'; import { GroupTypes } from '@modules/group/domain'; import { HttpService } from '@nestjs/axios'; import { InternalServerErrorException } from '@nestjs/common'; @@ -19,15 +27,12 @@ import { OauthDataStrategyInputDto, ProvisioningSystemDto, } from '../../dto'; -import { OidcProvisioningService } from '../oidc/service/oidc-provisioning.service'; import { - SanisGroupRole, - SanisGroupType, - SanisGruppenResponse, - SanisResponse, - SanisResponseValidationGroups, - SanisRole, -} from './response'; + SchulconnexCourseSyncService, + SchulconnexGroupProvisioningService, + SchulconnexSchoolProvisioningService, + SchulconnexUserProvisioningService, +} from '../oidc/service'; import { SanisResponseMapper } from './sanis-response.mapper'; import { SanisProvisioningStrategy } from './sanis.strategy'; import ArgsType = jest.ArgsType; @@ -65,8 +70,24 @@ describe('SanisStrategy', () => { useValue: createMock(), }, { - provide: OidcProvisioningService, - useValue: createMock(), + provide: SchulconnexSchoolProvisioningService, + useValue: createMock(), + }, + { + provide: SchulconnexUserProvisioningService, + useValue: createMock(), + }, + { + provide: SchulconnexGroupProvisioningService, + useValue: createMock(), + }, + { + provide: SchulconnexCourseSyncService, + useValue: createMock(), + }, + { + provide: GroupService, + useValue: createMock(), }, { provide: ProvisioningFeatures, @@ -93,52 +114,7 @@ describe('SanisStrategy', () => { jest.resetAllMocks(); }); - const setupSanisResponse = (): SanisResponse => { - return { - pid: 'aef1f4fd-c323-466e-962b-a84354c0e713', - person: { - name: { - vorname: 'Hans', - familienname: 'Peter', - }, - geburt: { - datum: '2023-11-17', - }, - }, - personenkontexte: [ - { - id: new UUID().toString(), - rolle: SanisRole.LEIT, - organisation: { - id: new UUID('df66c8e6-cfac-40f7-b35b-0da5d8ee680e').toString(), - name: 'schoolName', - kennung: 'Kennung', - anschrift: { - ort: 'Hannover', - }, - }, - gruppen: [ - { - gruppe: { - id: new UUID().toString(), - bezeichnung: 'bezeichnung', - typ: SanisGroupType.CLASS, - }, - gruppenzugehoerigkeit: { - rollen: [SanisGroupRole.TEACHER], - }, - sonstige_gruppenzugehoerige: [ - { - rollen: [SanisGroupRole.STUDENT], - ktid: 'ktid', - }, - ], - }, - ], - }, - ], - }; - }; + const setupSanisResponse = (): SanisResponse => schulconnexResponseFactory.build(); describe('getType is called', () => { describe('when it is called', () => { diff --git a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts index ca8d5942645..050adbe3a0f 100644 --- a/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/sanis/sanis.strategy.ts @@ -1,3 +1,5 @@ +import { SanisGruppenResponse, SanisResponse, SanisResponseValidationGroups } from '@infra/schulconnex-client/response'; +import { GroupService } from '@modules/group'; import { HttpService } from '@nestjs/axios'; import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ValidationErrorLoggableException } from '@shared/common/loggable-exception'; @@ -15,20 +17,35 @@ import { OauthDataDto, OauthDataStrategyInputDto, } from '../../dto'; -import { OidcProvisioningStrategy } from '../oidc/oidc.strategy'; -import { OidcProvisioningService } from '../oidc/service/oidc-provisioning.service'; -import { SanisGruppenResponse, SanisResponse, SanisResponseValidationGroups } from './response'; +import { SchulconnexProvisioningStrategy } from '../oidc'; +import { + SchulconnexCourseSyncService, + SchulconnexGroupProvisioningService, + SchulconnexSchoolProvisioningService, + SchulconnexUserProvisioningService, +} from '../oidc/service'; import { SanisResponseMapper } from './sanis-response.mapper'; @Injectable() -export class SanisProvisioningStrategy extends OidcProvisioningStrategy { +export class SanisProvisioningStrategy extends SchulconnexProvisioningStrategy { constructor( @Inject(ProvisioningFeatures) protected readonly provisioningFeatures: IProvisioningFeatures, - protected readonly oidcProvisioningService: OidcProvisioningService, + protected readonly schulconnexSchoolProvisioningService: SchulconnexSchoolProvisioningService, + protected readonly schulconnexUserProvisioningService: SchulconnexUserProvisioningService, + protected readonly schulconnexGroupProvisioningService: SchulconnexGroupProvisioningService, + protected readonly schulconnexCourseSyncService: SchulconnexCourseSyncService, + protected readonly groupService: GroupService, private readonly responseMapper: SanisResponseMapper, private readonly httpService: HttpService ) { - super(provisioningFeatures, oidcProvisioningService); + super( + provisioningFeatures, + schulconnexSchoolProvisioningService, + schulconnexUserProvisioningService, + schulconnexGroupProvisioningService, + schulconnexCourseSyncService, + groupService + ); } getType(): SystemProvisioningStrategy { @@ -42,6 +59,7 @@ export class SanisProvisioningStrategy extends OidcProvisioningStrategy { ); } + // TODO: N21-1678 use the schulconnex rest client const axiosConfig: AxiosRequestConfig = { headers: { Authorization: `Bearer ${input.accessToken}`, diff --git a/apps/server/src/modules/pseudonym/controller/api-test/pseudonym.api.spec.ts b/apps/server/src/modules/pseudonym/controller/api-test/pseudonym.api.spec.ts index f646a401885..84359d9e131 100644 --- a/apps/server/src/modules/pseudonym/controller/api-test/pseudonym.api.spec.ts +++ b/apps/server/src/modules/pseudonym/controller/api-test/pseudonym.api.spec.ts @@ -1,16 +1,16 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server'; import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; +import { externalToolEntityFactory } from '@modules/tool/external-tool/testing'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity } from '@shared/domain/entity'; import { - TestApiClient, - UserAndAccountTestFactory, cleanupCollections, - externalToolEntityFactory, externalToolPseudonymEntityFactory, - schoolFactory, + schoolEntityFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; import { UUID } from 'bson'; import { Response } from 'supertest'; @@ -55,7 +55,7 @@ describe('PseudonymController (API)', () => { describe('when valid params are given', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({ school }, []); const pseudonymString: string = new UUID().toString(); const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); @@ -89,7 +89,7 @@ describe('PseudonymController (API)', () => { describe('when pseudonym is not connected to the users school', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent(); const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }); const pseudonymString: string = new UUID().toString(); @@ -127,7 +127,7 @@ describe('PseudonymController (API)', () => { describe('when pseudonym does not exist in db', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({ school }); const pseudonymString: string = new UUID().toString(); const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); diff --git a/apps/server/src/modules/pseudonym/entity/pseudonym.scope.spec.ts b/apps/server/src/modules/pseudonym/entity/pseudonym.scope.spec.ts index 28b020f68e8..48b6401a477 100644 --- a/apps/server/src/modules/pseudonym/entity/pseudonym.scope.spec.ts +++ b/apps/server/src/modules/pseudonym/entity/pseudonym.scope.spec.ts @@ -1,4 +1,5 @@ -import { ObjectId, UUID } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { UUID } from 'bson'; import { PseudonymScope } from './pseudonym.scope'; describe('PseudonymScope', () => { diff --git a/apps/server/src/modules/pseudonym/entity/pseudonym.scope.ts b/apps/server/src/modules/pseudonym/entity/pseudonym.scope.ts index cba4e3b6247..a80b89bcc18 100644 --- a/apps/server/src/modules/pseudonym/entity/pseudonym.scope.ts +++ b/apps/server/src/modules/pseudonym/entity/pseudonym.scope.ts @@ -1,5 +1,5 @@ import { Scope } from '@shared/repo'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { ExternalToolPseudonymEntity } from './external-tool-pseudonym.entity'; export class PseudonymScope extends Scope { diff --git a/apps/server/src/modules/pseudonym/pseudonym.module.ts b/apps/server/src/modules/pseudonym/pseudonym.module.ts index 2b69307c6d2..477ccceda65 100644 --- a/apps/server/src/modules/pseudonym/pseudonym.module.ts +++ b/apps/server/src/modules/pseudonym/pseudonym.module.ts @@ -2,12 +2,13 @@ import { LearnroomModule } from '@modules/learnroom'; import { ToolModule } from '@modules/tool'; import { UserModule } from '@modules/user'; import { forwardRef, Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; import { LegacyLogger, LoggerModule } from '@src/core/logger'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from './repo'; import { FeathersRosterService, PseudonymService } from './service'; @Module({ - imports: [UserModule, LearnroomModule, forwardRef(() => ToolModule), LoggerModule], + imports: [UserModule, LearnroomModule, forwardRef(() => ToolModule), LoggerModule, CqrsModule], providers: [PseudonymService, PseudonymsRepo, ExternalToolPseudonymRepo, LegacyLogger, FeathersRosterService], exports: [PseudonymService, FeathersRosterService], }) diff --git a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts index 1b9e8e64843..bfd074b2599 100644 --- a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts +++ b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.integration.spec.ts @@ -240,6 +240,24 @@ describe('ExternalToolPseudonymRepo', () => { }); describe('deletePseudonymsByUserId', () => { + describe('when pseudonyms are not existing', () => { + const setup = () => { + const user = userFactory.buildWithId(); + + return { + user, + }; + }; + + it('should return empty array', async () => { + const { user } = setup(); + + const result = await repo.deletePseudonymsByUserId(user.id); + + expect(result).toEqual([]); + }); + }); + describe('when pseudonyms are existing', () => { const setup = async () => { const user1 = userFactory.buildWithId(); @@ -259,7 +277,10 @@ describe('ExternalToolPseudonymRepo', () => { await em.persistAndFlush([pseudonym1, pseudonym2, pseudonym3, pseudonym4]); + const expectedResult = [pseudonym1.id, pseudonym2.id]; + return { + expectedResult, user1, pseudonym1, pseudonym2, @@ -267,18 +288,11 @@ describe('ExternalToolPseudonymRepo', () => { }; it('should delete all pseudonyms for userId', async () => { - const { user1 } = await setup(); + const { expectedResult, user1 } = await setup(); - const result: number = await repo.deletePseudonymsByUserId(user1.id); - - expect(result).toEqual(2); - }); - }); + const result = await repo.deletePseudonymsByUserId(user1.id); - describe('should return empty array when there is no pseudonym', () => { - it('should return empty array', async () => { - const result: Pseudonym[] = await repo.findByUserId(new ObjectId().toHexString()); - expect(result).toHaveLength(0); + expect(result).toEqual(expectedResult); }); }); }); diff --git a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts index 77f90894e47..7b64c67e61b 100644 --- a/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts +++ b/apps/server/src/modules/pseudonym/repo/external-tool-pseudonym.repo.ts @@ -68,12 +68,17 @@ export class ExternalToolPseudonymRepo { return savedDomainObject; } - async deletePseudonymsByUserId(userId: EntityId): Promise { - const promise: Promise = this.em.nativeDelete(ExternalToolPseudonymEntity, { - userId: new ObjectId(userId), - }); + async deletePseudonymsByUserId(userId: EntityId): Promise { + const externalPseudonyms = await this.em.find(ExternalToolPseudonymEntity, { userId: new ObjectId(userId) }); + if (externalPseudonyms.length === 0) { + return []; + } + + const removePromises = externalPseudonyms.map((externalPseudonym) => this.em.removeAndFlush(externalPseudonym)); - return promise; + await Promise.all(removePromises); + + return this.getExternalPseudonymId(externalPseudonyms); } async findPseudonymByPseudonym(pseudonym: string): Promise { @@ -90,6 +95,10 @@ export class ExternalToolPseudonymRepo { return domainObject; } + private getExternalPseudonymId(externalPseudonyms: ExternalToolPseudonymEntity[]): EntityId[] { + return externalPseudonyms.map((externalPseudonym) => externalPseudonym.id); + } + protected mapEntityToDomainObject(entity: ExternalToolPseudonymEntity): Pseudonym { const pseudonym = new Pseudonym({ id: entity.id, diff --git a/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts b/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts index afa77d64935..404ada59a66 100644 --- a/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts +++ b/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.spec.ts @@ -230,6 +230,23 @@ describe('PseudonymRepo', () => { }); describe('deletePseudonymsByUserId', () => { + describe('when pseudonyms are not existing', () => { + const setup = () => { + const user = userFactory.buildWithId(); + + return { + user, + }; + }; + + it('should return empty array', async () => { + const { user } = setup(); + + const result = await repo.deletePseudonymsByUserId(user.id); + + expect(result).toEqual([]); + }); + }); describe('when pseudonyms are existing', () => { const setup = async () => { const user1 = userFactory.buildWithId(); @@ -241,24 +258,20 @@ describe('PseudonymRepo', () => { await em.persistAndFlush([pseudonym1, pseudonym2, pseudonym3, pseudonym4]); + const expectedResult = [pseudonym1.id, pseudonym2.id]; + return { + expectedResult, user1, }; }; it('should delete all pseudonyms for userId', async () => { - const { user1 } = await setup(); - - const result: number = await repo.deletePseudonymsByUserId(user1.id); + const { expectedResult, user1 } = await setup(); - expect(result).toEqual(2); - }); - }); + const result = await repo.deletePseudonymsByUserId(user1.id); - describe('should return empty array when there is no pseudonym', () => { - it('should return empty array', async () => { - const result: Pseudonym[] = await repo.findByUserId(new ObjectId().toHexString()); - expect(result).toHaveLength(0); + expect(result).toEqual(expectedResult); }); }); }); diff --git a/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.ts b/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.ts index d0979e91fca..2ba378ea73a 100644 --- a/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.ts +++ b/apps/server/src/modules/pseudonym/repo/pseudonyms.repo.ts @@ -63,10 +63,21 @@ export class PseudonymsRepo { return savedDomainObject; } - async deletePseudonymsByUserId(userId: EntityId): Promise { - const promise: Promise = this.em.nativeDelete(PseudonymEntity, { userId: new ObjectId(userId) }); + async deletePseudonymsByUserId(userId: EntityId): Promise { + const pseudonyms = await this.em.find(PseudonymEntity, { userId: new ObjectId(userId) }); + if (pseudonyms.length === 0) { + return []; + } + + const removePromises = pseudonyms.map((pseudonym) => this.em.removeAndFlush(pseudonym)); + + await Promise.all(removePromises); + + return this.getPseudonymId(pseudonyms); + } - return promise; + private getPseudonymId(pseudonyms: PseudonymEntity[]): EntityId[] { + return pseudonyms.map((pseudonym) => pseudonym.id); } protected mapEntityToDomainObject(entity: PseudonymEntity): Pseudonym { diff --git a/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts b/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts index de106a751ee..67dabfd63e8 100644 --- a/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts +++ b/apps/server/src/modules/pseudonym/service/feathers-roster.service.spec.ts @@ -21,13 +21,13 @@ import { externalToolFactory, legacySchoolDoFactory, pseudonymFactory, + schoolEntityFactory, schoolExternalToolFactory, - schoolFactory, setupEntities, UserAndAccountTestFactory, userDoFactory, } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { FeathersRosterService } from './feathers-roster.service'; import { PseudonymService } from './pseudonym.service'; @@ -362,7 +362,7 @@ describe('FeathersRosterService', () => { describe('when valid courseId and oauth2ClientId is given', () => { const setup = () => { let courseA: Course = courseFactory.buildWithId(); - const schoolEntity: SchoolEntity = schoolFactory.buildWithId(); + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId(); const school: LegacySchoolDo = legacySchoolDoFactory.build({ id: schoolEntity.id }); const externalTool: ExternalTool = externalToolFactory.buildWithId(); const externalToolId: string = externalTool.id as string; diff --git a/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts b/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts index 53c1c58cf3b..eccc00d7c04 100644 --- a/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts +++ b/apps/server/src/modules/pseudonym/service/pseudonym.service.spec.ts @@ -4,10 +4,19 @@ import { ExternalTool } from '@modules/tool/external-tool/domain'; import { InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { IFindOptions } from '@shared/domain/interface'; - import { LtiToolDO, Page, Pseudonym, UserDO } from '@shared/domain/domainobject'; import { externalToolFactory, ltiToolDOFactory, pseudonymFactory, userDoFactory } from '@shared/testing/factory'; import { Logger } from '@src/core/logger'; +import { ObjectId } from 'bson'; +import { EventBus } from '@nestjs/cqrs'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DataDeletedEvent, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { PseudonymSearchQuery } from '../domain'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from '../repo'; import { PseudonymService } from './pseudonym.service'; @@ -18,6 +27,7 @@ describe('PseudonymService', () => { let pseudonymRepo: DeepMocked; let externalToolPseudonymRepo: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -35,12 +45,19 @@ describe('PseudonymService', () => { provide: Logger, useValue: createMock(), }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); service = module.get(PseudonymService); pseudonymRepo = module.get(PseudonymsRepo); externalToolPseudonymRepo = module.get(ExternalToolPseudonymRepo); + eventBus = module.get(EventBus); }); beforeEach(() => { @@ -392,7 +409,7 @@ describe('PseudonymService', () => { }); }); - describe('deleteByUserId', () => { + describe('deleteUserData', () => { describe('when user is missing', () => { const setup = () => { const user: UserDO = userDoFactory.build({ id: undefined }); @@ -405,28 +422,43 @@ describe('PseudonymService', () => { it('should throw an error', async () => { const { user } = setup(); - await expect(service.deleteByUserId(user.id as string)).rejects.toThrowError(InternalServerErrorException); + await expect(service.deleteUserData(user.id as string)).rejects.toThrowError(InternalServerErrorException); }); }); describe('when deleting by userId', () => { const setup = () => { const user: UserDO = userDoFactory.buildWithId(); - - pseudonymRepo.deletePseudonymsByUserId.mockResolvedValue(2); - externalToolPseudonymRepo.deletePseudonymsByUserId.mockResolvedValue(3); + const pseudonymsDeleted = [new ObjectId().toHexString(), new ObjectId().toHexString()]; + const externalPseudonymsDeleted = [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]; + + const expectedResult = DomainDeletionReportBuilder.build(DomainName.PSEUDONYMS, [ + DomainOperationReportBuilder.build( + OperationType.DELETE, + pseudonymsDeleted.length + externalPseudonymsDeleted.length, + [...pseudonymsDeleted, ...externalPseudonymsDeleted] + ), + ]); + + pseudonymRepo.deletePseudonymsByUserId.mockResolvedValue(pseudonymsDeleted); + externalToolPseudonymRepo.deletePseudonymsByUserId.mockResolvedValue(externalPseudonymsDeleted); return { + expectedResult, user, }; }; it('should delete pseudonyms by userId', async () => { - const { user } = setup(); + const { expectedResult, user } = setup(); - const result5 = await service.deleteByUserId(user.id as string); + const result = await service.deleteUserData(user.id as string); - expect(result5).toEqual(5); + expect(result).toEqual(expectedResult); }); }); }); @@ -538,4 +570,48 @@ describe('PseudonymService', () => { }); }); }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILERECORDS; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in classService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(service.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); + }); + }); + }); }); diff --git a/apps/server/src/modules/pseudonym/service/pseudonym.service.ts b/apps/server/src/modules/pseudonym/service/pseudonym.service.ts index 7fbfd09464b..ea4e19ad836 100644 --- a/apps/server/src/modules/pseudonym/service/pseudonym.service.ts +++ b/apps/server/src/modules/pseudonym/service/pseudonym.service.ts @@ -3,24 +3,43 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ExternalTool } from '@modules/tool/external-tool/domain'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { LtiToolDO, Page, Pseudonym, UserDO } from '@shared/domain/domainobject'; -import { IFindOptions } from '@shared/domain/interface'; import { v4 as uuidv4 } from 'uuid'; import { Logger } from '@src/core/logger'; -import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; -import { DomainModel, StatusModel } from '@shared/domain/types'; -import { PseudonymSearchQuery } from '../domain'; +import { EntityId } from '@shared/domain/types'; +import { IEventHandler, EventBus, EventsHandler } from '@nestjs/cqrs'; +import { IFindOptions } from '@shared/domain/interface'; +import { + UserDeletedEvent, + DeletionService, + DataDeletedEvent, + DomainDeletionReport, + DataDeletionDomainOperationLoggable, + DomainName, + DomainDeletionReportBuilder, + DomainOperationReportBuilder, + OperationType, + StatusModel, +} from '@modules/deletion'; import { ExternalToolPseudonymRepo, PseudonymsRepo } from '../repo'; +import { PseudonymSearchQuery } from '../domain'; @Injectable() -export class PseudonymService { +@EventsHandler(UserDeletedEvent) +export class PseudonymService implements DeletionService, IEventHandler { constructor( private readonly pseudonymRepo: PseudonymsRepo, private readonly externalToolPseudonymRepo: ExternalToolPseudonymRepo, - private readonly logger: Logger + private readonly logger: Logger, + private readonly eventBus: EventBus ) { this.logger.setContext(PseudonymService.name); } + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + public async findByUserAndToolOrThrow(user: UserDO, tool: ExternalTool | LtiToolDO): Promise { if (!user.id || !tool.id) { throw new InternalServerErrorException('User or tool id is missing'); @@ -78,11 +97,11 @@ export class PseudonymService { return pseudonym; } - public async deleteByUserId(userId: string): Promise { + public async deleteUserData(userId: string): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting user data from Pseudonyms', - DomainModel.PSEUDONYMS, + DomainName.PSEUDONYMS, userId, StatusModel.PENDING ) @@ -96,12 +115,19 @@ export class PseudonymService { this.deleteExternalToolPseudonymsByUserId(userId), ]); - const numberOfDeletedPseudonyms = deletedPseudonyms + deletedExternalToolPseudonyms; + const numberOfDeletedPseudonyms = deletedPseudonyms.length + deletedExternalToolPseudonyms.length; + + const result = DomainDeletionReportBuilder.build(DomainName.PSEUDONYMS, [ + DomainOperationReportBuilder.build(OperationType.DELETE, numberOfDeletedPseudonyms, [ + ...deletedPseudonyms, + ...deletedExternalToolPseudonyms, + ]), + ]); this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully deleted user data from Pseudonyms', - DomainModel.PSEUDONYMS, + DomainName.PSEUDONYMS, userId, StatusModel.FINISHED, 0, @@ -109,7 +135,7 @@ export class PseudonymService { ) ); - return numberOfDeletedPseudonyms; + return result; } private async findPseudonymsByUserId(userId: string): Promise { @@ -124,14 +150,14 @@ export class PseudonymService { return externalToolPseudonymPromise; } - private async deletePseudonymsByUserId(userId: string): Promise { - const pseudonymPromise: Promise = this.pseudonymRepo.deletePseudonymsByUserId(userId); + private async deletePseudonymsByUserId(userId: string): Promise { + const pseudonymPromise: Promise = this.pseudonymRepo.deletePseudonymsByUserId(userId); return pseudonymPromise; } - private async deleteExternalToolPseudonymsByUserId(userId: string): Promise { - const externalToolPseudonymPromise: Promise = + private async deleteExternalToolPseudonymsByUserId(userId: string): Promise { + const externalToolPseudonymPromise: Promise = this.externalToolPseudonymRepo.deletePseudonymsByUserId(userId); return externalToolPseudonymPromise; diff --git a/apps/server/src/modules/pseudonym/uc/pseudonym.uc.spec.ts b/apps/server/src/modules/pseudonym/uc/pseudonym.uc.spec.ts index d7b2b861c88..8c8b6683b56 100644 --- a/apps/server/src/modules/pseudonym/uc/pseudonym.uc.spec.ts +++ b/apps/server/src/modules/pseudonym/uc/pseudonym.uc.spec.ts @@ -5,7 +5,13 @@ import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, Pseudonym } from '@shared/domain/domainobject'; import { SchoolEntity, User } from '@shared/domain/entity'; -import { legacySchoolDoFactory, pseudonymFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { + legacySchoolDoFactory, + pseudonymFactory, + schoolEntityFactory, + setupEntities, + userFactory, +} from '@shared/testing'; import { PseudonymService } from '../service'; import { PseudonymUc } from './pseudonym.uc'; @@ -57,7 +63,7 @@ describe('PseudonymUc', () => { const setup = () => { const userId = 'userId'; const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId(); - const schoolEntity: SchoolEntity = schoolFactory.buildWithId(); + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId(); const user: User = userFactory.buildWithId({ school: schoolEntity }); user.school = schoolEntity; const pseudonym: Pseudonym = new Pseudonym(pseudonymFactory.build({ userId: user.id })); @@ -115,7 +121,7 @@ describe('PseudonymUc', () => { const setup = () => { const userId = 'userId'; const user: User = userFactory.buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); user.school = school; const pseudonym: Pseudonym = new Pseudonym(pseudonymFactory.build()); diff --git a/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts index c357351fa37..a91a6801db3 100644 --- a/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts +++ b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.spec.ts @@ -28,6 +28,46 @@ describe(RegistrationPinRepo.name, () => { await cleanupCollections(em); }); + describe('findAllByEmail', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + const userWithoutRegistrationPin = userFactory.buildWithId(); + const registrationPinForUser = registrationPinEntityFactory.buildWithId({ email: user.email }); + + await em.persistAndFlush(registrationPinForUser); + + const expectedResult = [[registrationPinForUser], 1]; + const expectedResultForNoRegistrationPin = [[], 0]; + + return { + expectedResult, + expectedResultForNoRegistrationPin, + user, + userWithoutRegistrationPin, + }; + }; + + describe('when registrationPin exists', () => { + it('should delete registrationPins by email', async () => { + const { expectedResult, user } = await setup(); + + const result = await repo.findAllByEmail(user.email); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when there is no registrationPin', () => { + it('should return count equal 0 and an empty array', async () => { + const { expectedResultForNoRegistrationPin, userWithoutRegistrationPin } = await setup(); + + const result = await repo.findAllByEmail(userWithoutRegistrationPin.email); + + expect(result).toEqual(expectedResultForNoRegistrationPin); + }); + }); + }); + describe('deleteRegistrationPinByEmail', () => { const setup = async () => { const user = userFactory.buildWithId(); @@ -36,7 +76,10 @@ describe(RegistrationPinRepo.name, () => { await em.persistAndFlush(registrationPinForUser); + const expectedResult = 1; + return { + expectedResult, user, userWithoutRegistrationPin, }; @@ -44,11 +87,11 @@ describe(RegistrationPinRepo.name, () => { describe('when registrationPin exists', () => { it('should delete registrationPins by email', async () => { - const { user } = await setup(); + const { expectedResult, user } = await setup(); - const result: number = await repo.deleteRegistrationPinByEmail(user.email); + const result = await repo.deleteRegistrationPinByEmail(user.email); - expect(result).toEqual(1); + expect(result).toEqual(expectedResult); }); }); @@ -56,7 +99,8 @@ describe(RegistrationPinRepo.name, () => { it('should return empty array', async () => { const { userWithoutRegistrationPin } = await setup(); - const result: number = await repo.deleteRegistrationPinByEmail(userWithoutRegistrationPin.email); + const result = await repo.deleteRegistrationPinByEmail(userWithoutRegistrationPin.email); + expect(result).toEqual(0); }); }); diff --git a/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts index 6ca68bc089d..84d9fa31684 100644 --- a/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts +++ b/apps/server/src/modules/registration-pin/repo/registration-pin.repo.ts @@ -1,14 +1,23 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; +import { Counted } from '@shared/domain/types'; import { RegistrationPinEntity } from '../entity'; @Injectable() export class RegistrationPinRepo { constructor(private readonly em: EntityManager) {} + async findAllByEmail(email: string): Promise> { + const [registrationPins, count] = await this.em.findAndCount(RegistrationPinEntity, { + email, + }); + + return [registrationPins, count]; + } + async deleteRegistrationPinByEmail(email: string): Promise { - const promise: Promise = this.em.nativeDelete(RegistrationPinEntity, { email }); + const deletedRegistrationPinNumber = await this.em.nativeDelete(RegistrationPinEntity, { email }); - return promise; + return deletedRegistrationPinNumber; } } diff --git a/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts b/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts index c5a7545c32f..523f821000f 100644 --- a/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts +++ b/apps/server/src/modules/registration-pin/service/registration-pin.service.spec.ts @@ -2,6 +2,14 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities, userDoFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DeletionErrorLoggableException, +} from '@modules/deletion'; +import { registrationPinEntityFactory } from '../entity/testing'; import { RegistrationPinService } from '.'; import { RegistrationPinRepo } from '../repo'; @@ -40,13 +48,46 @@ describe(RegistrationPinService.name, () => { }); describe('deleteRegistrationPinByEmail', () => { - describe('when deleting registrationPin', () => { + describe('when there is no registrationPin', () => { const setup = () => { const user = userDoFactory.buildWithId(); + registrationPinRepo.findAllByEmail.mockResolvedValueOnce([[], 0]); + registrationPinRepo.deleteRegistrationPinByEmail.mockResolvedValueOnce(0); + + const expectedResult = DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 0, []), + ]); + + return { + expectedResult, + user, + }; + }; + + it('should return domainOperation object with proper values', async () => { + const { expectedResult, user } = setup(); + + const result = await service.deleteUserData(user.email); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when deleting existing registrationPin', () => { + const setup = () => { + const user = userDoFactory.buildWithId(); + const registrationPin = registrationPinEntityFactory.buildWithId({ email: user.email }); + + registrationPinRepo.findAllByEmail.mockResolvedValueOnce([[registrationPin], 1]); registrationPinRepo.deleteRegistrationPinByEmail.mockResolvedValueOnce(1); + const expectedResult = DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [registrationPin.id]), + ]); + return { + expectedResult, user, }; }; @@ -54,17 +95,42 @@ describe(RegistrationPinService.name, () => { it('should call registrationPinRep', async () => { const { user } = setup(); - await service.deleteRegistrationPinByEmail(user.email); + await service.deleteUserData(user.email); expect(registrationPinRepo.deleteRegistrationPinByEmail).toBeCalledWith(user.email); }); - it('should delete registrationPin by email', async () => { - const { user } = setup(); + it('should delete registrationPin by email and return domainOperation object with proper information', async () => { + const { expectedResult, user } = setup(); + + const result = await service.deleteUserData(user.email); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when registrationPin exists and failed to delete it', () => { + const setup = () => { + const user = userDoFactory.buildWithId(); + const registrationPin = registrationPinEntityFactory.buildWithId({ email: user.email }); + + registrationPinRepo.findAllByEmail.mockResolvedValueOnce([[registrationPin], 1]); + registrationPinRepo.deleteRegistrationPinByEmail.mockResolvedValueOnce(0); + + const expectedError = new DeletionErrorLoggableException( + `Failed to delete user data from RegistrationPin for '${user.email}'` + ); + + return { + expectedError, + user, + }; + }; - const result: number = await service.deleteRegistrationPinByEmail(user.email); + it('should throw an error', async () => { + const { expectedError, user } = setup(); - expect(result).toEqual(1); + await expect(service.deleteUserData(user.email)).rejects.toThrowError(expectedError); }); }); }); diff --git a/apps/server/src/modules/registration-pin/service/registration-pin.service.ts b/apps/server/src/modules/registration-pin/service/registration-pin.service.ts index 8b750802268..bf1a4a661a9 100644 --- a/apps/server/src/modules/registration-pin/service/registration-pin.service.ts +++ b/apps/server/src/modules/registration-pin/service/registration-pin.service.ts @@ -1,36 +1,65 @@ import { Injectable } from '@nestjs/common'; import { Logger } from '@src/core/logger'; -import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; -import { DomainModel, StatusModel } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; +import { + DeletionService, + DomainDeletionReport, + DataDeletionDomainOperationLoggable, + DomainName, + DeletionErrorLoggableException, + DomainDeletionReportBuilder, + DomainOperationReportBuilder, + OperationType, + StatusModel, +} from '@modules/deletion'; +import { RegistrationPinEntity } from '../entity'; import { RegistrationPinRepo } from '../repo'; @Injectable() -export class RegistrationPinService { +export class RegistrationPinService implements DeletionService { constructor(private readonly registrationPinRepo: RegistrationPinRepo, private readonly logger: Logger) { this.logger.setContext(RegistrationPinService.name); } - async deleteRegistrationPinByEmail(email: string): Promise { + public async deleteUserData(email: string): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting user data from RegistrationPin', - DomainModel.REGISTRATIONPIN, + DomainName.REGISTRATIONPIN, email, StatusModel.PENDING ) ); - const result = await this.registrationPinRepo.deleteRegistrationPinByEmail(email); + const [registrationPinToDelete, count] = await this.registrationPinRepo.findAllByEmail(email); + const numberOfDeletedRegistrationPins = await this.registrationPinRepo.deleteRegistrationPinByEmail(email); + + if (numberOfDeletedRegistrationPins !== count) { + throw new DeletionErrorLoggableException(`Failed to delete user data from RegistrationPin for '${email}'`); + } + + const result = DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build( + OperationType.DELETE, + numberOfDeletedRegistrationPins, + this.getRegistrationPinsId(registrationPinToDelete) + ), + ]); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully deleted user data from RegistrationPin', - DomainModel.REGISTRATIONPIN, + DomainName.REGISTRATIONPIN, email, StatusModel.FINISHED, 0, - result + numberOfDeletedRegistrationPins ) ); return result; } + + private getRegistrationPinsId(registrationPins: RegistrationPinEntity[]): EntityId[] { + return registrationPins.map((registrationPin) => registrationPin.id); + } } diff --git a/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.spec.ts b/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.spec.ts index bd5a07abb5c..864a6e2ab59 100644 --- a/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.spec.ts +++ b/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.spec.ts @@ -28,6 +28,44 @@ describe(RocketChatUserMapper.name, () => { }); }); + describe('mapToDOs', () => { + describe('When empty entities array is mapped for an empty domainObjects array', () => { + it('should return empty domain objects array for an empty entities array', () => { + const domainObjects = RocketChatUserMapper.mapToDOs([]); + + expect(domainObjects).toEqual([]); + }); + }); + + describe('When entities array is mapped for domainObjects array', () => { + const setup = () => { + const entities = [rocketChatUserEntityFactory.build()]; + + const expectedDomainObjects = entities.map( + (entity) => + new RocketChatUser({ + id: entity.id, + userId: entity.userId.toHexString(), + username: entity.username, + rcId: entity.rcId, + authToken: entity.authToken, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }) + ); + + return { entities, expectedDomainObjects }; + }; + it('should properly map the entities to the domain objects', () => { + const { entities, expectedDomainObjects } = setup(); + + const domainObjects = RocketChatUserMapper.mapToDOs(entities); + + expect(domainObjects).toEqual(expectedDomainObjects); + }); + }); + }); + describe('mapToEntity', () => { describe('When domainObject is mapped for entity', () => { beforeAll(() => { diff --git a/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.ts b/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.ts index 3d45c9c34ac..f4fee93fa6a 100644 --- a/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.ts +++ b/apps/server/src/modules/rocketchat-user/repo/mapper/rocket-chat-user.mapper.ts @@ -26,4 +26,8 @@ export class RocketChatUserMapper { updatedAt: domainObject.updatedAt, }); } + + static mapToDOs(entities: RocketChatUserEntity[]): RocketChatUser[] { + return entities.map((entity) => this.mapToDO(entity)); + } } diff --git a/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.spec.ts b/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.spec.ts index d58e5fc42d1..523f8f08e01 100644 --- a/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.spec.ts +++ b/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.spec.ts @@ -66,7 +66,6 @@ describe(RocketChatUserRepo.name, () => { createdAt: entity.createdAt, updatedAt: entity.updatedAt, }; - return { entity, expectedRocketChatUser, @@ -76,10 +75,10 @@ describe(RocketChatUserRepo.name, () => { it('should find the rocketChatUser', async () => { const { entity, expectedRocketChatUser } = await setup(); - const result: RocketChatUser = await repo.findByUserId(entity.userId.toHexString()); + const result: RocketChatUser[] = await repo.findByUserId(entity.userId.toHexString()); // Verify explicit fields. - expect(result).toEqual(expect.objectContaining(expectedRocketChatUser)); + expect(result[0]).toEqual(expect.objectContaining(expectedRocketChatUser)); }); }); }); diff --git a/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.ts b/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.ts index 539130cb5e3..a5f7443aada 100644 --- a/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.ts +++ b/apps/server/src/modules/rocketchat-user/repo/rocket-chat-user.repo.ts @@ -13,12 +13,12 @@ export class RocketChatUserRepo { return RocketChatUserEntity; } - async findByUserId(userId: EntityId): Promise { - const entity: RocketChatUserEntity = await this.em.findOneOrFail(RocketChatUserEntity, { + async findByUserId(userId: EntityId): Promise { + const entities: RocketChatUserEntity[] = await this.em.find(RocketChatUserEntity, { userId: new ObjectId(userId), }); - const mapped: RocketChatUser = RocketChatUserMapper.mapToDO(entity); + const mapped: RocketChatUser[] = RocketChatUserMapper.mapToDOs(entities); return mapped; } diff --git a/apps/server/src/modules/rocketchat-user/rocketchat-user.module.ts b/apps/server/src/modules/rocketchat-user/rocketchat-user.module.ts index 529fa75a471..5aef8638554 100644 --- a/apps/server/src/modules/rocketchat-user/rocketchat-user.module.ts +++ b/apps/server/src/modules/rocketchat-user/rocketchat-user.module.ts @@ -1,10 +1,23 @@ import { Module } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; +import { Configuration } from '@hpi-schul-cloud/commons'; +import { CqrsModule } from '@nestjs/cqrs'; +import { RocketChatModule } from '@modules/rocketchat/rocket-chat.module'; import { RocketChatUserRepo } from './repo'; import { RocketChatUserService } from './service/rocket-chat-user.service'; @Module({ - imports: [LoggerModule], + imports: [ + CqrsModule, + LoggerModule, + RocketChatModule.forRoot({ + uri: Configuration.get('ROCKET_CHAT_URI') as string, + adminId: Configuration.get('ROCKET_CHAT_ADMIN_ID') as string, + adminToken: Configuration.get('ROCKET_CHAT_ADMIN_TOKEN') as string, + adminUser: Configuration.get('ROCKET_CHAT_ADMIN_USER') as string, + adminPassword: Configuration.get('ROCKET_CHAT_ADMIN_PASSWORD') as string, + }), + ], providers: [RocketChatUserRepo, RocketChatUserService], exports: [RocketChatUserService], }) diff --git a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts index 578171003fd..d960979c1bf 100644 --- a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts +++ b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.spec.ts @@ -3,6 +3,18 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { setupEntities } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { EventBus } from '@nestjs/cqrs'; +import { RocketChatService } from '@modules/rocketchat/rocket-chat.service'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DomainDeletionReport, + DataDeletedEvent, + DeletionErrorLoggableException, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { RocketChatUserService } from './rocket-chat-user.service'; import { RocketChatUserRepo } from '../repo'; import { rocketChatUserFactory } from '../domain/testing/rocket-chat-user.factory'; @@ -12,6 +24,8 @@ describe(RocketChatUserService.name, () => { let module: TestingModule; let service: RocketChatUserService; let rocketChatUserRepo: DeepMocked; + let rocketChatService: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -21,15 +35,27 @@ describe(RocketChatUserService.name, () => { provide: RocketChatUserRepo, useValue: createMock(), }, + { + provide: RocketChatService, + useValue: createMock(), + }, { provide: Logger, useValue: createMock(), }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); service = module.get(RocketChatUserService); rocketChatUserRepo = module.get(RocketChatUserRepo); + rocketChatService = module.get(RocketChatService); + eventBus = module.get(EventBus); await setupEntities(); }); @@ -49,7 +75,7 @@ describe(RocketChatUserService.name, () => { const rocketChatUser: RocketChatUser = rocketChatUserFactory.build(); - rocketChatUserRepo.findByUserId.mockResolvedValueOnce(rocketChatUser); + rocketChatUserRepo.findByUserId.mockResolvedValueOnce([rocketChatUser]); return { userId, @@ -60,39 +86,187 @@ describe(RocketChatUserService.name, () => { it('should return the rocketChatUser', async () => { const { userId, rocketChatUser } = setup(); - const result: RocketChatUser = await service.findByUserId(userId); + const result: RocketChatUser[] = await service.findByUserId(userId); - expect(result).toEqual(rocketChatUser); + expect(result[0]).toEqual(rocketChatUser); }); }); }); describe('delete RocketChatUser', () => { - describe('when deleting rocketChatUser', () => { + describe('when rocketChatUser does not exist', () => { const setup = () => { const userId = new ObjectId().toHexString(); - rocketChatUserRepo.deleteByUserId.mockResolvedValueOnce(1); + rocketChatUserRepo.findByUserId.mockResolvedValueOnce([]); + + const expectedResult = DomainDeletionReportBuilder.build( + DomainName.ROCKETCHATUSER, + [DomainOperationReportBuilder.build(OperationType.DELETE, 0, [])], + [ + DomainDeletionReportBuilder.build(DomainName.ROCKETCHATSERVICE, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 0, []), + ]), + ] + ); return { userId, + expectedResult, }; }; it('should call rocketChatUserRepo', async () => { const { userId } = setup(); - await service.deleteByUserId(userId); + await service.deleteUserData(userId); + + expect(rocketChatUserRepo.findByUserId).toBeCalledWith(userId); + }); + + it('should return domainOperation object with information about deleted user', async () => { + const { userId, expectedResult } = setup(); + + const result = await service.deleteUserData(userId); - expect(rocketChatUserRepo.deleteByUserId).toBeCalledWith(userId); + expect(result).toEqual(expectedResult); }); - it('should delete rocketChatUser by userId', async () => { + it('should Not call rocketChatUserRepo.deleteByUserId with userId', async () => { const { userId } = setup(); - const result: number = await service.deleteByUserId(userId); + await service.deleteUserData(userId); + + expect(rocketChatUserRepo.deleteByUserId).not.toHaveBeenCalled(); + }); + }); + + describe('when rocketChatUser exists', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const rocketChatUser: RocketChatUser = rocketChatUserFactory.build(); + + rocketChatUserRepo.findByUserId.mockResolvedValueOnce([rocketChatUser]); + rocketChatUserRepo.deleteByUserId.mockResolvedValueOnce(1); + rocketChatService.deleteUser.mockResolvedValueOnce({ success: true }); + + const expectedResult = DomainDeletionReportBuilder.build( + DomainName.ROCKETCHATUSER, + [DomainOperationReportBuilder.build(OperationType.DELETE, 1, [rocketChatUser.id])], + [ + DomainDeletionReportBuilder.build(DomainName.ROCKETCHATSERVICE, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [rocketChatUser.username]), + ]), + ] + ); + + return { + userId, + expectedResult, + rocketChatUser, + }; + }; + + it('should call rocketChatUserRepo', async () => { + const { userId } = setup(); + + await service.deleteUserData(userId); + + expect(rocketChatUserRepo.findByUserId).toBeCalledWith(userId); + }); + + it('should call rocketChatService.deleteUser with username', async () => { + const { rocketChatUser, userId } = setup(); + + await service.deleteUserData(userId); + + expect(rocketChatService.deleteUser).toBeCalledWith(rocketChatUser.username); + }); + + it('should call rocketChatUserRepo.deleteByUserId with userId', async () => { + const { rocketChatUser, userId } = setup(); + + await service.deleteUserData(userId); + + expect(rocketChatUserRepo.deleteByUserId).toBeCalledWith(rocketChatUser.userId); + }); + + it('should return domainOperation object with information about deleted user', async () => { + const { userId, expectedResult } = setup(); + + const result: DomainDeletionReport = await service.deleteUserData(userId); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when rocketChatUser exists and failed to delete this user', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const rocketChatUser: RocketChatUser = rocketChatUserFactory.build(); + + rocketChatUserRepo.findByUserId.mockResolvedValueOnce([rocketChatUser]); + rocketChatUserRepo.deleteByUserId.mockResolvedValueOnce(1); + rocketChatService.deleteUser.mockRejectedValueOnce(new Error()); + + const expectedError = `Failed to delete user data for userId '${userId}' from RocketChatUser collection / RocketChat service`; + + return { + expectedError, + userId, + }; + }; + + it('should throw an error', async () => { + const { expectedError, userId } = setup(); + + await expect(service.deleteUserData(userId)).rejects.toThrowError( + new DeletionErrorLoggableException(expectedError) + ); + }); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILERECORDS; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in classService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(service.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); - expect(result).toEqual(1); + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); }); }); }); diff --git a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.ts b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.ts index c0f6e64b18f..2f5a795a424 100644 --- a/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.ts +++ b/apps/server/src/modules/rocketchat-user/service/rocket-chat-user.service.ts @@ -1,44 +1,115 @@ import { Injectable } from '@nestjs/common'; -import { DomainModel, EntityId, StatusModel } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; import { Logger } from '@src/core/logger'; -import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; +import { EventsHandler, IEventHandler, EventBus } from '@nestjs/cqrs'; +import { RocketChatService } from '@modules/rocketchat'; +import { + UserDeletedEvent, + DeletionService, + DataDeletedEvent, + DomainDeletionReport, + DomainName, + DomainDeletionReportBuilder, + DomainOperationReportBuilder, + OperationType, + DataDeletionDomainOperationLoggable, + DeletionErrorLoggableException, + StatusModel, +} from '@modules/deletion'; import { RocketChatUser } from '../domain'; import { RocketChatUserRepo } from '../repo'; @Injectable() -export class RocketChatUserService { - constructor(private readonly rocketChatUserRepo: RocketChatUserRepo, private readonly logger: Logger) { +@EventsHandler(UserDeletedEvent) +export class RocketChatUserService implements DeletionService, IEventHandler { + constructor( + private readonly rocketChatUserRepo: RocketChatUserRepo, + private readonly rocketChatService: RocketChatService, + private readonly logger: Logger, + private readonly eventBus: EventBus + ) { this.logger.setContext(RocketChatUserService.name); } - public async findByUserId(userId: EntityId): Promise { - const user: RocketChatUser = await this.rocketChatUserRepo.findByUserId(userId); + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + + public async findByUserId(userId: EntityId): Promise { + const user: RocketChatUser[] = await this.rocketChatUserRepo.findByUserId(userId); return user; } - public async deleteByUserId(userId: EntityId): Promise { + public async deleteUserData(userId: EntityId): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting user from rocket chat', - DomainModel.ROCKETCHATUSER, + DomainName.ROCKETCHATUSER, userId, StatusModel.PENDING ) ); - const deletedRocketChatUser = await this.rocketChatUserRepo.deleteByUserId(userId); + const rocketChatUser = await this.rocketChatUserRepo.findByUserId(userId); - this.logger.info( - new DataDeletionDomainOperationLoggable( - 'Successfully deleted user from rocket chat', - DomainModel.ROCKETCHATUSER, - userId, - StatusModel.FINISHED, - 0, - deletedRocketChatUser - ) - ); + if (rocketChatUser.length > 0) { + try { + const [, rocketChatUserDeleted] = await Promise.all([ + this.rocketChatService.deleteUser(rocketChatUser[0].username), + this.rocketChatUserRepo.deleteByUserId(rocketChatUser[0].userId), + ]); + + const result = DomainDeletionReportBuilder.build( + DomainName.ROCKETCHATUSER, + [DomainOperationReportBuilder.build(OperationType.DELETE, rocketChatUserDeleted, [rocketChatUser[0].id])], + [ + DomainDeletionReportBuilder.build(DomainName.ROCKETCHATSERVICE, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [rocketChatUser[0].username]), + ]), + ] + ); + + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'Successfully deleted user from rocket chat', + DomainName.ROCKETCHATUSER, + userId, + StatusModel.FINISHED, + 0, + rocketChatUserDeleted + ) + ); + + return result; + } catch { + throw new DeletionErrorLoggableException( + `Failed to delete user data for userId '${userId}' from RocketChatUser collection / RocketChat service` + ); + } + } else { + const result = DomainDeletionReportBuilder.build( + DomainName.ROCKETCHATUSER, + [DomainOperationReportBuilder.build(OperationType.DELETE, 0, [])], + [ + DomainDeletionReportBuilder.build(DomainName.ROCKETCHATSERVICE, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 0, []), + ]), + ] + ); + + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'RocketChat user already deleted', + DomainName.ROCKETCHATUSER, + userId, + StatusModel.FINISHED, + 0, + 0 + ) + ); - return deletedRocketChatUser; + return result; + } } } diff --git a/apps/server/src/modules/role/index.ts b/apps/server/src/modules/role/index.ts index a76408570bc..f62345e1a95 100644 --- a/apps/server/src/modules/role/index.ts +++ b/apps/server/src/modules/role/index.ts @@ -1,2 +1,2 @@ -export * from './role.module'; -export * from './service/role.service'; +export { RoleModule } from './role.module'; +export { RoleService, RoleDto } from './service'; diff --git a/apps/server/src/modules/role/role.module.ts b/apps/server/src/modules/role/role.module.ts index 42063b400e3..a80847c7899 100644 --- a/apps/server/src/modules/role/role.module.ts +++ b/apps/server/src/modules/role/role.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { RoleRepo } from '@shared/repo'; -import { RoleService } from '@modules/role/service/role.service'; -import { RoleUc } from '@modules/role/uc/role.uc'; +import { RoleService } from './service'; +import { RoleUc } from './uc'; @Module({ providers: [RoleRepo, RoleService, RoleUc], diff --git a/apps/server/src/modules/role/service/dto/index.ts b/apps/server/src/modules/role/service/dto/index.ts new file mode 100644 index 00000000000..985a22da32f --- /dev/null +++ b/apps/server/src/modules/role/service/dto/index.ts @@ -0,0 +1 @@ +export * from './role.dto'; diff --git a/apps/server/src/modules/role/service/index.ts b/apps/server/src/modules/role/service/index.ts new file mode 100644 index 00000000000..b2b4d90d60f --- /dev/null +++ b/apps/server/src/modules/role/service/index.ts @@ -0,0 +1,2 @@ +export * from './dto'; +export * from './role.service'; diff --git a/apps/server/src/modules/role/service/role.service.spec.ts b/apps/server/src/modules/role/service/role.service.spec.ts index 0ad82b3833b..c088fadaf0b 100644 --- a/apps/server/src/modules/role/service/role.service.spec.ts +++ b/apps/server/src/modules/role/service/role.service.spec.ts @@ -5,9 +5,8 @@ import { Role } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { RoleRepo } from '@shared/repo'; import { roleFactory } from '@shared/testing'; -import { RoleDto } from './dto/role.dto'; +import { RoleDto } from './dto'; import { RoleService } from './role.service'; -import resetAllMocks = jest.resetAllMocks; describe('RoleService', () => { let module: TestingModule; @@ -39,7 +38,7 @@ describe('RoleService', () => { }); afterEach(() => { - resetAllMocks(); + jest.resetAllMocks(); }); describe('findById', () => { @@ -80,7 +79,7 @@ describe('RoleService', () => { }); }); - describe('findByName', () => { + describe('findByNames', () => { it('should find role entity', async () => { roleRepo.findByNames.mockResolvedValue([testRoleEntity]); @@ -97,6 +96,30 @@ describe('RoleService', () => { }); }); + describe('findByName', () => { + describe('when a role exists', () => { + it('should find a role', async () => { + roleRepo.findByName.mockResolvedValue(testRoleEntity); + + const result: RoleDto = await roleService.findByName(testRoleEntity.name); + + expect(result).toEqual({ + id: testRoleEntity.id, + name: testRoleEntity.name, + permissions: [], + }); + }); + }); + + describe('when no role exists with a name', () => { + it('should throw an error', async () => { + roleRepo.findByName.mockRejectedValue(new NotFoundError('not found')); + + await expect(roleService.findByName('unknown role' as unknown as RoleName)).rejects.toThrow(NotFoundError); + }); + }); + }); + describe('getProtectedRoles', () => { it('should call the repo', async () => { roleRepo.findByNames.mockResolvedValue([testRoleEntity]); diff --git a/apps/server/src/modules/role/service/role.service.ts b/apps/server/src/modules/role/service/role.service.ts index 3c0c66c1226..ffa4837ce10 100644 --- a/apps/server/src/modules/role/service/role.service.ts +++ b/apps/server/src/modules/role/service/role.service.ts @@ -4,7 +4,7 @@ import { RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { RoleRepo } from '@shared/repo'; import { RoleMapper } from '../mapper/role.mapper'; -import { RoleDto } from './dto/role.dto'; +import { RoleDto } from './dto'; @Injectable() export class RoleService { @@ -12,24 +12,39 @@ export class RoleService { async getProtectedRoles(): Promise { const roleDtos: RoleDto[] = await this.findByNames([RoleName.ADMINISTRATOR, RoleName.TEACHER]); + return roleDtos; } async findById(id: EntityId): Promise { const entity: Role = await this.roleRepo.findById(id); + const roleDto: RoleDto = RoleMapper.mapFromEntityToDto(entity); + return roleDto; } async findByIds(ids: EntityId[]): Promise { const roles: Role[] = await this.roleRepo.findByIds(ids); + const roleDtos: RoleDto[] = RoleMapper.mapFromEntitiesToDtos(roles); + return roleDtos; } async findByNames(names: RoleName[]): Promise { const entities: Role[] = await this.roleRepo.findByNames(names); + const roleDtos: RoleDto[] = RoleMapper.mapFromEntitiesToDtos(entities); + return roleDtos; } + + async findByName(names: RoleName): Promise { + const entity: Role = await this.roleRepo.findByName(names); + + const roleDto: RoleDto = RoleMapper.mapFromEntityToDto(entity); + + return roleDto; + } } diff --git a/apps/server/src/modules/role/uc/index.ts b/apps/server/src/modules/role/uc/index.ts new file mode 100644 index 00000000000..30b1da60cfe --- /dev/null +++ b/apps/server/src/modules/role/uc/index.ts @@ -0,0 +1 @@ +export * from './role.uc'; diff --git a/apps/server/src/modules/school/api/dto/param/index.ts b/apps/server/src/modules/school/api/dto/param/index.ts index 530e5be501e..0ee15436cde 100644 --- a/apps/server/src/modules/school/api/dto/param/index.ts +++ b/apps/server/src/modules/school/api/dto/param/index.ts @@ -1,2 +1,4 @@ -export * from './school-query.params'; -export * from './school-url.params'; +export * from './school-query.params'; +export * from './school-remove-system-url.params'; +export * from './school-update-body.params'; +export * from './school-url.params'; diff --git a/apps/server/src/modules/school/api/dto/param/school-remove-system-url.params.ts b/apps/server/src/modules/school/api/dto/param/school-remove-system-url.params.ts new file mode 100644 index 00000000000..a31ac762936 --- /dev/null +++ b/apps/server/src/modules/school/api/dto/param/school-remove-system-url.params.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsMongoId } from 'class-validator'; + +export class SchoolRemoveSystemUrlParams { + @IsMongoId() + @ApiProperty() + schoolId!: string; + + @IsMongoId() + @ApiProperty() + systemId!: string; +} diff --git a/apps/server/src/modules/school/api/dto/param/school-update-body.params.ts b/apps/server/src/modules/school/api/dto/param/school-update-body.params.ts new file mode 100644 index 00000000000..6b6726a72d6 --- /dev/null +++ b/apps/server/src/modules/school/api/dto/param/school-update-body.params.ts @@ -0,0 +1,101 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { SanitizeHtml } from '@shared/controller'; +import { LanguageType, Permission } from '@shared/domain/interface'; +import { EntityId, SchoolFeature } from '@shared/domain/types'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsEnum, IsMongoId, IsOptional, IsString, Matches, ValidateNested } from 'class-validator'; +import { + FileStorageType, + SchoolPermissions, + SchoolUpdateBody, + StudentPermission, + TeacherPermission, +} from '../../../domain'; + +export class SchoolLogo { + @ApiPropertyOptional() + @IsString() + @IsOptional() + dataUrl?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + @SanitizeHtml() + name?: string; +} + +class TeacherPermissionParams implements TeacherPermission { + @ApiPropertyOptional() + @IsBoolean() + [Permission.STUDENT_LIST]?: boolean; +} + +class StudentPermissionParams implements StudentPermission { + @ApiPropertyOptional() + @IsBoolean() + [Permission.LERNSTORE_VIEW]?: boolean; +} + +class SchoolPermissionsParams implements SchoolPermissions { + @IsOptional() + @ValidateNested() + @ApiPropertyOptional() + teacher?: TeacherPermissionParams; + + @IsOptional() + @ValidateNested() + @ApiPropertyOptional() + student?: StudentPermissionParams; +} + +export class SchoolUpdateBodyParams implements SchoolUpdateBody { + @IsString() + @IsOptional() + @ApiPropertyOptional() + @SanitizeHtml() + name?: string; + + @IsString() + @Matches(/^[a-zA-Z0-9-]+$/) + @IsOptional() + @ApiPropertyOptional() + officialSchoolNumber?: string; + + @IsOptional() + @ApiPropertyOptional() + @ValidateNested() + logo?: SchoolLogo; + + @IsEnum(FileStorageType) + @IsOptional() + @ApiPropertyOptional({ enum: FileStorageType }) + fileStorageType?: FileStorageType; + + @IsEnum(LanguageType) + @IsOptional() + @ApiPropertyOptional({ enum: LanguageType, enumName: 'LanguageType' }) + language?: LanguageType; + + @IsEnum(SchoolFeature, { each: true }) + @IsOptional() + @ApiPropertyOptional({ enum: SchoolFeature, enumName: 'SchoolFeature', isArray: true }) + @Transform(({ value }: { value: SchoolFeature[] }) => new Set(value)) + features?: Set; + + @Type(() => SchoolPermissionsParams) + @IsOptional() + @ApiPropertyOptional() + @ValidateNested() + permissions?: SchoolPermissionsParams; + + @IsMongoId() + @IsOptional() + @ApiPropertyOptional() + countyId?: EntityId; + + @IsBoolean() + @IsOptional() + @ApiPropertyOptional() + enableStudentTeamCreation?: boolean; +} diff --git a/apps/server/src/modules/school/api/dto/response/federal-state.response.ts b/apps/server/src/modules/school/api/dto/response/federal-state.response.ts index 452b7abda94..33406851f7f 100644 --- a/apps/server/src/modules/school/api/dto/response/federal-state.response.ts +++ b/apps/server/src/modules/school/api/dto/response/federal-state.response.ts @@ -14,7 +14,7 @@ export class FederalStateResponse { @ApiProperty() logoUrl: string; - @ApiProperty({ type: () => [CountyResponse] }) + @ApiProperty({ type: [CountyResponse] }) counties: CountyResponse[]; constructor(props: FederalStateResponse) { diff --git a/apps/server/src/modules/school/api/dto/response/index.ts b/apps/server/src/modules/school/api/dto/response/index.ts index b658bcb4355..ebd515ee5e4 100644 --- a/apps/server/src/modules/school/api/dto/response/index.ts +++ b/apps/server/src/modules/school/api/dto/response/index.ts @@ -1,6 +1,10 @@ -export * from './county.response'; -export * from './federal-state.response'; -export * from './school-for-external-invite.response'; -export * from './school-year.response'; -export * from './school.response'; -export * from './years.response'; +export * from './county.response'; +export * from './federal-state.response'; +export * from './school-exists.response'; +export * from './school-for-external-invite.response'; +export * from './school-for-ldap-login.response'; +export * from './school-system.response'; +export * from './school-year.response'; +export * from './school.response'; +export * from './system-for-ldap-login.response'; +export * from './years.response'; diff --git a/apps/server/src/modules/school/api/dto/response/school-exists.response.ts b/apps/server/src/modules/school/api/dto/response/school-exists.response.ts new file mode 100644 index 00000000000..771fa040397 --- /dev/null +++ b/apps/server/src/modules/school/api/dto/response/school-exists.response.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SchoolExistsResponse { + @ApiProperty() + exists: boolean; + + constructor(props: SchoolExistsResponse) { + this.exists = props.exists; + } +} diff --git a/apps/server/src/modules/school/api/dto/response/school-for-ldap-login.response.ts b/apps/server/src/modules/school/api/dto/response/school-for-ldap-login.response.ts new file mode 100644 index 00000000000..34d900fe5a9 --- /dev/null +++ b/apps/server/src/modules/school/api/dto/response/school-for-ldap-login.response.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { SystemForLdapLoginResponse } from './system-for-ldap-login.response'; + +export class SchoolForLdapLoginResponse { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty({ type: [SystemForLdapLoginResponse] }) + systems: SystemForLdapLoginResponse[]; + + constructor(props: SchoolForLdapLoginResponse) { + this.id = props.id; + this.name = props.name; + this.systems = props.systems; + } +} diff --git a/apps/server/src/modules/school/api/dto/response/school-system.response.ts b/apps/server/src/modules/school/api/dto/response/school-system.response.ts new file mode 100644 index 00000000000..f62e3a20445 --- /dev/null +++ b/apps/server/src/modules/school/api/dto/response/school-system.response.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { LdapConfig, OauthConfig } from '@src/modules/system'; + +export class ProviderConfigResponse { + @ApiProperty() + provider?: string; + + constructor(props: Partial | Partial) { + this.provider = props.provider; + } +} + +export class SchoolSystemResponse { + @ApiProperty() + id: string; + + @ApiProperty() + type: string; + + @ApiPropertyOptional() + alias?: string; + + @ApiPropertyOptional({ type: ProviderConfigResponse }) + ldapConfig?: ProviderConfigResponse; + + @ApiPropertyOptional({ type: ProviderConfigResponse }) + oauthConfig?: ProviderConfigResponse; + + constructor(props: SchoolSystemResponse) { + this.id = props.id; + this.type = props.type; + this.alias = props.alias; + this.ldapConfig = props.ldapConfig; + this.oauthConfig = props.oauthConfig; + } +} diff --git a/apps/server/src/modules/school/api/dto/response/school.response.ts b/apps/server/src/modules/school/api/dto/response/school.response.ts index d69f23716d2..ef2151d0db8 100644 --- a/apps/server/src/modules/school/api/dto/response/school.response.ts +++ b/apps/server/src/modules/school/api/dto/response/school.response.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { SchoolFeature, SchoolPurpose } from '@shared/domain/types'; -import { FileStorageType, SchoolPermissions } from '../../../domain'; +import { FileStorageType, InstanceFeature, SchoolPermissions } from '../../../domain'; +import { SchoolLogo } from '../param'; import { CountyResponse } from './county.response'; import { FederalStateResponse } from './federal-state.response'; import { SchoolYearResponse } from './school-year.response'; @@ -22,10 +23,10 @@ export class SchoolResponse { @ApiPropertyOptional() officialSchoolNumber?: string; - @ApiPropertyOptional({ type: () => SchoolYearResponse }) + @ApiPropertyOptional({ type: SchoolYearResponse }) currentYear?: SchoolYearResponse; - @ApiProperty({ type: () => FederalStateResponse }) + @ApiProperty({ type: FederalStateResponse }) federalState: FederalStateResponse; @ApiPropertyOptional() @@ -40,6 +41,9 @@ export class SchoolResponse { @ApiProperty() systemIds: string[]; + @ApiPropertyOptional() + inUserMigration?: boolean; + @ApiProperty() inMaintenance: boolean; @@ -47,10 +51,7 @@ export class SchoolResponse { isExternal: boolean; @ApiPropertyOptional() - logo_dataUrl?: string; - - @ApiPropertyOptional() - logo_name?: string; + logo?: SchoolLogo; @ApiPropertyOptional({ enum: FileStorageType, enumName: 'FileStorageType' }) fileStorageType?: FileStorageType; @@ -64,9 +65,12 @@ export class SchoolResponse { @ApiPropertyOptional() permissions?: SchoolPermissions; - @ApiProperty({ type: () => YearsResponse }) + @ApiProperty({ type: YearsResponse }) years: YearsResponse; + @ApiProperty({ enum: InstanceFeature, enumName: 'InstanceFeature', isArray: true }) + instanceFeatures: InstanceFeature[]; + constructor(props: SchoolResponse) { this.id = props.id; this.createdAt = props.createdAt; @@ -79,14 +83,15 @@ export class SchoolResponse { this.features = props.features; this.county = props.county; this.systemIds = props.systemIds; + this.inUserMigration = props.inUserMigration; this.inMaintenance = props.inMaintenance; this.isExternal = props.isExternal; - this.logo_dataUrl = props.logo_dataUrl; - this.logo_name = props.logo_name; + this.logo = props.logo; this.fileStorageType = props.fileStorageType; this.language = props.language; this.timezone = props.timezone; this.permissions = props.permissions; this.years = props.years; + this.instanceFeatures = props.instanceFeatures; } } diff --git a/apps/server/src/modules/school/api/dto/response/system-for-ldap-login.response.ts b/apps/server/src/modules/school/api/dto/response/system-for-ldap-login.response.ts new file mode 100644 index 00000000000..1e992cafb02 --- /dev/null +++ b/apps/server/src/modules/school/api/dto/response/system-for-ldap-login.response.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SystemForLdapLoginResponse { + @ApiProperty() + id: string; + + @ApiProperty() + type: string; + + @ApiProperty() + alias?: string; + + constructor(props: SystemForLdapLoginResponse) { + this.id = props.id; + this.type = props.type; + this.alias = props.alias; + } +} diff --git a/apps/server/src/modules/school/api/dto/response/years.response.ts b/apps/server/src/modules/school/api/dto/response/years.response.ts index 3fd3b9d83b7..2bb273ad46a 100644 --- a/apps/server/src/modules/school/api/dto/response/years.response.ts +++ b/apps/server/src/modules/school/api/dto/response/years.response.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { SchoolYearResponse } from './school-year.response'; export class YearsResponse { - @ApiProperty({ type: () => [SchoolYearResponse] }) + @ApiProperty({ type: [SchoolYearResponse] }) schoolYears: SchoolYearResponse[]; @ApiProperty() diff --git a/apps/server/src/modules/school/api/mapper/index.ts b/apps/server/src/modules/school/api/mapper/index.ts index 7b89cc54f76..25feaadc06b 100644 --- a/apps/server/src/modules/school/api/mapper/index.ts +++ b/apps/server/src/modules/school/api/mapper/index.ts @@ -1 +1,2 @@ +export * from './school-systems.response.mapper'; export * from './school.response.mapper'; diff --git a/apps/server/src/modules/school/api/mapper/school-systems.response.mapper.ts b/apps/server/src/modules/school/api/mapper/school-systems.response.mapper.ts new file mode 100644 index 00000000000..1c108cd66bb --- /dev/null +++ b/apps/server/src/modules/school/api/mapper/school-systems.response.mapper.ts @@ -0,0 +1,45 @@ +import { System } from '@src/modules/system'; +import { SystemForLdapLogin } from '../../domain'; +import { ProviderConfigResponse, SchoolSystemResponse, SystemForLdapLoginResponse } from '../dto/response'; + +export class SystemResponseMapper { + public static mapToLdapLoginResponses(systems: SystemForLdapLogin[]): SystemForLdapLoginResponse[] { + const res = systems.map((system) => SystemResponseMapper.mapToLdapLoginResponse(system)); + + return res; + } + + public static mapToLdapLoginResponse(system: SystemForLdapLogin): SystemForLdapLoginResponse { + const systemProps = system.getProps(); + + const res = new SystemForLdapLoginResponse({ + id: system.id, + type: systemProps.type, + alias: systemProps.alias, + }); + + return res; + } + + public static mapToSchoolSystemResponse(systems: System[]): SchoolSystemResponse[] { + const systemsDto = systems.map((system) => { + const params = this.prepareParams(system); + const schoolSystemResponse = new SchoolSystemResponse(params); + + return schoolSystemResponse; + }); + + return systemsDto; + } + + private static prepareParams(system: System): SchoolSystemResponse { + const { ldapConfig, oauthConfig } = system.getProps(); + const params = { + ...system.getProps(), + ldapConfig: ldapConfig ? new ProviderConfigResponse(ldapConfig) : undefined, + oauthConfig: oauthConfig ? new ProviderConfigResponse(oauthConfig) : undefined, + }; + + return params; + } +} diff --git a/apps/server/src/modules/school/api/mapper/school.response.mapper.ts b/apps/server/src/modules/school/api/mapper/school.response.mapper.ts index 2c58012e2b7..37dc0ff3f87 100644 --- a/apps/server/src/modules/school/api/mapper/school.response.mapper.ts +++ b/apps/server/src/modules/school/api/mapper/school.response.mapper.ts @@ -1,7 +1,9 @@ -import { School } from '../../domain'; +import { School, SchoolForLdapLogin } from '../../domain'; import { SchoolForExternalInviteResponse, SchoolResponse, YearsResponse } from '../dto/response'; +import { SchoolForLdapLoginResponse } from '../dto/response/school-for-ldap-login.response'; import { CountyResponseMapper } from './county.response.mapper'; import { FederalStateResponseMapper } from './federal-state.response.mapper'; +import { SystemResponseMapper } from './school-systems.response.mapper'; import { SchoolYearResponseMapper } from './school-year.response.mapper'; export class SchoolResponseMapper { @@ -13,6 +15,7 @@ export class SchoolResponseMapper { const features = Array.from(schoolProps.features); const county = schoolProps.county && CountyResponseMapper.mapToResponse(schoolProps.county); const systemIds = schoolProps.systemIds ?? []; + const instanceFeatures = Array.from(schoolProps.instanceFeatures ?? []); const dto = new SchoolResponse({ ...schoolProps, @@ -21,9 +24,11 @@ export class SchoolResponseMapper { features, county, systemIds, + inUserMigration: schoolProps.inUserMigration, inMaintenance: school.isInMaintenance(), isExternal: school.isExternal(), years, + instanceFeatures, }); return dto; @@ -45,4 +50,24 @@ export class SchoolResponseMapper { return dto; } + + public static mapToListForLdapLoginResponses(schools: SchoolForLdapLogin[]): SchoolForLdapLoginResponse[] { + const dtos = schools.map((school) => SchoolResponseMapper.mapToLdapLoginResponse(school)); + + return dtos; + } + + private static mapToLdapLoginResponse(school: SchoolForLdapLogin): SchoolForLdapLoginResponse { + const schoolProps = school.getProps(); + + const systems = SystemResponseMapper.mapToLdapLoginResponses(schoolProps.systems); + + const dto = new SchoolForLdapLoginResponse({ + id: school.id, + name: schoolProps.name, + systems, + }); + + return dto; + } } diff --git a/apps/server/src/modules/school/api/school.controller.ts b/apps/server/src/modules/school/api/school.controller.ts index 6e274a0dbe6..c88b2c9f032 100644 --- a/apps/server/src/modules/school/api/school.controller.ts +++ b/apps/server/src/modules/school/api/school.controller.ts @@ -1,18 +1,20 @@ -import { Authenticate, CurrentUser } from '@modules/authentication/decorator/auth.decorator'; -import { ICurrentUser } from '@modules/authentication/interface/user'; -import { Controller, Get, Param, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { SchoolQueryParams, SchoolUrlParams } from './dto/param'; -import { SchoolForExternalInviteResponse, SchoolResponse } from './dto/response'; +import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; +import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiValidationError } from '@shared/common'; +import { SchoolQueryParams, SchoolRemoveSystemUrlParams, SchoolUpdateBodyParams, SchoolUrlParams } from './dto/param'; +import { SchoolForExternalInviteResponse, SchoolResponse, SchoolSystemResponse } from './dto/response'; +import { SchoolExistsResponse } from './dto/response/school-exists.response'; +import { SchoolForLdapLoginResponse } from './dto/response/school-for-ldap-login.response'; import { SchoolUc } from './school.uc'; @ApiTags('School') -@Authenticate('jwt') @Controller('school') export class SchoolController { constructor(private readonly schoolUc: SchoolUc) {} @Get('/id/:schoolId') + @Authenticate('jwt') public async getSchoolById( @Param() urlParams: SchoolUrlParams, @CurrentUser() user: ICurrentUser @@ -23,6 +25,7 @@ export class SchoolController { } @Get('/list-for-external-invite') + @Authenticate('jwt') public async getSchoolListForExternalInvite( @Query() query: SchoolQueryParams, @CurrentUser() user: ICurrentUser @@ -31,4 +34,61 @@ export class SchoolController { return res; } + + @Get('/exists/id/:schoolId') + public async doesSchoolExist(@Param() urlParams: SchoolUrlParams): Promise { + const res = await this.schoolUc.doesSchoolExist(urlParams.schoolId); + + return res; + } + + @Get('/list-for-ldap-login') + public async getSchoolListForLadpLogin(): Promise { + const res = await this.schoolUc.getSchoolListForLdapLogin(); + + return res; + } + + @ApiOperation({ summary: 'Get systems from school' }) + @ApiResponse({ status: 200, type: SchoolSystemResponse, isArray: true }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @Authenticate('jwt') + @Get('/:schoolId/systems') + public async getSchoolSystems( + @Param() urlParams: SchoolUrlParams, + @CurrentUser() user: ICurrentUser + ): Promise { + const { schoolId } = urlParams; + const res = await this.schoolUc.getSchoolSystems(schoolId, user.userId); + + return res; + } + + @ApiOperation({ summary: 'Updating school props by school administrators' }) + @ApiResponse({ status: 200, type: SchoolResponse }) + @ApiResponse({ status: 400, type: ApiValidationError }) + @ApiResponse({ status: 403, type: ForbiddenException }) + @ApiResponse({ status: 404, type: NotFoundException }) + @Patch('/:schoolId') + @Authenticate('jwt') + public async updateSchool( + @Param() urlParams: SchoolUrlParams, + @Body() body: SchoolUpdateBodyParams, + @CurrentUser() user: ICurrentUser + ): Promise { + const res = await this.schoolUc.updateSchool(user.userId, urlParams.schoolId, body); + + return res; + } + + @Patch('/:schoolId/system/:systemId/remove') + @Authenticate('jwt') + public async removeSystemFromSchool( + @Param() urlParams: SchoolRemoveSystemUrlParams, + @CurrentUser() user: ICurrentUser + ): Promise { + await this.schoolUc.removeSystemFromSchool(urlParams.schoolId, urlParams.systemId, user.userId); + } } diff --git a/apps/server/src/modules/school/api/school.uc.ts b/apps/server/src/modules/school/api/school.uc.ts index a4106ba69f9..e8b111e509b 100644 --- a/apps/server/src/modules/school/api/school.uc.ts +++ b/apps/server/src/modules/school/api/school.uc.ts @@ -1,10 +1,17 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { Injectable } from '@nestjs/common'; -import { SortOrder } from '@shared/domain/interface'; +import { Permission, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { SchoolQuery, SchoolService, SchoolYearService, SchoolYearUtils } from '../domain'; -import { SchoolForExternalInviteResponse, SchoolResponse } from './dto/response'; -import { SchoolResponseMapper } from './mapper'; +import { School, SchoolQuery, SchoolService, SchoolYear, SchoolYearHelper, SchoolYearService } from '../domain'; +import { SchoolUpdateBodyParams } from './dto/param'; +import { + SchoolExistsResponse, + SchoolForExternalInviteResponse, + SchoolResponse, + SchoolSystemResponse, +} from './dto/response'; +import { SchoolForLdapLoginResponse } from './dto/response/school-for-ldap-login.response'; +import { SchoolResponseMapper, SystemResponseMapper } from './mapper'; import { YearsResponseMapper } from './mapper/years.response.mapper'; @Injectable() @@ -25,12 +32,25 @@ export class SchoolUc { const authContext = AuthorizationContextBuilder.read([]); this.authorizationService.checkPermission(user, school, authContext); - const { activeYear, lastYear, nextYear } = SchoolYearUtils.computeActiveAndLastAndNextYear(school, schoolYears); - const yearsResponse = YearsResponseMapper.mapToResponse(schoolYears, activeYear, lastYear, nextYear); + const responseDto = this.mapToSchoolResponseDto(school, schoolYears); - const dto = SchoolResponseMapper.mapToResponse(school, yearsResponse); + return responseDto; + } - return dto; + public async getSchoolSystems(schoolId: EntityId, userId: EntityId): Promise { + const [school, user] = await Promise.all([ + this.schoolService.getSchoolById(schoolId), + this.authorizationService.getUserWithPermissions(userId), + ]); + + const authContext = AuthorizationContextBuilder.read([Permission.SCHOOL_SYSTEM_VIEW]); + this.authorizationService.checkPermission(user, school, authContext); + + const systems = await this.schoolService.getSchoolSystems(school); + + const responseDto = SystemResponseMapper.mapToSchoolSystemResponse(systems); + + return responseDto; } public async getSchoolListForExternalInvite( @@ -49,4 +69,61 @@ export class SchoolUc { return dtos; } + + public async doesSchoolExist(schoolId: EntityId): Promise { + const result = await this.schoolService.doesSchoolExist(schoolId); + + const res = new SchoolExistsResponse({ exists: result }); + + return res; + } + + public async getSchoolListForLdapLogin(): Promise { + const schools = await this.schoolService.getSchoolsForLdapLogin(); + + const dtos = SchoolResponseMapper.mapToListForLdapLoginResponses(schools); + + return dtos; + } + + public async updateSchool(userId: string, schoolId: string, body: SchoolUpdateBodyParams): Promise { + const [school, user, schoolYears] = await Promise.all([ + this.schoolService.getSchoolById(schoolId), + this.authorizationService.getUserWithPermissions(userId), + this.schoolYearService.getAllSchoolYears(), + ]); + + const authContext = AuthorizationContextBuilder.write([Permission.SCHOOL_EDIT]); + this.authorizationService.checkPermission(user, school, authContext); + + const updatedSchool = await this.schoolService.updateSchool(school, body); + + const responseDto = this.mapToSchoolResponseDto(updatedSchool, schoolYears); + + return responseDto; + } + + public async removeSystemFromSchool(schoolId: EntityId, systemId: EntityId, userId: EntityId): Promise { + const [user, school] = await Promise.all([ + this.authorizationService.getUserWithPermissions(userId), + this.schoolService.getSchoolById(schoolId), + ]); + + this.authorizationService.checkPermission( + user, + school, + AuthorizationContextBuilder.write([Permission.SCHOOL_EDIT, Permission.SCHOOL_SYSTEM_EDIT]) + ); + + await this.schoolService.removeSystemFromSchool(school, systemId); + } + + private mapToSchoolResponseDto(school: School, schoolYears: SchoolYear[]): SchoolResponse { + const { activeYear, lastYear, nextYear } = SchoolYearHelper.computeActiveAndLastAndNextYear(school, schoolYears); + const yearsResponse = YearsResponseMapper.mapToResponse(schoolYears, activeYear, lastYear, nextYear); + + const dto = SchoolResponseMapper.mapToResponse(school, yearsResponse); + + return dto; + } } diff --git a/apps/server/src/modules/school/api/test/school-get.api.spec.ts b/apps/server/src/modules/school/api/test/school-get.api.spec.ts new file mode 100644 index 00000000000..2246f8c18a9 --- /dev/null +++ b/apps/server/src/modules/school/api/test/school-get.api.spec.ts @@ -0,0 +1,466 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { + TestApiClient, + UserAndAccountTestFactory, + cleanupCollections, + countyEmbeddableFactory, + federalStateFactory, + schoolEntityFactory, + schoolYearFactory, + systemEntityFactory, +} from '@shared/testing'; +import { ServerTestModule } from '@src/modules/server'; + +describe('School Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'school'); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('getSchool', () => { + describe('when no user is logged in', () => { + it('should return 401', async () => { + const someId = new ObjectId().toHexString(); + + const response = await testApiClient.get(`id/${someId}}`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when id in params is not a mongo id', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return 400', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get(`id/123`); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [{ errors: ['schoolId must be a mongodb id'], field: ['schoolId'] }], + }) + ); + }); + }); + + describe('when requested school is not found', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return 404', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + + const response = await loggedInClient.get(`id/${someId}`); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + }); + }); + + describe('when user is not in requested school', () => { + const setup = async () => { + const school = schoolEntityFactory.build(); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([school, studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { schoolId: school.id, loggedInClient }; + }; + + it('should return 403', async () => { + const { schoolId, loggedInClient } = await setup(); + + const response = await loggedInClient.get(`id/${schoolId}`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user is in requested school', () => { + const setup = async () => { + const schoolYears = schoolYearFactory.withStartYear(2002).buildList(3); + const currentYear = schoolYears[1]; + const federalState = federalStateFactory.build(); + const county = countyEmbeddableFactory.build(); + const systems = systemEntityFactory.buildList(3); + const school = schoolEntityFactory.build({ currentYear, federalState, systems, county }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); + + await em.persistAndFlush([...schoolYears, federalState, school, studentAccount, studentUser]); + em.clear(); + + const schoolYearResponses = schoolYears.map((schoolYear) => { + return { + id: schoolYear.id, + name: schoolYear.name, + startDate: schoolYear.startDate.toISOString(), + endDate: schoolYear.endDate.toISOString(), + }; + }); + + const expectedResponse = { + id: school.id, + createdAt: school.createdAt.toISOString(), + updatedAt: school.updatedAt.toISOString(), + name: school.name, + federalState: { + id: federalState.id, + name: federalState.name, + abbreviation: federalState.abbreviation, + logoUrl: federalState.logoUrl, + counties: federalState.counties?.map((item) => { + return { + id: item._id.toHexString(), + name: item.name, + countyId: item.countyId, + antaresKey: item.antaresKey, + }; + }), + }, + county: { + id: county._id.toHexString(), + name: county.name, + countyId: county.countyId, + antaresKey: county.antaresKey, + }, + inUserMigration: undefined, + inMaintenance: false, + isExternal: false, + currentYear: schoolYearResponses[1], + years: { + schoolYears: schoolYearResponses, + activeYear: schoolYearResponses[1], + lastYear: schoolYearResponses[0], + nextYear: schoolYearResponses[2], + }, + features: [], + systemIds: systems.map((system) => system.id), + // TODO: The feature isTeamCreationByStudentsEnabled is set based on the config value STUDENT_TEAM_CREATION. + // We need to discuss how to go about the config in API tests! + instanceFeatures: ['isTeamCreationByStudentsEnabled'], + }; + + const loggedInClient = await testApiClient.login(studentAccount); + + return { schoolId: school.id, loggedInClient, expectedResponse }; + }; + + it('should return school', async () => { + const { schoolId, loggedInClient, expectedResponse } = await setup(); + + const response = await loggedInClient.get(`id/${schoolId}`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual(expectedResponse); + }); + }); + }); + + describe('getSchoolListForExternalInvite', () => { + describe('when no user is logged in', () => { + it('should return 401', async () => { + const response = await testApiClient.get('list-for-external-invite'); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when a user is logged in', () => { + const setup = async () => { + const schools = schoolEntityFactory.buildList(3); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + await em.persistAndFlush([...schools, studentAccount, studentUser]); + + const loggedInClient = await testApiClient.login(studentAccount); + + const expectedResponse = schools.map((school) => { + return { + id: school.id, + name: school.name, + purpose: school.purpose, + }; + }); + + return { loggedInClient, expectedResponse }; + }; + + it('should return school list for external invite', async () => { + const { loggedInClient, expectedResponse } = await setup(); + + const response = await loggedInClient.get('list-for-external-invite'); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual(expectedResponse); + }); + }); + }); + + describe('doesSchoolExist', () => { + describe('when id in params is not a mongo id', () => { + it('should return 400', async () => { + const response = await testApiClient.get(`exists/id/123`); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [{ errors: ['schoolId must be a mongodb id'], field: ['schoolId'] }], + }) + ); + }); + }); + + describe('when id in params is a mongo id', () => { + it('should work unauthenticated', async () => { + const someId = new ObjectId().toHexString(); + + const response = await testApiClient.get(`exists/id/${someId}`); + + expect(response.status).toEqual(HttpStatus.OK); + }); + }); + + describe('when requested school is not found', () => { + it('should return false', async () => { + const someId = new ObjectId().toHexString(); + + const response = await testApiClient.get(`exists/id/${someId}`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ exists: false }); + }); + }); + + describe('when requested school is found', () => { + const setup = async () => { + const school = schoolEntityFactory.build(); + await em.persistAndFlush(school); + + return { schoolId: school.id }; + }; + + it('should return true', async () => { + const { schoolId } = await setup(); + + const response = await testApiClient.get(`exists/id/${schoolId}`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ exists: true }); + }); + }); + }); + + describe('getSchoolListForLadpLogin', () => { + it('should work unauthenticated', async () => { + const response = await testApiClient.get('list-for-ldap-login'); + + expect(response.status).toEqual(HttpStatus.OK); + }); + + describe('when no school has an LDAP login system', () => { + const setup = async () => { + const schools = schoolEntityFactory.buildList(3); + await em.persistAndFlush(schools); + }; + + it('should return empty list', async () => { + await setup(); + + const response = await testApiClient.get('list-for-ldap-login'); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual([]); + }); + }); + + describe('when some schools have LDAP login systems', () => { + const setup = async () => { + const ldapLoginSystem = systemEntityFactory.withLdapConfig().build(); + const schoolWithLdapLoginSystem = schoolEntityFactory.build({ systems: [ldapLoginSystem] }); + const schoolWithoutLdapLoginSystem = schoolEntityFactory.build(); + await em.persistAndFlush([schoolWithLdapLoginSystem, schoolWithoutLdapLoginSystem]); + + const expectedResponse = [ + { + id: schoolWithLdapLoginSystem.id, + name: schoolWithLdapLoginSystem.name, + systems: [{ id: ldapLoginSystem.id, type: ldapLoginSystem.type, alias: ldapLoginSystem.alias }], + }, + ]; + + return { expectedResponse }; + }; + + it('should return list with these schools', async () => { + const { expectedResponse } = await setup(); + + const response = await testApiClient.get('list-for-ldap-login'); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual(expectedResponse); + }); + }); + }); + + describe('getSchoolSystems', () => { + describe('when no user is logged in', () => { + it('should return 401', async () => { + const someId = new ObjectId().toHexString(); + + const response = await testApiClient.get(`${someId}/systems`); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when id in params is not a mongo id', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return 400', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get(`123/systems`); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [{ errors: ['schoolId must be a mongodb id'], field: ['schoolId'] }], + }) + ); + }); + }); + + describe('when requested school is not found', () => { + const setup = async () => { + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient }; + }; + + it('should return 404', async () => { + const { loggedInClient } = await setup(); + const someId = new ObjectId().toHexString(); + + const response = await loggedInClient.get(`${someId}/systems`); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + }); + }); + + describe('when user is not in requested school', () => { + const setup = async () => { + const school = schoolEntityFactory.build(); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); + + await em.persistAndFlush([school, studentAccount, studentUser]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { schoolId: school.id, loggedInClient }; + }; + + it('should return 403', async () => { + const { schoolId, loggedInClient } = await setup(); + + const response = await loggedInClient.get(`${schoolId}/systems`); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user is in requested school', () => { + const setup = async () => { + const systemWithLdapConfig = systemEntityFactory.withLdapConfig().build(); + const systemWithOauthConfig = systemEntityFactory.withOauthConfig().build(); + const systemWithoutProvider = systemEntityFactory.build(); + + const systems = [systemWithLdapConfig, systemWithOauthConfig, systemWithoutProvider]; + + const school = schoolEntityFactory.build({ systems }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + await em.persistAndFlush([school, adminAccount, adminUser]); + em.clear(); + + const expectedResponse = systems.map((system) => { + return { + id: system.id, + type: system.type, + alias: system.alias, + ldapConfig: system.ldapConfig ? { provider: system.ldapConfig.provider } : undefined, + oauthConfig: system.oauthConfig ? { provider: system.oauthConfig.provider } : undefined, + }; + }); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { schoolId: school.id, loggedInClient, expectedResponse }; + }; + + it('should return school systems', async () => { + const { schoolId, loggedInClient, expectedResponse } = await setup(); + + const response = await loggedInClient.get(`${schoolId}/systems`); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual(expectedResponse); + }); + }); + }); +}); diff --git a/apps/server/src/modules/school/api/test/school-patch.api.spec.ts b/apps/server/src/modules/school/api/test/school-patch.api.spec.ts new file mode 100644 index 00000000000..336aae739e8 --- /dev/null +++ b/apps/server/src/modules/school/api/test/school-patch.api.spec.ts @@ -0,0 +1,524 @@ +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { + TestApiClient, + UserAndAccountTestFactory, + cleanupCollections, + countyEmbeddableFactory, + federalStateFactory, + schoolEntityFactory, + schoolYearFactory, + systemEntityFactory, +} from '@shared/testing'; +import { ServerTestModule } from '@src/modules/server'; +import { SchoolErrorEnum } from '../../domain/error'; + +describe('School Controller (API)', () => { + let app: INestApplication; + let em: EntityManager; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'school'); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('PATCH /:id', () => { + describe('when user is not logged in', () => { + it('should return 401', async () => { + const id = new ObjectId().toHexString(); + + const response = await testApiClient.patch(id).send({ + name: 'new name', + }); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when user is logged in', () => { + describe('when user is an admin', () => { + describe('when request is not valid', () => { + const setup = async () => { + const school = schoolEntityFactory.build(); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + await em.persistAndFlush([adminAccount, adminUser, school]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { loggedInClient, school }; + }; + + describe('when id in params is not a mongo id', () => { + it('should return 400', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.get(`id/123`); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [{ errors: ['schoolId must be a mongodb id'], field: ['schoolId'] }], + }) + ); + }); + }); + + describe('when requested school is not found', () => { + it('should return 404', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.patch(new ObjectId().toHexString()).send({ + name: 'new name', + }); + + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + }); + }); + + describe('when FileStorageType param is not valid', () => { + it('should return 400', async () => { + const { loggedInClient, school } = await setup(); + + const response = await loggedInClient.patch(school.id).send({ + fileStorageType: 'invalid', + }); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [ + { + errors: ['fileStorageType must be one of the following values: awsS3'], + field: ['fileStorageType'], + }, + ], + }) + ); + }); + }); + + describe('when language param is not valid', () => { + it('should return 400', async () => { + const { loggedInClient, school } = await setup(); + + const response = await loggedInClient.patch(school.id).send({ + language: 'invalid', + }); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [ + { errors: ['language must be one of the following values: de, en, es, uk'], field: ['language'] }, + ], + }) + ); + }); + }); + + describe('when officialSchoolNumber param is not valid', () => { + it('should return 400', async () => { + const { loggedInClient, school } = await setup(); + + const response = await loggedInClient.patch(school.id).send({ + officialSchoolNumber: 'invalid school number', + }); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [ + { + errors: ['officialSchoolNumber must match /^[a-zA-Z0-9-]+$/ regular expression'], + field: ['officialSchoolNumber'], + }, + ], + }) + ); + }); + }); + + describe('when countyId in params is not a mongodb id', () => { + it('should return 400', async () => { + const { loggedInClient, school } = await setup(); + + const response = await loggedInClient.patch(school.id).send({ + countyId: 'invalidId', + }); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [{ errors: ['countyId must be a mongodb id'], field: ['countyId'] }], + }) + ); + }); + }); + + describe.skip('when enableStudentTeamCreation in params is not a boolean', () => { + it('should return 400', async () => { + const { loggedInClient, school } = await setup(); + + const response = await loggedInClient.patch(school.id).send({ + enableStudentTeamCreation: '123', + }); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [ + { + errors: ['enableStudentTeamCreation must be a boolean value'], + field: ['enableStudentTeamCreation'], + }, + ], + }) + ); + }); + }); + + describe('when features param is not valid', () => { + it('should return 400', async () => { + const { loggedInClient, school } = await setup(); + + const response = await loggedInClient.patch(school.id).send({ + features: 'invalid', + }); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [ + { + errors: [ + 'each value in features must be one of the following values: rocketChat, videoconference, nextcloud, studentVisibility, ldapUniventionMigrationSchool, oauthProvisioningEnabled, showOutdatedUsers, enableLdapSyncDuringMigration', + ], + field: ['features'], + }, + ], + }) + ); + }); + }); + + describe('when permissions param is not valid', () => { + it('should return 400', async () => { + const { loggedInClient, school } = await setup(); + + const response = await loggedInClient.patch(school.id).send({ + permissions: { + teacher: { + invalid: 'invalid', + }, + student: { + invalid: 'invalid', + }, + }, + }); + + expect(response.status).toEqual(HttpStatus.BAD_REQUEST); + expect(response.body).toEqual( + expect.objectContaining({ + validationErrors: [ + { + errors: ['STUDENT_LIST must be a boolean value'], + field: ['permissions', 'teacher', 'STUDENT_LIST'], + }, + { + errors: ['LERNSTORE_VIEW must be a boolean value'], + field: ['permissions', 'student', 'LERNSTORE_VIEW'], + }, + ], + }) + ); + }); + }); + }); + + describe('when request is valid', () => { + describe('when the school is not the user´s school', () => { + const setup = async () => { + const adminsSchool = schoolEntityFactory.build(); + const otherSchool = schoolEntityFactory.build(); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school: adminsSchool }); + + await em.persistAndFlush([adminAccount, adminUser, adminsSchool, otherSchool]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { loggedInClient, otherSchool }; + }; + + it('should return FORBIDDEN', async () => { + const { loggedInClient, otherSchool } = await setup(); + + const response = await loggedInClient.patch(otherSchool.id).send({ + name: 'new name', + }); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when the school is the user´s school', () => { + const setup = async () => { + const schoolYears = schoolYearFactory.withStartYear(2002).buildList(3); + const currentYear = schoolYears[1]; + const federalState = federalStateFactory.build(); + const county = countyEmbeddableFactory.build(); + const systems = systemEntityFactory.buildList(3); + const school = schoolEntityFactory.build({ currentYear, federalState, systems, county }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + await em.persistAndFlush([...schoolYears, federalState, adminAccount, adminUser, school]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + const newParams = { + name: 'new name', + officialSchoolNumber: 'new-school-number', + logo: { + dataUrl: 'new logo data url', + name: 'new logo name', + }, + fileStorageType: 'awsS3', + language: 'en', + features: ['rocketChat'], + }; + + const schoolYearResponses = schoolYears.map((schoolYear) => { + return { + id: schoolYear.id, + name: schoolYear.name, + startDate: schoolYear.startDate.toISOString(), + endDate: schoolYear.endDate.toISOString(), + }; + }); + + const expectedResponse = { + id: school.id, + createdAt: school.createdAt.toISOString(), + updatedAt: school.updatedAt.toISOString(), + federalState: { + id: federalState.id, + name: federalState.name, + abbreviation: federalState.abbreviation, + logoUrl: federalState.logoUrl, + counties: federalState.counties?.map((item) => { + return { + id: item._id.toHexString(), + name: item.name, + countyId: item.countyId, + antaresKey: item.antaresKey, + }; + }), + }, + county: { + id: county._id.toHexString(), + name: county.name, + countyId: county.countyId, + antaresKey: county.antaresKey, + }, + inMaintenance: false, + isExternal: false, + currentYear: schoolYearResponses[1], + years: { + schoolYears: schoolYearResponses, + activeYear: schoolYearResponses[1], + lastYear: schoolYearResponses[0], + nextYear: schoolYearResponses[2], + }, + name: newParams.name, + features: ['rocketChat'], + systemIds: systems.map((system) => system.id), + language: newParams.language, + fileStorageType: newParams.fileStorageType, + logo: newParams.logo, + officialSchoolNumber: newParams.officialSchoolNumber, + instanceFeatures: ['isTeamCreationByStudentsEnabled'], + }; + + return { loggedInClient, school, expectedResponse, newParams }; + }; + + it('should update school', async () => { + const { loggedInClient, school, expectedResponse, newParams } = await setup(); + + const response = await loggedInClient.patch(school.id).send(newParams); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual(expectedResponse); + + const updatedSchool = await em.findOne(SchoolEntity, { id: school.id }); + const { logo, ...expectedParams } = newParams; + + expect(updatedSchool).toEqual( + expect.objectContaining({ ...expectedParams, logo_name: logo.name, logo_dataUrl: logo.dataUrl }) + ); + }); + + it('should not update school', async () => { + const { loggedInClient, school } = await setup(); + + const firstResponse = await loggedInClient.get(`id/${school.id}`); + const response = await loggedInClient.patch(school.id).send({}); + + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual(firstResponse.body); + }); + }); + }); + }); + + describe('when user is a teacher', () => { + const setup = async () => { + const school = schoolEntityFactory.build(); + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); + + await em.persistAndFlush([teacherAccount, teacherUser, school]); + em.clear(); + + const loggedInClient = await testApiClient.login(teacherAccount); + + return { loggedInClient, school }; + }; + + it('should return FORBIDDEN', async () => { + const { loggedInClient, school } = await setup(); + + const response = await loggedInClient.patch(school.id).send({ + name: 'new name', + }); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + + describe('when user is a student', () => { + const setup = async () => { + const school = schoolEntityFactory.build(); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); + + await em.persistAndFlush([studentAccount, studentUser, school]); + em.clear(); + + const loggedInClient = await testApiClient.login(studentAccount); + + return { loggedInClient, school }; + }; + + it('should return FORBIDDEN', async () => { + const { loggedInClient, school } = await setup(); + + const response = await loggedInClient.patch(school.id).send({ + name: 'new name', + }); + + expect(response.status).toEqual(HttpStatus.FORBIDDEN); + }); + }); + }); + }); + + describe('PATCH /:schoolId/system/:systemId/remove', () => { + describe('when user is not logged in', () => { + it('should return 401', async () => { + const someSchoolId = new ObjectId().toHexString(); + const someSystemId = new ObjectId().toHexString(); + + const response = await testApiClient.patch(`/${someSchoolId}/system/${someSystemId}/remove`).send({ + name: 'new name', + }); + + expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when user is logged in', () => { + describe('when user is an admin with needed permissions and the system is not deletable', () => { + const setup = async () => { + const system = systemEntityFactory.build({ ldapConfig: { provider: 'ldap' } }); + const school = schoolEntityFactory.build({ systems: [system] }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + await em.persistAndFlush([adminAccount, adminUser, school, system]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { loggedInClient, school, system }; + }; + + it('should remove the given systemId from the systemIds of the school but not the system itself', async () => { + const { loggedInClient, school, system } = await setup(); + + await loggedInClient.patch(`/${school.id}/system/${system.id}/remove`); + const updatedSchool = await em.findOne(SchoolEntity, { id: school.id }); + expect(updatedSchool?.systems.getIdentifiers()).toContain(system.id); + const systemAfterUpdate = await em.findOne(SystemEntity, { id: system.id }); + expect(systemAfterUpdate).not.toBeNull(); + }); + + it('should throw SYSTEM_CAN_NOT_BE_DELETED error', async () => { + const { loggedInClient, school, system } = await setup(); + + const response = await loggedInClient.patch(`/${school.id}/system/${system.id}/remove`); + expect(response.status).toEqual(HttpStatus.NOT_FOUND); + expect(response.body).toEqual( + expect.objectContaining({ + type: SchoolErrorEnum.SYSTEM_CAN_NOT_BE_DELETED, + }) + ); + }); + }); + + describe('when user is an admin with needed permissions and the system is deletable', () => { + const setup = async () => { + const system = systemEntityFactory.build({ ldapConfig: { provider: 'general' } }); + const school = schoolEntityFactory.build({ systems: [system] }); + const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); + + await em.persistAndFlush([adminAccount, adminUser, school, system]); + em.clear(); + + const loggedInClient = await testApiClient.login(adminAccount); + + return { loggedInClient, school, system }; + }; + + it('should remove the given systemId from the systemIds of the school and delete the system itself', async () => { + const { loggedInClient, school, system } = await setup(); + + const response = await loggedInClient.patch(`/${school.id}/system/${system.id}/remove`); + + expect(response.status).toEqual(HttpStatus.OK); + const updatedSchool = await em.findOne(SchoolEntity, { id: school.id }); + expect(updatedSchool?.systems.getIdentifiers()).not.toContain(system.id); + const systemAfterUpdate = await em.findOne(SystemEntity, { id: system.id }); + expect(systemAfterUpdate).toBeNull(); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/school/api/test/school.controller.api.spec.ts b/apps/server/src/modules/school/api/test/school.controller.api.spec.ts deleted file mode 100644 index 7cd7c6d7242..00000000000 --- a/apps/server/src/modules/school/api/test/school.controller.api.spec.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { HttpStatus, INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import { cleanupCollections, TestApiClient } from '@shared/testing'; -import { - federalStateFactory, - schoolFactory, - schoolYearFactory, - systemEntityFactory, - UserAndAccountTestFactory, -} from '@shared/testing/factory'; -import { countyEmbeddableFactory } from '@shared/testing/factory/county.embeddable.factory'; -import { ServerTestModule } from '@src/modules/server'; - -describe('School Controller (API)', () => { - let app: INestApplication; - let em: EntityManager; - let testApiClient: TestApiClient; - - beforeAll(async () => { - const moduleFixture = await Test.createTestingModule({ - imports: [ServerTestModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - em = app.get(EntityManager); - testApiClient = new TestApiClient(app, 'school'); - }); - - beforeEach(async () => { - await cleanupCollections(em); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('getSchool', () => { - describe('when no user is logged in', () => { - it('should return 401', async () => { - const someId = new ObjectId().toHexString(); - - const response = await testApiClient.get(`id/${someId}}`); - - expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); - }); - }); - - describe('when id in params is not a mongo id', () => { - const setup = async () => { - const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); - - await em.persistAndFlush([studentAccount, studentUser]); - em.clear(); - - const loggedInClient = await testApiClient.login(studentAccount); - - return { loggedInClient }; - }; - - it('should return 400', async () => { - const { loggedInClient } = await setup(); - - const response = await loggedInClient.get(`id/123`); - - expect(response.status).toEqual(HttpStatus.BAD_REQUEST); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - expect(response.body.validationErrors).toEqual([ - { errors: ['schoolId must be a mongodb id'], field: ['schoolId'] }, - ]); - }); - }); - - describe('when requested school is not found', () => { - const setup = async () => { - const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); - - await em.persistAndFlush([studentAccount, studentUser]); - em.clear(); - - const loggedInClient = await testApiClient.login(studentAccount); - - return { loggedInClient }; - }; - - it('should return 404', async () => { - const { loggedInClient } = await setup(); - const someId = new ObjectId().toHexString(); - - const response = await loggedInClient.get(`id/${someId}`); - - expect(response.status).toEqual(HttpStatus.NOT_FOUND); - }); - }); - - describe('when user is not in requested school', () => { - const setup = async () => { - const school = schoolFactory.build(); - const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); - - await em.persistAndFlush([school, studentAccount, studentUser]); - em.clear(); - - const loggedInClient = await testApiClient.login(studentAccount); - - return { schoolId: school.id, loggedInClient }; - }; - - it('should return 403', async () => { - const { schoolId, loggedInClient } = await setup(); - - const response = await loggedInClient.get(`id/${schoolId}`); - - expect(response.status).toEqual(HttpStatus.FORBIDDEN); - }); - }); - - describe('when user is in requested school', () => { - const setup = async () => { - const schoolYears = schoolYearFactory.withStartYear(2002).buildList(3); - const currentYear = schoolYears[1]; - const federalState = federalStateFactory.build(); - const county = countyEmbeddableFactory.build(); - const systems = systemEntityFactory.buildList(3); - const school = schoolFactory.build({ currentYear, federalState, systems, county }); - const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }); - - await em.persistAndFlush([...schoolYears, federalState, school, studentAccount, studentUser]); - em.clear(); - - const schoolYearResponses = schoolYears.map((schoolYear) => { - return { - id: schoolYear.id, - name: schoolYear.name, - startDate: schoolYear.startDate.toISOString(), - endDate: schoolYear.endDate.toISOString(), - }; - }); - - const expectedResponse = { - id: school.id, - createdAt: school.createdAt.toISOString(), - updatedAt: school.updatedAt.toISOString(), - name: school.name, - federalState: { - id: federalState.id, - name: federalState.name, - abbreviation: federalState.abbreviation, - logoUrl: federalState.logoUrl, - counties: federalState.counties?.map((item) => { - return { - id: item._id.toHexString(), - name: item.name, - countyId: item.countyId, - antaresKey: item.antaresKey, - }; - }), - }, - county: { - id: county._id.toHexString(), - name: county.name, - countyId: county.countyId, - antaresKey: county.antaresKey, - }, - inMaintenance: false, - isExternal: false, - currentYear: schoolYearResponses[1], - years: { - schoolYears: schoolYearResponses, - activeYear: schoolYearResponses[1], - lastYear: schoolYearResponses[0], - nextYear: schoolYearResponses[2], - }, - // TODO: The feature isTeamCreationByStudentsEnabled is set based on the config value STUDENT_TEAM_CREATION. - // We need to discuss how to go about the config in API tests! - features: ['isTeamCreationByStudentsEnabled'], - systemIds: systems.map((system) => system.id), - }; - - const loggedInClient = await testApiClient.login(studentAccount); - - return { schoolId: school.id, loggedInClient, expectedResponse }; - }; - - it('should return school', async () => { - const { schoolId, loggedInClient, expectedResponse } = await setup(); - - const response = await loggedInClient.get(`id/${schoolId}`); - - expect(response.status).toEqual(HttpStatus.OK); - expect(response.body).toEqual(expectedResponse); - }); - }); - }); - - describe('getSchoolListForExternalInvite', () => { - describe('when no user is logged in', () => { - it('should return 401', async () => { - const response = await testApiClient.get('list-for-external-invite'); - - expect(response.status).toEqual(HttpStatus.UNAUTHORIZED); - }); - }); - - describe('when a user is logged in', () => { - const setup = async () => { - const schools = schoolFactory.buildList(3); - const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent(); - await em.persistAndFlush([...schools, studentAccount, studentUser]); - - const loggedInClient = await testApiClient.login(studentAccount); - - const expectedResponse = schools.map((school) => { - return { - id: school.id, - name: school.name, - purpose: school.purpose, - }; - }); - - return { loggedInClient, expectedResponse }; - }; - - it('should return school list for external invite', async () => { - const { loggedInClient, expectedResponse } = await setup(); - - const response = await loggedInClient.get('list-for-external-invite'); - - expect(response.status).toEqual(HttpStatus.OK); - expect(response.body).toEqual(expectedResponse); - }); - }); - }); -}); diff --git a/apps/server/src/modules/school/domain/do/index.ts b/apps/server/src/modules/school/domain/do/index.ts index f6b22596b97..2b53ecfe53d 100644 --- a/apps/server/src/modules/school/domain/do/index.ts +++ b/apps/server/src/modules/school/domain/do/index.ts @@ -1,4 +1,6 @@ export * from './county'; export * from './federal-state'; export * from './school'; +export * from './school-for-ldap-login'; export * from './school-year'; +export * from './system-for-ldap-login'; diff --git a/apps/server/src/modules/school/domain/do/school-for-ldap-login.ts b/apps/server/src/modules/school/domain/do/school-for-ldap-login.ts new file mode 100644 index 00000000000..657327029cb --- /dev/null +++ b/apps/server/src/modules/school/domain/do/school-for-ldap-login.ts @@ -0,0 +1,10 @@ +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { SystemForLdapLogin } from './system-for-ldap-login'; + +export interface SchoolForLdapLoginProps extends AuthorizableObject { + id: string; + name: string; + systems: SystemForLdapLogin[]; +} + +export class SchoolForLdapLogin extends DomainObject {} diff --git a/apps/server/src/modules/school/domain/do/school.spec.ts b/apps/server/src/modules/school/domain/do/school.spec.ts index 479f48f28f6..6608ae5d9a4 100644 --- a/apps/server/src/modules/school/domain/do/school.spec.ts +++ b/apps/server/src/modules/school/domain/do/school.spec.ts @@ -1,5 +1,8 @@ -import { SchoolFeature, SchoolPurpose } from '@shared/domain/types'; -import { schoolFactory } from '../../testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { LanguageType } from '@shared/domain/interface'; +import { SchoolPurpose } from '@shared/domain/types'; +import { federalStateFactory, schoolFactory } from '../../testing'; +import { InstanceFeature } from '../type'; describe('School', () => { beforeAll(() => { @@ -7,29 +10,46 @@ describe('School', () => { jest.setSystemTime(new Date('2022-02-22')); }); - describe('addFeature', () => { - const setup = () => { - const feature = 'test feature' as SchoolFeature; - const school = schoolFactory.build(); + describe('addInstanceFeature', () => { + describe('when instanceFeatures is already initialized', () => { + const setup = () => { + const feature = InstanceFeature.IS_TEAM_CREATION_BY_STUDENTS_ENABLED; + const school = schoolFactory.build({ instanceFeatures: new Set() }); - return { school, feature }; - }; + return { school, feature }; + }; - it('should add the given feature to the features set', () => { - const { school, feature } = setup(); + it('should add the given feature to the set', () => { + const { school, feature } = setup(); - school.addFeature(feature); + school.addInstanceFeature(feature); - expect(school.getProps().features).toContain(feature); + expect(school.getProps().instanceFeatures).toContain(feature); + }); + }); + + describe('when instanceFeatures is not initialized', () => { + const setup = () => { + const feature = InstanceFeature.IS_TEAM_CREATION_BY_STUDENTS_ENABLED; + const school = schoolFactory.build({ instanceFeatures: undefined }); + + return { school, feature }; + }; + + it('should initialize it and add the given feature to the set', () => { + const { school, feature } = setup(); + + school.addInstanceFeature(feature); + + expect(school.getProps().instanceFeatures).toContain(feature); + }); }); }); - describe('removeFeature', () => { + describe('removeInstanceFeature', () => { const setup = () => { - const feature = 'test feature' as SchoolFeature; - const school = schoolFactory.build({ - features: new Set([feature]), - }); + const feature = InstanceFeature.IS_TEAM_CREATION_BY_STUDENTS_ENABLED; + const school = schoolFactory.build({ instanceFeatures: new Set([feature]) }); return { school, feature }; }; @@ -37,11 +57,103 @@ describe('School', () => { it('should remove the given feature from the features set', () => { const { school, feature } = setup(); - school.removeFeature(feature); + school.removeInstanceFeature(feature); + + expect(school.getProps().instanceFeatures).not.toContain(feature); + }); + }); + + describe('update county', () => { + describe('when county is not set in federal state', () => { + const setup = () => { + const school = schoolFactory.build(); + const countyId = new ObjectId().toHexString(); + + return { school, countyId }; + }; + + it('should throw `county not found` error', () => { + const { school, countyId } = setup(); + + expect(() => school.updateCounty(countyId)).toThrowError('County not found.'); + }); + }); + + describe('when county is already set', () => { + const setup = () => { + const federalState = federalStateFactory.build(); + // @ts-expect-error test case + const county = federalState.getProps().counties[0]; + const school = schoolFactory.build({ federalState, county }); + const countyId = new ObjectId().toHexString(); + + return { school, countyId }; + }; + + it('should throw `county cannot be updated, once it is set` error', () => { + const { school, countyId } = setup(); + + expect(() => school.updateCounty(countyId)).toThrowError('County cannot be updated, once it is set.'); + }); + }); + + describe('when county is not set', () => { + const setup = () => { + const federalState = federalStateFactory.build(); + // @ts-expect-error test case + const county = federalState.getProps().counties[0]; + const school = schoolFactory.build({ federalState }); + const countyId = county.id; + + return { school, countyId, county }; + }; + + it('should return school with county', () => { + const { school, countyId, county } = setup(); + + school.updateCounty(countyId); + + expect(school.getProps().county).toEqual(county); + }); + }); + }); + + describe('updateOfficialSchoolNumber', () => { + describe('when officialSchoolNumber is already set', () => { + const setup = () => { + const school = schoolFactory.build({ officialSchoolNumber: '123' }); + const officialSchoolNumber = '456'; + + return { school, officialSchoolNumber }; + }; + + it('should throw `official school number cannot be updated, once it is set` error', () => { + const { school, officialSchoolNumber } = setup(); + + expect(() => school.updateOfficialSchoolNumber(officialSchoolNumber)).toThrowError( + 'Official school number cannot be updated, once it is set.' + ); + }); + }); + + describe('when officialSchoolNumber is not set', () => { + const setup = () => { + const school = schoolFactory.build({ officialSchoolNumber: undefined }); + const officialSchoolNumber = '456'; + + return { school, officialSchoolNumber }; + }; + + it('should return school with updated officialSchoolNumber', () => { + const { school, officialSchoolNumber } = setup(); - expect(school.getProps().features).not.toContain(feature); + school.updateOfficialSchoolNumber(officialSchoolNumber); + + expect(school.getProps().officialSchoolNumber).toEqual(officialSchoolNumber); + }); }); }); + // TODO N21-1623 add test for getPermissions describe('getPermissions', () => { describe('when permissions exist', () => { @@ -248,4 +360,94 @@ describe('School', () => { }); }); }); + + describe('getInfo', () => { + describe('when in school logo informations and language are NOT set', () => { + const setup = () => { + const expectedResult = { + id: new ObjectId().toHexString(), + name: 'abc', + }; + + const school = schoolFactory.build(expectedResult); + + return { school, expectedResult }; + }; + + it('should return an object with id and name', () => { + const { school, expectedResult } = setup(); + + const result = school.getInfo(); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when in school logo informations and language are set', () => { + const setup = () => { + const expectedResult = { + id: new ObjectId().toHexString(), + name: 'abc', + language: LanguageType.DE, + logo: { dataUrl: 'adsbasdh', name: 'logoA' }, + }; + + const school = schoolFactory.build(expectedResult); + + return { school, expectedResult }; + }; + + it('should return an object with all expected keys', () => { + const { school, expectedResult } = setup(); + + const result = school.getInfo(); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('removeSystem', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const otherSystemId = new ObjectId().toHexString(); + const school = schoolFactory.build({ systemIds: [systemId, otherSystemId] }); + + return { school, systemId, otherSystemId }; + }; + + it('should remove the given systemId from the systemIds array', () => { + const { school, systemId, otherSystemId } = setup(); + + school.removeSystem(systemId); + + expect(school.getProps().systemIds).not.toContain(systemId); + expect(school.getProps().systemIds).toContain(otherSystemId); + }); + }); + + describe('hasSystem', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const school = schoolFactory.build({ systemIds: [systemId] }); + + return { school, systemId }; + }; + + it('should return true if the systemId is in the systemIds array', () => { + const { school, systemId } = setup(); + + const result = school.hasSystem(systemId); + + expect(result).toBe(true); + }); + + it('should return false if the systemId is not in the systemIds array', () => { + const { school } = setup(); + + const result = school.hasSystem('123'); + + expect(result).toBe(false); + }); + }); }); diff --git a/apps/server/src/modules/school/domain/do/school.ts b/apps/server/src/modules/school/domain/do/school.ts index 0cfb3594c7e..0ae47a767e6 100644 --- a/apps/server/src/modules/school/domain/do/school.ts +++ b/apps/server/src/modules/school/domain/do/school.ts @@ -1,17 +1,34 @@ +import { ValidationError } from '@shared/common'; import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { LanguageType } from '@shared/domain/interface'; import { EntityId, SchoolFeature, SchoolPurpose } from '@shared/domain/types'; -import { FileStorageType, SchoolPermissions } from '../type'; +import { FileStorageType, InstanceFeature, SchoolPermissions } from '../type'; import { County } from './county'; import { FederalState } from './federal-state'; import { SchoolYear } from './school-year'; +interface SchoolLogo { + dataUrl?: string; + name?: string; +} + +interface SchoolInfo { + id: EntityId; + name: string; + language?: LanguageType; + logo?: SchoolLogo; +} + export class School extends DomainObject { - public addFeature(feature: SchoolFeature): void { - this.props.features.add(feature); - } + public getInfo(): SchoolInfo { + const info = { + id: this.props.id, + name: this.props.name, + language: this.props.language, + logo: this.props.logo, + }; - public removeFeature(feature: SchoolFeature): void { - this.props.features.delete(feature); + return info; } public getPermissions(): SchoolPermissions | undefined { @@ -20,6 +37,43 @@ export class School extends DomainObject { return permissions; } + public addInstanceFeature(feature: InstanceFeature): void { + if (!this.props.instanceFeatures) { + this.props.instanceFeatures = new Set(); + } + this.props.instanceFeatures.add(feature); + } + + public removeInstanceFeature(feature: InstanceFeature): void { + if (this.props.instanceFeatures) { + this.props.instanceFeatures.delete(feature); + } + } + + public updateCounty(countyId: EntityId): void { + const { county, federalState } = this.props; + + if (county) { + throw new ValidationError('County cannot be updated, once it is set.'); + } + const { counties } = federalState.getProps(); + const countyObject = counties?.find((item) => item.id === countyId); + + if (!countyObject) { + throw new ValidationError('County not found.'); + } + + this.props.county = countyObject; + } + + public updateOfficialSchoolNumber(officialSchoolNumber: string): void { + if (this.props.officialSchoolNumber) { + throw new ValidationError('Official school number cannot be updated, once it is set.'); + } + + this.props.officialSchoolNumber = officialSchoolNumber; + } + public isInMaintenance(): boolean { const result = this.props.inMaintenanceSince ? this.props.inMaintenanceSince <= new Date() : false; @@ -42,6 +96,20 @@ export class School extends DomainObject { return result; } + + public hasSystem(systemId: EntityId): boolean { + const { systemIds } = this.props; + + const result = systemIds?.includes(systemId) ?? false; + + return result; + } + + public removeSystem(systemId: EntityId) { + if (this.props.systemIds) { + this.props.systemIds = this.props.systemIds.filter((id) => id !== systemId); + } + } } export interface SchoolProps extends AuthorizableObject { @@ -58,15 +126,15 @@ export interface SchoolProps extends AuthorizableObject { county?: County; purpose?: SchoolPurpose; features: Set; + instanceFeatures?: Set; systemIds?: EntityId[]; - logo_dataUrl?: string; - logo_name?: string; + logo?: SchoolLogo; fileStorageType?: FileStorageType; - language?: string; + language?: LanguageType; timezone?: string; permissions?: SchoolPermissions; // The enableStudentTeamCreation property is for compatibility with the existing data. - // It can't be mapped to a feature straight-forwardly in the repo, + // It can't be mapped to a feature straight-forwardly, // because the config value STUDENT_TEAM_CREATION has to be taken into account. enableStudentTeamCreation?: boolean; } diff --git a/apps/server/src/modules/school/domain/do/system-for-ldap-login.ts b/apps/server/src/modules/school/domain/do/system-for-ldap-login.ts new file mode 100644 index 00000000000..19a3c268597 --- /dev/null +++ b/apps/server/src/modules/school/domain/do/system-for-ldap-login.ts @@ -0,0 +1,9 @@ +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; + +interface SystemForLdapLoginProps extends AuthorizableObject { + id: string; + type: string; + alias?: string; +} + +export class SystemForLdapLogin extends DomainObject {} diff --git a/apps/server/src/modules/school/domain/error/error.enum.ts b/apps/server/src/modules/school/domain/error/error.enum.ts new file mode 100644 index 00000000000..9db08aed416 --- /dev/null +++ b/apps/server/src/modules/school/domain/error/error.enum.ts @@ -0,0 +1,5 @@ +export enum SchoolErrorEnum { + SCHOOL_HAS_NO_SYSTEM = 'SCHOOL_HAS_NO_SYSTEM', + SYSTEM_NOT_FOUND = 'SYSTEM_NOT_FOUND', + SYSTEM_CAN_NOT_BE_DELETED = 'SYSTEM_CAN_NOT_BE_DELETED', +} diff --git a/apps/server/src/modules/school/domain/error/index.ts b/apps/server/src/modules/school/domain/error/index.ts index 8a7c3f68e34..55566afba17 100644 --- a/apps/server/src/modules/school/domain/error/index.ts +++ b/apps/server/src/modules/school/domain/error/index.ts @@ -1 +1,5 @@ -export * from './missing-years.loggable-exception'; +export * from './error.enum'; +export * from './missing-years.loggable-exception'; +export * from './school-has-no-system.loggable-exception'; +export * from './system-can-not-be-deleted.loggable-exception'; +export * from './system-not-found.loggable-exception'; diff --git a/apps/server/src/modules/school/domain/error/school-has-no-system.loggable-exception.spec.ts b/apps/server/src/modules/school/domain/error/school-has-no-system.loggable-exception.spec.ts new file mode 100644 index 00000000000..6257f9510ea --- /dev/null +++ b/apps/server/src/modules/school/domain/error/school-has-no-system.loggable-exception.spec.ts @@ -0,0 +1,21 @@ +import { SchoolErrorEnum } from './error.enum'; +import { SchoolHasNoSystemLoggableException } from './school-has-no-system.loggable-exception'; + +describe('SchoolHasNoSystemLoggableException', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const exception = new SchoolHasNoSystemLoggableException('schoolId', 'systemId'); + + const result = exception.getLogMessage(); + + expect(result).toStrictEqual({ + type: SchoolErrorEnum.SCHOOL_HAS_NO_SYSTEM, + stack: exception.stack, + data: { + schoolId: 'schoolId', + systemId: 'systemId', + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/school/domain/error/school-has-no-system.loggable-exception.ts b/apps/server/src/modules/school/domain/error/school-has-no-system.loggable-exception.ts new file mode 100644 index 00000000000..9a58d09115e --- /dev/null +++ b/apps/server/src/modules/school/domain/error/school-has-no-system.loggable-exception.ts @@ -0,0 +1,26 @@ +import { NotFoundException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage } from '@src/core/logger/types'; +import { SchoolErrorEnum } from './error.enum'; + +export class SchoolHasNoSystemLoggableException extends NotFoundException implements Loggable { + constructor(private readonly schoolId: EntityId, private readonly systemId: EntityId) { + super({ + type: SchoolErrorEnum.SCHOOL_HAS_NO_SYSTEM, + }); + } + + getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: SchoolErrorEnum.SCHOOL_HAS_NO_SYSTEM, + stack: this.stack, + data: { + schoolId: this.schoolId, + systemId: this.systemId, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/school/domain/error/system-can-not-be-deleted.loggable-exception.ts b/apps/server/src/modules/school/domain/error/system-can-not-be-deleted.loggable-exception.ts new file mode 100644 index 00000000000..065782c0540 --- /dev/null +++ b/apps/server/src/modules/school/domain/error/system-can-not-be-deleted.loggable-exception.ts @@ -0,0 +1,25 @@ +import { NotFoundException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage } from '@src/core/logger/types'; +import { SchoolErrorEnum } from './error.enum'; + +export class SystemCanNotBeDeletedLoggableException extends NotFoundException implements Loggable { + constructor(private readonly systemId: EntityId) { + super({ + type: SchoolErrorEnum.SYSTEM_CAN_NOT_BE_DELETED, + }); + } + + getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: SchoolErrorEnum.SYSTEM_CAN_NOT_BE_DELETED, + stack: this.stack, + data: { + systemId: this.systemId, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/school/domain/error/system-can-not-be-deleted.spec.ts b/apps/server/src/modules/school/domain/error/system-can-not-be-deleted.spec.ts new file mode 100644 index 00000000000..1e01956731a --- /dev/null +++ b/apps/server/src/modules/school/domain/error/system-can-not-be-deleted.spec.ts @@ -0,0 +1,20 @@ +import { SchoolErrorEnum } from './error.enum'; +import { SystemCanNotBeDeletedLoggableException } from './system-can-not-be-deleted.loggable-exception'; + +describe('SystemCanNotBeDeletedLoggableException', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const exception = new SystemCanNotBeDeletedLoggableException('systemId'); + + const result = exception.getLogMessage(); + + expect(result).toStrictEqual({ + type: SchoolErrorEnum.SYSTEM_CAN_NOT_BE_DELETED, + stack: exception.stack, + data: { + systemId: 'systemId', + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/school/domain/error/system-not-found.loggable-exception.spec.ts b/apps/server/src/modules/school/domain/error/system-not-found.loggable-exception.spec.ts new file mode 100644 index 00000000000..4a78e636cdf --- /dev/null +++ b/apps/server/src/modules/school/domain/error/system-not-found.loggable-exception.spec.ts @@ -0,0 +1,20 @@ +import { SchoolErrorEnum } from './error.enum'; +import { SystemNotFoundLoggableException } from './system-not-found.loggable-exception'; + +describe('SystemNotFoundLoggableException', () => { + describe('getLogMessage', () => { + it('should return log message', () => { + const exception = new SystemNotFoundLoggableException('systemId'); + + const result = exception.getLogMessage(); + + expect(result).toStrictEqual({ + type: SchoolErrorEnum.SYSTEM_NOT_FOUND, + stack: exception.stack, + data: { + systemId: 'systemId', + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/school/domain/error/system-not-found.loggable-exception.ts b/apps/server/src/modules/school/domain/error/system-not-found.loggable-exception.ts new file mode 100644 index 00000000000..b717d8eec5d --- /dev/null +++ b/apps/server/src/modules/school/domain/error/system-not-found.loggable-exception.ts @@ -0,0 +1,25 @@ +import { NotFoundException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage } from '@src/core/logger/types'; +import { SchoolErrorEnum } from './error.enum'; + +export class SystemNotFoundLoggableException extends NotFoundException implements Loggable { + constructor(private readonly systemId: EntityId) { + super({ + type: SchoolErrorEnum.SYSTEM_NOT_FOUND, + }); + } + + getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: SchoolErrorEnum.SYSTEM_NOT_FOUND, + stack: this.stack, + data: { + systemId: this.systemId, + }, + }; + + return message; + } +} diff --git a/apps/server/src/modules/school/domain/factory/index.ts b/apps/server/src/modules/school/domain/factory/index.ts new file mode 100644 index 00000000000..5633d1b0346 --- /dev/null +++ b/apps/server/src/modules/school/domain/factory/index.ts @@ -0,0 +1 @@ +export * from './school.factory'; diff --git a/apps/server/src/modules/school/domain/factory/school.factory.spec.ts b/apps/server/src/modules/school/domain/factory/school.factory.spec.ts new file mode 100644 index 00000000000..f01c10e257f --- /dev/null +++ b/apps/server/src/modules/school/domain/factory/school.factory.spec.ts @@ -0,0 +1,84 @@ +import { LanguageType } from '@shared/domain/interface'; +import { SchoolFeature } from '@shared/domain/types'; +import { federalStateFactory } from '../../testing'; +import { School } from '../do'; +import { FileStorageType } from '../type'; +import { SchoolFactory } from './school.factory'; + +describe('SchoolFactory', () => { + describe('buildFromPartialBody', () => { + const buildSchool = () => { + const school = new School({ + id: 'school-id', + name: 'school-name', + officialSchoolNumber: 'school-number', + logo: { + dataUrl: 'school-logo-dataUrl', + name: 'school-logo-name', + }, + fileStorageType: FileStorageType.AWS_S3, + language: LanguageType.DE, + features: new Set([SchoolFeature.ENABLE_LDAP_SYNC_DURING_MIGRATION]), + createdAt: new Date(), + updatedAt: new Date(), + federalState: federalStateFactory.build(), + }); + + return school; + }; + + describe('when the partialBody is empty object', () => { + const setup = () => { + const school = buildSchool(); + const partialBody = {}; + + return { school, partialBody }; + }; + + it('should return the same school', () => { + const { school, partialBody } = setup(); + + const result = SchoolFactory.buildFromPartialBody(school, partialBody); + + expect(result).toEqual(school); + }); + }); + + describe('when the partialBody has all properties', () => { + const setup = () => { + const school = buildSchool(); + const partialBody = { + name: 'new-school-name', + officialSchoolNumber: 'new-school-number', + logo: { + dataUrl: 'new-school-logo-dataUrl', + name: 'new-school-logo-name', + }, + fileStorageType: FileStorageType.AWS_S3, + language: LanguageType.EN, + features: new Set([SchoolFeature.ROCKET_CHAT]), + }; + + return { school, partialBody }; + }; + + it('should return a new school with the new properties', () => { + const { school, partialBody } = setup(); + + const result = SchoolFactory.buildFromPartialBody(school, partialBody); + + const props = result.getProps(); + + expect(props).toEqual({ + ...school.getProps(), + name: partialBody.name, + officialSchoolNumber: partialBody.officialSchoolNumber, + logo: partialBody.logo, + fileStorageType: partialBody.fileStorageType, + language: partialBody.language, + features: partialBody.features, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/school/domain/factory/school.factory.ts b/apps/server/src/modules/school/domain/factory/school.factory.ts new file mode 100644 index 00000000000..08984d0fec4 --- /dev/null +++ b/apps/server/src/modules/school/domain/factory/school.factory.ts @@ -0,0 +1,41 @@ +import { School, SchoolProps } from '../do'; +import { SchoolUpdateBody } from '../interface'; + +export class SchoolFactory { + static build(props: SchoolProps) { + return new School(props); + } + + static buildFromPartialBody(school: School, partialBody: SchoolUpdateBody) { + const { + name, + officialSchoolNumber, + logo, + fileStorageType, + language, + features, + permissions, + countyId, + enableStudentTeamCreation, + } = partialBody; + + if (countyId) { + school.updateCounty(countyId); + } + + const props = school.getProps(); + + props.name = name ?? props.name; + props.officialSchoolNumber = officialSchoolNumber ?? props.officialSchoolNumber; + props.logo = logo ?? props.logo; + props.fileStorageType = fileStorageType ?? props.fileStorageType; + props.language = language ?? props.language; + props.features = features ?? props.features; + props.permissions = permissions ?? props.permissions; + props.enableStudentTeamCreation = enableStudentTeamCreation ?? props.enableStudentTeamCreation; + + const result = SchoolFactory.build(props); + + return result; + } +} diff --git a/apps/server/src/modules/school/domain/helper/index.ts b/apps/server/src/modules/school/domain/helper/index.ts new file mode 100644 index 00000000000..6fec72a5f38 --- /dev/null +++ b/apps/server/src/modules/school/domain/helper/index.ts @@ -0,0 +1 @@ +export * from './school-year.helper'; diff --git a/apps/server/src/modules/school/domain/util/school-year.utils.spec.ts b/apps/server/src/modules/school/domain/helper/school-year.helper.spec.ts similarity index 81% rename from apps/server/src/modules/school/domain/util/school-year.utils.spec.ts rename to apps/server/src/modules/school/domain/helper/school-year.helper.spec.ts index 629ffb7557f..88021d0e9b2 100644 --- a/apps/server/src/modules/school/domain/util/school-year.utils.spec.ts +++ b/apps/server/src/modules/school/domain/helper/school-year.helper.spec.ts @@ -1,8 +1,8 @@ import { schoolFactory, schoolYearFactory } from '../../testing'; import { MissingYearsLoggableException } from '../error'; -import { SchoolYearUtils } from './school-year.utils'; +import { SchoolYearHelper } from './school-year.helper'; -describe('SchoolYearUtils', () => { +describe('SchoolYearHelper', () => { beforeAll(() => { jest.useFakeTimers(); jest.setSystemTime(new Date('2020-10-23')); @@ -20,7 +20,7 @@ describe('SchoolYearUtils', () => { it('should return all years', () => { const { school, schoolYears } = setup(); - const result = SchoolYearUtils.computeActiveAndLastAndNextYear(school, schoolYears); + const result = SchoolYearHelper.computeActiveAndLastAndNextYear(school, schoolYears); expect(result).toStrictEqual({ activeYear: schoolYears[1], @@ -41,7 +41,7 @@ describe('SchoolYearUtils', () => { it('should throw error', () => { const { school, schoolYears } = setup(); - expect(() => SchoolYearUtils.computeActiveAndLastAndNextYear(school, schoolYears)).toThrow( + expect(() => SchoolYearHelper.computeActiveAndLastAndNextYear(school, schoolYears)).toThrow( MissingYearsLoggableException ); }); @@ -60,7 +60,7 @@ describe('SchoolYearUtils', () => { it('should return this current year', () => { const { school, schoolYears } = setup(); - const result = SchoolYearUtils.computeActiveYear(school, schoolYears); + const result = SchoolYearHelper.computeActiveYear(school, schoolYears); expect(result).toStrictEqual(schoolYears[1]); }); @@ -78,7 +78,7 @@ describe('SchoolYearUtils', () => { it('should return active year according to current date', () => { const { school, schoolYears } = setup(); - const result = SchoolYearUtils.computeActiveYear(school, schoolYears); + const result = SchoolYearHelper.computeActiveYear(school, schoolYears); expect(result).toStrictEqual(schoolYears[1]); }); @@ -95,7 +95,7 @@ describe('SchoolYearUtils', () => { it('should throw error', () => { const { school, schoolYears } = setup(); - expect(() => SchoolYearUtils.computeActiveYear(school, schoolYears)).toThrow(MissingYearsLoggableException); + expect(() => SchoolYearHelper.computeActiveYear(school, schoolYears)).toThrow(MissingYearsLoggableException); }); }); }); @@ -113,7 +113,7 @@ describe('SchoolYearUtils', () => { it('should return last year', () => { const { schoolYears, activeYear } = setup(); - const result = SchoolYearUtils.computeLastYear(schoolYears, activeYear); + const result = SchoolYearHelper.computeLastYear(schoolYears, activeYear); expect(result).toStrictEqual(schoolYears[0]); }); @@ -130,7 +130,7 @@ describe('SchoolYearUtils', () => { it('should throw error', () => { const { schoolYears, activeYear } = setup(); - expect(() => SchoolYearUtils.computeLastYear(schoolYears, activeYear)).toThrow(MissingYearsLoggableException); + expect(() => SchoolYearHelper.computeLastYear(schoolYears, activeYear)).toThrow(MissingYearsLoggableException); }); }); }); @@ -147,7 +147,7 @@ describe('SchoolYearUtils', () => { it('should return next year', () => { const { schoolYears, activeYear } = setup(); - const result = SchoolYearUtils.computeNextYear(schoolYears, activeYear); + const result = SchoolYearHelper.computeNextYear(schoolYears, activeYear); expect(result).toStrictEqual(schoolYears[2]); }); @@ -164,7 +164,7 @@ describe('SchoolYearUtils', () => { it('should throw error', () => { const { schoolYears, activeYear } = setup(); - expect(() => SchoolYearUtils.computeNextYear(schoolYears, activeYear)).toThrow(MissingYearsLoggableException); + expect(() => SchoolYearHelper.computeNextYear(schoolYears, activeYear)).toThrow(MissingYearsLoggableException); }); }); }); diff --git a/apps/server/src/modules/school/domain/util/school-year.utils.ts b/apps/server/src/modules/school/domain/helper/school-year.helper.ts similarity index 85% rename from apps/server/src/modules/school/domain/util/school-year.utils.ts rename to apps/server/src/modules/school/domain/helper/school-year.helper.ts index 08b0e446733..826312237d9 100644 --- a/apps/server/src/modules/school/domain/util/school-year.utils.ts +++ b/apps/server/src/modules/school/domain/helper/school-year.helper.ts @@ -1,14 +1,14 @@ import { School, SchoolYear } from '../do'; import { MissingYearsLoggableException } from '../error'; -export class SchoolYearUtils { +export class SchoolYearHelper { public static computeActiveAndLastAndNextYear( school: School, schoolYears: SchoolYear[] ): { activeYear: SchoolYear; lastYear: SchoolYear; nextYear: SchoolYear } { - const activeYear = SchoolYearUtils.computeActiveYear(school, schoolYears); - const nextYear = SchoolYearUtils.computeNextYear(schoolYears, activeYear); - const lastYear = SchoolYearUtils.computeLastYear(schoolYears, activeYear); + const activeYear = SchoolYearHelper.computeActiveYear(school, schoolYears); + const nextYear = SchoolYearHelper.computeNextYear(schoolYears, activeYear); + const lastYear = SchoolYearHelper.computeLastYear(schoolYears, activeYear); return { activeYear, lastYear, nextYear }; } diff --git a/apps/server/src/modules/school/domain/index.ts b/apps/server/src/modules/school/domain/index.ts index 0b65fdf0bf1..1d142f5aa33 100644 --- a/apps/server/src/modules/school/domain/index.ts +++ b/apps/server/src/modules/school/domain/index.ts @@ -3,4 +3,4 @@ export * from './interface'; export * from './query'; export * from './service'; export * from './type'; -export * from './util'; +export * from './helper'; diff --git a/apps/server/src/modules/school/domain/interface/index.ts b/apps/server/src/modules/school/domain/interface/index.ts index 3c64b116e57..9a7decd7832 100644 --- a/apps/server/src/modules/school/domain/interface/index.ts +++ b/apps/server/src/modules/school/domain/interface/index.ts @@ -1,2 +1,3 @@ +export * from './school-update-body.interface'; export * from './school-year.repo.interface'; export * from './school.repo.interface'; diff --git a/apps/server/src/modules/school/domain/interface/school-update-body.interface.ts b/apps/server/src/modules/school/domain/interface/school-update-body.interface.ts new file mode 100644 index 00000000000..964b738fa0a --- /dev/null +++ b/apps/server/src/modules/school/domain/interface/school-update-body.interface.ts @@ -0,0 +1,13 @@ +import { SchoolProps } from '../do'; + +export interface SchoolUpdateBody { + name?: SchoolProps['name']; + officialSchoolNumber?: SchoolProps['officialSchoolNumber']; + logo?: SchoolProps['logo']; + fileStorageType?: SchoolProps['fileStorageType']; + language?: SchoolProps['language']; + features?: SchoolProps['features']; + permissions?: SchoolProps['permissions']; + countyId?: string; + enableStudentTeamCreation?: SchoolProps['enableStudentTeamCreation']; +} diff --git a/apps/server/src/modules/school/domain/interface/school.repo.interface.ts b/apps/server/src/modules/school/domain/interface/school.repo.interface.ts index cbc6a0b2fdf..dc70f08d8fd 100644 --- a/apps/server/src/modules/school/domain/interface/school.repo.interface.ts +++ b/apps/server/src/modules/school/domain/interface/school.repo.interface.ts @@ -7,6 +7,10 @@ export interface SchoolRepo { getSchools(query: SchoolQuery, options?: IFindOptions): Promise; getSchoolById(schoolId: EntityId): Promise; + + getSchoolsBySystemIds(systemIds: EntityId[]): Promise; + + save(domainObject: School): Promise; } export const SCHOOL_REPO = 'SCHOOL_REPO'; diff --git a/apps/server/src/modules/school/domain/service/school.service.spec.ts b/apps/server/src/modules/school/domain/service/school.service.spec.ts index 00fa0e31f84..8c2ecbd0e6d 100644 --- a/apps/server/src/modules/school/domain/service/school.service.spec.ts +++ b/apps/server/src/modules/school/domain/service/school.service.spec.ts @@ -1,9 +1,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { IFindOptions, SortOrder } from '@shared/domain/interface'; +import { systemFactory } from '@shared/testing'; +import { SystemService } from '@src/modules/system'; import { schoolFactory } from '../../testing'; -import { SchoolProps } from '../do'; +import { SchoolForLdapLogin, SchoolProps, SystemForLdapLogin } from '../do'; +import { + SchoolHasNoSystemLoggableException, + SystemCanNotBeDeletedLoggableException, + SystemNotFoundLoggableException, +} from '../error'; +import { SchoolFactory } from '../factory'; import { SchoolRepo } from '../interface'; import { SchoolQuery } from '../query'; import { SchoolService } from './school.service'; @@ -11,6 +20,7 @@ import { SchoolService } from './school.service'; describe('SchoolService', () => { let service: SchoolService; let schoolRepo: DeepMocked; + let systemService: DeepMocked; let configService: DeepMocked; beforeAll(async () => { @@ -25,11 +35,16 @@ describe('SchoolService', () => { provide: ConfigService, useValue: createMock(), }, + { + provide: SystemService, + useValue: createMock(), + }, ], }).compile(); service = module.get(SchoolService); schoolRepo = module.get('SCHOOL_REPO'); + systemService = module.get(SystemService); configService = module.get(ConfigService); }); @@ -75,7 +90,7 @@ describe('SchoolService', () => { const result = await service.getSchoolById(id); expect(result).toEqual(school); - expect(result.getProps().features).toContain('isTeamCreationByStudentsEnabled'); + expect(result.getProps().instanceFeatures).toContain('isTeamCreationByStudentsEnabled'); }); }); @@ -115,7 +130,7 @@ describe('SchoolService', () => { const result = await service.getSchoolById(id); expect(result).toEqual(school); - expect(result.getProps().features).toContain('isTeamCreationByStudentsEnabled'); + expect(result.getProps().instanceFeatures).toContain('isTeamCreationByStudentsEnabled'); }); }); @@ -175,7 +190,7 @@ describe('SchoolService', () => { const result = await service.getSchoolById(id); expect(result).toEqual(school); - expect(result.getProps().features).toContain('isTeamCreationByStudentsEnabled'); + expect(result.getProps().instanceFeatures).toContain('isTeamCreationByStudentsEnabled'); }); }); @@ -215,7 +230,7 @@ describe('SchoolService', () => { const result = await service.getSchoolById(id); expect(result).toEqual(school); - expect(result.getProps().features).toContain('isTeamCreationByStudentsEnabled'); + expect(result.getProps().instanceFeatures).toContain('isTeamCreationByStudentsEnabled'); }); }); }); @@ -324,4 +339,405 @@ describe('SchoolService', () => { }); }); }); + + describe('doesSchoolExist', () => { + describe('when school exists', () => { + const setup = () => { + const school = schoolFactory.build(); + schoolRepo.getSchoolById.mockResolvedValueOnce(school); + + return { id: school.id }; + }; + + it('should return true', async () => { + const { id } = setup(); + + const result = await service.doesSchoolExist(id); + + expect(result).toEqual(true); + }); + }); + + describe('when school does not exist', () => { + const setup = () => { + const id = '1'; + schoolRepo.getSchoolById.mockRejectedValueOnce(new NotFoundException()); + + return { id }; + }; + + it('should return false', async () => { + const { id } = setup(); + + const result = await service.doesSchoolExist(id); + + expect(result).toEqual(false); + }); + }); + + describe('when school repo throws any other error than NotFoundException', () => { + const setup = () => { + const id = '1'; + schoolRepo.getSchoolById.mockRejectedValueOnce(new Error()); + + return { id }; + }; + + it('should throw this error', async () => { + const { id } = setup(); + + await expect(service.doesSchoolExist(id)).rejects.toThrowError(); + }); + }); + }); + + describe('getSchoolsForLdapLogin', () => { + describe('when some schools exist that have ldap login systems', () => { + const setup = () => { + const ldapLoginSystem = systemFactory.build({ type: 'ldap', ldapConfig: { active: true } }); + const otherSystem = systemFactory.build({ type: 'oauth2' }); + const schoolWithLdapLoginSystem = schoolFactory.build({ systemIds: [ldapLoginSystem.id] }); + + systemService.findAllForLdapLogin.mockResolvedValueOnce([ldapLoginSystem, otherSystem]); + schoolRepo.getSchoolsBySystemIds.mockResolvedValueOnce([schoolWithLdapLoginSystem]); + + const expected = new SchoolForLdapLogin({ + id: schoolWithLdapLoginSystem.id, + name: schoolWithLdapLoginSystem.getProps().name, + systems: [ + new SystemForLdapLogin({ + id: ldapLoginSystem.id, + type: ldapLoginSystem.getProps().type, + alias: ldapLoginSystem.getProps().alias, + }), + ], + }); + + return { expected }; + }; + + it('should return these schools', async () => { + const { expected } = setup(); + + const result = await service.getSchoolsForLdapLogin(); + + expect(result).toEqual([expected]); + }); + }); + + describe('when a school has several systems', () => { + const setup = () => { + const ldapLoginSystem = systemFactory.build({ type: 'ldap', ldapConfig: { active: true } }); + const otherSystem = systemFactory.build({ type: 'oauth2' }); + const school = schoolFactory.build({ systemIds: [ldapLoginSystem.id, otherSystem.id] }); + + systemService.findAllForLdapLogin.mockResolvedValueOnce([ldapLoginSystem]); + schoolRepo.getSchoolsBySystemIds.mockResolvedValueOnce([school]); + + const expected = new SchoolForLdapLogin({ + id: school.id, + name: school.getProps().name, + systems: [ + new SystemForLdapLogin({ + id: ldapLoginSystem.id, + type: ldapLoginSystem.getProps().type, + alias: ldapLoginSystem.getProps().alias, + }), + ], + }); + + return { expected }; + }; + + it('should return the school with only the LDAP login systems', async () => { + const { expected } = setup(); + + const result = await service.getSchoolsForLdapLogin(); + + expect(result).toEqual([expected]); + }); + }); + }); + + describe('updateSchool', () => { + describe('when school exists and save is successfull', () => { + const setup = () => { + const school = schoolFactory.build({ name: 'old name' }); + + return { school }; + }; + + it('should call save', async () => { + const { school } = setup(); + const partialBody = { name: 'new name' }; + + const updatedSchool = SchoolFactory.buildFromPartialBody(school, partialBody); + schoolRepo.save.mockResolvedValueOnce(updatedSchool); + + await service.updateSchool(school, partialBody); + + expect(schoolRepo.save).toHaveBeenCalledWith(updatedSchool); + }); + + it('should return the updated school', async () => { + const { school } = setup(); + const partialBody = { name: 'new name' }; + + const updatedSchool = SchoolFactory.buildFromPartialBody(school, partialBody); + schoolRepo.save.mockResolvedValueOnce(updatedSchool); + + const result = await service.updateSchool(school, partialBody); + + expect(result).toEqual(updatedSchool); + }); + }); + + describe('when school repo save throws error', () => { + const setup = () => { + const school = schoolFactory.build(); + const error = new Error('saveError'); + schoolRepo.getSchoolById.mockResolvedValueOnce(school); + schoolRepo.save.mockRejectedValueOnce(error); + + return { school, error }; + }; + + it('should throw this error', async () => { + const { school, error } = setup(); + + await expect(service.updateSchool(school, {})).rejects.toThrowError(error); + }); + }); + }); + + describe('getSchoolSystems', () => { + describe('when school has systems', () => { + const setup = () => { + const school = schoolFactory.build({ systemIds: ['1', '2'] }); + const systems = systemFactory.buildList(2); + + schoolRepo.getSchoolById.mockResolvedValueOnce(school); + systemService.getSystems.mockResolvedValueOnce(systems); + + return { school, systems }; + }; + + it('should call systemService.getSystems with expected props', async () => { + const { school } = setup(); + + await service.getSchoolSystems(school); + + expect(systemService.getSystems).toBeCalledWith(['1', '2']); + }); + + it('should return these systems', async () => { + const { school, systems } = setup(); + + const result = await service.getSchoolSystems(school); + + expect(result).toEqual(systems); + }); + }); + + describe('when school has no systems', () => { + const setup = () => { + const school = schoolFactory.build({ systemIds: [] }); + + schoolRepo.getSchoolById.mockResolvedValueOnce(school); + + return { school }; + }; + + it('should dont call systemService.getSystems', async () => { + const { school } = setup(); + + await service.getSchoolSystems(school); + + expect(systemService.getSystems).not.toBeCalled(); + }); + + it('should return empty array', async () => { + const { school } = setup(); + + const result = await service.getSchoolSystems(school); + + expect(result).toEqual([]); + }); + }); + + describe('when school has undefined systems', () => { + const setup = () => { + const school = schoolFactory.build({ systemIds: undefined }); + + schoolRepo.getSchoolById.mockResolvedValueOnce(school); + + return { school }; + }; + + it('should dont call systemService.getSystems', async () => { + const { school } = setup(); + + await service.getSchoolSystems(school); + + expect(systemService.getSystems).not.toBeCalled(); + }); + + it('should return empty array', async () => { + const { school } = setup(); + + const result = await service.getSchoolSystems(school); + + expect(result).toEqual([]); + }); + }); + + describe('when systemService.getSystems throws error', () => { + const setup = () => { + const school = schoolFactory.build({ systemIds: ['1'] }); + systemService.getSystems.mockRejectedValueOnce(new NotFoundException()); + + return { school }; + }; + + it('should throw NotFoundException', async () => { + const { school } = setup(); + + await expect(service.getSchoolSystems(school)).rejects.toThrowError(NotFoundException); + }); + }); + }); + + describe('removeSystemFromSchool', () => { + describe('when school has system', () => { + const setup = () => { + const system = systemFactory.build({ ldapConfig: { provider: 'general' } }); + const school = schoolFactory.build({ systemIds: [system.id] }); + + systemService.findById.mockResolvedValueOnce(system); + schoolRepo.getSchoolById.mockResolvedValueOnce(school); + + return { school, systemId: system.id }; + }; + + it('should call hasSystem', async () => { + const { school, systemId } = setup(); + const spyHasSystem = jest.spyOn(school, 'hasSystem'); + + await service.removeSystemFromSchool(school, systemId); + + expect(spyHasSystem).toHaveBeenCalledWith(systemId); + }); + + it('should call removeSystem', async () => { + const { school, systemId } = setup(); + const spyRemoveSystem = jest.spyOn(school, 'removeSystem'); + + await service.removeSystemFromSchool(school, systemId); + + expect(spyRemoveSystem).toHaveBeenCalledWith(systemId); + }); + + it('should call remove system form school', async () => { + const { school, systemId } = setup(); + + await service.removeSystemFromSchool(school, systemId); + + expect(school.hasSystem(systemId)).toEqual(false); + }); + + it('should save school', async () => { + const { school, systemId } = setup(); + + await service.removeSystemFromSchool(school, systemId); + + expect(schoolRepo.save).toBeCalledWith(school); + }); + }); + + describe('when school has deletable system', () => { + const setup = () => { + const system = systemFactory.build({ ldapConfig: { provider: 'general' } }); + const school = schoolFactory.build({ systemIds: [system.id] }); + + systemService.findById.mockResolvedValueOnce(system); + schoolRepo.getSchoolById.mockResolvedValueOnce(school); + + return { school, systemId: system.id, system }; + }; + + it('should call systemService.findById', async () => { + const { school, systemId } = setup(); + + await service.removeSystemFromSchool(school, systemId); + + expect(systemService.findById).toBeCalledWith(systemId); + }); + + it('should call systemService.delete', async () => { + const { school, systemId, system } = setup(); + + await service.removeSystemFromSchool(school, systemId); + + expect(systemService.delete).toBeCalledWith(system); + }); + }); + + describe('when school has a not deletable system', () => { + const setup = () => { + const system = systemFactory.build({ ldapConfig: { provider: 'test' } }); + const school = schoolFactory.build({ systemIds: [system.id] }); + + const expectedError = new SystemCanNotBeDeletedLoggableException(system.id); + + systemService.findById.mockResolvedValueOnce(system); + schoolRepo.getSchoolById.mockResolvedValueOnce(school); + + return { school, systemId: system.id, system, expectedError }; + }; + + it('should throw an error', async () => { + const { school, systemId, expectedError } = setup(); + + await expect(service.removeSystemFromSchool(school, systemId)).rejects.toThrowError(expectedError); + }); + }); + + describe('when school has no system', () => { + const setup = () => { + const school = schoolFactory.build({ systemIds: [] }); + const systemId = '1'; + + schoolRepo.getSchoolById.mockResolvedValueOnce(school); + const expectedError = new SchoolHasNoSystemLoggableException(school.id, systemId); + + return { school, systemId, expectedError }; + }; + + it('should throws an error', async () => { + const { school, systemId, expectedError } = setup(); + + await expect(service.removeSystemFromSchool(school, systemId)).rejects.toThrow(expectedError); + }); + }); + + describe('when school has systemId but system not exists', () => { + const setup = () => { + const systemId = '1'; + const school = schoolFactory.build({ systemIds: [systemId] }); + + const expectedError = new SystemNotFoundLoggableException(systemId); + + systemService.findById.mockResolvedValueOnce(null); + schoolRepo.getSchoolById.mockResolvedValueOnce(school); + + return { school, systemId, expectedError }; + }; + + it('should throws an error', async () => { + const { school, systemId, expectedError } = setup(); + + await expect(service.removeSystemFromSchool(school, systemId)).rejects.toThrow(expectedError); + }); + }); + }); }); diff --git a/apps/server/src/modules/school/domain/service/school.service.ts b/apps/server/src/modules/school/domain/service/school.service.ts index 8213d7fbaa8..cf22c9a7dd0 100644 --- a/apps/server/src/modules/school/domain/service/school.service.ts +++ b/apps/server/src/modules/school/domain/service/school.service.ts @@ -1,32 +1,39 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { SchoolFeature } from '@shared/domain/types'; +import { TypeGuard } from '@shared/common'; import { IFindOptions } from '@shared/domain/interface/find-options'; import { EntityId } from '@shared/domain/types/entity-id'; +import { System, SystemService } from '@src/modules/system'; import { SchoolConfig } from '../../school.config'; -import { School, SchoolProps } from '../do'; -import { SchoolRepo, SCHOOL_REPO } from '../interface'; +import { School, SchoolProps, SystemForLdapLogin } from '../do'; +import { SchoolForLdapLogin, SchoolForLdapLoginProps } from '../do/school-for-ldap-login'; +import { SchoolHasNoSystemLoggableException, SystemCanNotBeDeletedLoggableException } from '../error'; +import { SystemNotFoundLoggableException } from '../error/system-not-found.loggable-exception'; +import { SchoolFactory } from '../factory'; +import { SCHOOL_REPO, SchoolRepo, SchoolUpdateBody } from '../interface'; import { SchoolQuery } from '../query'; +import { InstanceFeature } from '../type'; @Injectable() export class SchoolService { constructor( @Inject(SCHOOL_REPO) private readonly schoolRepo: SchoolRepo, + private readonly systemService: SystemService, private readonly configService: ConfigService ) {} public async getSchoolById(schoolId: EntityId): Promise { - const school = await this.schoolRepo.getSchoolById(schoolId); + let school = await this.schoolRepo.getSchoolById(schoolId); - this.setStudentTeamCreationFeature(school); + school = this.addInstanceFeatures(school); return school; } public async getSchools(query: SchoolQuery = {}, options?: IFindOptions): Promise { - const schools = await this.schoolRepo.getSchools(query, options); + let schools = await this.schoolRepo.getSchools(query, options); - schools.forEach((school) => this.setStudentTeamCreationFeature(school)); + schools = schools.map((school) => this.addInstanceFeatures(school)); return schools; } @@ -43,21 +50,137 @@ export class SchoolService { return schoolsForExternalInvite; } + public async doesSchoolExist(schoolId: EntityId): Promise { + try { + await this.schoolRepo.getSchoolById(schoolId); + + return true; + } catch (error) { + if (error instanceof NotFoundException) { + return false; + } + + throw error; + } + } + + public async getSchoolSystems(school: School): Promise { + const { systemIds } = school.getProps(); + + let schoolSystems: System[] = []; + + if (TypeGuard.isArrayWithElements(systemIds)) { + schoolSystems = await this.systemService.getSystems(systemIds); + } + + return schoolSystems; + } + + public async getSchoolsForLdapLogin(): Promise { + const ldapLoginSystems = await this.systemService.findAllForLdapLogin(); + const ldapLoginSystemsIds = ldapLoginSystems.map((system) => system.id); + + const schoolsWithLdapLoginSystems = await this.schoolRepo.getSchoolsBySystemIds(ldapLoginSystemsIds); + + const schoolsForLdapLogin = schoolsWithLdapLoginSystems.map((school) => + this.mapToSchoolForLdapLogin(school, ldapLoginSystems) + ); + + return schoolsForLdapLogin; + } + + public async updateSchool(school: School, body: SchoolUpdateBody) { + const fullSchoolObject = SchoolFactory.buildFromPartialBody(school, body); + + let updatedSchool = await this.schoolRepo.save(fullSchoolObject); + updatedSchool = this.addInstanceFeatures(updatedSchool); + + return updatedSchool; + } + + public async removeSystemFromSchool(school: School, systemId: EntityId): Promise { + if (!school.hasSystem(systemId)) { + throw new SchoolHasNoSystemLoggableException(school.id, systemId); + } + + const system = await this.tryFindAndRemoveSystem(systemId); + + school.removeSystem(system.id); + + await this.schoolRepo.save(school); + } + + private async tryFindAndRemoveSystem(systemId: string) { + const system = await this.systemService.findById(systemId); + if (!system) { + throw new SystemNotFoundLoggableException(systemId); + } + + if (system.isDeletable()) { + await this.systemService.delete(system); + } else { + throw new SystemCanNotBeDeletedLoggableException(systemId); + } + + return system; + } + + private mapToSchoolForLdapLogin(school: School, ldapLoginSystems: System[]): SchoolForLdapLogin { + const schoolProps = school.getProps(); + + const schoolForLdapLoginProps: SchoolForLdapLoginProps = { + id: school.id, + name: schoolProps.name, + systems: [], + }; + + this.addLdapLoginSystems(schoolProps, ldapLoginSystems, schoolForLdapLoginProps); + + return new SchoolForLdapLogin(schoolForLdapLoginProps); + } + + private addLdapLoginSystems( + schoolProps: SchoolProps, + ldapLoginSystems: System[], + schoolForLdapLoginProps: SchoolForLdapLoginProps + ): void { + schoolProps.systemIds?.forEach((systemIdInSchool) => { + const relatedSystem = ldapLoginSystems.find((system) => system.id === systemIdInSchool); + + if (relatedSystem) { + const relatedSystemProps = relatedSystem.getProps(); + + const systemForLdapLogin = new SystemForLdapLogin({ + id: relatedSystemProps.id, + type: relatedSystemProps.type, + alias: relatedSystemProps.alias, + }); + + schoolForLdapLoginProps.systems.push(systemForLdapLogin); + } + }); + } + // TODO: The logic for setting this feature should better be part of the creation of a school object. // But it has to be discussed, how to implement that. Thus we leave the logic here for now. - private setStudentTeamCreationFeature(school: School): School { + private addInstanceFeatures(school: School): School { + if (this.canStudentCreateTeam(school)) { + school.addInstanceFeature(InstanceFeature.IS_TEAM_CREATION_BY_STUDENTS_ENABLED); + } + + return school; + } + + private canStudentCreateTeam(school: School): boolean { const configValue = this.configService.get('STUDENT_TEAM_CREATION'); + const { enableStudentTeamCreation } = school.getProps(); - if ( + return ( configValue === 'enabled' || - (configValue === 'opt-in' && school.getProps().enableStudentTeamCreation) || + (configValue === 'opt-in' && enableStudentTeamCreation) || // It is necessary to check enableStudentTeamCreation to be not false here, // because it being undefined means that the school has not opted out yet. - (configValue === 'opt-out' && school.getProps().enableStudentTeamCreation !== false) - ) { - school.addFeature(SchoolFeature.IS_TEAM_CREATION_BY_STUDENTS_ENABLED); - } - - return school; + (configValue === 'opt-out' && enableStudentTeamCreation !== false) + ); } } diff --git a/apps/server/src/modules/school/domain/type/index.ts b/apps/server/src/modules/school/domain/type/index.ts index c9fc28152b7..8c56f089542 100644 --- a/apps/server/src/modules/school/domain/type/index.ts +++ b/apps/server/src/modules/school/domain/type/index.ts @@ -1,2 +1,3 @@ -export * from './file-storage-type.enum'; -export * from './school-permissions'; +export * from './file-storage-type.enum'; +export * from './instance-feature.enum'; +export * from './school-permissions'; diff --git a/apps/server/src/modules/school/domain/type/instance-feature.enum.ts b/apps/server/src/modules/school/domain/type/instance-feature.enum.ts new file mode 100644 index 00000000000..7b7bb79b318 --- /dev/null +++ b/apps/server/src/modules/school/domain/type/instance-feature.enum.ts @@ -0,0 +1,3 @@ +export enum InstanceFeature { + IS_TEAM_CREATION_BY_STUDENTS_ENABLED = 'isTeamCreationByStudentsEnabled', +} diff --git a/apps/server/src/modules/school/domain/type/school-permissions.ts b/apps/server/src/modules/school/domain/type/school-permissions.ts index 9f0971bd70a..5e8fe1048e7 100644 --- a/apps/server/src/modules/school/domain/type/school-permissions.ts +++ b/apps/server/src/modules/school/domain/type/school-permissions.ts @@ -1,8 +1,14 @@ +import { Permission } from '@shared/domain/interface'; + +export interface TeacherPermission { + [Permission.STUDENT_LIST]?: boolean; +} + +export interface StudentPermission { + [Permission.LERNSTORE_VIEW]?: boolean; +} + export interface SchoolPermissions { - teacher?: { - STUDENT_LIST?: boolean; - }; - student?: { - LERNSTORE_VIEW?: boolean; - }; + teacher?: TeacherPermission; + student?: StudentPermission; } diff --git a/apps/server/src/modules/school/domain/util/index.ts b/apps/server/src/modules/school/domain/util/index.ts deleted file mode 100644 index 6cf761c93db..00000000000 --- a/apps/server/src/modules/school/domain/util/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './school-year.utils'; diff --git a/apps/server/src/modules/school/index.ts b/apps/server/src/modules/school/index.ts index f4aa6357c5e..b267a521591 100644 --- a/apps/server/src/modules/school/index.ts +++ b/apps/server/src/modules/school/index.ts @@ -1,2 +1,3 @@ export { SchoolConfig } from './school.config'; export { SchoolModule } from './school.module'; +export { SchoolService, School } from './domain'; diff --git a/apps/server/src/modules/school/repo/mikro-orm/mapper/county.embeddable.mapper.spec.ts b/apps/server/src/modules/school/repo/mikro-orm/mapper/county.embeddable.mapper.spec.ts new file mode 100644 index 00000000000..5de0a798407 --- /dev/null +++ b/apps/server/src/modules/school/repo/mikro-orm/mapper/county.embeddable.mapper.spec.ts @@ -0,0 +1,21 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { CountyEmbeddable } from '@shared/domain/entity'; +import { countyFactory } from '@src/modules/school/testing/county.factory'; +import { CountyEmbeddableMapper } from './county.embeddable.mapper'; + +describe('CountyEmbeddableMapper', () => { + describe('mapToEntity', () => { + it('should map to entity', () => { + const county = countyFactory.build(); + + const countyEmbeddable = CountyEmbeddableMapper.mapToEntity(county); + + expect(countyEmbeddable).toBeInstanceOf(CountyEmbeddable); + expect(countyEmbeddable._id).toBeInstanceOf(ObjectId); + expect(JSON.stringify(countyEmbeddable._id)).toEqual(JSON.stringify(county.id)); + expect(countyEmbeddable.name).toEqual(county.getProps().name); + expect(countyEmbeddable.countyId).toEqual(county.getProps().countyId); + expect(countyEmbeddable.antaresKey).toEqual(county.getProps().antaresKey); + }); + }); +}); diff --git a/apps/server/src/modules/school/repo/mikro-orm/mapper/county.embeddable.mapper.ts b/apps/server/src/modules/school/repo/mikro-orm/mapper/county.embeddable.mapper.ts index 65644013f96..a3e1847b834 100644 --- a/apps/server/src/modules/school/repo/mikro-orm/mapper/county.embeddable.mapper.ts +++ b/apps/server/src/modules/school/repo/mikro-orm/mapper/county.embeddable.mapper.ts @@ -1,7 +1,16 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { CountyEmbeddable } from '@shared/domain/entity'; import { County } from '../../../domain'; export class CountyEmbeddableMapper { + static mapToEntity(county: County): CountyEmbeddable { + const countyProps = county.getProps(); + + const countyEmbeddable = new CountyEmbeddable({ ...countyProps, _id: new ObjectId(countyProps.id) }); + + return countyEmbeddable; + } + static mapToDo(embeddable: CountyEmbeddable): County { const county = new County({ id: embeddable._id.toHexString(), diff --git a/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.spec.ts b/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.spec.ts index ecfda128c30..d045785e78e 100644 --- a/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.spec.ts +++ b/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.spec.ts @@ -1,4 +1,5 @@ -import { schoolFactory } from '@shared/testing'; +import { SystemEntity } from '@shared/domain/entity'; +import { schoolEntityFactory, setupEntities } from '@shared/testing'; import { School } from '../../../domain'; import { CountyEmbeddableMapper } from './county.embeddable.mapper'; import { FederalStateEntityMapper } from './federal-state.entity.mapper'; @@ -8,8 +9,13 @@ import { SchoolEntityMapper } from './school.entity.mapper'; describe('SchoolEntityMapper', () => { describe('mapToDo', () => { describe('when school entity is passed', () => { - const setup = () => { - const entity = schoolFactory.build(); + const setup = async () => { + await setupEntities(); + + const system = new SystemEntity({ + type: 'type', + }); + const entity = schoolEntityFactory.build({ systems: [system], logo_dataUrl: 'dataUrl', logo_name: 'name' }); const expected = new School({ id: entity.id, createdAt: entity.createdAt, @@ -21,8 +27,10 @@ describe('SchoolEntityMapper', () => { inMaintenanceSince: entity.inMaintenanceSince, inUserMigration: entity.inUserMigration, purpose: entity.purpose, - logo_dataUrl: entity.logo_dataUrl, - logo_name: entity.logo_name, + logo: { + dataUrl: entity.logo_dataUrl, + name: entity.logo_name, + }, fileStorageType: entity.fileStorageType, language: entity.language, timezone: entity.timezone, @@ -32,22 +40,22 @@ describe('SchoolEntityMapper', () => { federalState: FederalStateEntityMapper.mapToDo(entity.federalState), county: entity.county && CountyEmbeddableMapper.mapToDo(entity.county), currentYear: entity.currentYear && SchoolYearEntityMapper.mapToDo(entity.currentYear), - systemIds: entity.systems.getItems().map((system) => system.id), + systemIds: [system.id], }); return { entity, expected }; }; - it('should return an instance of school', () => { - const { entity } = setup(); + it('should return an instance of school', async () => { + const { entity } = await setup(); const result = SchoolEntityMapper.mapToDo(entity); expect(result).toBeInstanceOf(School); }); - it('should return a school with all properties', () => { - const { entity, expected } = setup(); + it('should return a school with all properties', async () => { + const { entity, expected } = await setup(); const result = SchoolEntityMapper.mapToDo(entity); diff --git a/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.ts b/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.ts index 19f2105ae80..993d3897216 100644 --- a/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.ts +++ b/apps/server/src/modules/school/repo/mikro-orm/mapper/school.entity.mapper.ts @@ -1,4 +1,8 @@ +import { EntityData } from '@mikro-orm/core'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { FederalStateEntity, SchoolYearEntity, SystemEntity } from '@shared/domain/entity'; import { SchoolEntity } from '@shared/domain/entity/school.entity'; +import { SchoolFactory } from '@src/modules/school/domain/factory'; import { School } from '../../../domain'; import { CountyEmbeddableMapper } from './county.embeddable.mapper'; import { FederalStateEntityMapper } from './federal-state.entity.mapper'; @@ -11,8 +15,9 @@ export class SchoolEntityMapper { const features = new Set(entity.features); const county = entity.county && CountyEmbeddableMapper.mapToDo(entity.county); const systemIds = entity.systems.getItems().map((system) => system.id); + const logo = entity.logo_dataUrl ? { dataUrl: entity.logo_dataUrl, name: entity.logo_name } : undefined; - const school = new School({ + const school = SchoolFactory.build({ id: entity.id, createdAt: entity.createdAt, updatedAt: entity.updatedAt, @@ -23,8 +28,7 @@ export class SchoolEntityMapper { inMaintenanceSince: entity.inMaintenanceSince, inUserMigration: entity.inUserMigration, purpose: entity.purpose, - logo_dataUrl: entity.logo_dataUrl, - logo_name: entity.logo_name, + logo, fileStorageType: entity.fileStorageType, language: entity.language, timezone: entity.timezone, @@ -45,4 +49,37 @@ export class SchoolEntityMapper { return schools; } + + public static mapToEntityProperties(domainObject: School, em: EntityManager): EntityData { + const props = domainObject.getProps(); + const federalState = props.federalState ? em.getReference(FederalStateEntity, props.federalState?.id) : undefined; + const features = Array.from(props.features); + const currentYear = props.currentYear ? em.getReference(SchoolYearEntity, props.currentYear?.id) : undefined; + const county = props.county ? CountyEmbeddableMapper.mapToEntity(props.county) : undefined; + const systems = props.systemIds ? props.systemIds.map((id) => em.getReference(SystemEntity, id)) : []; + + const schoolEntityProps = { + name: props.name, + officialSchoolNumber: props.officialSchoolNumber, + externalId: props.externalId, + previousExternalId: props.previousExternalId, + inMaintenanceSince: props.inMaintenanceSince, + inUserMigration: props.inUserMigration, + purpose: props.purpose, + logo_dataUrl: props.logo?.dataUrl, + logo_name: props.logo?.name, + fileStorageType: props.fileStorageType, + language: props.language, + timezone: props.timezone, + permissions: props.permissions, + enableStudentTeamCreation: props.enableStudentTeamCreation, + federalState, + features, + currentYear, + county, + systems, + }; + + return schoolEntityProps; + } } diff --git a/apps/server/src/modules/school/repo/mikro-orm/school.repo.integration.spec.ts b/apps/server/src/modules/school/repo/mikro-orm/school.repo.integration.spec.ts index 3b537047a83..6ae7e8ca94e 100644 --- a/apps/server/src/modules/school/repo/mikro-orm/school.repo.integration.spec.ts +++ b/apps/server/src/modules/school/repo/mikro-orm/school.repo.integration.spec.ts @@ -2,12 +2,21 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity } from '@shared/domain/entity/school.entity'; -import { SortOrder } from '@shared/domain/interface'; -import { cleanupCollections, federalStateFactory, schoolFactory, systemEntityFactory } from '@shared/testing'; +import { LanguageType, SortOrder } from '@shared/domain/interface'; +import { SchoolFeature, SchoolPurpose } from '@shared/domain/types'; +import { + cleanupCollections, + federalStateFactory as federalStateEntityFactory, + schoolEntityFactory, + schoolYearFactory as schoolYearEntityFactory, + systemEntityFactory, +} from '@shared/testing'; import { countyEmbeddableFactory } from '@shared/testing/factory/county.embeddable.factory'; import { MongoMemoryDatabaseModule } from '@src/infra/database'; -import { SCHOOL_REPO } from '../../domain'; -import { SchoolEntityMapper } from './mapper'; +import { FileStorageType, SCHOOL_REPO } from '../../domain'; +import { federalStateFactory, schoolFactory } from '../../testing'; +import { countyFactory } from '../../testing/county.factory'; +import { FederalStateEntityMapper, SchoolEntityMapper, SchoolYearEntityMapper } from './mapper'; import { SchoolMikroOrmRepo } from './school.repo'; describe('SchoolMikroOrmRepo', () => { @@ -37,7 +46,7 @@ describe('SchoolMikroOrmRepo', () => { describe('getSchools', () => { describe('when no query and options are given', () => { const setup = async () => { - const entities = schoolFactory.buildList(3); + const entities = schoolEntityFactory.buildList(3); await em.persistAndFlush(entities); em.clear(); const schools = entities.map((entity) => SchoolEntityMapper.mapToDo(entity)); @@ -57,8 +66,8 @@ describe('SchoolMikroOrmRepo', () => { describe('when query is given', () => { const setup = async () => { const federalState = federalStateFactory.build(); - const entity1 = schoolFactory.build({ federalState }); - const entity2 = schoolFactory.build(); + const entity1 = schoolEntityFactory.build({ federalState }); + const entity2 = schoolEntityFactory.build(); await em.persistAndFlush([entity1, entity2]); em.clear(); const schoolDo1 = SchoolEntityMapper.mapToDo(entity1); @@ -81,7 +90,7 @@ describe('SchoolMikroOrmRepo', () => { describe('when pagination option is given', () => { const setup = async () => { - const entities = schoolFactory.buildList(3); + const entities = schoolEntityFactory.buildList(3); await em.persistAndFlush(entities); em.clear(); const schoolDos = entities.map((entity) => SchoolEntityMapper.mapToDo(entity)); @@ -107,8 +116,8 @@ describe('SchoolMikroOrmRepo', () => { describe('when order option is given', () => { const setup = async () => { - const entity1 = schoolFactory.build({ name: 'bbb' }); - const entity2 = schoolFactory.build({ name: 'aaa' }); + const entity1 = schoolEntityFactory.build({ name: 'bbb' }); + const entity2 = schoolEntityFactory.build({ name: 'aaa' }); await em.persistAndFlush([entity1, entity2]); em.clear(); const schoolDo1 = SchoolEntityMapper.mapToDo(entity1); @@ -147,7 +156,7 @@ describe('SchoolMikroOrmRepo', () => { const systems = systemEntityFactory.buildList(2); const county = countyEmbeddableFactory.build(); const schoolId = new ObjectId().toHexString(); - const entity = schoolFactory.buildWithId({ systems, county }, schoolId); + const entity = schoolEntityFactory.buildWithId({ systems, county }, schoolId); await em.persistAndFlush([entity]); em.clear(); const schoolDo = SchoolEntityMapper.mapToDo(entity); @@ -164,4 +173,143 @@ describe('SchoolMikroOrmRepo', () => { }); }); }); + + describe('getSchoolsBySystemIds', () => { + describe('when no school has systems', () => { + const setup = async () => { + const entities = schoolEntityFactory.buildList(2); + await em.persistAndFlush(entities); + em.clear(); + + return { entities }; + }; + + it('should return empty array', async () => { + await setup(); + + const result = await repo.getSchoolsBySystemIds([]); + + expect(result).toEqual([]); + }); + }); + + describe('when some schools have specified systems', () => { + const setup = async () => { + const specifiedSystem = systemEntityFactory.build(); + const otherSystem = systemEntityFactory.build(); + const schoolEntityWithSpecifiedSystem = schoolEntityFactory.build({ systems: [specifiedSystem] }); + const schoolEntityWithSpecifiedSystemAndOtherSystem = schoolEntityFactory.build({ + systems: [specifiedSystem, otherSystem], + }); + const schoolEntityWithOtherSystem = schoolEntityFactory.build({ systems: [otherSystem] }); + const schoolEntityWithEmptySystemsArray = schoolEntityFactory.build({ systems: [] }); + const schoolEntityWithoutSystems = schoolEntityFactory.build(); + await em.persistAndFlush([ + specifiedSystem, + otherSystem, + schoolEntityWithSpecifiedSystem, + schoolEntityWithSpecifiedSystemAndOtherSystem, + schoolEntityWithOtherSystem, + schoolEntityWithEmptySystemsArray, + schoolEntityWithoutSystems, + ]); + em.clear(); + + const expected = [ + SchoolEntityMapper.mapToDo(schoolEntityWithSpecifiedSystem), + SchoolEntityMapper.mapToDo(schoolEntityWithSpecifiedSystemAndOtherSystem), + ]; + + return { expected, specifiedSystem }; + }; + + it('should return these schools', async () => { + const { expected, specifiedSystem } = await setup(); + + const result = await repo.getSchoolsBySystemIds([specifiedSystem.id]); + + expect(result).toEqual(expected); + }); + }); + }); + + describe('save', () => { + describe('when entity is new', () => { + const setup = async () => { + const federalState = federalStateEntityFactory.build(); + const currentYear = schoolYearEntityFactory.build(); + + await em.persistAndFlush([federalState, currentYear]); + em.clear(); + + const entity = schoolEntityFactory.build({ federalState, currentYear }); + const schoolDo = SchoolEntityMapper.mapToDo(entity); + + return { schoolDo }; + }; + + it('should save entity', async () => { + const { schoolDo } = await setup(); + + const result = await repo.save(schoolDo); + + expect(result).toEqual(schoolDo); + }); + }); + + describe('when entity is existing', () => { + const setup = async () => { + const entity = schoolEntityFactory.build(); + + const newFederalStateEntity = federalStateEntityFactory.build(); + const newSchoolYearEntity = schoolYearEntityFactory.build(); + const newSystemEntity = systemEntityFactory.build(); + + await em.persistAndFlush([entity, newFederalStateEntity, newSchoolYearEntity, newSystemEntity]); + em.clear(); + + const newCounty = countyFactory.build(); + const newFederalState = FederalStateEntityMapper.mapToDo(newFederalStateEntity); + const newSchoolYear = SchoolYearEntityMapper.mapToDo(newSchoolYearEntity); + const expectedProps = { + id: entity.id, + name: 'new name', + officialSchoolNumber: 'new officialSchoolNumber', + externalId: 'new externalId', + previousExternalId: 'new previousExternalId', + inMaintenanceSince: new Date(), + inUserMigration: true, + purpose: SchoolPurpose.EXPERT, + logo: { + dataUrl: 'new logo_dataUrl', + name: 'new logo_name', + }, + fileStorageType: FileStorageType.AWS_S3, + language: LanguageType.EN, + timezone: 'new timezone', + permissions: {}, + enableStudentTeamCreation: true, + federalState: newFederalState, + features: new Set([SchoolFeature.ENABLE_LDAP_SYNC_DURING_MIGRATION]), + currentYear: newSchoolYear, + county: newCounty, + systemIds: [newSystemEntity._id.toHexString()], + }; + const newSchool = schoolFactory.build(expectedProps); + + return { newSchool, expectedProps }; + }; + + it('should update entity', async () => { + const { newSchool, expectedProps } = await setup(); + + const result = await repo.save(newSchool); + + expect(result).toEqual(newSchool); + + const updatedSchool = await repo.getSchoolById(newSchool.id); + expect(updatedSchool.getProps()).toEqual(expect.objectContaining(expectedProps)); + }); + }); + }); }); diff --git a/apps/server/src/modules/school/repo/mikro-orm/school.repo.ts b/apps/server/src/modules/school/repo/mikro-orm/school.repo.ts index cee1d480ec2..2f7a7a63910 100644 --- a/apps/server/src/modules/school/repo/mikro-orm/school.repo.ts +++ b/apps/server/src/modules/school/repo/mikro-orm/school.repo.ts @@ -1,17 +1,19 @@ import { FindOptions } from '@mikro-orm/core'; -import { AutoPath } from '@mikro-orm/core/typings'; -import { EntityManager } from '@mikro-orm/mongodb'; +import { AutoPath, EntityData, EntityName } from '@mikro-orm/core/typings'; import { Injectable } from '@nestjs/common'; import { SchoolEntity } from '@shared/domain/entity/school.entity'; import { IFindOptions, SortOrder } from '@shared/domain/interface/find-options'; import { EntityId } from '@shared/domain/types/entity-id'; +import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; import { School, SchoolProps, SchoolQuery, SchoolRepo } from '../../domain'; import { SchoolEntityMapper } from './mapper/school.entity.mapper'; import { SchoolScope } from './scope/school.scope'; @Injectable() -export class SchoolMikroOrmRepo implements SchoolRepo { - constructor(private readonly em: EntityManager) {} +export class SchoolMikroOrmRepo extends BaseDomainObjectRepo implements SchoolRepo { + get entityName(): EntityName { + return SchoolEntity; + } public async getSchools(query: SchoolQuery, options?: IFindOptions): Promise { const scope = new SchoolScope(); @@ -39,6 +41,24 @@ export class SchoolMikroOrmRepo implements SchoolRepo { return school; } + public async getSchoolsBySystemIds(systemIds: EntityId[]): Promise { + const entities = await this.em.find( + SchoolEntity, + { systems: { $in: systemIds } }, + { populate: ['federalState', 'currentYear'] } + ); + + const schools = SchoolEntityMapper.mapToDos(entities); + + return schools; + } + + protected mapDOToEntityProperties(domainObject: School): EntityData { + const entityProps = SchoolEntityMapper.mapToEntityProperties(domainObject, this.em); + + return entityProps; + } + private mapToMikroOrmOptions

( options?: IFindOptions, populate?: AutoPath[] diff --git a/apps/server/src/modules/school/school-api.module.ts b/apps/server/src/modules/school/school-api.module.ts index 235ac516d43..018e81b4afe 100644 --- a/apps/server/src/modules/school/school-api.module.ts +++ b/apps/server/src/modules/school/school-api.module.ts @@ -1,7 +1,7 @@ -import { Module } from '@nestjs/common'; import { AuthorizationModule } from '@modules/authorization/authorization.module'; -import { SchoolModule } from './school.module'; +import { Module } from '@nestjs/common'; import { SchoolController, SchoolUc } from './api'; +import { SchoolModule } from './school.module'; @Module({ imports: [SchoolModule, AuthorizationModule], diff --git a/apps/server/src/modules/school/school.module.ts b/apps/server/src/modules/school/school.module.ts index d8d026cb75b..d9ebac6e83e 100644 --- a/apps/server/src/modules/school/school.module.ts +++ b/apps/server/src/modules/school/school.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; -import { SchoolService, SchoolYearService, SCHOOL_REPO, SCHOOL_YEAR_REPO } from './domain'; +import { SystemModule } from '../system'; +import { SCHOOL_REPO, SCHOOL_YEAR_REPO, SchoolService, SchoolYearService } from './domain'; import { SchoolYearMikroOrmRepo } from './repo/mikro-orm/school-year.repo'; import { SchoolMikroOrmRepo } from './repo/mikro-orm/school.repo'; @Module({ + imports: [SystemModule], providers: [ SchoolService, SchoolYearService, diff --git a/apps/server/src/modules/school/testing/county.factory.ts b/apps/server/src/modules/school/testing/county.factory.ts index ee17746dfc6..b7ea95b66d6 100644 --- a/apps/server/src/modules/school/testing/county.factory.ts +++ b/apps/server/src/modules/school/testing/county.factory.ts @@ -1,5 +1,5 @@ import { BaseFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { County, CountyProps } from '../domain'; export const countyFactory = BaseFactory.define(County, ({ sequence }) => { diff --git a/apps/server/src/modules/school/testing/federal-state.factory.ts b/apps/server/src/modules/school/testing/federal-state.factory.ts index ff368b5d920..884c61905c0 100644 --- a/apps/server/src/modules/school/testing/federal-state.factory.ts +++ b/apps/server/src/modules/school/testing/federal-state.factory.ts @@ -1,5 +1,5 @@ import { BaseFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { FederalState, FederalStateProps } from '../domain'; import { countyFactory } from './county.factory'; diff --git a/apps/server/src/modules/school/testing/school-year.factory.ts b/apps/server/src/modules/school/testing/school-year.factory.ts index 197ebd3c794..e32f61c191a 100644 --- a/apps/server/src/modules/school/testing/school-year.factory.ts +++ b/apps/server/src/modules/school/testing/school-year.factory.ts @@ -1,5 +1,5 @@ import { BaseFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { SchoolYear, SchoolYearProps } from '../domain'; type SchoolYearTransientParams = { @@ -16,8 +16,16 @@ class SchoolYearFactory extends BaseFactory { const id = new ObjectId().toHexString(); - const startYearWithoutSequence = transientParams?.startYear ?? new Date().getFullYear(); - const startYear = startYearWithoutSequence + sequence - 1; + const now = new Date(); + const startYearWithoutSequence = transientParams?.startYear ?? now.getFullYear(); + const sequenceStartingWithZero = sequence - 1; + let correction = 0; + + if (now.getMonth() < 7 && !transientParams?.startYear) { + correction = 1; + } + + const startYear = startYearWithoutSequence + sequenceStartingWithZero - correction; const name = `${startYear}/${(startYear + 1).toString().slice(-2)}`; const startDate = new Date(`${startYear}-08-01`); diff --git a/apps/server/src/modules/school/testing/school.factory.ts b/apps/server/src/modules/school/testing/school.factory.ts index 021206b452c..ccb05fde5ee 100644 --- a/apps/server/src/modules/school/testing/school.factory.ts +++ b/apps/server/src/modules/school/testing/school.factory.ts @@ -1,6 +1,6 @@ import { SchoolFeature } from '@shared/domain/types'; import { BaseFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { School, SchoolProps } from '../domain'; import { federalStateFactory } from './federal-state.factory'; diff --git a/apps/server/src/modules/server/admin-api.server.module.ts b/apps/server/src/modules/server/admin-api.server.module.ts index f0ffa765d58..b8917193351 100644 --- a/apps/server/src/modules/server/admin-api.server.module.ts +++ b/apps/server/src/modules/server/admin-api.server.module.ts @@ -1,6 +1,5 @@ import { MikroOrmModule } from '@mikro-orm/nestjs'; import { DynamicModule, Module } from '@nestjs/common'; -// import { ALL_ENTITIES } from '@shared/domain'; import { FileEntity } from '@modules/files/entity'; import { ConfigModule } from '@nestjs/config'; import { ALL_ENTITIES } from '@shared/domain/entity'; @@ -8,11 +7,19 @@ import { DB_PASSWORD, DB_URL, DB_USERNAME, createConfigModuleOptions } from '@sr import { LoggerModule } from '@src/core/logger'; import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@src/infra/database'; import { RabbitMQWrapperModule, RabbitMQWrapperTestModule } from '@src/infra/rabbitmq'; -import { DeletionApiModule } from '../deletion/deletion-api.module'; +import { CqrsModule } from '@nestjs/cqrs'; +import { DeletionApiModule } from '@modules/deletion/deletion-api.module'; +import { LegacySchoolAdminApiModule } from '@modules/legacy-school/legacy-school-admin.api-module'; +import { UserAdminApiModule } from '@modules/user/user-admin-api.module'; import { serverConfig } from './server.config'; import { defaultMikroOrmOptions } from './server.module'; -const serverModules = [ConfigModule.forRoot(createConfigModuleOptions(serverConfig)), DeletionApiModule]; +const serverModules = [ + ConfigModule.forRoot(createConfigModuleOptions(serverConfig)), + DeletionApiModule, + LegacySchoolAdminApiModule, + UserAdminApiModule, +]; @Module({ imports: [ @@ -27,6 +34,7 @@ const serverModules = [ConfigModule.forRoot(createConfigModuleOptions(serverConf entities: [...ALL_ENTITIES, FileEntity], debug: true, }), + CqrsModule, LoggerModule, ], }) diff --git a/apps/server/src/modules/server/api/dto/config.response.ts b/apps/server/src/modules/server/api/dto/config.response.ts new file mode 100644 index 00000000000..2c7e0df485f --- /dev/null +++ b/apps/server/src/modules/server/api/dto/config.response.ts @@ -0,0 +1,268 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { LanguageType } from '@shared/domain/interface'; +import type { ServerConfig } from '../..'; +import { SchulcloudTheme } from '../../types/schulcloud-theme.enum'; +import { Timezone } from '../../types/timezone.enum'; + +export class ConfigResponse { + @ApiProperty() + ACCESSIBILITY_REPORT_EMAIL: string; + + @ApiProperty() + FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED: boolean; + + @ApiProperty() + MIGRATION_END_GRACE_PERIOD_MS: number; + + @ApiProperty() + FEATURE_CTL_TOOLS_TAB_ENABLED: boolean; + + @ApiProperty() + FEATURE_LTI_TOOLS_TAB_ENABLED: boolean; + + @ApiProperty() + FEATURE_SHOW_OUTDATED_USERS: boolean; + + @ApiProperty() + FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION: boolean; + + @ApiProperty() + FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED: boolean; + + @ApiProperty() + CTL_TOOLS_RELOAD_TIME_MS: number; + + @ApiProperty() + FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED: boolean; + + @ApiProperty() + FEATURE_CTL_TOOLS_COPY_ENABLED: boolean; + + @ApiProperty() + FEATURE_SHOW_MIGRATION_WIZARD: boolean; + + @ApiPropertyOptional() + MIGRATION_WIZARD_DOCUMENTATION_LINK?: string; + + @ApiProperty() + FEATURE_TLDRAW_ENABLED: boolean; + + @ApiProperty() + TLDRAW__ASSETS_ENABLED: boolean; + + @ApiProperty() + TLDRAW__ASSETS_MAX_SIZE: number; + + @ApiProperty() + TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST: string[]; + + @ApiProperty() + ADMIN_TABLES_DISPLAY_CONSENT_COLUMN: boolean; + + @ApiProperty({ type: String, nullable: true }) + ALERT_STATUS_URL: string | null; + + @ApiProperty() + FEATURE_ES_COLLECTIONS_ENABLED: boolean; + + @ApiProperty() + FEATURE_EXTENSIONS_ENABLED: boolean; + + @ApiProperty() + FEATURE_TEAMS_ENABLED: boolean; + + @ApiProperty() + FEATURE_LERNSTORE_ENABLED: boolean; + + @ApiProperty() + FEATURE_ADMIN_TOGGLE_STUDENT_LERNSTORE_VIEW_ENABLED: boolean; + + @ApiProperty() + TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE: boolean; + + @ApiProperty() + TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT: boolean; + + @ApiProperty() + TEACHER_STUDENT_VISIBILITY__IS_VISIBLE: boolean; + + @ApiProperty() + FEATURE_SCHOOL_POLICY_ENABLED_NEW: boolean; + + @ApiProperty() + FEATURE_SCHOOL_TERMS_OF_USE_ENABLED: boolean; + + @ApiProperty() + FEATURE_NEXBOARD_COPY_ENABLED: boolean; + + @ApiProperty() + FEATURE_VIDEOCONFERENCE_ENABLED: boolean; + + @ApiProperty() + FEATURE_COLUMN_BOARD_ENABLED: boolean; + + @ApiProperty() + FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED: boolean; + + @ApiProperty() + FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED: boolean; + + @ApiProperty() + FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED: boolean; + + @ApiProperty() + FEATURE_COLUMN_BOARD_SHARE: boolean; + + @ApiProperty() + FEATURE_COURSE_SHARE: boolean; + + @ApiProperty() + FEATURE_LOGIN_LINK_ENABLED: boolean; + + @ApiProperty() + FEATURE_LESSON_SHARE: boolean; + + @ApiProperty() + FEATURE_TASK_SHARE: boolean; + + @ApiProperty() + FEATURE_USER_MIGRATION_ENABLED: boolean; + + @ApiProperty() + FEATURE_COPY_SERVICE_ENABLED: boolean; + + @ApiProperty() + FEATURE_CONSENT_NECESSARY: boolean; + + @ApiProperty() + FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED: boolean; + + @ApiProperty() + FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED: boolean; + + @ApiProperty() + FEATURE_SCHOOL_SANIS_USER_MIGRATION_ENABLED: boolean; + + @ApiProperty() + FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED: boolean; + + @ApiProperty() + FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED: boolean; + + @ApiProperty() + GHOST_BASE_URL: string; + + @ApiProperty() + ROCKETCHAT_SERVICE_ENABLED: boolean; + + // LERNSTORE_MODE: boolean; looks like not in use anymore + + @ApiProperty({ + isArray: true, + enum: LanguageType, + enumName: 'LanguageType', + }) + I18N__AVAILABLE_LANGUAGES: LanguageType[]; + + @ApiProperty({ + enum: LanguageType, + enumName: 'LanguageType', + }) + I18N__DEFAULT_LANGUAGE: LanguageType; + + @ApiProperty({ + enum: LanguageType, + enumName: 'LanguageType', + }) + I18N__FALLBACK_LANGUAGE: LanguageType; + + @ApiProperty({ enum: Timezone, enumName: 'Timezone' }) + I18N__DEFAULT_TIMEZONE: Timezone; + + @ApiProperty() + JWT_SHOW_TIMEOUT_WARNING_SECONDS: number; + + @ApiProperty() + JWT_TIMEOUT_SECONDS: number; + + @ApiProperty() + NOT_AUTHENTICATED_REDIRECT_URL: string; + + @ApiProperty() + DOCUMENT_BASE_DIR: string; + + @ApiProperty({ enum: SchulcloudTheme, enumName: 'SchulcloudTheme' }) + SC_THEME: SchulcloudTheme; + + @ApiProperty() + SC_TITLE: string; + + @ApiProperty() + FEATURE_MEDIA_SHELF_ENABLED: boolean; + + constructor(config: ServerConfig) { + this.ACCESSIBILITY_REPORT_EMAIL = config.ACCESSIBILITY_REPORT_EMAIL; + this.ADMIN_TABLES_DISPLAY_CONSENT_COLUMN = config.ADMIN_TABLES_DISPLAY_CONSENT_COLUMN; + this.ALERT_STATUS_URL = config.ALERT_STATUS_URL; + this.FEATURE_ES_COLLECTIONS_ENABLED = config.FEATURE_ES_COLLECTIONS_ENABLED; + this.FEATURE_EXTENSIONS_ENABLED = config.FEATURE_EXTENSIONS_ENABLED; + this.FEATURE_TEAMS_ENABLED = config.FEATURE_TEAMS_ENABLED; + this.FEATURE_LERNSTORE_ENABLED = config.FEATURE_LERNSTORE_ENABLED; + this.FEATURE_ADMIN_TOGGLE_STUDENT_LERNSTORE_VIEW_ENABLED = + config.FEATURE_ADMIN_TOGGLE_STUDENT_LERNSTORE_VIEW_ENABLED; + this.TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE = config.TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE; + this.TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT = config.TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT; + this.TEACHER_STUDENT_VISIBILITY__IS_VISIBLE = config.TEACHER_STUDENT_VISIBILITY__IS_VISIBLE; + this.FEATURE_SCHOOL_POLICY_ENABLED_NEW = config.FEATURE_SCHOOL_POLICY_ENABLED_NEW; + this.FEATURE_SCHOOL_TERMS_OF_USE_ENABLED = config.FEATURE_SCHOOL_TERMS_OF_USE_ENABLED; + this.FEATURE_NEXBOARD_COPY_ENABLED = config.FEATURE_NEXBOARD_COPY_ENABLED; + this.FEATURE_COLUMN_BOARD_ENABLED = config.FEATURE_COLUMN_BOARD_ENABLED; + this.FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED = config.FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED; + this.FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED = config.FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED; + this.FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED = config.FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED; + this.FEATURE_COLUMN_BOARD_SHARE = config.FEATURE_COLUMN_BOARD_SHARE; + this.FEATURE_COURSE_SHARE = config.FEATURE_COURSE_SHARE; + this.FEATURE_LOGIN_LINK_ENABLED = config.FEATURE_LOGIN_LINK_ENABLED; + this.FEATURE_LESSON_SHARE = config.FEATURE_LESSON_SHARE; + this.FEATURE_TASK_SHARE = config.FEATURE_TASK_SHARE; + this.FEATURE_USER_MIGRATION_ENABLED = config.userMigrationEnabled; + this.FEATURE_COPY_SERVICE_ENABLED = config.FEATURE_COPY_SERVICE_ENABLED; + this.FEATURE_CONSENT_NECESSARY = config.FEATURE_CONSENT_NECESSARY; + this.FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED = config.FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED; + this.FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED = config.FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED; + this.FEATURE_SCHOOL_SANIS_USER_MIGRATION_ENABLED = config.FEATURE_SCHOOL_SANIS_USER_MIGRATION_ENABLED; + this.FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED = config.FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED; + this.GHOST_BASE_URL = config.GHOST_BASE_URL; + this.ROCKETCHAT_SERVICE_ENABLED = config.ROCKETCHAT_SERVICE_ENABLED; + this.I18N__AVAILABLE_LANGUAGES = config.I18N__AVAILABLE_LANGUAGES; + this.I18N__DEFAULT_LANGUAGE = config.I18N__DEFAULT_LANGUAGE; + this.I18N__FALLBACK_LANGUAGE = config.I18N__FALLBACK_LANGUAGE; + this.I18N__DEFAULT_TIMEZONE = config.I18N__DEFAULT_TIMEZONE; + this.JWT_SHOW_TIMEOUT_WARNING_SECONDS = config.JWT_SHOW_TIMEOUT_WARNING_SECONDS; + this.JWT_TIMEOUT_SECONDS = config.JWT_TIMEOUT_SECONDS; + this.NOT_AUTHENTICATED_REDIRECT_URL = config.NOT_AUTHENTICATED_REDIRECT_URL; + this.DOCUMENT_BASE_DIR = config.DOCUMENT_BASE_DIR; + this.SC_THEME = config.SC_THEME; + this.SC_TITLE = config.SC_TITLE; + this.FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED = + config.FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED; + this.MIGRATION_END_GRACE_PERIOD_MS = config.MIGRATION_END_GRACE_PERIOD_MS; + this.FEATURE_CTL_TOOLS_TAB_ENABLED = config.ctlToolsTabEnabled; + this.FEATURE_LTI_TOOLS_TAB_ENABLED = config.ltiToolsTabEnabled; + this.FEATURE_SHOW_OUTDATED_USERS = config.FEATURE_SHOW_OUTDATED_USERS; + this.FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION = config.FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION; + this.FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED = config.contextConfigurationEnabled; + this.CTL_TOOLS_RELOAD_TIME_MS = config.ctlToolsReloadTimeMs; + this.FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED = config.FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED; + this.FEATURE_CTL_TOOLS_COPY_ENABLED = config.ctlToolsCopyEnabled; + this.FEATURE_SHOW_MIGRATION_WIZARD = config.FEATURE_SHOW_MIGRATION_WIZARD; + this.MIGRATION_WIZARD_DOCUMENTATION_LINK = config.MIGRATION_WIZARD_DOCUMENTATION_LINK; + this.FEATURE_TLDRAW_ENABLED = config.FEATURE_TLDRAW_ENABLED; + this.TLDRAW__ASSETS_ENABLED = config.TLDRAW__ASSETS_ENABLED; + this.TLDRAW__ASSETS_MAX_SIZE = config.TLDRAW__ASSETS_MAX_SIZE; + this.TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST = config.TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST; + this.FEATURE_VIDEOCONFERENCE_ENABLED = config.enabled; + this.FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED = config.FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED; + this.FEATURE_MEDIA_SHELF_ENABLED = config.FEATURE_MEDIA_SHELF_ENABLED; + } +} diff --git a/apps/server/src/modules/server/api/dto/index.ts b/apps/server/src/modules/server/api/dto/index.ts new file mode 100644 index 00000000000..02705719dfb --- /dev/null +++ b/apps/server/src/modules/server/api/dto/index.ts @@ -0,0 +1 @@ +export * from './config.response'; diff --git a/apps/server/src/modules/server/api/index.ts b/apps/server/src/modules/server/api/index.ts new file mode 100644 index 00000000000..82287a627d4 --- /dev/null +++ b/apps/server/src/modules/server/api/index.ts @@ -0,0 +1,3 @@ +export * from './server.controller'; +export * from './server.uc'; +export * from './server-config.controller'; diff --git a/apps/server/src/modules/server/api/mapper/config.response.mapper.ts b/apps/server/src/modules/server/api/mapper/config.response.mapper.ts new file mode 100644 index 00000000000..75d9ac047ea --- /dev/null +++ b/apps/server/src/modules/server/api/mapper/config.response.mapper.ts @@ -0,0 +1,10 @@ +import { ServerConfig } from '../../server.config'; +import { ConfigResponse } from '../dto'; + +export class ConfigResponseMapper { + public static mapToResponse(config: ServerConfig): ConfigResponse { + const configResponse = new ConfigResponse(config); + + return configResponse; + } +} diff --git a/apps/server/src/modules/server/api/mapper/index.ts b/apps/server/src/modules/server/api/mapper/index.ts new file mode 100644 index 00000000000..dc75769c906 --- /dev/null +++ b/apps/server/src/modules/server/api/mapper/index.ts @@ -0,0 +1 @@ +export * from './config.response.mapper'; diff --git a/apps/server/src/modules/server/api/server-config.controller.ts b/apps/server/src/modules/server/api/server-config.controller.ts new file mode 100644 index 00000000000..3555f4d40da --- /dev/null +++ b/apps/server/src/modules/server/api/server-config.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ConfigResponse } from './dto'; +import { ServerUc } from './server.uc'; + +@Controller('config') +export class ServerConfigController { + constructor(private readonly serverUc: ServerUc) {} + + @ApiOperation({ summary: 'Useable configuration for clients' }) + @ApiResponse({ status: 200, type: ConfigResponse }) + @Get('/public') + publicConfig(): ConfigResponse { + const configResponse = this.serverUc.getConfig(); + + return configResponse; + } +} diff --git a/apps/server/src/modules/server/api/server.controller.ts b/apps/server/src/modules/server/api/server.controller.ts new file mode 100644 index 00000000000..bd231e39e47 --- /dev/null +++ b/apps/server/src/modules/server/api/server.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; + +@Controller() +export class ServerController { + @ApiOperation({ summary: 'Default route to test public access' }) + @ApiResponse({ status: 200, type: String }) + @Get() + getHello(): { message: string } { + return { message: 'Schulcloud Server API' }; + } +} diff --git a/apps/server/src/modules/server/api/server.uc.ts b/apps/server/src/modules/server/api/server.uc.ts new file mode 100644 index 00000000000..1c2af09ccee --- /dev/null +++ b/apps/server/src/modules/server/api/server.uc.ts @@ -0,0 +1,15 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigResponseMapper } from './mapper/config.response.mapper'; +import { ConfigResponse } from './dto/config.response'; +import { SERVER_CONFIG_TOKEN, ServerConfig } from '../server.config'; + +@Injectable() +export class ServerUc { + constructor(@Inject(SERVER_CONFIG_TOKEN) private readonly config: ServerConfig) {} + + public getConfig(): ConfigResponse { + const configDto = ConfigResponseMapper.mapToResponse(this.config); + + return configDto; + } +} diff --git a/apps/server/src/modules/server/api/test/server.api.spec.ts b/apps/server/src/modules/server/api/test/server.api.spec.ts new file mode 100644 index 00000000000..2785dbbd871 --- /dev/null +++ b/apps/server/src/modules/server/api/test/server.api.spec.ts @@ -0,0 +1,103 @@ +import { ServerTestModule } from '@modules/server'; +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TestApiClient } from '@shared/testing'; +import { ConfigResponse } from '../dto'; + +describe('Server Controller (API)', () => { + let app: INestApplication; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + testApiClient = new TestApiClient(app, ''); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('[GET] /', () => { + it('should be return string', async () => { + const response = await testApiClient.get('/'); + + expect(response.body).toEqual({ message: 'Schulcloud Server API' }); + }); + }); + + describe('[GET] /config/public', () => { + it('should be return configuration as json with all required keys', async () => { + const response = await testApiClient.get('/config/public'); + const expectedResultKeys: (keyof ConfigResponse)[] = [ + 'ACCESSIBILITY_REPORT_EMAIL', + 'ADMIN_TABLES_DISPLAY_CONSENT_COLUMN', + 'ALERT_STATUS_URL', + 'CTL_TOOLS_RELOAD_TIME_MS', + 'DOCUMENT_BASE_DIR', + 'FEATURE_ADMIN_TOGGLE_STUDENT_LERNSTORE_VIEW_ENABLED', + 'FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED', + 'FEATURE_COLUMN_BOARD_ENABLED', + 'FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED', + 'FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED', + 'FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED', + 'FEATURE_COLUMN_BOARD_SHARE', + 'FEATURE_CONSENT_NECESSARY', + 'FEATURE_COPY_SERVICE_ENABLED', + 'FEATURE_COURSE_SHARE', + 'FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED', + 'FEATURE_CTL_TOOLS_COPY_ENABLED', + 'FEATURE_CTL_TOOLS_TAB_ENABLED', + 'FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION', + 'FEATURE_ES_COLLECTIONS_ENABLED', + 'FEATURE_EXTENSIONS_ENABLED', + 'FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED', + 'FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED', + 'FEATURE_LERNSTORE_ENABLED', + 'FEATURE_LESSON_SHARE', + 'FEATURE_LOGIN_LINK_ENABLED', + 'FEATURE_LTI_TOOLS_TAB_ENABLED', + 'FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED', + 'FEATURE_NEXBOARD_COPY_ENABLED', + 'FEATURE_SCHOOL_POLICY_ENABLED_NEW', + 'FEATURE_SCHOOL_SANIS_USER_MIGRATION_ENABLED', + 'FEATURE_SCHOOL_TERMS_OF_USE_ENABLED', + 'FEATURE_SHOW_MIGRATION_WIZARD', + 'FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED', + 'FEATURE_SHOW_OUTDATED_USERS', + 'FEATURE_TASK_SHARE', + 'FEATURE_TEAMS_ENABLED', + 'FEATURE_TLDRAW_ENABLED', + 'FEATURE_USER_MIGRATION_ENABLED', + 'FEATURE_VIDEOCONFERENCE_ENABLED', + 'GHOST_BASE_URL', + 'I18N__AVAILABLE_LANGUAGES', + 'I18N__DEFAULT_LANGUAGE', + 'I18N__DEFAULT_TIMEZONE', + 'I18N__FALLBACK_LANGUAGE', + 'JWT_SHOW_TIMEOUT_WARNING_SECONDS', + 'JWT_TIMEOUT_SECONDS', + 'MIGRATION_END_GRACE_PERIOD_MS', + 'NOT_AUTHENTICATED_REDIRECT_URL', + 'ROCKETCHAT_SERVICE_ENABLED', + 'SC_THEME', + 'SC_TITLE', + 'TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE', + 'TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT', + 'TEACHER_STUDENT_VISIBILITY__IS_VISIBLE', + 'TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST', + 'TLDRAW__ASSETS_ENABLED', + 'TLDRAW__ASSETS_MAX_SIZE', + 'FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED', + 'FEATURE_MEDIA_SHELF_ENABLED', + ]; + + expect(response.status).toEqual(HttpStatus.OK); + expect(Object.keys(response.body as Record).sort()).toEqual(expectedResultKeys.sort()); + }); + }); +}); diff --git a/apps/server/src/modules/server/controller/api-test/server.api.spec.ts b/apps/server/src/modules/server/controller/api-test/server.api.spec.ts deleted file mode 100644 index a6e42141c6a..00000000000 --- a/apps/server/src/modules/server/controller/api-test/server.api.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ServerTestModule } from '@modules/server'; -import request from 'supertest'; - -describe('Server Controller (API)', () => { - let app: INestApplication; - beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [ServerTestModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - it('/ (GET)', () => request(app.getHttpServer()).get('/').expect(200).expect('Schulcloud Server API')); -}); diff --git a/apps/server/src/modules/server/controller/index.ts b/apps/server/src/modules/server/controller/index.ts deleted file mode 100644 index 27d99f9a2dc..00000000000 --- a/apps/server/src/modules/server/controller/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './server.controller'; diff --git a/apps/server/src/modules/server/controller/server.controller.spec.ts b/apps/server/src/modules/server/controller/server.controller.spec.ts deleted file mode 100644 index d5397ce241a..00000000000 --- a/apps/server/src/modules/server/controller/server.controller.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ServerController } from './server.controller'; - -describe('ServerController', () => { - let module: TestingModule; - let serverController: ServerController; - - beforeAll(async () => { - module = await Test.createTestingModule({ - controllers: [ServerController], - }).compile(); - - serverController = module.get(ServerController); - }); - - afterAll(async () => { - await module.close(); - }); - - describe('root', () => { - it('should return "Schulcloud Server API"', () => { - expect(serverController.getHello()).toBe('Schulcloud Server API'); - }); - }); -}); diff --git a/apps/server/src/modules/server/controller/server.controller.ts b/apps/server/src/modules/server/controller/server.controller.ts deleted file mode 100644 index 96c54652d53..00000000000 --- a/apps/server/src/modules/server/controller/server.controller.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; - -@Controller() -export class ServerController { - /** default route to test public access */ - @Get() - getHello(): string { - return 'Schulcloud Server API'; - } -} diff --git a/apps/server/src/modules/server/server.config.ts b/apps/server/src/modules/server/server.config.ts index 8dd20a019a5..69cd3547b44 100644 --- a/apps/server/src/modules/server/server.config.ts +++ b/apps/server/src/modules/server/server.config.ts @@ -1,13 +1,29 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import type { IdentityManagementConfig } from '@infra/identity-management'; +import type { SchulconnexClientConfig } from '@infra/schulconnex-client'; import type { AccountConfig } from '@modules/account'; -import type { XApiKeyConfig } from '@modules/authentication'; +import type { AuthenticationConfig, XApiKeyConfig } from '@modules/authentication'; +import type { BoardConfig } from '@modules/board'; import type { FilesStorageClientConfig } from '@modules/files-storage-client'; -import type { CommonCartridgeConfig } from '@modules/learnroom/common-cartridge'; +import type { LearnroomConfig } from '@modules/learnroom'; +import type { LessonConfig } from '@modules/lesson'; import type { SchoolConfig } from '@modules/school'; +import type { SharingConfig } from '@modules/sharing'; +import { getTldrawClientConfig, type TldrawClientConfig } from '@modules/tldraw-client'; +import { type IToolFeatures, ToolConfiguration } from '@modules/tool'; import type { UserConfig } from '@modules/user'; +import { type IUserImportFeatures, UserImportConfiguration } from '@modules/user-import'; +import type { UserLoginMigrationConfig } from '@modules/user-login-migration'; +import { type IVideoConferenceSettings, VideoConferenceConfiguration } from '@modules/video-conference'; +import { LanguageType } from '@shared/domain/interface'; import type { CoreModuleConfig } from '@src/core'; -import { MailConfig } from '@src/infra/mail/interfaces/mail-config'; +import type { MailConfig } from '@src/infra/mail/interfaces/mail-config'; +import { DeletionConfig } from '@modules/deletion'; +import type { MediaBoardConfig } from '@modules/board/media-board.config'; +import { ProvisioningConfig } from '@modules/provisioning'; +import { SynchronizationConfig } from '@modules/idp-console'; +import { SchulcloudTheme } from './types/schulcloud-theme.enum'; +import { Timezone } from './types/timezone.enum'; export enum NodeEnvType { TEST = 'test', @@ -16,42 +32,193 @@ export enum NodeEnvType { MIGRATION = 'migration', } +// Environment keys should be added over configs from modules, directly adding is only allow for legacy stuff +// Maye some of them must be outsourced to additional microservice config endpoints. export interface ServerConfig extends CoreModuleConfig, UserConfig, FilesStorageClientConfig, AccountConfig, IdentityManagementConfig, - CommonCartridgeConfig, SchoolConfig, MailConfig, - XApiKeyConfig { - NODE_ENV: string; + XApiKeyConfig, + LearnroomConfig, + AuthenticationConfig, + IToolFeatures, + TldrawClientConfig, + UserLoginMigrationConfig, + LessonConfig, + IVideoConferenceSettings, + BoardConfig, + MediaBoardConfig, + SharingConfig, + IUserImportFeatures, + SchulconnexClientConfig, + SynchronizationConfig, + DeletionConfig, + ProvisioningConfig { + NODE_ENV: NodeEnvType; SC_DOMAIN: string; + ACCESSIBILITY_REPORT_EMAIL: string; + ADMIN_TABLES_DISPLAY_CONSENT_COLUMN: boolean; + ALERT_STATUS_URL: string | null; + FEATURE_ES_COLLECTIONS_ENABLED: boolean; + FEATURE_EXTENSIONS_ENABLED: boolean; + FEATURE_TEAMS_ENABLED: boolean; + FEATURE_LERNSTORE_ENABLED: boolean; + FEATURE_ADMIN_TOGGLE_STUDENT_LERNSTORE_VIEW_ENABLED: boolean; + TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT: boolean; + TEACHER_STUDENT_VISIBILITY__IS_VISIBLE: boolean; + FEATURE_SCHOOL_POLICY_ENABLED_NEW: boolean; + FEATURE_SCHOOL_TERMS_OF_USE_ENABLED: boolean; + FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED: boolean; + FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED: boolean; + FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED: boolean; + FEATURE_COLUMN_BOARD_SHARE: boolean; + FEATURE_LOGIN_LINK_ENABLED: boolean; + FEATURE_CONSENT_NECESSARY: boolean; + FEATURE_SCHOOL_SANIS_USER_MIGRATION_ENABLED: boolean; + FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED: boolean; + GHOST_BASE_URL: string; + ROCKETCHAT_SERVICE_ENABLED: boolean; + JWT_SHOW_TIMEOUT_WARNING_SECONDS: number; + JWT_TIMEOUT_SECONDS: number; + NOT_AUTHENTICATED_REDIRECT_URL: string; + DOCUMENT_BASE_DIR: string; + SC_THEME: SchulcloudTheme; + SC_TITLE: string; + FEATURE_SHOW_OUTDATED_USERS: boolean; + FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED: boolean; + FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION: boolean; + FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED: boolean; + FEATURE_SHOW_MIGRATION_WIZARD: boolean; + MIGRATION_WIZARD_DOCUMENTATION_LINK?: string; + FEATURE_TLDRAW_ENABLED: boolean; + TLDRAW__ASSETS_ENABLED: boolean; + TLDRAW__ASSETS_MAX_SIZE: number; + TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST: string[]; + I18N__AVAILABLE_LANGUAGES: LanguageType[]; + I18N__DEFAULT_LANGUAGE: LanguageType; + I18N__FALLBACK_LANGUAGE: LanguageType; + I18N__DEFAULT_TIMEZONE: Timezone; } const config: ServerConfig = { + ACCESSIBILITY_REPORT_EMAIL: Configuration.get('ACCESSIBILITY_REPORT_EMAIL') as string, + ADMIN_TABLES_DISPLAY_CONSENT_COLUMN: Configuration.get('ADMIN_TABLES_DISPLAY_CONSENT_COLUMN') as boolean, + ALERT_STATUS_URL: + Configuration.get('ALERT_STATUS_URL') === null + ? (Configuration.get('ALERT_STATUS_URL') as null) + : (Configuration.get('ALERT_STATUS_URL') as string), + FEATURE_ES_COLLECTIONS_ENABLED: Configuration.get('FEATURE_ES_COLLECTIONS_ENABLED') as boolean, + FEATURE_EXTENSIONS_ENABLED: Configuration.get('FEATURE_EXTENSIONS_ENABLED') as boolean, + FEATURE_TEAMS_ENABLED: Configuration.get('FEATURE_TEAMS_ENABLED') as boolean, + FEATURE_LERNSTORE_ENABLED: Configuration.get('FEATURE_LERNSTORE_ENABLED') as boolean, + FEATURE_ADMIN_TOGGLE_STUDENT_LERNSTORE_VIEW_ENABLED: Configuration.get( + 'FEATURE_ADMIN_TOGGLE_STUDENT_LERNSTORE_VIEW_ENABLED' + ) as boolean, + FEATURE_COLUMN_BOARD_ENABLED: Configuration.get('FEATURE_COLUMN_BOARD_ENABLED') as boolean, + FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED: Configuration.get('FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED') as boolean, + FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED: Configuration.get('FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED') as boolean, + FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED: Configuration.get( + 'FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED' + ) as boolean, + FEATURE_COLUMN_BOARD_SHARE: Configuration.get('FEATURE_COLUMN_BOARD_SHARE') as boolean, + FEATURE_COURSE_SHARE: Configuration.get('FEATURE_COURSE_SHARE') as boolean, + FEATURE_LESSON_SHARE: Configuration.get('FEATURE_LESSON_SHARE') as boolean, + FEATURE_TASK_SHARE: Configuration.get('FEATURE_TASK_SHARE') as boolean, + FEATURE_LOGIN_LINK_ENABLED: Configuration.get('FEATURE_LOGIN_LINK_ENABLED') as boolean, + FEATURE_COPY_SERVICE_ENABLED: Configuration.get('FEATURE_COPY_SERVICE_ENABLED') as boolean, + FEATURE_CONSENT_NECESSARY: Configuration.get('FEATURE_CONSENT_NECESSARY') as boolean, + FEATURE_SCHOOL_SANIS_USER_MIGRATION_ENABLED: Configuration.get( + 'FEATURE_SCHOOL_SANIS_USER_MIGRATION_ENABLED' + ) as boolean, + TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT: Configuration.get( + 'TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT' + ) as boolean, + TEACHER_STUDENT_VISIBILITY__IS_VISIBLE: Configuration.get('TEACHER_STUDENT_VISIBILITY__IS_VISIBLE') as boolean, + FEATURE_SCHOOL_POLICY_ENABLED_NEW: Configuration.get('FEATURE_SCHOOL_POLICY_ENABLED_NEW') as boolean, + FEATURE_SCHOOL_TERMS_OF_USE_ENABLED: Configuration.get('FEATURE_SCHOOL_TERMS_OF_USE_ENABLED') as boolean, + FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED: Configuration.get('FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED') as boolean, + GHOST_BASE_URL: Configuration.get('GHOST_BASE_URL') as string, + ROCKETCHAT_SERVICE_ENABLED: Configuration.get('ROCKETCHAT_SERVICE_ENABLED') as boolean, + JWT_SHOW_TIMEOUT_WARNING_SECONDS: Configuration.get('JWT_SHOW_TIMEOUT_WARNING_SECONDS') as number, + JWT_TIMEOUT_SECONDS: Configuration.get('JWT_TIMEOUT_SECONDS') as number, + NOT_AUTHENTICATED_REDIRECT_URL: Configuration.get('NOT_AUTHENTICATED_REDIRECT_URL') as string, + DOCUMENT_BASE_DIR: Configuration.get('DOCUMENT_BASE_DIR') as string, + SC_THEME: Configuration.get('SC_THEME') as SchulcloudTheme, + SC_TITLE: Configuration.get('SC_TITLE') as string, SC_DOMAIN: Configuration.get('SC_DOMAIN') as string, INCOMING_REQUEST_TIMEOUT: Configuration.get('INCOMING_REQUEST_TIMEOUT_API') as number, INCOMING_REQUEST_TIMEOUT_COPY_API: Configuration.get('INCOMING_REQUEST_TIMEOUT_COPY_API') as number, NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, - AVAILABLE_LANGUAGES: (Configuration.get('I18N__AVAILABLE_LANGUAGES') as string).split(','), + EXIT_ON_ERROR: Configuration.get('EXIT_ON_ERROR') as boolean, + AVAILABLE_LANGUAGES: (Configuration.get('I18N__AVAILABLE_LANGUAGES') as string).split(',') as LanguageType[], NODE_ENV: Configuration.get('NODE_ENV') as NodeEnvType, LOGIN_BLOCK_TIME: Configuration.get('LOGIN_BLOCK_TIME') as number, TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE: Configuration.get( 'TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE' ) as boolean, - FEATURE_IMSCC_COURSE_EXPORT_ENABLED: Configuration.get('FEATURE_IMSCC_COURSE_EXPORT_ENABLED') as boolean, + FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED: Configuration.get( + 'FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED' + ) as boolean, + GEOGEBRA_BASE_URL: Configuration.get('GEOGEBRA_BASE_URL') as string, FEATURE_IDENTITY_MANAGEMENT_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_ENABLED') as boolean, FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_STORE_ENABLED') as boolean, FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED: Configuration.get('FEATURE_IDENTITY_MANAGEMENT_LOGIN_ENABLED') as boolean, STUDENT_TEAM_CREATION: Configuration.get('STUDENT_TEAM_CREATION') as string, + SYNCHRONIZATION_CHUNK: Configuration.get('SYNCHRONIZATION_CHUNK') as number, + // parse [:],[:]... and discard description + ADMIN_API__MODIFICATION_THRESHOLD_MS: Configuration.get('ADMIN_API__MODIFICATION_THRESHOLD_MS') as number, ADMIN_API__ALLOWED_API_KEYS: (Configuration.get('ADMIN_API__ALLOWED_API_KEYS') as string) .split(',') - .map((apiKey) => apiKey.trim()), + .map((part) => (part.split(':').pop() ?? '').trim()), BLOCKLIST_OF_EMAIL_DOMAINS: (Configuration.get('BLOCKLIST_OF_EMAIL_DOMAINS') as string) .split(',') .map((domain) => domain.trim()), + TLDRAW__ASSETS_ENABLED: Configuration.get('TLDRAW__ASSETS_ENABLED') as boolean, + TLDRAW__ASSETS_MAX_SIZE: Configuration.get('TLDRAW__ASSETS_MAX_SIZE') as number, + TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST: (Configuration.get('TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST') as string).split( + ',' + ), + FEATURE_TLDRAW_ENABLED: Configuration.get('FEATURE_TLDRAW_ENABLED') as boolean, + FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED: Configuration.get( + 'FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED' + ) as boolean, + MIGRATION_END_GRACE_PERIOD_MS: Configuration.get('MIGRATION_END_GRACE_PERIOD_MS') as number, + FEATURE_SHOW_OUTDATED_USERS: Configuration.get('FEATURE_SHOW_OUTDATED_USERS') as boolean, + FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION: Configuration.get('FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION') as boolean, + FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED: Configuration.get('FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED') as boolean, + FEATURE_SHOW_MIGRATION_WIZARD: Configuration.get('FEATURE_SHOW_MIGRATION_WIZARD') as boolean, + FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED: Configuration.get( + 'FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED' + ) as boolean, + FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE: Configuration.get( + 'FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE' + ) as number, + MIGRATION_WIZARD_DOCUMENTATION_LINK: Configuration.has('MIGRATION_WIZARD_DOCUMENTATION_LINK') + ? (Configuration.get('MIGRATION_WIZARD_DOCUMENTATION_LINK') as string) + : undefined, + FEATURE_NEXBOARD_COPY_ENABLED: Configuration.get('FEATURE_NEXBOARD_COPY_ENABLED') as boolean, + FEATURE_ETHERPAD_ENABLED: Configuration.get('FEATURE_ETHERPAD_ENABLED') as boolean, + ETHERPAD__PAD_URI: Configuration.has('ETHERPAD__PAD_URI') + ? (Configuration.get('ETHERPAD__PAD_URI') as string) + : undefined, + I18N__AVAILABLE_LANGUAGES: (Configuration.get('I18N__AVAILABLE_LANGUAGES') as string).split(',') as LanguageType[], + I18N__DEFAULT_LANGUAGE: Configuration.get('I18N__DEFAULT_LANGUAGE') as unknown as LanguageType, + I18N__FALLBACK_LANGUAGE: Configuration.get('I18N__FALLBACK_LANGUAGE') as unknown as LanguageType, + I18N__DEFAULT_TIMEZONE: Configuration.get('I18N__DEFAULT_TIMEZONE') as Timezone, + SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS: Configuration.get( + 'SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS' + ) as number, + FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED: Configuration.get('FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED') as boolean, + ...getTldrawClientConfig(), + ...ToolConfiguration.toolFeatures, + ...VideoConferenceConfiguration.videoConference, + ...UserImportConfiguration.userImportFeatures, + FEATURE_MEDIA_SHELF_ENABLED: Configuration.get('FEATURE_MEDIA_SHELF_ENABLED') as boolean, }; export const serverConfig = () => config; +export const SERVER_CONFIG_TOKEN = 'SERVER_CONFIG_TOKEN'; diff --git a/apps/server/src/modules/server/server.module.ts b/apps/server/src/modules/server/server.module.ts index 1f4bcc608df..57e4aa7d704 100644 --- a/apps/server/src/modules/server/server.module.ts +++ b/apps/server/src/modules/server/server.module.ts @@ -2,18 +2,20 @@ import { Configuration } from '@hpi-schul-cloud/commons'; import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; import { MailModule } from '@infra/mail'; import { RabbitMQWrapperModule, RabbitMQWrapperTestModule } from '@infra/rabbitmq'; -import { RedisModule, REDIS_CLIENT } from '@infra/redis'; +import { SchulconnexClientModule } from '@infra/schulconnex-client'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; import { AccountApiModule } from '@modules/account/account-api.module'; import { AuthenticationApiModule } from '@modules/authentication/authentication-api.module'; import { BoardApiModule } from '@modules/board/board-api.module'; +import { MediaBoardApiModule } from '@modules/board/media-board-api.module'; import { CollaborativeStorageModule } from '@modules/collaborative-storage'; import { FilesStorageClientModule } from '@modules/files-storage-client'; import { GroupApiModule } from '@modules/group/group-api.module'; import { LearnroomApiModule } from '@modules/learnroom/learnroom-api.module'; import { LegacySchoolApiModule } from '@modules/legacy-school/legacy-school.api-module'; import { LessonApiModule } from '@modules/lesson/lesson-api.module'; +import { MeApiModule } from '@modules/me/me-api.module'; import { MetaTagExtractorApiModule, MetaTagExtractorModule } from '@modules/meta-tag-extractor'; import { NewsModule } from '@modules/news'; import { OauthProviderApiModule } from '@modules/oauth-provider'; @@ -26,21 +28,19 @@ import { SystemApiModule } from '@modules/system/system-api.module'; import { TaskApiModule } from '@modules/task/task-api.module'; import { TeamsApiModule } from '@modules/teams/teams-api.module'; import { ToolApiModule } from '@modules/tool/tool-api.module'; -import { ImportUserModule } from '@modules/user-import'; +import { ImportUserModule, UserImportConfigModule } from '@modules/user-import'; import { UserLoginMigrationApiModule } from '@modules/user-login-migration/user-login-migration-api.module'; +import { UsersAdminApiModule } from '@modules/user/legacy/users-admin-api.module'; import { UserApiModule } from '@modules/user/user-api.module'; import { VideoConferenceApiModule } from '@modules/video-conference/video-conference-api.module'; -import { DynamicModule, Inject, MiddlewareConsumer, Module, NestModule, NotFoundException } from '@nestjs/common'; +import { DynamicModule, Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { ALL_ENTITIES } from '@shared/domain/entity'; import { createConfigModuleOptions, DB_PASSWORD, DB_URL, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; -import { LegacyLogger, LoggerModule } from '@src/core/logger'; -import RedisStore from 'connect-redis'; -import session from 'express-session'; -import { RedisClientType } from 'redis'; -import { ServerController } from './controller/server.controller'; -import { serverConfig } from './server.config'; +import { LoggerModule } from '@src/core/logger'; +import { ServerConfigController, ServerController, ServerUc } from './api'; +import { SERVER_CONFIG_TOKEN, serverConfig } from './server.config'; const serverModules = [ ConfigModule.forRoot(createConfigModuleOptions(serverConfig)), @@ -54,7 +54,16 @@ const serverModules = [ LessonApiModule, NewsModule, UserApiModule, + UsersAdminApiModule, + SchulconnexClientModule.register({ + apiUrl: Configuration.get('SCHULCONNEX_CLIENT__API_URL') as string, + tokenEndpoint: Configuration.get('SCHULCONNEX_CLIENT__TOKEN_ENDPOINT') as string, + clientId: Configuration.get('SCHULCONNEX_CLIENT__CLIENT_ID') as string, + clientSecret: Configuration.get('SCHULCONNEX_CLIENT__CLIENT_SECRET') as string, + personenInfoTimeoutInMs: Configuration.get('SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS') as number, + }), ImportUserModule, + UserImportConfigModule, LearnroomApiModule, FilesStorageClientModule, SystemApiModule, @@ -81,6 +90,8 @@ const serverModules = [ PseudonymApiModule, SchoolApiModule, LegacySchoolApiModule, + MeApiModule, + MediaBoardApiModule, ]; export const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { @@ -89,44 +100,8 @@ export const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { new NotFoundException(`The requested ${entityName}: ${where} has not been found.`), }; -const setupSessions = ( - consumer: MiddlewareConsumer, - redisClient: RedisClientType | undefined, - logger: LegacyLogger -) => { - const sessionDuration: number = Configuration.get('SESSION__EXPIRES_SECONDS') as number; - - let store: RedisStore | undefined; - if (redisClient) { - store = new RedisStore({ - client: redisClient, - ttl: sessionDuration, - }); - } else { - logger.warn( - 'The RedisStore for sessions is not setup, since the environment variable REDIS_URI is not defined. Sessions are using the build-in MemoryStore. This should not be used in production!' - ); - } - - consumer - .apply( - session({ - store, - secret: Configuration.get('SESSION__SECRET') as string, - resave: false, - saveUninitialized: false, - name: Configuration.has('SESSION__NAME') ? (Configuration.get('SESSION__NAME') as string) : undefined, - proxy: Configuration.has('SESSION__PROXY') ? (Configuration.get('SESSION__PROXY') as boolean) : undefined, - cookie: { - secure: Configuration.get('SESSION__SECURE') as boolean, - sameSite: Configuration.get('SESSION__SAME_SITE') as boolean | 'lax' | 'strict' | 'none', - httpOnly: Configuration.get('SESSION__HTTP_ONLY') as boolean, - maxAge: sessionDuration * 1000, - }, - }) - ) - .forRoutes('*'); -}; +const providers = [ServerUc, { provide: SERVER_CONFIG_TOKEN, useValue: serverConfig() }]; +const controllers = [ServerController, ServerConfigController]; /** * Server Module used for production @@ -147,22 +122,11 @@ const setupSessions = ( // debug: true, // use it for locally debugging of queries }), LoggerModule, - RedisModule, ], - controllers: [ServerController], + providers, + controllers, }) -export class ServerModule implements NestModule { - constructor( - @Inject(REDIS_CLIENT) private readonly redisClient: RedisClientType | undefined, - private readonly logger: LegacyLogger - ) { - logger.setContext(ServerModule.name); - } - - configure(consumer: MiddlewareConsumer) { - setupSessions(consumer, this.redisClient, this.logger); - } -} +export class ServerModule {} /** * Server module used for testing. @@ -178,22 +142,11 @@ export class ServerModule implements NestModule { MongoMemoryDatabaseModule.forRoot({ ...defaultMikroOrmOptions }), RabbitMQWrapperTestModule, LoggerModule, - RedisModule, ], - controllers: [ServerController], + providers, + controllers, }) -export class ServerTestModule implements NestModule { - constructor( - @Inject(REDIS_CLIENT) private readonly redisClient: RedisClientType | undefined, - private readonly logger: LegacyLogger - ) { - logger.setContext(ServerTestModule.name); - } - - configure(consumer: MiddlewareConsumer) { - setupSessions(consumer, undefined, this.logger); - } - +export class ServerTestModule { static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { return { module: ServerTestModule, @@ -202,7 +155,8 @@ export class ServerTestModule implements NestModule { MongoMemoryDatabaseModule.forRoot({ ...defaultMikroOrmOptions, ...options }), RabbitMQWrapperTestModule, ], - controllers: [ServerController], + providers, + controllers, }; } } diff --git a/apps/server/src/modules/server/types/schulcloud-theme.enum.ts b/apps/server/src/modules/server/types/schulcloud-theme.enum.ts new file mode 100644 index 00000000000..aea9a19cf82 --- /dev/null +++ b/apps/server/src/modules/server/types/schulcloud-theme.enum.ts @@ -0,0 +1,6 @@ +export enum SchulcloudTheme { + BRANDENBURG = 'brb', + DEFAULT = 'default', + NIEDERSACHSEN = 'n21', + THUERINGEN = 'thr', +} diff --git a/apps/server/src/modules/server/types/timezone.enum.ts b/apps/server/src/modules/server/types/timezone.enum.ts new file mode 100644 index 00000000000..e83fd6f6cf5 --- /dev/null +++ b/apps/server/src/modules/server/types/timezone.enum.ts @@ -0,0 +1,3 @@ +export enum Timezone { + EUROPE_BERLIN = 'Europe/Berlin', +} diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts index f69411a7149..d6b00ae9b78 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-create-token.api.spec.ts @@ -12,7 +12,7 @@ import { courseFactory, mapUserToCurrentUser, roleFactory, - schoolFactory, + schoolEntityFactory, userFactory, } from '@shared/testing'; import { Request } from 'express'; @@ -74,12 +74,12 @@ describe(`share token creation (api)`, () => { }); beforeEach(() => { - Configuration.set('FEATURE_COURSE_SHARE_NEW', true); + Configuration.set('FEATURE_COURSE_SHARE', true); }); const setup = async () => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.COURSE_CREATE], }); @@ -96,7 +96,7 @@ describe(`share token creation (api)`, () => { describe('with the feature disabled', () => { it('should return status 500', async () => { - Configuration.set('FEATURE_COURSE_SHARE_NEW', false); + Configuration.set('FEATURE_COURSE_SHARE', false); const { course } = await setup(); const response = await api.post({ parentId: course.id, parentType: ShareTokenParentType.Course }); diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts index b679b40b1db..7d840d85def 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-import-token.api.spec.ts @@ -13,7 +13,7 @@ import { courseFactory, mapUserToCurrentUser, roleFactory, - schoolFactory, + schoolEntityFactory, userFactory, } from '@shared/testing'; import { Request } from 'express'; @@ -80,12 +80,12 @@ describe(`share token import (api)`, () => { }); beforeEach(() => { - Configuration.set('FEATURE_COURSE_SHARE_NEW', true); + Configuration.set('FEATURE_COURSE_SHARE', true); }); const setup = async (context?: ShareTokenContext) => { await cleanupCollections(em); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.COURSE_CREATE], }); @@ -113,7 +113,7 @@ describe(`share token import (api)`, () => { describe('with the feature disabled', () => { it('should return status 500', async () => { - Configuration.set('FEATURE_COURSE_SHARE_NEW', false); + Configuration.set('FEATURE_COURSE_SHARE', false); const { token } = await setup(); const response = await api.post({ token }, { newName: 'NewName' }); @@ -159,8 +159,8 @@ describe(`share token import (api)`, () => { describe('with invalid context', () => { const setup2 = async () => { - const school = schoolFactory.build(); - const otherSchool = schoolFactory.build(); + const school = schoolEntityFactory.build(); + const otherSchool = schoolEntityFactory.build(); const roles = roleFactory.buildList(1, { permissions: [Permission.COURSE_CREATE], }); diff --git a/apps/server/src/modules/sharing/controller/api-test/sharing-lookup-token.api.spec.ts b/apps/server/src/modules/sharing/controller/api-test/sharing-lookup-token.api.spec.ts index 5b7f0edc659..57498162304 100644 --- a/apps/server/src/modules/sharing/controller/api-test/sharing-lookup-token.api.spec.ts +++ b/apps/server/src/modules/sharing/controller/api-test/sharing-lookup-token.api.spec.ts @@ -4,7 +4,7 @@ import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; -import { TestApiClient, UserAndAccountTestFactory, courseFactory, schoolFactory } from '@shared/testing'; +import { courseFactory, schoolEntityFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; import { ShareTokenContextType, ShareTokenParentType } from '../../domainobject/share-token.do'; import { ShareTokenService } from '../../service'; import { ShareTokenInfoResponse } from '../dto'; @@ -34,7 +34,7 @@ describe(`share token lookup (api)`, () => { describe('with the feature disabled', () => { const setup = async () => { - Configuration.set('FEATURE_COURSE_SHARE_NEW', false); + Configuration.set('FEATURE_COURSE_SHARE', false); const parentType = ShareTokenParentType.Course; const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({}, [Permission.COURSE_CREATE]); @@ -78,7 +78,7 @@ describe(`share token lookup (api)`, () => { describe('with a valid token', () => { const setup = async () => { - Configuration.set('FEATURE_COURSE_SHARE_NEW', true); + Configuration.set('FEATURE_COURSE_SHARE', true); const parentType = ShareTokenParentType.Course; const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({}, [Permission.COURSE_CREATE]); @@ -122,7 +122,7 @@ describe(`share token lookup (api)`, () => { describe('with invalid token', () => { const setup = async () => { - Configuration.set('FEATURE_COURSE_SHARE_NEW', true); + Configuration.set('FEATURE_COURSE_SHARE', true); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({}, [Permission.COURSE_CREATE]); const course = courseFactory.build({ teachers: [teacherUser] }); @@ -155,11 +155,11 @@ describe(`share token lookup (api)`, () => { describe('with invalid context', () => { const setup = async () => { - Configuration.set('FEATURE_COURSE_SHARE_NEW', true); + Configuration.set('FEATURE_COURSE_SHARE', true); const parentType = ShareTokenParentType.Course; const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({}, [Permission.COURSE_CREATE]); - const otherSchool = schoolFactory.build(); + const otherSchool = schoolEntityFactory.build(); const course = courseFactory.build({ teachers: [teacherUser] }); await em.persistAndFlush([course, teacherAccount, teacherUser, otherSchool]); diff --git a/apps/server/src/modules/sharing/controller/dto/share-token.body.params.ts b/apps/server/src/modules/sharing/controller/dto/share-token.body.params.ts index a6f288bb604..0a39dc6bc22 100644 --- a/apps/server/src/modules/sharing/controller/dto/share-token.body.params.ts +++ b/apps/server/src/modules/sharing/controller/dto/share-token.body.params.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsEnum, IsInt, IsMongoId, IsOptional, IsPositive } from 'class-validator'; import { ShareTokenParentType } from '../../domainobject/share-token.do'; @@ -23,7 +23,7 @@ export class ShareTokenBodyParams { @IsInt() @IsOptional() @IsPositive() - @ApiProperty({ + @ApiPropertyOptional({ description: 'when defined, the sharetoken will expire after the given number of days.', required: false, nullable: true, @@ -33,7 +33,7 @@ export class ShareTokenBodyParams { @IsBoolean() @IsOptional() - @ApiProperty({ + @ApiPropertyOptional({ description: 'when defined, the sharetoken will be usable exclusively by members of the users school.', required: false, nullable: true, diff --git a/apps/server/src/modules/sharing/controller/share-token.controller.ts b/apps/server/src/modules/sharing/controller/share-token.controller.ts index 5d990e1d99f..63c88fdded6 100644 --- a/apps/server/src/modules/sharing/controller/share-token.controller.ts +++ b/apps/server/src/modules/sharing/controller/share-token.controller.ts @@ -13,8 +13,6 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiValidationError, RequestTimeout } from '@shared/common'; -// invalid import can produce dependency cycles -import { serverConfig } from '@modules/server/server.config'; import { ShareTokenInfoResponseMapper, ShareTokenResponseMapper } from '../mapper'; import { ShareTokenUC } from '../uc'; import { @@ -82,7 +80,7 @@ export class ShareTokenController { @ApiResponse({ status: 500, type: InternalServerErrorException }) @ApiResponse({ status: 501, type: NotImplementedException }) @Post(':token/import') - @RequestTimeout(serverConfig().INCOMING_REQUEST_TIMEOUT_COPY_API) + @RequestTimeout('INCOMING_REQUEST_TIMEOUT_COPY_API') async importShareToken( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: ShareTokenUrlParams, diff --git a/apps/server/src/modules/sharing/domainobject/share-token.do.ts b/apps/server/src/modules/sharing/domainobject/share-token.do.ts index 040f81f4ebd..a83fa3a54e9 100644 --- a/apps/server/src/modules/sharing/domainobject/share-token.do.ts +++ b/apps/server/src/modules/sharing/domainobject/share-token.do.ts @@ -5,6 +5,7 @@ export enum ShareTokenParentType { 'Course' = 'courses', 'Task' = 'tasks', 'Lesson' = 'lessons', + 'ColumnBoard' = 'columnBoard', } export enum ShareTokenContextType { diff --git a/apps/server/src/modules/sharing/entity/share-token.entity.spec.ts b/apps/server/src/modules/sharing/entity/share-token.entity.spec.ts index 27832cb21e1..6d822ba8c8e 100644 --- a/apps/server/src/modules/sharing/entity/share-token.entity.spec.ts +++ b/apps/server/src/modules/sharing/entity/share-token.entity.spec.ts @@ -1,5 +1,5 @@ import { setupEntities } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { ShareTokenContextType, ShareTokenParentType } from '../domainobject/share-token.do'; import { ShareToken } from './share-token.entity'; diff --git a/apps/server/src/modules/sharing/index.ts b/apps/server/src/modules/sharing/index.ts index 5b9f93bbc35..d2d945d7db9 100644 --- a/apps/server/src/modules/sharing/index.ts +++ b/apps/server/src/modules/sharing/index.ts @@ -1,2 +1,3 @@ -export * from './sharing.module'; +export { SharingModule } from './sharing.module'; export * from './service/share-token.service'; +export { SharingConfig } from './sharing.config'; diff --git a/apps/server/src/modules/sharing/mapper/parent-type.mapper.spec.ts b/apps/server/src/modules/sharing/mapper/parent-type.mapper.spec.ts index 99a9ea19453..b83f509edcc 100644 --- a/apps/server/src/modules/sharing/mapper/parent-type.mapper.spec.ts +++ b/apps/server/src/modules/sharing/mapper/parent-type.mapper.spec.ts @@ -17,6 +17,10 @@ describe('ShareTokenParentTypeMapper', () => { expect(ShareTokenParentTypeMapper.mapToAllowedAuthorizationEntityType(ShareTokenParentType.Task)).toBe( AuthorizableReferenceType.Task )); + it('should return allowed type equal BoardNode', () => + expect(ShareTokenParentTypeMapper.mapToAllowedAuthorizationEntityType(ShareTokenParentType.ColumnBoard)).toBe( + AuthorizableReferenceType.BoardNode + )); it('should throw Error', () => { const exec = () => { ShareTokenParentTypeMapper.mapToAllowedAuthorizationEntityType('' as ShareTokenParentType); diff --git a/apps/server/src/modules/sharing/mapper/parent-type.mapper.ts b/apps/server/src/modules/sharing/mapper/parent-type.mapper.ts index 7f2f0fdea60..1a78de0becd 100644 --- a/apps/server/src/modules/sharing/mapper/parent-type.mapper.ts +++ b/apps/server/src/modules/sharing/mapper/parent-type.mapper.ts @@ -8,6 +8,7 @@ export class ShareTokenParentTypeMapper { types.set(ShareTokenParentType.Course, AuthorizableReferenceType.Course); types.set(ShareTokenParentType.Lesson, AuthorizableReferenceType.Lesson); types.set(ShareTokenParentType.Task, AuthorizableReferenceType.Task); + types.set(ShareTokenParentType.ColumnBoard, AuthorizableReferenceType.BoardNode); const res = types.get(type); diff --git a/apps/server/src/modules/sharing/repo/share-token.repo.integration.spec.ts b/apps/server/src/modules/sharing/repo/share-token.repo.integration.spec.ts index f5e01b0e9ea..dc0f03f1d49 100644 --- a/apps/server/src/modules/sharing/repo/share-token.repo.integration.spec.ts +++ b/apps/server/src/modules/sharing/repo/share-token.repo.integration.spec.ts @@ -1,8 +1,8 @@ import { createMock } from '@golevelup/ts-jest'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { cleanupCollections, schoolFactory, shareTokenFactory } from '@shared/testing'; +import { cleanupCollections, schoolEntityFactory, shareTokenFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { ShareTokenContextType } from '../domainobject/share-token.do'; import { ShareTokenRepo } from './share-token.repo'; @@ -47,7 +47,7 @@ describe('ShareTokenRepo', () => { }); it('should include context id', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); await em.persistAndFlush([school]); const shareToken = shareTokenFactory.build({ context: { contextType: ShareTokenContextType.School, contextId: school.id }, diff --git a/apps/server/src/modules/sharing/service/share-token.service.spec.ts b/apps/server/src/modules/sharing/service/share-token.service.spec.ts index 08680427058..3244491399b 100644 --- a/apps/server/src/modules/sharing/service/share-token.service.spec.ts +++ b/apps/server/src/modules/sharing/service/share-token.service.spec.ts @@ -1,11 +1,19 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { courseFactory, lessonFactory, setupEntities, shareTokenFactory, taskFactory } from '@shared/testing'; +import { + courseFactory, + columnBoardFactory, + lessonFactory, + setupEntities, + shareTokenFactory, + taskFactory, +} from '@shared/testing'; import { CourseService } from '@modules/learnroom/service'; +import { ColumnBoardService } from '@modules/board/service'; import { LessonService } from '@modules/lesson/service'; import { TaskService } from '@modules/task/service'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { ShareTokenContextType, ShareTokenParentType } from '../domainobject/share-token.do'; import { ShareTokenRepo } from '../repo/share-token.repo'; import { ShareTokenService } from './share-token.service'; @@ -21,6 +29,7 @@ describe('ShareTokenService', () => { let courseService: DeepMocked; let lessonService: DeepMocked; let taskService: DeepMocked; + let columnBoardService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -46,6 +55,10 @@ describe('ShareTokenService', () => { provide: TaskService, useValue: createMock(), }, + { + provide: ColumnBoardService, + useValue: createMock(), + }, ], }).compile(); @@ -55,6 +68,7 @@ describe('ShareTokenService', () => { courseService = await module.get(CourseService); lessonService = await module.get(LessonService); taskService = await module.get(TaskService); + columnBoardService = await module.get(ColumnBoardService); await setupEntities(); }); @@ -180,5 +194,35 @@ describe('ShareTokenService', () => { expect(result).toEqual({ shareToken, parentName: task.name }); }); + + describe('when parent is column board', () => { + const setup = () => { + const columnBoard = columnBoardFactory.build(); + columnBoardService.findById.mockResolvedValue(columnBoard); + + const payload = { parentId: columnBoard.id, parentType: ShareTokenParentType.ColumnBoard }; + const shareToken = shareTokenFactory.build({ payload }); + repo.findOneByToken.mockResolvedValue(shareToken); + + return { columnBoard, shareToken }; + }; + it('should return shareToken and parent name', async () => { + const { columnBoard, shareToken } = setup(); + + const result = await service.lookupTokenWithParentName(shareToken.token); + expect(result).toEqual({ shareToken, parentName: columnBoard.title }); + }); + }); + + it('should throw if parent type is not supported', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const shareToken = shareTokenFactory.build({ payload: { parentType: 'invalid' } }); + repo.findOneByToken.mockResolvedValue(shareToken); + + const lookupToken = async () => service.lookupTokenWithParentName(shareToken.token); + + await expect(lookupToken).rejects.toThrowError('Invalid parent type'); + }); }); }); diff --git a/apps/server/src/modules/sharing/service/share-token.service.ts b/apps/server/src/modules/sharing/service/share-token.service.ts index befdc580b2b..051bc7efb69 100644 --- a/apps/server/src/modules/sharing/service/share-token.service.ts +++ b/apps/server/src/modules/sharing/service/share-token.service.ts @@ -1,5 +1,6 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { CourseService } from '@modules/learnroom/service'; +import { ColumnBoardService } from '@modules/board/service'; import { LessonService } from '@modules/lesson/service'; import { TaskService } from '@modules/task/service'; import { @@ -19,7 +20,8 @@ export class ShareTokenService { private readonly shareTokenRepo: ShareTokenRepo, private readonly courseService: CourseService, private readonly lessonService: LessonService, - private readonly taskService: TaskService + private readonly taskService: TaskService, + private readonly columnBoardService: ColumnBoardService ) {} async createToken( @@ -61,7 +63,11 @@ export class ShareTokenService { case ShareTokenParentType.Task: parentName = (await this.taskService.findById(shareToken.payload.parentId)).name; break; + case ShareTokenParentType.ColumnBoard: + parentName = (await this.columnBoardService.findById(shareToken.payload.parentId)).title; + break; default: + throw new UnprocessableEntityException('Invalid parent type'); } return { shareToken, parentName }; diff --git a/apps/server/src/modules/sharing/sharing.config.ts b/apps/server/src/modules/sharing/sharing.config.ts new file mode 100644 index 00000000000..f95520e1e15 --- /dev/null +++ b/apps/server/src/modules/sharing/sharing.config.ts @@ -0,0 +1,5 @@ +export interface SharingConfig { + FEATURE_COURSE_SHARE: boolean; + FEATURE_LESSON_SHARE: boolean; + FEATURE_TASK_SHARE: boolean; +} diff --git a/apps/server/src/modules/sharing/sharing.module.ts b/apps/server/src/modules/sharing/sharing.module.ts index 183141e19a7..e8e2e8a1834 100644 --- a/apps/server/src/modules/sharing/sharing.module.ts +++ b/apps/server/src/modules/sharing/sharing.module.ts @@ -1,17 +1,26 @@ -import { Module } from '@nestjs/common'; -import { LoggerModule } from '@src/core/logger'; import { AuthorizationModule } from '@modules/authorization'; import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; +import { BoardModule } from '@modules/board'; +import { LearnroomModule } from '@modules/learnroom'; +import { LessonModule } from '@modules/lesson'; +import { TaskModule } from '@modules/task'; +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; import { ShareTokenController } from './controller/share-token.controller'; -import { ShareTokenUC } from './uc'; -import { ShareTokenService, TokenGenerator } from './service'; import { ShareTokenRepo } from './repo/share-token.repo'; -import { LessonModule } from '../lesson'; -import { LearnroomModule } from '../learnroom'; -import { TaskModule } from '../task'; +import { ShareTokenService, TokenGenerator } from './service'; +import { ShareTokenUC } from './uc'; @Module({ - imports: [AuthorizationModule, AuthorizationReferenceModule, LoggerModule, LearnroomModule, LessonModule, TaskModule], + imports: [ + AuthorizationModule, + AuthorizationReferenceModule, + LoggerModule, + LearnroomModule, + LessonModule, + TaskModule, + BoardModule, + ], controllers: [], providers: [ShareTokenService, TokenGenerator, ShareTokenRepo], exports: [ShareTokenService], @@ -27,6 +36,7 @@ export class SharingModule {} LessonModule, TaskModule, LoggerModule, + BoardModule, ], controllers: [ShareTokenController], providers: [ShareTokenUC], diff --git a/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts b/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts index 63c171a04e4..278db2697c2 100644 --- a/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts +++ b/apps/server/src/modules/sharing/uc/share-token.uc.spec.ts @@ -1,12 +1,10 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { BadRequestException, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { Permission } from '@shared/domain/interface'; import { Action, AuthorizableReferenceType, + AuthorizationContextBuilder, AuthorizationReferenceService, AuthorizationService, } from '@modules/authorization'; @@ -14,10 +12,16 @@ import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helpe import { CourseCopyService, CourseService } from '@modules/learnroom'; import { LessonCopyService } from '@modules/lesson'; import { TaskCopyService } from '@modules/task'; +import { ColumnBoardCopyService } from '@modules/board'; +import { BadRequestException, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; +import { Permission } from '@shared/domain/interface'; import { courseFactory, + columnBoardFactory, lessonFactory, - schoolFactory, + schoolEntityFactory, setupEntities, shareTokenFactory, taskFactory, @@ -35,7 +39,8 @@ describe('ShareTokenUC', () => { let courseCopyService: DeepMocked; let lessonCopyService: DeepMocked; let taskCopyService: DeepMocked; - let authorization: DeepMocked; + let columnBoardCopyService: DeepMocked; + let authorizationService: DeepMocked; let authorizationReferenceService: DeepMocked; let courseService: DeepMocked; @@ -63,14 +68,18 @@ describe('ShareTokenUC', () => { provide: LessonCopyService, useValue: createMock(), }, - { - provide: CourseService, - useValue: createMock(), - }, { provide: TaskCopyService, useValue: createMock(), }, + { + provide: ColumnBoardCopyService, + useValue: createMock(), + }, + { + provide: CourseService, + useValue: createMock(), + }, { provide: LegacyLogger, useValue: createMock(), @@ -83,7 +92,8 @@ describe('ShareTokenUC', () => { courseCopyService = module.get(CourseCopyService); lessonCopyService = module.get(LessonCopyService); taskCopyService = module.get(TaskCopyService); - authorization = module.get(AuthorizationService); + columnBoardCopyService = module.get(ColumnBoardCopyService); + authorizationService = module.get(AuthorizationService); authorizationReferenceService = module.get(AuthorizationReferenceService); courseService = module.get(CourseService); @@ -98,25 +108,23 @@ describe('ShareTokenUC', () => { jest.resetAllMocks(); jest.clearAllMocks(); // configuration sets must be part of the setup functions and part of the describe when ...and feature x is activated - Configuration.set('FEATURE_COURSE_SHARE_NEW', true); + Configuration.set('FEATURE_COURSE_SHARE', true); Configuration.set('FEATURE_LESSON_SHARE', true); Configuration.set('FEATURE_TASK_SHARE', true); + Configuration.set('FEATURE_COLUMN_BOARD_SHARE', true); }); describe('create a sharetoken', () => { - const setup = () => { - const user = userFactory.buildWithId(); - const course = courseFactory.buildWithId(); - const lesson = lessonFactory.buildWithId(); - const task = taskFactory.buildWithId(); - - return { user, course, lesson, task }; - }; - describe('when parent is a course', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const course = courseFactory.buildWithId(); + + return { user, course }; + }; it('should throw if the feature is not enabled', async () => { const { user, course } = setup(); - Configuration.set('FEATURE_COURSE_SHARE_NEW', false); + Configuration.set('FEATURE_COURSE_SHARE', false); await expect( uc.createShareToken(user.id, { @@ -175,6 +183,13 @@ describe('ShareTokenUC', () => { }); describe('when parent is a lesson', () => { + const setup = () => { + const user = userFactory.buildWithId(); + + const lesson = lessonFactory.buildWithId(); + + return { user, lesson }; + }; it('should throw if the feature is not enabled', async () => { const { user, lesson } = setup(); Configuration.set('FEATURE_LESSON_SHARE', false); @@ -236,6 +251,12 @@ describe('ShareTokenUC', () => { }); describe('when parent is a task', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const task = taskFactory.buildWithId(); + + return { user, task }; + }; it('should throw if the feature is not enabled', async () => { const { user, task } = setup(); Configuration.set('FEATURE_TASK_SHARE', false); @@ -296,12 +317,49 @@ describe('ShareTokenUC', () => { }); }); + describe('when parent is a columnboard', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const columnBoard = columnBoardFactory.build(); + + return { user, columnBoard }; + }; + it('should throw if the feature is not enabled', async () => { + const { user } = setup(); + Configuration.set('FEATURE_COLUMN_BOARD_SHARE', false); + + await expect( + uc.createShareToken(user.id, { + parentId: '123', + parentType: ShareTokenParentType.ColumnBoard, + }) + ).rejects.toThrowError(); + }); + it('should check permission for parent', async () => { + const { user, columnBoard } = setup(); + + await uc.createShareToken(user.id, { + parentId: columnBoard.id, + parentType: ShareTokenParentType.ColumnBoard, + }); + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( + user.id, + AuthorizableReferenceType.BoardNode, + columnBoard.id, + { + action: Action.write, + requiredPermissions: [Permission.COURSE_EDIT], + } + ); + }); + }); + describe('when restricted to same school', () => { it('should check parent write permission', async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); const course = courseFactory.buildWithId(); - authorization.getUserWithPermissions.mockResolvedValue(user); + authorizationService.getUserWithPermissions.mockResolvedValue(user); await uc.createShareToken( user.id, @@ -326,10 +384,10 @@ describe('ShareTokenUC', () => { }); it('should check context read permission', async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); const course = courseFactory.buildWithId(); - authorization.getUserWithPermissions.mockResolvedValue(user); + authorizationService.getUserWithPermissions.mockResolvedValue(user); await uc.createShareToken( user.id, @@ -354,10 +412,10 @@ describe('ShareTokenUC', () => { }); it('should call the service', async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); const course = courseFactory.buildWithId(); - authorization.getUserWithPermissions.mockResolvedValue(user); + authorizationService.getUserWithPermissions.mockResolvedValue(user); await uc.createShareToken( user.id, @@ -410,7 +468,7 @@ describe('ShareTokenUC', () => { const course = courseFactory.buildWithId(); const shareToken = shareTokenFactory.build(); - service.createToken.mockResolvedValue(shareToken); + service.createToken.mockResolvedValueOnce(shareToken); const result = await uc.createShareToken(user.id, { parentId: course.id, @@ -424,10 +482,10 @@ describe('ShareTokenUC', () => { describe('lookup a sharetoken', () => { describe('when parent is a course', () => { const setup = () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); - authorization.getUserWithPermissions.mockResolvedValue(user); + authorizationService.getUserWithPermissions.mockResolvedValue(user); const course = courseFactory.buildWithId(); const payload: ShareTokenPayload = { @@ -435,14 +493,14 @@ describe('ShareTokenUC', () => { parentId: course.id, }; const shareToken = shareTokenFactory.build({ payload }); - service.lookupTokenWithParentName.mockResolvedValue({ shareToken, parentName: course.name }); + service.lookupTokenWithParentName.mockResolvedValueOnce({ shareToken, parentName: course.name }); return { user, school, shareToken, course }; }; it('should throw if the feature is not enabled', async () => { const { user, shareToken } = setup(); - Configuration.set('FEATURE_COURSE_SHARE_NEW', false); + Configuration.set('FEATURE_COURSE_SHARE', false); await expect(uc.lookupShareToken(user.id, shareToken.token)).rejects.toThrowError(); }); @@ -455,6 +513,13 @@ describe('ShareTokenUC', () => { expect(service.lookupTokenWithParentName).toBeCalledWith(shareToken.token); }); + it('should check for permission', async () => { + const { user, shareToken } = setup(); + await uc.lookupShareToken(user.id, shareToken.token); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.COURSE_CREATE]); + }); + it('should return the result', async () => { const { user, shareToken, course } = setup(); @@ -470,10 +535,10 @@ describe('ShareTokenUC', () => { describe('when parent is a lesson', () => { const setup = () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); - authorization.getUserWithPermissions.mockResolvedValue(user); + authorizationService.getUserWithPermissions.mockResolvedValue(user); const course = courseFactory.buildWithId(); const lesson = lessonFactory.buildWithId({ course }); @@ -482,7 +547,7 @@ describe('ShareTokenUC', () => { parentId: lesson.id, }; const shareToken = shareTokenFactory.build({ payload }); - service.lookupTokenWithParentName.mockResolvedValue({ shareToken, parentName: lesson.name }); + service.lookupTokenWithParentName.mockResolvedValueOnce({ shareToken, parentName: lesson.name }); return { user, school, shareToken, lesson, course }; }; @@ -502,6 +567,13 @@ describe('ShareTokenUC', () => { expect(service.lookupTokenWithParentName).toBeCalledWith(shareToken.token); }); + it('should check for permission', async () => { + const { user, shareToken } = setup(); + await uc.lookupShareToken(user.id, shareToken.token); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.TOPIC_CREATE]); + }); + it('should return the result', async () => { const { user, shareToken, lesson } = setup(); @@ -517,10 +589,10 @@ describe('ShareTokenUC', () => { describe('when parent is a task', () => { const setup = () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); - authorization.getUserWithPermissions.mockResolvedValueOnce(user); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); const course = courseFactory.buildWithId(); const task = taskFactory.buildWithId({ course }); @@ -549,6 +621,13 @@ describe('ShareTokenUC', () => { expect(service.lookupTokenWithParentName).toBeCalledWith(shareToken.token); }); + it('should check for permission', async () => { + const { user, shareToken } = setup(); + await uc.lookupShareToken(user.id, shareToken.token); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.HOMEWORK_CREATE]); + }); + it('should return the result', async () => { const { user, shareToken, task } = setup(); @@ -562,15 +641,67 @@ describe('ShareTokenUC', () => { }); }); + describe('when parent is a columnboard', () => { + const setup = () => { + const user = userFactory.buildWithId(); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const course = courseFactory.buildWithId(); + const columnBoard = columnBoardFactory.build(); + const payload: ShareTokenPayload = { + parentType: ShareTokenParentType.ColumnBoard, + parentId: columnBoard.id, + }; + const shareToken = shareTokenFactory.build({ payload }); + service.lookupTokenWithParentName.mockResolvedValueOnce({ shareToken, parentName: columnBoard.title }); + + return { user, shareToken, columnBoard, course }; + }; + + it('should throw if the feature is not enabled', async () => { + const { user, shareToken } = setup(); + Configuration.set('FEATURE_COLUMN_BOARD_SHARE', false); + + await expect(uc.lookupShareToken(user.id, shareToken.token)).rejects.toThrowError(); + }); + + it('should load the share token', async () => { + const { user, shareToken } = setup(); + + await uc.lookupShareToken(user.id, shareToken.token); + + expect(service.lookupTokenWithParentName).toBeCalledWith(shareToken.token); + }); + + it('should check for permission', async () => { + const { user, shareToken } = setup(); + await uc.lookupShareToken(user.id, shareToken.token); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [Permission.COURSE_EDIT]); + }); + + it('should return the result', async () => { + const { user, shareToken, columnBoard } = setup(); + + const result = await uc.lookupShareToken(user.id, shareToken.token); + + expect(result).toEqual({ + token: shareToken.token, + parentType: ShareTokenParentType.ColumnBoard, + parentName: columnBoard.title, + }); + }); + }); + describe('when restricted to same school', () => { const setup = () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); const shareToken = shareTokenFactory.build({ context: { contextType: ShareTokenContextType.School, contextId: school.id }, }); const parentName = 'name'; - service.lookupTokenWithParentName.mockResolvedValue({ shareToken, parentName }); + service.lookupTokenWithParentName.mockResolvedValueOnce({ shareToken, parentName }); return { user, school, shareToken }; }; @@ -593,11 +724,11 @@ describe('ShareTokenUC', () => { describe('when not restricted to same school', () => { const setup = () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); const shareToken = shareTokenFactory.build(); const parentName = 'name'; - service.lookupTokenWithParentName.mockResolvedValue({ shareToken, parentName }); + service.lookupTokenWithParentName.mockResolvedValueOnce({ shareToken, parentName }); return { user, school, shareToken }; }; @@ -609,18 +740,43 @@ describe('ShareTokenUC', () => { expect(authorizationReferenceService.checkPermissionByReferences).not.toHaveBeenCalled(); }); }); + + describe('when parent type is not allowed', () => { + const setup = () => { + const user = userFactory.buildWithId(); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const course = courseFactory.buildWithId(); + const payload: ShareTokenPayload = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + parentType: 'invalid', + parentId: course.id, + }; + const shareToken = shareTokenFactory.build({ payload }); + service.lookupTokenWithParentName.mockResolvedValueOnce({ shareToken, parentName: 'foo' }); + + return { user, shareToken }; + }; + + it('should throw', async () => { + const { user, shareToken } = setup(); + + await expect(uc.lookupShareToken(user.id, shareToken.token)).rejects.toThrow(NotImplementedException); + }); + }); }); describe('import share token', () => { describe('when parent is a course', () => { const setup = () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); - authorization.getUserWithPermissions.mockResolvedValue(user); + authorizationService.getUserWithPermissions.mockResolvedValue(user); const shareToken = shareTokenFactory.build(); - service.lookupToken.mockResolvedValue(shareToken); + service.lookupToken.mockResolvedValueOnce(shareToken); const course = courseFactory.buildWithId(); const status: CopyStatus = { @@ -628,14 +784,14 @@ describe('ShareTokenUC', () => { status: CopyStatusEnum.SUCCESS, copyEntity: course, }; - courseCopyService.copyCourse.mockResolvedValue(status); + courseCopyService.copyCourse.mockResolvedValueOnce(status); return { user, school, shareToken, status }; }; it('should throw if the feature is not enabled', async () => { const { user, shareToken } = setup(); - Configuration.set('FEATURE_COURSE_SHARE_NEW', false); + Configuration.set('FEATURE_COURSE_SHARE', false); await expect(uc.importShareToken(user.id, shareToken.token, 'NewName')).rejects.toThrowError( InternalServerErrorException @@ -656,7 +812,7 @@ describe('ShareTokenUC', () => { await uc.importShareToken(user.id, shareToken.token, 'NewName'); - expect(authorization.checkAllPermissions).toBeCalledWith(user, [Permission.COURSE_CREATE]); + expect(authorizationService.checkAllPermissions).toBeCalledWith(user, [Permission.COURSE_CREATE]); }); it('should use the service to copy the course', async () => { @@ -680,50 +836,16 @@ describe('ShareTokenUC', () => { expect(result).toEqual(status); }); - - describe('when restricted to same school', () => { - it('should check context read permission', async () => { - const { user, school } = setup(); - const shareToken = shareTokenFactory.build({ - context: { contextType: ShareTokenContextType.School, contextId: school.id }, - }); - service.lookupToken.mockResolvedValue(shareToken); - - await uc.importShareToken(user.id, shareToken.token, 'NewName'); - - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( - user.id, - AuthorizableReferenceType.School, - school.id, - { - action: Action.read, - requiredPermissions: [], - } - ); - }); - }); - - describe('when not restricted to same school', () => { - it('should not check context read permission', async () => { - const { user } = setup(); - const shareToken = shareTokenFactory.build(); - service.lookupToken.mockResolvedValue(shareToken); - - await uc.importShareToken(user.id, shareToken.token, 'NewName'); - - expect(authorizationReferenceService.checkPermissionByReferences).not.toHaveBeenCalled(); - }); - }); }); describe('when parent is a lesson', () => { const setup = () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); - authorization.getUserWithPermissions.mockResolvedValue(user); + authorizationService.getUserWithPermissions.mockResolvedValue(user); const course = courseFactory.buildWithId(); - courseService.findById.mockResolvedValue(course); + courseService.findById.mockResolvedValueOnce(course); const lesson = lessonFactory.buildWithId({ course }); const status: CopyStatus = { @@ -731,11 +853,11 @@ describe('ShareTokenUC', () => { status: CopyStatusEnum.SUCCESS, copyEntity: lesson, }; - lessonCopyService.copyLesson.mockResolvedValue(status); + lessonCopyService.copyLesson.mockResolvedValueOnce(status); const payload: ShareTokenPayload = { parentType: ShareTokenParentType.Lesson, parentId: lesson._id.toString() }; const shareToken = shareTokenFactory.build({ payload }); - service.lookupToken.mockResolvedValue(shareToken); + service.lookupToken.mockResolvedValueOnce(shareToken); return { user, school, shareToken, status, course, lesson }; }; @@ -766,12 +888,17 @@ describe('ShareTokenUC', () => { expect(result.status).toBe(CopyStatusEnum.SUCCESS); }); - it('should check the permission to create the topic', async () => { + it('should check the permission to create topic in destination course ', async () => { const { user, shareToken, course } = setup(); - await uc.importShareToken(user.id, shareToken.token, 'NewName', course._id.toHexString()); + await uc.importShareToken(user.id, shareToken.token, 'NewName', course.id); - expect(authorization.checkAllPermissions).toBeCalledWith(user, [Permission.TOPIC_CREATE]); + expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( + user.id, + AuthorizableReferenceType.Course, + course.id, + AuthorizationContextBuilder.write([Permission.TOPIC_CREATE]) + ); }); it('should use the service to copy the lesson', async () => { @@ -796,48 +923,14 @@ describe('ShareTokenUC', () => { expect(result).toEqual(status); }); - - describe('when restricted to same school', () => { - it('should check context read permission', async () => { - const { user, school } = setup(); - const shareToken = shareTokenFactory.build({ - context: { contextType: ShareTokenContextType.School, contextId: school.id }, - }); - service.lookupToken.mockResolvedValue(shareToken); - - await uc.importShareToken(user.id, shareToken.token, 'NewName'); - - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( - user.id, - AuthorizableReferenceType.School, - school.id, - { - action: Action.read, - requiredPermissions: [], - } - ); - }); - }); - - describe('when not restricted to same school', () => { - it('should not check context read permission', async () => { - const { user } = setup(); - const shareToken = shareTokenFactory.build(); - service.lookupToken.mockResolvedValue(shareToken); - - await uc.importShareToken(user.id, shareToken.token, 'NewName'); - - expect(authorizationReferenceService.checkPermissionByReferences).not.toHaveBeenCalled(); - }); - }); }); describe('when parent is a task', () => { const setupTask = () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); - authorization.getUserWithPermissions.mockResolvedValue(user); + authorizationService.getUserWithPermissions.mockResolvedValue(user); const course = courseFactory.buildWithId(); courseService.findById.mockResolvedValueOnce(course); @@ -851,7 +944,7 @@ describe('ShareTokenUC', () => { const payload: ShareTokenPayload = { parentType: ShareTokenParentType.Task, parentId: task._id.toString() }; const shareToken = shareTokenFactory.build({ payload }); - service.lookupToken.mockResolvedValue(shareToken); + service.lookupToken.mockResolvedValueOnce(shareToken); return { user, school, shareToken, status, course, task }; }; @@ -874,20 +967,25 @@ describe('ShareTokenUC', () => { }); it('should load the share token', async () => { - const { user, shareToken, task } = setupTask(); + const { user, shareToken, course } = setupTask(); - const result = await uc.importShareToken(user.id, shareToken.token, 'NewName', task.id); + const result = await uc.importShareToken(user.id, shareToken.token, 'NewName', course.id); expect(service.lookupToken).toBeCalledWith(shareToken.token); expect(result.status).toBe(CopyStatusEnum.SUCCESS); }); - it('should check the permission to create the task', async () => { - const { user, shareToken, task } = setupTask(); + it('should check the permission to create the task in the destination course', async () => { + const { user, shareToken, course } = setupTask(); - await uc.importShareToken(user.id, shareToken.token, 'NewName', task.id); + await uc.importShareToken(user.id, shareToken.token, 'NewName', course.id); - expect(authorization.checkAllPermissions).toBeCalledWith(user, [Permission.HOMEWORK_CREATE]); + expect(authorizationReferenceService.checkPermissionByReferences).toBeCalledWith( + user.id, + AuthorizableReferenceType.Course, + course.id, + AuthorizationContextBuilder.write([Permission.HOMEWORK_CREATE]) + ); }); it('should use the service to copy the task', async () => { @@ -912,64 +1010,178 @@ describe('ShareTokenUC', () => { expect(result).toEqual(status); }); + }); + + describe('when parent is a columnboard', () => { + const setup = () => { + const school = schoolEntityFactory.buildWithId(); + const user = userFactory.buildWithId({ school }); + const course = courseFactory.buildWithId(); + courseService.findById.mockResolvedValueOnce(course); - describe('when restricted to same school', () => { - it('should check context read permission', async () => { - const { user, school } = setupTask(); - const shareToken = shareTokenFactory.build({ - context: { contextType: ShareTokenContextType.School, contextId: school.id }, - }); - service.lookupToken.mockResolvedValue(shareToken); - - await uc.importShareToken(user.id, shareToken.token, 'NewName'); - - expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( - user.id, - AuthorizableReferenceType.School, - school.id, - { - action: Action.read, - requiredPermissions: [], - } - ); + const columnBoard = columnBoardFactory.build(); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const payload: ShareTokenPayload = { parentType: ShareTokenParentType.ColumnBoard, parentId: columnBoard.id }; + const shareToken = shareTokenFactory.build({ payload }); + service.lookupToken.mockResolvedValueOnce(shareToken); + + return { user, shareToken, school, course, columnBoard }; + }; + it('should get token from service', async () => { + const { user, shareToken, course } = setup(); + await uc.importShareToken(user.id, shareToken.token, 'NewName', course.id); + expect(service.lookupToken).toHaveBeenCalledWith(shareToken.token); + }); + it('should throw if the FEATURE_COLUMBBOARD_SHARE is not enabled', async () => { + const { user, shareToken } = setup(); + Configuration.set('FEATURE_COLUMN_BOARD_SHARE', false); + await expect(uc.importShareToken(user.id, shareToken.token, 'NewName')).rejects.toThrowError( + InternalServerErrorException + ); + }); + it('should check the permission to create the columnboard', async () => { + const { user, shareToken, course } = setup(); + await uc.importShareToken(user.id, shareToken.token, 'NewName', course.id); + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenCalledWith( + user.id, + AuthorizableReferenceType.Course, + course.id, + AuthorizationContextBuilder.write([Permission.COURSE_EDIT]) + ); + }); + it('should throw if destination course id is not given', async () => { + const { user, shareToken } = setup(); + courseService.findById.mockRestore(); + + await expect(uc.importShareToken(user.id, shareToken.token, 'NewName')).rejects.toThrowError( + BadRequestException + ); + }); + it('should call the columnboard copy service', async () => { + const { user, shareToken, course, columnBoard } = setup(); + const newName = 'NewName'; + await uc.importShareToken(user.id, shareToken.token, newName, course.id); + expect(columnBoardCopyService.copyColumnBoard).toHaveBeenCalledWith({ + originalColumnBoardId: columnBoard.id, + destinationExternalReference: { type: BoardExternalReferenceType.Course, id: course.id }, + userId: user.id, + copyTitle: newName, }); }); + it('should return the result', async () => { + const { user, shareToken, columnBoard } = setup(); + const status: CopyStatus = { + type: CopyElementType.COLUMNBOARD, + status: CopyStatusEnum.SUCCESS, + copyEntity: columnBoard, + }; + columnBoardCopyService.copyColumnBoard.mockResolvedValueOnce(status); + const newName = 'NewName'; - describe('when not restricted to same school', () => { - it('should not check context read permission', async () => { - const { user } = setupTask(); - const shareToken = shareTokenFactory.build(); - service.lookupToken.mockResolvedValue(shareToken); + const result = await uc.importShareToken(user.id, shareToken.token, newName, columnBoard.id); - await uc.importShareToken(user.id, shareToken.token, 'NewName'); + expect(result).toEqual(status); + }); + }); - expect(authorizationReferenceService.checkPermissionByReferences).not.toHaveBeenCalled(); - }); + describe('when parent type is not allowed', () => { + const setup = () => { + const user = userFactory.buildWithId(); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + + const course = courseFactory.buildWithId(); + const payload: ShareTokenPayload = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + parentType: 'invalid', + parentId: course.id, + }; + const shareToken = shareTokenFactory.build({ payload }); + service.lookupToken.mockResolvedValueOnce(shareToken); + + return { user, shareToken }; + }; + + it('should throw', async () => { + const { user, shareToken } = setup(); + + await expect(uc.importShareToken(user.id, shareToken.token, 'newName')).rejects.toThrow( + NotImplementedException + ); }); }); - it('should throw if the checkFeatureEnabled is not implemented', async () => { - const payload: any = { parentType: 'none', parentId: 'id' }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const shareToken = shareTokenFactory.build({ payload }); - service.lookupToken.mockResolvedValue(shareToken); + describe('when restricted to same school', () => { + const setup = () => { + const school = schoolEntityFactory.buildWithId(); + + const user = userFactory.buildWithId({ school }); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + + const course = courseFactory.buildWithId(); + const status: CopyStatus = { + type: CopyElementType.COURSE, + status: CopyStatusEnum.SUCCESS, + copyEntity: course, + }; + courseCopyService.copyCourse.mockResolvedValueOnce(status); + + const shareToken = shareTokenFactory.build({ + payload: { parentType: ShareTokenParentType.Course, parentId: course.id }, + context: { contextType: ShareTokenContextType.School, contextId: school.id }, + }); + service.lookupToken.mockResolvedValueOnce(shareToken); + + return { user, school, course, shareToken, status }; + }; + it('should check context read permission', async () => { + const { user, shareToken, school, course } = setup(); + + await uc.importShareToken(user.id, shareToken.token, 'NewName', course.id); - await expect(uc.importShareToken('userId', shareToken.token, 'NewName')).rejects.toThrowError( - NotImplementedException - ); + expect(authorizationReferenceService.checkPermissionByReferences).toHaveBeenNthCalledWith( + 1, + user.id, + AuthorizableReferenceType.School, + school.id, + { + action: Action.read, + requiredPermissions: [], + } + ); + }); }); - it('should throw if the importShareToken is not implemented', async () => { - const payload: any = { parentType: 'none', parentId: 'id' }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const shareToken = shareTokenFactory.build({ payload }); - service.lookupToken.mockResolvedValue(shareToken); - jest.spyOn(ShareTokenUC.prototype as any, 'checkFeatureEnabled').mockReturnValue(undefined); - jest.spyOn(ShareTokenUC.prototype as any, 'checkCreatePermission').mockReturnValue(undefined); - - await expect(uc.importShareToken('userId', shareToken.token, 'NewName')).rejects.toThrowError( - NotImplementedException - ); + describe('when not restricted to same school', () => { + const setup = () => { + const school = schoolEntityFactory.buildWithId(); + + const user = userFactory.buildWithId({ school }); + authorizationService.getUserWithPermissions.mockResolvedValue(user); + + const course = courseFactory.buildWithId(); + const status: CopyStatus = { + type: CopyElementType.COURSE, + status: CopyStatusEnum.SUCCESS, + copyEntity: course, + }; + courseCopyService.copyCourse.mockResolvedValueOnce(status); + + const shareToken = shareTokenFactory.build({ + payload: { parentType: ShareTokenParentType.Course, parentId: course.id }, + }); + service.lookupToken.mockResolvedValueOnce(shareToken); + + return { user, school, course, shareToken, status }; + }; + it('should not check context read permission', async () => { + const { user, shareToken } = setup(); + + await uc.importShareToken(user.id, shareToken.token, 'NewName'); + + expect(authorizationReferenceService.checkPermissionByReferences).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/apps/server/src/modules/sharing/uc/share-token.uc.ts b/apps/server/src/modules/sharing/uc/share-token.uc.ts index 3cef45063aa..2eafce43ad1 100644 --- a/apps/server/src/modules/sharing/uc/share-token.uc.ts +++ b/apps/server/src/modules/sharing/uc/share-token.uc.ts @@ -1,15 +1,16 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; -import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { AuthorizableReferenceType, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { AuthorizationReferenceService } from '@modules/authorization/domain'; import { CopyStatus } from '@modules/copy-helper'; -import { CourseCopyService } from '@modules/learnroom'; -import { CourseService } from '@modules/learnroom/service'; -import { LessonCopyService } from '@modules/lesson/service'; -import { TaskCopyService } from '@modules/task/service'; +import { CourseCopyService, CourseService } from '@modules/learnroom'; +import { LessonCopyService } from '@modules/lesson'; +import { TaskCopyService } from '@modules/task'; import { BadRequestException, Injectable, InternalServerErrorException, NotImplementedException } from '@nestjs/common'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { LegacyLogger } from '@src/core/logger'; +import { ColumnBoardCopyService } from '@modules/board'; +import { BoardExternalReferenceType } from '@shared/domain/domainobject'; import { ShareTokenContext, ShareTokenContextType, @@ -31,7 +32,7 @@ export class ShareTokenUC { private readonly lessonCopyService: LessonCopyService, private readonly courseService: CourseService, private readonly taskCopyService: TaskCopyService, - + private readonly columnBoardCopyService: ColumnBoardCopyService, private readonly logger: LegacyLogger ) { this.logger.setContext(ShareTokenUC.name); @@ -46,7 +47,7 @@ export class ShareTokenUC { this.logger.debug({ action: 'createShareToken', userId, payload, options }); - await this.checkParentWritePermission(userId, payload); + await this.checkCreatePermission(userId, payload); const serviceOptions: { context?: ShareTokenContext; expiresAt?: Date } = {}; if (options?.schoolExclusive) { @@ -72,7 +73,7 @@ export class ShareTokenUC { this.checkFeatureEnabled(shareToken.payload.parentType); - await this.checkCreatePermission(userId, shareToken.payload.parentType); + await this.checkLookupPermission(userId, shareToken.payload.parentType); if (shareToken.context) { await this.checkContextReadPermission(userId, shareToken.context); @@ -103,9 +104,8 @@ export class ShareTokenUC { await this.checkContextReadPermission(userId, shareToken.context); } - await this.checkCreatePermission(userId, shareToken.payload.parentType); - let result: CopyStatus; + // eslint-disable-next-line default-case switch (shareToken.payload.parentType) { case ShareTokenParentType.Course: result = await this.copyCourse(userId, shareToken.payload.parentId, newName); @@ -122,30 +122,47 @@ export class ShareTokenUC { } result = await this.copyTask(userId, shareToken.payload.parentId, destinationCourseId, newName); break; - default: - throw new NotImplementedException('Copy not implemented'); + case ShareTokenParentType.ColumnBoard: + if (destinationCourseId === undefined) { + throw new BadRequestException('Destination course id is required to copy task'); + } + result = await this.copyColumnBoard(userId, shareToken.payload.parentId, destinationCourseId, newName); + break; } return result; } private async copyCourse(userId: EntityId, courseId: string, newName: string): Promise { - return this.courseCopyService.copyCourse({ + const user = await this.authorizationService.getUserWithPermissions(userId); + const requiredPermissions = [Permission.COURSE_CREATE]; + this.authorizationService.checkAllPermissions(user, requiredPermissions); + const copyStatus = await this.courseCopyService.copyCourse({ userId, courseId, newName, }); + + return copyStatus; } private async copyLesson(userId: string, lessonId: string, courseId: string, copyName?: string): Promise { + await this.authorizationReferenceService.checkPermissionByReferences( + userId, + AuthorizableReferenceType.Course, + courseId, + AuthorizationContextBuilder.write([Permission.TOPIC_CREATE]) + ); const user = await this.authorizationService.getUserWithPermissions(userId); const destinationCourse = await this.courseService.findById(courseId); - return this.lessonCopyService.copyLesson({ + const copyStatus = await this.lessonCopyService.copyLesson({ user, originalLessonId: lessonId, destinationCourse, copyName, }); + + return copyStatus; } private async copyTask( @@ -154,17 +171,46 @@ export class ShareTokenUC { courseId: string, copyName?: string ): Promise { + await this.authorizationReferenceService.checkPermissionByReferences( + userId, + AuthorizableReferenceType.Course, + courseId, + AuthorizationContextBuilder.write([Permission.HOMEWORK_CREATE]) + ); const user = await this.authorizationService.getUserWithPermissions(userId); const destinationCourse = await this.courseService.findById(courseId); - return this.taskCopyService.copyTask({ + const copyStatus = await this.taskCopyService.copyTask({ user, originalTaskId, destinationCourse, copyName, }); + + return copyStatus; + } + + private async copyColumnBoard( + userId: string, + originalColumnBoardId: string, + courseId: string, + copyTitle?: string + ): Promise { + await this.authorizationReferenceService.checkPermissionByReferences( + userId, + AuthorizableReferenceType.Course, + courseId, + AuthorizationContextBuilder.write([Permission.COURSE_EDIT]) + ); + const copyStatus = this.columnBoardCopyService.copyColumnBoard({ + originalColumnBoardId, + destinationExternalReference: { type: BoardExternalReferenceType.Course, id: courseId }, + userId, + copyTitle, + }); + return copyStatus; } - private async checkParentWritePermission(userId: EntityId, payload: ShareTokenPayload) { + private async checkCreatePermission(userId: EntityId, payload: ShareTokenPayload) { const allowedParentType = ShareTokenParentTypeMapper.mapToAllowedAuthorizationEntityType(payload.parentType); let requiredPermissions: Permission[] = []; @@ -178,6 +224,10 @@ export class ShareTokenUC { break; case ShareTokenParentType.Task: requiredPermissions = [Permission.HOMEWORK_CREATE]; + break; + case ShareTokenParentType.ColumnBoard: + requiredPermissions = [Permission.COURSE_EDIT]; + break; } const authorizationContext = AuthorizationContextBuilder.write(requiredPermissions); @@ -202,12 +252,8 @@ export class ShareTokenUC { ); } - private async checkCreatePermission(userId: EntityId, parentType: ShareTokenParentType) { - // checks if parent type is supported - ShareTokenParentTypeMapper.mapToAllowedAuthorizationEntityType(parentType); - + private async checkLookupPermission(userId: EntityId, parentType: ShareTokenParentType) { const user = await this.authorizationService.getUserWithPermissions(userId); - let requiredPermissions: Permission[] = []; // eslint-disable-next-line default-case switch (parentType) { @@ -219,6 +265,10 @@ export class ShareTokenUC { break; case ShareTokenParentType.Task: requiredPermissions = [Permission.HOMEWORK_CREATE]; + break; + case ShareTokenParentType.ColumnBoard: + requiredPermissions = [Permission.COURSE_EDIT]; + break; } this.authorizationService.checkAllPermissions(user, requiredPermissions); } @@ -233,7 +283,7 @@ export class ShareTokenUC { switch (parentType) { case ShareTokenParentType.Course: // Configuration.get is the deprecated way to read envirment variables - if (!(Configuration.get('FEATURE_COURSE_SHARE_NEW') as boolean)) { + if (!(Configuration.get('FEATURE_COURSE_SHARE') as boolean)) { throw new InternalServerErrorException('Import Course Feature not enabled'); } break; @@ -249,6 +299,12 @@ export class ShareTokenUC { throw new InternalServerErrorException('Import Task Feature not enabled'); } break; + case ShareTokenParentType.ColumnBoard: + // Configuration.get is the deprecated way to read envirment variables + if (!(Configuration.get('FEATURE_COLUMN_BOARD_SHARE') as boolean)) { + throw new InternalServerErrorException('Import Task Feature not enabled'); + } + break; default: throw new NotImplementedException('Import Feature not implemented'); } diff --git a/apps/server/src/modules/synchronization/domain/do/index.ts b/apps/server/src/modules/synchronization/domain/do/index.ts new file mode 100644 index 00000000000..ee619915664 --- /dev/null +++ b/apps/server/src/modules/synchronization/domain/do/index.ts @@ -0,0 +1 @@ +export * from './synchronization.do'; diff --git a/apps/server/src/modules/synchronization/domain/do/synchronization.do.spec.ts b/apps/server/src/modules/synchronization/domain/do/synchronization.do.spec.ts new file mode 100644 index 00000000000..ec47a08c6d1 --- /dev/null +++ b/apps/server/src/modules/synchronization/domain/do/synchronization.do.spec.ts @@ -0,0 +1,68 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Synchronization } from './synchronization.do'; +import { synchronizationFactory } from '../testing'; +import { SynchronizationStatusModel } from '../types'; + +describe(Synchronization.name, () => { + describe('constructor', () => { + describe('When constructor is called', () => { + it('should create a sychronizations by passing required properties', () => { + const domainObject: Synchronization = synchronizationFactory.build(); + + expect(domainObject instanceof Synchronization).toEqual(true); + }); + }); + + describe('when passed a valid id', () => { + const setup = () => { + const domainObject: Synchronization = synchronizationFactory.buildWithId(); + + return { domainObject }; + }; + + it('should set the id', () => { + const { domainObject } = setup(); + + const synchronizationsDomainObject: Synchronization = new Synchronization(domainObject); + + expect(synchronizationsDomainObject.id).toEqual(domainObject.id); + }); + }); + }); + + describe('getters', () => { + describe('When getters are used', () => { + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + systemId: new ObjectId().toHexString(), + count: 1, + failureCause: '', + status: SynchronizationStatusModel.REGISTERED, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const synchronizationsDo = new Synchronization(props); + + return { props, synchronizationsDo }; + }; + + it('getters should return proper values', () => { + const { props, synchronizationsDo } = setup(); + + const gettersValues = { + id: synchronizationsDo.id, + systemId: synchronizationsDo.systemId, + count: synchronizationsDo.count, + failureCause: synchronizationsDo?.failureCause, + status: synchronizationsDo?.status, + createdAt: synchronizationsDo.createdAt, + updatedAt: synchronizationsDo.updatedAt, + }; + + expect(gettersValues).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/synchronization/domain/do/synchronization.do.ts b/apps/server/src/modules/synchronization/domain/do/synchronization.do.ts new file mode 100644 index 00000000000..9e6471b7475 --- /dev/null +++ b/apps/server/src/modules/synchronization/domain/do/synchronization.do.ts @@ -0,0 +1,37 @@ +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { SynchronizationStatusModel } from '../types'; + +export interface SynchronizationProps extends AuthorizableObject { + createdAt?: Date; + updatedAt?: Date; + systemId?: string; + count?: number; + failureCause?: string; + status?: SynchronizationStatusModel; +} + +export class Synchronization extends DomainObject { + get createdAt(): Date | undefined { + return this.props.createdAt; + } + + get updatedAt(): Date | undefined { + return this.props.updatedAt; + } + + get systemId(): string | undefined { + return this.props.systemId; + } + + get count(): number | undefined { + return this.props.count; + } + + get failureCause(): string | undefined { + return this.props.failureCause; + } + + get status(): SynchronizationStatusModel | undefined { + return this.props.status; + } +} diff --git a/apps/server/src/modules/synchronization/domain/index.ts b/apps/server/src/modules/synchronization/domain/index.ts new file mode 100644 index 00000000000..ea6c26e614e --- /dev/null +++ b/apps/server/src/modules/synchronization/domain/index.ts @@ -0,0 +1,4 @@ +export * from './do'; +export * from './service'; +export * from './types'; +export * from './testing'; diff --git a/apps/server/src/modules/synchronization/domain/service/index.ts b/apps/server/src/modules/synchronization/domain/service/index.ts new file mode 100644 index 00000000000..a5038aa7cdb --- /dev/null +++ b/apps/server/src/modules/synchronization/domain/service/index.ts @@ -0,0 +1 @@ +export * from './synchronization.service'; diff --git a/apps/server/src/modules/synchronization/domain/service/synchonization.service.spec.ts b/apps/server/src/modules/synchronization/domain/service/synchonization.service.spec.ts new file mode 100644 index 00000000000..589474203ba --- /dev/null +++ b/apps/server/src/modules/synchronization/domain/service/synchonization.service.spec.ts @@ -0,0 +1,114 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { setupEntities } from '@shared/testing'; +import { ObjectId } from 'bson'; +import { SynchronizationService } from './synchronization.service'; +import { Synchronization } from '..'; +import { SynchronizationRepo } from '../../repo'; +import { synchronizationFactory } from '../testing'; + +describe(SynchronizationService.name, () => { + let module: TestingModule; + let service: SynchronizationService; + let repo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + SynchronizationService, + { + provide: SynchronizationRepo, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(SynchronizationService); + repo = module.get(SynchronizationRepo); + + await setupEntities(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('defined', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + }); + + describe('createSynchronization', () => { + describe('when creating a synchronization', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + + return { systemId }; + }; + + it('should call synchronizationRepo.create', async () => { + const { systemId } = setup(); + + await service.createSynchronization(systemId); + + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + }) + ); + }); + }); + }); + + describe('findById', () => { + describe('when finding synchronization', () => { + const setup = () => { + const synchronizationId = new ObjectId().toHexString(); + const synchronization = new Synchronization({ id: synchronizationId }); + + repo.findById.mockResolvedValueOnce(synchronization); + + return { synchronizationId, synchronization }; + }; + + it('should call synchronizationRepo.findById', async () => { + const { synchronizationId } = setup(); + await service.findById(synchronizationId); + + expect(repo.findById).toBeCalledWith(synchronizationId); + }); + + it('should return synchronization', async () => { + const { synchronizationId, synchronization } = setup(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const result = await service.findById(synchronizationId); + + expect(result).toEqual(synchronization); + }); + }); + }); + + describe('update', () => { + describe('when updating synchronization', () => { + const setup = () => { + const synchronization = synchronizationFactory.buildWithId(); + + return { synchronization }; + }; + + it('should call synchronizationRepo.update', async () => { + const { synchronization } = setup(); + + await service.update(synchronization); + + expect(repo.update).toBeCalledWith(synchronization); + }); + }); + }); +}); diff --git a/apps/server/src/modules/synchronization/domain/service/synchronization.service.ts b/apps/server/src/modules/synchronization/domain/service/synchronization.service.ts new file mode 100644 index 00000000000..d2e5f7f3607 --- /dev/null +++ b/apps/server/src/modules/synchronization/domain/service/synchronization.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { ObjectId } from 'bson'; +import { Synchronization } from '../do'; +import { SynchronizationRepo } from '../../repo'; +import { SynchronizationStatusModel } from '../types'; + +@Injectable() +export class SynchronizationService { + constructor(private readonly synchronizationRepo: SynchronizationRepo) {} + + async createSynchronization(systemId: string): Promise { + const newSynchronization = new Synchronization({ + id: new ObjectId().toHexString(), + systemId, + status: SynchronizationStatusModel.REGISTERED, + }); + + await this.synchronizationRepo.create(newSynchronization); + + return newSynchronization.id; + } + + async findById(synchronizationId: EntityId): Promise { + const synchronization = await this.synchronizationRepo.findById(synchronizationId); + + return synchronization; + } + + async update(synchronization: Synchronization): Promise { + await this.synchronizationRepo.update(synchronization); + } +} diff --git a/apps/server/src/modules/synchronization/domain/testing/factory/index.ts b/apps/server/src/modules/synchronization/domain/testing/factory/index.ts new file mode 100644 index 00000000000..e783a250626 --- /dev/null +++ b/apps/server/src/modules/synchronization/domain/testing/factory/index.ts @@ -0,0 +1 @@ +export * from './synchronization.factory'; diff --git a/apps/server/src/modules/synchronization/domain/testing/factory/synchronization.factory.ts b/apps/server/src/modules/synchronization/domain/testing/factory/synchronization.factory.ts new file mode 100644 index 00000000000..4d8c7e1ee91 --- /dev/null +++ b/apps/server/src/modules/synchronization/domain/testing/factory/synchronization.factory.ts @@ -0,0 +1,19 @@ +import { DoBaseFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Synchronization, SynchronizationProps } from '../../do'; +import { SynchronizationStatusModel } from '../../types'; + +export const synchronizationFactory = DoBaseFactory.define( + Synchronization, + () => { + return { + id: new ObjectId().toHexString(), + systemId: new ObjectId().toHexString(), + count: 1, + failureCause: '', + status: SynchronizationStatusModel.REGISTERED, + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/synchronization/domain/testing/index.ts b/apps/server/src/modules/synchronization/domain/testing/index.ts new file mode 100644 index 00000000000..d847d7abce6 --- /dev/null +++ b/apps/server/src/modules/synchronization/domain/testing/index.ts @@ -0,0 +1 @@ +export * from './factory'; diff --git a/apps/server/src/modules/synchronization/domain/types/index.ts b/apps/server/src/modules/synchronization/domain/types/index.ts new file mode 100644 index 00000000000..e966e2b2a9e --- /dev/null +++ b/apps/server/src/modules/synchronization/domain/types/index.ts @@ -0,0 +1 @@ +export * from './synchronization-status-model.enum'; diff --git a/apps/server/src/modules/deletion/domain/types/deletion-status-model.enum.ts b/apps/server/src/modules/synchronization/domain/types/synchronization-status-model.enum.ts similarity index 60% rename from apps/server/src/modules/deletion/domain/types/deletion-status-model.enum.ts rename to apps/server/src/modules/synchronization/domain/types/synchronization-status-model.enum.ts index 5681d1be214..a985b59fd16 100644 --- a/apps/server/src/modules/deletion/domain/types/deletion-status-model.enum.ts +++ b/apps/server/src/modules/synchronization/domain/types/synchronization-status-model.enum.ts @@ -1,4 +1,4 @@ -export const enum DeletionStatusModel { +export const enum SynchronizationStatusModel { FAILED = 'failed', REGISTERED = 'registered', SUCCESS = 'success', diff --git a/apps/server/src/modules/synchronization/index.ts b/apps/server/src/modules/synchronization/index.ts new file mode 100644 index 00000000000..9d4e28e58ca --- /dev/null +++ b/apps/server/src/modules/synchronization/index.ts @@ -0,0 +1,3 @@ +export { SynchronizationModule } from './synchronization.module'; +export * from './domain'; +export { SynchronizationEntity, SynchronizationRepo } from './repo'; diff --git a/apps/server/src/modules/synchronization/repo/entity/index.ts b/apps/server/src/modules/synchronization/repo/entity/index.ts new file mode 100644 index 00000000000..e871cf13d6e --- /dev/null +++ b/apps/server/src/modules/synchronization/repo/entity/index.ts @@ -0,0 +1 @@ +export * from './synchronization.entity'; diff --git a/apps/server/src/modules/synchronization/repo/entity/synchronization.entity.spec.ts b/apps/server/src/modules/synchronization/repo/entity/synchronization.entity.spec.ts new file mode 100644 index 00000000000..250c7cfac1d --- /dev/null +++ b/apps/server/src/modules/synchronization/repo/entity/synchronization.entity.spec.ts @@ -0,0 +1,58 @@ +import { setupEntities } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { SynchronizationEntity } from './synchronization.entity'; +import { SynchronizationStatusModel } from '../../domain/types'; + +describe(SynchronizationEntity.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('constructor', () => { + describe('When constructor is called', () => { + const setup = () => { + const props = { + id: new ObjectId().toHexString(), + systemId: new ObjectId().toHexString(), + count: 1, + failureCause: '', + status: SynchronizationStatusModel.REGISTERED, + createdAt: new Date(), + updatedAt: new Date(), + }; + + return { props }; + }; + + it('should throw an error by empty constructor', () => { + // @ts-expect-error: Test case + const test = () => new SynchronizationEntity(); + expect(test).toThrow(); + }); + + it('should create a synchronizationsEntity by passing required properties', () => { + const { props } = setup(); + const entity: SynchronizationEntity = new SynchronizationEntity(props); + + expect(entity instanceof SynchronizationEntity).toEqual(true); + }); + + it(`should return a valid object with fields values set from the provided complete props object`, () => { + const { props } = setup(); + const entity: SynchronizationEntity = new SynchronizationEntity(props); + + const entityProps = { + id: entity.id, + systemId: entity.systemId, + count: entity.count, + failureCause: entity?.failureCause, + status: entity?.status, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + expect(entityProps).toEqual(props); + }); + }); + }); +}); diff --git a/apps/server/src/modules/synchronization/repo/entity/synchronization.entity.ts b/apps/server/src/modules/synchronization/repo/entity/synchronization.entity.ts new file mode 100644 index 00000000000..18076679391 --- /dev/null +++ b/apps/server/src/modules/synchronization/repo/entity/synchronization.entity.ts @@ -0,0 +1,61 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { EntityId } from '@shared/domain/types'; +import { SynchronizationStatusModel } from '../../domain/types'; + +export interface SynchronizationEntityProps { + id?: EntityId; + systemId?: string; + count?: number; + failureCause?: string; + status?: SynchronizationStatusModel; + createdAt?: Date; + updatedAt?: Date; +} + +@Entity({ tableName: 'synchronizations' }) +export class SynchronizationEntity extends BaseEntityWithTimestamps { + @Property({ nullable: true }) + systemId?: string; + + @Property({ nullable: true }) + count?: number; + + @Property({ nullable: true }) + failureCause?: string; + + @Property({ nullable: true }) + status?: SynchronizationStatusModel; + + constructor(props: SynchronizationEntityProps) { + super(); + + if (props.id !== undefined) { + this.id = props.id; + } + + if (props.systemId !== undefined) { + this.systemId = props.systemId; + } + + if (props.count !== undefined) { + this.count = props.count; + } + + if (props.failureCause !== undefined) { + this.failureCause = props.failureCause; + } + + if (props.status !== undefined) { + this.status = props.status; + } + + if (props.createdAt !== undefined) { + this.createdAt = props.createdAt; + } + + if (props.updatedAt !== undefined) { + this.updatedAt = props.updatedAt; + } + } +} diff --git a/apps/server/src/modules/synchronization/repo/entity/testing/factory/index.ts b/apps/server/src/modules/synchronization/repo/entity/testing/factory/index.ts new file mode 100644 index 00000000000..62e03411446 --- /dev/null +++ b/apps/server/src/modules/synchronization/repo/entity/testing/factory/index.ts @@ -0,0 +1 @@ +export * from './synchronization.entity.factory'; diff --git a/apps/server/src/modules/synchronization/repo/entity/testing/factory/synchronization.entity.factory.ts b/apps/server/src/modules/synchronization/repo/entity/testing/factory/synchronization.entity.factory.ts new file mode 100644 index 00000000000..8f006080da5 --- /dev/null +++ b/apps/server/src/modules/synchronization/repo/entity/testing/factory/synchronization.entity.factory.ts @@ -0,0 +1,18 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BaseFactory } from '@shared/testing'; +import { SynchronizationEntity, SynchronizationEntityProps } from '../../synchronization.entity'; +import { SynchronizationStatusModel } from '../../../../domain/types'; + +export const synchronizationEntityFactory = BaseFactory.define( + SynchronizationEntity, + () => { + return { + id: new ObjectId().toHexString(), + count: 1, + failureCause: '', + status: SynchronizationStatusModel.REGISTERED, + createdAt: new Date(), + updatedAt: new Date(), + }; + } +); diff --git a/apps/server/src/modules/synchronization/repo/entity/testing/index.ts b/apps/server/src/modules/synchronization/repo/entity/testing/index.ts new file mode 100644 index 00000000000..d847d7abce6 --- /dev/null +++ b/apps/server/src/modules/synchronization/repo/entity/testing/index.ts @@ -0,0 +1 @@ +export * from './factory'; diff --git a/apps/server/src/modules/synchronization/repo/index.ts b/apps/server/src/modules/synchronization/repo/index.ts new file mode 100644 index 00000000000..d9e439d5871 --- /dev/null +++ b/apps/server/src/modules/synchronization/repo/index.ts @@ -0,0 +1,2 @@ +export * from './entity'; +export * from './synchronization.repo'; diff --git a/apps/server/src/modules/synchronization/repo/mapper/index.ts b/apps/server/src/modules/synchronization/repo/mapper/index.ts new file mode 100644 index 00000000000..5b1ccd6a474 --- /dev/null +++ b/apps/server/src/modules/synchronization/repo/mapper/index.ts @@ -0,0 +1 @@ +export * from './synchronization.mapper'; diff --git a/apps/server/src/modules/synchronization/repo/mapper/synchronization.mapper.spec.ts b/apps/server/src/modules/synchronization/repo/mapper/synchronization.mapper.spec.ts new file mode 100644 index 00000000000..9f95e7677d4 --- /dev/null +++ b/apps/server/src/modules/synchronization/repo/mapper/synchronization.mapper.spec.ts @@ -0,0 +1,72 @@ +import { Synchronization } from '../../domain'; +import { synchronizationFactory } from '../../domain/testing'; +import { SynchronizationEntity } from '../entity'; +import { synchronizationEntityFactory } from '../entity/testing'; +import { SynchronizationMapper } from './synchronization.mapper'; + +describe(SynchronizationMapper.name, () => { + describe('mapToDO', () => { + describe('When entity is mapped for domainObject', () => { + const setup = () => { + const entity = synchronizationEntityFactory.build(); + + const expectedDomainObject = new Synchronization({ + id: entity.id, + systemId: entity.systemId, + count: entity.count, + failureCause: entity?.failureCause, + status: entity?.status, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }); + + return { entity, expectedDomainObject }; + }; + + it('should properly map the entity to the domain object', () => { + const { entity, expectedDomainObject } = setup(); + + const domainObject = SynchronizationMapper.mapToDO(entity); + + expect(domainObject).toEqual(expectedDomainObject); + }); + }); + }); + + describe('mapToEntity', () => { + describe('When domainObject is mapped for entity', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date()); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const setup = () => { + const domainObject = synchronizationFactory.build(); + + const expectedEntities = new SynchronizationEntity({ + id: domainObject.id, + systemId: domainObject.systemId, + count: domainObject.count, + failureCause: domainObject?.failureCause, + status: domainObject?.status, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }); + + return { domainObject, expectedEntities }; + }; + + it('should properly map the domainObject to the entity', () => { + const { domainObject, expectedEntities } = setup(); + + const entities = SynchronizationMapper.mapToEntity(domainObject); + + expect(entities).toEqual(expectedEntities); + }); + }); + }); +}); diff --git a/apps/server/src/modules/synchronization/repo/mapper/synchronization.mapper.ts b/apps/server/src/modules/synchronization/repo/mapper/synchronization.mapper.ts new file mode 100644 index 00000000000..cd4c7a53817 --- /dev/null +++ b/apps/server/src/modules/synchronization/repo/mapper/synchronization.mapper.ts @@ -0,0 +1,28 @@ +import { SynchronizationEntity } from '../entity'; +import { Synchronization } from '../../domain/do'; + +export class SynchronizationMapper { + static mapToDO(entity: SynchronizationEntity): Synchronization { + return new Synchronization({ + id: entity.id, + systemId: entity.systemId, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + count: entity.count, + failureCause: entity?.failureCause, + status: entity?.status, + }); + } + + static mapToEntity(domainObject: Synchronization): SynchronizationEntity { + return new SynchronizationEntity({ + id: domainObject.id, + systemId: domainObject.systemId, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + count: domainObject.count, + failureCause: domainObject.failureCause, + status: domainObject.status, + }); + } +} diff --git a/apps/server/src/modules/synchronization/repo/synchronization.repo.spec.ts b/apps/server/src/modules/synchronization/repo/synchronization.repo.spec.ts new file mode 100644 index 00000000000..e864e021a69 --- /dev/null +++ b/apps/server/src/modules/synchronization/repo/synchronization.repo.spec.ts @@ -0,0 +1,139 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test } from '@nestjs/testing'; +import { TestingModule } from '@nestjs/testing/testing-module'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { cleanupCollections } from '@shared/testing'; +import { Synchronization } from '../domain'; +import { synchronizationFactory } from '../domain/testing'; +import { SynchronizationEntity } from './entity'; +import { synchronizationEntityFactory } from './entity/testing'; +import { SynchronizationMapper } from './mapper'; +import { SynchronizationRepo } from './synchronization.repo'; +import { SynchronizationStatusModel } from '../domain/types'; + +describe(SynchronizationRepo.name, () => { + let module: TestingModule; + let repo: SynchronizationRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + MongoMemoryDatabaseModule.forRoot({ + entities: [SynchronizationEntity], + }), + ], + providers: [SynchronizationRepo, SynchronizationMapper], + }).compile(); + + repo = module.get(SynchronizationRepo); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('defined', () => { + it('repo should be defined', () => { + expect(repo).toBeDefined(); + }); + + it('entity manager should be defined', () => { + expect(em).toBeDefined(); + }); + + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(SynchronizationEntity); + }); + }); + + describe('create synchronization', () => { + describe('when synchronization is new', () => { + const setup = () => { + const domainObject: Synchronization = synchronizationFactory.build(); + const synchronizationId = domainObject.id; + + const expectedDomainObject = { + id: domainObject.id, + count: domainObject.count, + failureCause: domainObject.failureCause, + status: domainObject.status, + createdAt: domainObject.createdAt, + updatedAt: domainObject.updatedAt, + }; + + return { domainObject, synchronizationId, expectedDomainObject }; + }; + + it('should create a new deletionLog', async () => { + const { domainObject, synchronizationId, expectedDomainObject } = setup(); + await repo.create(domainObject); + + const result = await repo.findById(synchronizationId); + + expect(result).toEqual(expect.objectContaining(expectedDomainObject)); + }); + }); + }); + + describe('findById', () => { + describe('when searching by Id', () => { + const setup = async () => { + // Test synchronization entity + const entity: SynchronizationEntity = synchronizationEntityFactory.build(); + await em.persistAndFlush(entity); + + const expectedSynchronization = { + id: entity.id, + count: entity.count, + failureCause: entity.failureCause, + status: entity.status, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + + return { + entity, + expectedSynchronization, + }; + }; + + it('should find the synchronization', async () => { + const { entity, expectedSynchronization } = await setup(); + + const result: Synchronization = await repo.findById(entity.id); + + expect(result).toEqual(expect.objectContaining(expectedSynchronization)); + }); + }); + }); + + describe('update', () => { + describe('when update a synchronization', () => { + const setup = async () => { + const entity: SynchronizationEntity = synchronizationEntityFactory.build(); + await em.persistAndFlush(entity); + + // Arrange expected DeletionRequestEntity after changing status + entity.status = SynchronizationStatusModel.SUCCESS; + const synchronizationToUpdate = SynchronizationMapper.mapToDO(entity); + + return { entity, synchronizationToUpdate }; + }; + it('should update a new deletionLog', async () => { + const { entity, synchronizationToUpdate } = await setup(); + + await repo.update(synchronizationToUpdate); + + const result = await repo.findById(entity.id); + + expect(result.status).toEqual(entity.status); + }); + }); + }); +}); diff --git a/apps/server/src/modules/synchronization/repo/synchronization.repo.ts b/apps/server/src/modules/synchronization/repo/synchronization.repo.ts new file mode 100644 index 00000000000..9f56093a1b7 --- /dev/null +++ b/apps/server/src/modules/synchronization/repo/synchronization.repo.ts @@ -0,0 +1,41 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { Synchronization } from '../domain/do'; +import { SynchronizationEntity } from './entity'; +import { SynchronizationMapper } from './mapper'; + +@Injectable() +export class SynchronizationRepo { + constructor(private readonly em: EntityManager) {} + + get entityName() { + return SynchronizationEntity; + } + + async findById(id: EntityId): Promise { + const synchronizations: SynchronizationEntity = await this.em.findOneOrFail(SynchronizationEntity, { + id, + }); + + const mapped: Synchronization = SynchronizationMapper.mapToDO(synchronizations); + + return mapped; + } + + async create(synchronizations: Synchronization): Promise { + const synchronizationsEntity: SynchronizationEntity = SynchronizationMapper.mapToEntity(synchronizations); + + await this.em.persistAndFlush(synchronizationsEntity); + } + + async update(synchronization: Synchronization): Promise { + const referencedEntity = this.em.getReference(SynchronizationEntity, synchronization.id); + + referencedEntity.status = synchronization.status; + referencedEntity.count = synchronization.count; + referencedEntity.failureCause = synchronization.failureCause; + + await this.em.persistAndFlush(referencedEntity); + } +} diff --git a/apps/server/src/modules/synchronization/synchronization.module.ts b/apps/server/src/modules/synchronization/synchronization.module.ts new file mode 100644 index 00000000000..c358ce50dcd --- /dev/null +++ b/apps/server/src/modules/synchronization/synchronization.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { UserModule } from '@modules/user'; +import { SynchronizationRepo } from './repo'; +import { SynchronizationService } from './domain/service'; + +@Module({ + imports: [UserModule], + providers: [SynchronizationRepo, SynchronizationService], + exports: [SynchronizationService], +}) +export class SynchronizationModule {} diff --git a/apps/server/src/modules/system/controller/api-test/system.api.spec.ts b/apps/server/src/modules/system/controller/api-test/system.api.spec.ts index 0ccc6e4a59f..4aaf7fff046 100644 --- a/apps/server/src/modules/system/controller/api-test/system.api.spec.ts +++ b/apps/server/src/modules/system/controller/api-test/system.api.spec.ts @@ -3,7 +3,7 @@ import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { OauthConfigEntity, SchoolEntity, SystemEntity } from '@shared/domain/entity'; -import { TestApiClient, UserAndAccountTestFactory, schoolFactory, systemEntityFactory } from '@shared/testing'; +import { schoolEntityFactory, systemEntityFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; import { Response } from 'supertest'; import { PublicSystemListResponse, PublicSystemResponse } from '../dto'; @@ -112,7 +112,7 @@ describe('System (API)', () => { describe('when the endpoint is called with a known systemId', () => { const setup = async () => { const system: SystemEntity = systemEntityFactory.withLdapConfig({ provider: 'general' }).buildWithId(); - const school: SchoolEntity = schoolFactory.build({ systems: [system] }); + const school: SchoolEntity = schoolEntityFactory.build({ systems: [system] }); const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); await em.persistAndFlush([system, adminAccount, adminUser, school]); diff --git a/apps/server/src/modules/system/controller/system.controller.ts b/apps/server/src/modules/system/controller/system.controller.ts index 6eabcc53762..a0630a8119a 100644 --- a/apps/server/src/modules/system/controller/system.controller.ts +++ b/apps/server/src/modules/system/controller/system.controller.ts @@ -48,6 +48,6 @@ export class SystemController { @ApiOperation({ summary: 'Deletes a system.' }) @HttpCode(HttpStatus.NO_CONTENT) async deleteSystem(@CurrentUser() currentUser: ICurrentUser, @Param() params: SystemIdParams): Promise { - await this.systemUc.delete(currentUser.userId, params.systemId); + await this.systemUc.delete(currentUser.userId, currentUser.schoolId, params.systemId); } } diff --git a/apps/server/src/modules/system/domain/factory/index.ts b/apps/server/src/modules/system/domain/factory/index.ts new file mode 100644 index 00000000000..1fe32e7e8b0 --- /dev/null +++ b/apps/server/src/modules/system/domain/factory/index.ts @@ -0,0 +1 @@ +export * from './system.factory'; diff --git a/apps/server/src/modules/system/domain/factory/system.factory.spec.ts b/apps/server/src/modules/system/domain/factory/system.factory.spec.ts new file mode 100644 index 00000000000..69285dcb6de --- /dev/null +++ b/apps/server/src/modules/system/domain/factory/system.factory.spec.ts @@ -0,0 +1,18 @@ +import { SystemType } from '../system-type.enum'; +import { System, SystemProps } from '../system.do'; +import { SystemFactory } from './system.factory'; + +describe('SystemFactory', () => { + describe('build', () => { + it('should return a system', () => { + const props: SystemProps = { + id: 'id', + type: SystemType.ISERV, + }; + + const result = SystemFactory.build(props); + + expect(result).toBeInstanceOf(System); + }); + }); +}); diff --git a/apps/server/src/modules/system/domain/factory/system.factory.ts b/apps/server/src/modules/system/domain/factory/system.factory.ts new file mode 100644 index 00000000000..dfcd31a4bbc --- /dev/null +++ b/apps/server/src/modules/system/domain/factory/system.factory.ts @@ -0,0 +1,7 @@ +import { System, SystemProps } from '../system.do'; + +export class SystemFactory { + static build(props: SystemProps) { + return new System(props); + } +} diff --git a/apps/server/src/modules/system/domain/index.ts b/apps/server/src/modules/system/domain/index.ts index 16e2a044335..128d3542fcc 100644 --- a/apps/server/src/modules/system/domain/index.ts +++ b/apps/server/src/modules/system/domain/index.ts @@ -1,3 +1,6 @@ -export { System, SystemProps } from './system.do'; +export { SystemFactory } from './factory/system.factory'; +export * from './interface'; export { LdapConfig } from './ldap-config'; export { OauthConfig } from './oauth-config'; +export { SystemType } from './system-type.enum'; +export { System, SystemProps } from './system.do'; diff --git a/apps/server/src/modules/system/domain/interface/index.ts b/apps/server/src/modules/system/domain/interface/index.ts new file mode 100644 index 00000000000..89555b6b881 --- /dev/null +++ b/apps/server/src/modules/system/domain/interface/index.ts @@ -0,0 +1 @@ +export * from './system.repo.interface'; diff --git a/apps/server/src/modules/system/domain/interface/system.repo.interface.ts b/apps/server/src/modules/system/domain/interface/system.repo.interface.ts new file mode 100644 index 00000000000..2600c79b0dc --- /dev/null +++ b/apps/server/src/modules/system/domain/interface/system.repo.interface.ts @@ -0,0 +1,14 @@ +import { EntityId } from '@shared/domain/types/entity-id'; +import { System } from '../system.do'; + +export interface SystemRepo { + getSystemsByIds(systemIds: EntityId[]): Promise; + + getSystemById(systemId: EntityId): Promise; + + findAllForLdapLogin(): Promise; + + delete(domainObject: System): Promise; +} + +export const SYSTEM_REPO = 'SYSTEM_REPO'; diff --git a/apps/server/src/modules/system/domain/query/index.ts b/apps/server/src/modules/system/domain/query/index.ts new file mode 100644 index 00000000000..c8ad01154ee --- /dev/null +++ b/apps/server/src/modules/system/domain/query/index.ts @@ -0,0 +1 @@ +export * from './system-query'; diff --git a/apps/server/src/modules/system/domain/query/system-query.ts b/apps/server/src/modules/system/domain/query/system-query.ts new file mode 100644 index 00000000000..677b2f160f4 --- /dev/null +++ b/apps/server/src/modules/system/domain/query/system-query.ts @@ -0,0 +1,5 @@ +import { EntityId } from '@shared/domain/types/entity-id'; + +export interface SystemQuery { + ids?: EntityId[]; +} diff --git a/apps/server/src/modules/system/domain/system-type.enum.ts b/apps/server/src/modules/system/domain/system-type.enum.ts new file mode 100644 index 00000000000..75ba87b2a1d --- /dev/null +++ b/apps/server/src/modules/system/domain/system-type.enum.ts @@ -0,0 +1,13 @@ +export enum SystemType { + OAUTH = 'oauth', + LDAP = 'ldap', + OIDC = 'oidc', + TSP_BASE = 'tsp-base', + TSP_SCHOOL = 'tsp-school', + // Legacy + LOCAL = 'local', + ISERV = 'iserv', + LERNSAX = 'lernsax', + ITSLEARNING = 'itslearning', + MOODLE = 'moodle', +} diff --git a/apps/server/src/modules/system/domain/system.do.spec.ts b/apps/server/src/modules/system/domain/system.do.spec.ts new file mode 100644 index 00000000000..9d5f9dc5a08 --- /dev/null +++ b/apps/server/src/modules/system/domain/system.do.spec.ts @@ -0,0 +1,25 @@ +import { systemFactory } from '@shared/testing'; + +describe('System', () => { + describe('isDeletable', () => { + describe('when ldapConfig provider is "general"', () => { + it('should return true', () => { + const system = systemFactory.build({ ldapConfig: { provider: 'general' } }); + + const result = system.isDeletable(); + + expect(result).toBe(true); + }); + }); + + describe('when ldapConfig provider is not "general"', () => { + it('should return true', () => { + const system = systemFactory.build({ ldapConfig: {} }); + + const result = system.isDeletable(); + + expect(result).toBe(false); + }); + }); + }); +}); diff --git a/apps/server/src/modules/system/domain/system.do.ts b/apps/server/src/modules/system/domain/system.do.ts index 915cd22b3e0..51b3e92d2c0 100644 --- a/apps/server/src/modules/system/domain/system.do.ts +++ b/apps/server/src/modules/system/domain/system.do.ts @@ -2,6 +2,7 @@ import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { LdapConfig } from './ldap-config'; import { OauthConfig } from './oauth-config'; +import { SystemType } from './system-type.enum'; export interface SystemProps extends AuthorizableObject { type: string; @@ -22,6 +23,10 @@ export interface SystemProps extends AuthorizableObject { } export class System extends DomainObject { + get type(): SystemType | string { + return this.props.type; + } + get ldapConfig(): LdapConfig | undefined { return this.props.ldapConfig; } @@ -29,4 +34,10 @@ export class System extends DomainObject { get provisioningStrategy(): SystemProvisioningStrategy | undefined { return this.props.provisioningStrategy; } + + public isDeletable(): boolean { + const isDeletable = this.ldapConfig?.provider === 'general'; + + return isDeletable; + } } diff --git a/apps/server/src/modules/system/index.ts b/apps/server/src/modules/system/index.ts index 9cedfa41938..5789bd88f84 100644 --- a/apps/server/src/modules/system/index.ts +++ b/apps/server/src/modules/system/index.ts @@ -1,3 +1,3 @@ -export * from './system.module'; -export { SystemService, LegacySystemService, SystemDto, OauthConfigDto, OidcConfigDto } from './service'; -export { System, SystemProps, OauthConfig, LdapConfig } from './domain'; +export { LdapConfig, OauthConfig, System, SystemProps } from './domain'; +export { LegacySystemService, OauthConfigDto, OidcConfigDto, SystemDto, SystemService } from './service'; +export { SystemModule } from './system.module'; diff --git a/apps/server/src/modules/system/repo/index.ts b/apps/server/src/modules/system/repo/index.ts index 7bf41b20479..db355c4e658 100644 --- a/apps/server/src/modules/system/repo/index.ts +++ b/apps/server/src/modules/system/repo/index.ts @@ -1 +1 @@ -export { SystemRepo } from './system.repo'; +export { SystemMikroOrmRepo } from './mikro-orm/system.repo'; diff --git a/apps/server/src/modules/system/repo/mikro-orm/mapper/index.ts b/apps/server/src/modules/system/repo/mikro-orm/mapper/index.ts new file mode 100644 index 00000000000..b1fbc75434c --- /dev/null +++ b/apps/server/src/modules/system/repo/mikro-orm/mapper/index.ts @@ -0,0 +1 @@ +export * from './system-entity.mapper'; diff --git a/apps/server/src/modules/system/repo/system-domain.mapper.ts b/apps/server/src/modules/system/repo/mikro-orm/mapper/system-entity.mapper.ts similarity index 85% rename from apps/server/src/modules/system/repo/system-domain.mapper.ts rename to apps/server/src/modules/system/repo/mikro-orm/mapper/system-entity.mapper.ts index 86fa95c1ae5..13fe03437e2 100644 --- a/apps/server/src/modules/system/repo/system-domain.mapper.ts +++ b/apps/server/src/modules/system/repo/mikro-orm/mapper/system-entity.mapper.ts @@ -1,9 +1,9 @@ import { LdapConfigEntity, OauthConfigEntity, SystemEntity } from '@shared/domain/entity'; -import { LdapConfig, OauthConfig, SystemProps } from '../domain'; +import { LdapConfig, OauthConfig, System, SystemFactory } from '../../../domain'; -export class SystemDomainMapper { - public static mapEntityToDomainObjectProperties(entity: SystemEntity): SystemProps { - const mapped: SystemProps = { +export class SystemEntityMapper { + public static mapToDo(entity: SystemEntity): System { + const system = SystemFactory.build({ id: entity.id, url: entity.url, type: entity.type, @@ -13,9 +13,9 @@ export class SystemDomainMapper { alias: entity.alias, oauthConfig: entity.oauthConfig ? this.mapOauthConfigEntityToDomainObject(entity.oauthConfig) : undefined, ldapConfig: entity.ldapConfig ? this.mapLdapConfigEntityToDomainObject(entity.ldapConfig) : undefined, - }; + }); - return mapped; + return system; } private static mapOauthConfigEntityToDomainObject(oauthConfig: OauthConfigEntity): OauthConfig { diff --git a/apps/server/src/modules/system/repo/mikro-orm/scope/system.scope.ts b/apps/server/src/modules/system/repo/mikro-orm/scope/system.scope.ts new file mode 100644 index 00000000000..073d677e322 --- /dev/null +++ b/apps/server/src/modules/system/repo/mikro-orm/scope/system.scope.ts @@ -0,0 +1,11 @@ +import { SystemEntity } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types/entity-id'; +import { Scope } from '@shared/repo/scope'; + +export class SystemScope extends Scope { + byIds(ids?: EntityId[]) { + if (ids) { + this.addQuery({ id: { $in: ids } }); + } + } +} diff --git a/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts b/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts new file mode 100644 index 00000000000..157930f04fe --- /dev/null +++ b/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts @@ -0,0 +1,305 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { NotImplementedException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LdapConfigEntity, OauthConfigEntity, SystemEntity } from '@shared/domain/entity'; +import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { SystemTypeEnum } from '@shared/domain/types'; +import { cleanupCollections, systemEntityFactory } from '@shared/testing'; +import { SYSTEM_REPO, System, SystemProps, SystemRepo } from '../../domain'; +import { SystemEntityMapper } from './mapper/system-entity.mapper'; +import { SystemMikroOrmRepo } from './system.repo'; + +describe(SystemMikroOrmRepo.name, () => { + let module: TestingModule; + let repo: SystemRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [{ provide: SYSTEM_REPO, useClass: SystemMikroOrmRepo }], + }).compile(); + + repo = module.get(SYSTEM_REPO); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('getSystemById', () => { + describe('when the system exists', () => { + const setup = async () => { + const oauthConfig = new OauthConfigEntity({ + clientId: '12345', + clientSecret: 'mocksecret', + idpHint: 'mock-oauth-idpHint', + tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', + grantType: 'authorization_code', + redirectUri: 'http://mockhost:3030/api/v3/sso/oauth/', + scope: 'openid uuid', + responseType: 'code', + authEndpoint: 'http://mock.de/auth', + provider: 'mock_type', + logoutEndpoint: 'http://mock.de/logout', + issuer: 'mock_issuer', + jwksEndpoint: 'http://mock.de/jwks', + }); + const ldapConfig = new LdapConfigEntity({ + url: 'ldaps:mock.de:389', + active: true, + provider: 'mock_provider', + }); + const system: SystemEntity = systemEntityFactory.buildWithId({ + type: 'oauth', + url: 'https://mock.de', + alias: 'alias', + displayName: 'displayName', + provisioningStrategy: SystemProvisioningStrategy.OIDC, + provisioningUrl: 'https://provisioningurl.de', + oauthConfig, + ldapConfig, + }); + + await em.persistAndFlush([system]); + em.clear(); + + return { + system, + oauthConfig, + ldapConfig, + }; + }; + + it('should return the system', async () => { + const { system, oauthConfig, ldapConfig } = await setup(); + + const result = await repo.getSystemById(system.id); + + expect(result?.getProps()).toEqual({ + id: system.id, + type: system.type, + url: system.url, + displayName: system.displayName, + alias: system.alias, + provisioningStrategy: system.provisioningStrategy, + provisioningUrl: system.provisioningUrl, + oauthConfig: { + issuer: oauthConfig.issuer, + provider: oauthConfig.provider, + jwksEndpoint: oauthConfig.jwksEndpoint, + redirectUri: oauthConfig.redirectUri, + idpHint: oauthConfig.idpHint, + authEndpoint: oauthConfig.authEndpoint, + clientSecret: oauthConfig.clientSecret, + grantType: oauthConfig.grantType, + logoutEndpoint: oauthConfig.logoutEndpoint, + responseType: oauthConfig.responseType, + tokenEndpoint: oauthConfig.tokenEndpoint, + clientId: oauthConfig.clientId, + scope: oauthConfig.scope, + }, + ldapConfig: { + url: ldapConfig.url, + provider: ldapConfig.provider, + active: !!ldapConfig.active, + }, + }); + }); + }); + + describe('when the system does not exist', () => { + it('should return null', async () => { + const result = await repo.getSystemById(new ObjectId().toHexString()); + + expect(result).toBeNull(); + }); + }); + }); + + describe('findAllForLdapLogin', () => { + describe('when no system exists', () => { + it('should return empty array', async () => { + const result = await repo.findAllForLdapLogin(); + + expect(result).toEqual([]); + }); + }); + + describe('when different systems exist', () => { + const setup = async () => { + const activeLdapSystem: SystemEntity = systemEntityFactory.buildWithId({ + type: SystemTypeEnum.LDAP, + ldapConfig: { active: true }, + }); + const inActiveLdapSystem: SystemEntity = systemEntityFactory.buildWithId({ + type: SystemTypeEnum.LDAP, + ldapConfig: { active: false }, + }); + const activeLdapSystemWithOauthConfig = systemEntityFactory.buildWithId({ + type: SystemTypeEnum.LDAP, + ldapConfig: { + active: true, + }, + oauthConfig: {}, + }); + const otherSystem = systemEntityFactory.buildWithId({ type: SystemTypeEnum.OAUTH }); + + await em.persistAndFlush([activeLdapSystem, inActiveLdapSystem, activeLdapSystemWithOauthConfig, otherSystem]); + em.clear(); + + const activeLdapSystemDo = SystemEntityMapper.mapToDo(activeLdapSystem); + + const expectedSystems = [activeLdapSystemDo]; + + return { expectedSystems }; + }; + + it('should return only the systems eligible for LDAP login', async () => { + const { expectedSystems } = await setup(); + + const result = await repo.findAllForLdapLogin(); + + expect(result).toEqual(expectedSystems); + }); + }); + }); + + describe('getSystemsByIds', () => { + describe('when no system exists', () => { + it('should return empty array', async () => { + const result = await repo.getSystemsByIds([new ObjectId().toHexString()]); + + expect(result).toEqual([]); + }); + }); + + describe('when different systems exist', () => { + const setup = async () => { + const system1: SystemEntity = systemEntityFactory.buildWithId(); + const system2: SystemEntity = systemEntityFactory.buildWithId(); + const system3: SystemEntity = systemEntityFactory.buildWithId(); + + await em.persistAndFlush([system1, system2, system3]); + em.clear(); + + const system1Props = SystemEntityMapper.mapToDo(system1); + const system2Props = SystemEntityMapper.mapToDo(system2); + const system3Props = SystemEntityMapper.mapToDo(system3); + + const expectedSystems = [system1Props, system2Props, system3Props]; + const systemIds = expectedSystems.map((s) => s.id); + + return { expectedSystems, systemIds }; + }; + + it('should return the systems', async () => { + const { expectedSystems, systemIds } = await setup(); + + const result = await repo.getSystemsByIds(systemIds); + + expect(result).toEqual(expectedSystems); + }); + }); + + describe('when throwing an error', () => { + const setup = () => { + const systemIds = [new ObjectId().toHexString()]; + const error = new Error('Connection error'); + const spy = jest.spyOn(em, 'find'); + spy.mockRejectedValueOnce(error); + + return { + systemIds, + error, + }; + }; + + it('should throw an error', async () => { + const { systemIds, error } = setup(); + + await expect(repo.getSystemsByIds(systemIds)).rejects.toThrow(error); + }); + }); + }); + + describe('delete', () => { + describe('when the system exists', () => { + const setup = async () => { + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + + await em.persistAndFlush([systemEntity]); + em.clear(); + + const props: SystemProps = SystemEntityMapper.mapToDo(systemEntity); + const system: System = new System(props); + + return { + system, + }; + }; + + it('should delete the system', async () => { + const { system } = await setup(); + + await repo.delete(system); + + expect(await em.findOne(SystemEntity, { id: system.id })).toBeNull(); + }); + }); + + describe('when the system does not exists', () => { + const setup = () => { + const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); + const props: SystemProps = SystemEntityMapper.mapToDo(systemEntity); + const system: System = new System(props); + + return { + system, + }; + }; + + it('should return void', async () => { + const { system } = setup(); + + const result = await repo.delete(system); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe('save', () => { + describe('when the valid system is passed', () => { + const setup = () => { + const system = new System({ + id: new ObjectId().toHexString(), + type: SystemTypeEnum.OAUTH, + url: 'https://mock.de', + alias: 'alias', + displayName: 'displayName', + provisioningStrategy: SystemProvisioningStrategy.OIDC, + provisioningUrl: 'https://provisioningurl.de', + }); + + return { + system, + }; + }; + + it('should throw error because mapDOToEntityProperties is not implement', async () => { + const { system } = setup(); + + // @ts-expect-error Testcase + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + await expect(repo.save(system)).rejects.toThrowError(NotImplementedException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/system/repo/mikro-orm/system.repo.ts b/apps/server/src/modules/system/repo/mikro-orm/system.repo.ts new file mode 100644 index 00000000000..cfd64f8dd66 --- /dev/null +++ b/apps/server/src/modules/system/repo/mikro-orm/system.repo.ts @@ -0,0 +1,55 @@ +import { EntityData, EntityName } from '@mikro-orm/core'; +import { Injectable, NotImplementedException } from '@nestjs/common'; +import { SystemEntity } from '@shared/domain/entity'; +import { EntityId, SystemTypeEnum } from '@shared/domain/types'; +import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; +import { System, SystemRepo } from '../../domain'; +import { SystemEntityMapper } from './mapper/system-entity.mapper'; +import { SystemScope } from './scope/system.scope'; + +@Injectable() +export class SystemMikroOrmRepo extends BaseDomainObjectRepo implements SystemRepo { + protected get entityName(): EntityName { + return SystemEntity; + } + + protected mapDOToEntityProperties(): EntityData { + throw new NotImplementedException('Method `mapDOToEntityProperties` not implemented.'); + } + + public async getSystemById(id: EntityId): Promise { + const entity: SystemEntity | null = await this.em.findOne(SystemEntity, { id }); + + if (!entity) { + return null; + } + + const domainObject = SystemEntityMapper.mapToDo(entity); + + return domainObject; + } + + public async getSystemsByIds(ids: EntityId[]): Promise { + const scope = new SystemScope(); + scope.byIds(ids); + + const entities: SystemEntity[] = await this.em.find(SystemEntity, scope.query); + + const domainObjects: System[] = entities.map((entity) => SystemEntityMapper.mapToDo(entity)); + + return domainObjects; + } + + public async findAllForLdapLogin(): Promise { + // Systems with an oauthConfig are filtered out here to exclude IServ. IServ is of type LDAP for syncing purposes, but the login is done via OAuth2. + const entities: SystemEntity[] = await this.em.find(SystemEntity, { + type: SystemTypeEnum.LDAP, + ldapConfig: { active: true }, + oauthConfig: undefined, + }); + + const domainObjects: System[] = entities.map((entity) => SystemEntityMapper.mapToDo(entity)); + + return domainObjects; + } +} diff --git a/apps/server/src/modules/system/repo/system.repo.spec.ts b/apps/server/src/modules/system/repo/system.repo.spec.ts deleted file mode 100644 index abf64049e9c..00000000000 --- a/apps/server/src/modules/system/repo/system.repo.spec.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { Test, TestingModule } from '@nestjs/testing'; -import { LdapConfigEntity, OauthConfigEntity, SystemEntity } from '@shared/domain/entity'; -import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { cleanupCollections, systemEntityFactory } from '@shared/testing'; -import { System, SystemProps } from '../domain'; -import { SystemDomainMapper } from './system-domain.mapper'; -import { SystemRepo } from './system.repo'; - -describe(SystemRepo.name, () => { - let module: TestingModule; - let repo: SystemRepo; - let em: EntityManager; - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot()], - providers: [SystemRepo], - }).compile(); - - repo = module.get(SystemRepo); - em = module.get(EntityManager); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(async () => { - await cleanupCollections(em); - }); - - describe('findById', () => { - describe('when the system exists', () => { - const setup = async () => { - const oauthConfig = new OauthConfigEntity({ - clientId: '12345', - clientSecret: 'mocksecret', - idpHint: 'mock-oauth-idpHint', - tokenEndpoint: 'http://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - redirectUri: 'http://mockhost:3030/api/v3/sso/oauth/', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'http://mock.de/auth', - provider: 'mock_type', - logoutEndpoint: 'http://mock.de/logout', - issuer: 'mock_issuer', - jwksEndpoint: 'http://mock.de/jwks', - }); - const ldapConfig = new LdapConfigEntity({ - url: 'ldaps:mock.de:389', - active: true, - provider: 'mock_provider', - }); - const system: SystemEntity = systemEntityFactory.buildWithId({ - type: 'oauth', - url: 'https://mock.de', - alias: 'alias', - displayName: 'displayName', - provisioningStrategy: SystemProvisioningStrategy.OIDC, - provisioningUrl: 'https://provisioningurl.de', - oauthConfig, - ldapConfig, - }); - - await em.persistAndFlush([system]); - em.clear(); - - return { - system, - oauthConfig, - ldapConfig, - }; - }; - - it('should return the system', async () => { - const { system, oauthConfig, ldapConfig } = await setup(); - - const result = await repo.findById(system.id); - - expect(result?.getProps()).toEqual({ - id: system.id, - type: system.type, - url: system.url, - displayName: system.displayName, - alias: system.alias, - provisioningStrategy: system.provisioningStrategy, - provisioningUrl: system.provisioningUrl, - oauthConfig: { - issuer: oauthConfig.issuer, - provider: oauthConfig.provider, - jwksEndpoint: oauthConfig.jwksEndpoint, - redirectUri: oauthConfig.redirectUri, - idpHint: oauthConfig.idpHint, - authEndpoint: oauthConfig.authEndpoint, - clientSecret: oauthConfig.clientSecret, - grantType: oauthConfig.grantType, - logoutEndpoint: oauthConfig.logoutEndpoint, - responseType: oauthConfig.responseType, - tokenEndpoint: oauthConfig.tokenEndpoint, - clientId: oauthConfig.clientId, - scope: oauthConfig.scope, - }, - ldapConfig: { - url: ldapConfig.url, - provider: ldapConfig.provider, - active: !!ldapConfig.active, - }, - }); - }); - }); - - describe('when the system does not exist', () => { - it('should return null', async () => { - const result = await repo.findById(new ObjectId().toHexString()); - - expect(result).toBeNull(); - }); - }); - }); - - describe('delete', () => { - describe('when the system exists', () => { - const setup = async () => { - const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); - - await em.persistAndFlush([systemEntity]); - em.clear(); - - const props: SystemProps = SystemDomainMapper.mapEntityToDomainObjectProperties(systemEntity); - const system: System = new System(props); - - return { - system, - }; - }; - - it('should delete the system', async () => { - const { system } = await setup(); - - await repo.delete(system); - - expect(await em.findOne(SystemEntity, { id: system.id })).toBeNull(); - }); - - it('should return true', async () => { - const { system } = await setup(); - - const result = await repo.delete(system); - - expect(result).toEqual(true); - }); - }); - - describe('when the system does not exists', () => { - const setup = () => { - const systemEntity: SystemEntity = systemEntityFactory.buildWithId(); - const props: SystemProps = SystemDomainMapper.mapEntityToDomainObjectProperties(systemEntity); - const system: System = new System(props); - - return { - system, - }; - }; - - it('should return false', async () => { - const { system } = setup(); - - const result = await repo.delete(system); - - expect(result).toEqual(false); - }); - }); - }); -}); diff --git a/apps/server/src/modules/system/repo/system.repo.ts b/apps/server/src/modules/system/repo/system.repo.ts deleted file mode 100644 index 7b54e45219f..00000000000 --- a/apps/server/src/modules/system/repo/system.repo.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { EntityManager } from '@mikro-orm/mongodb'; -import { Injectable } from '@nestjs/common'; -import { SystemEntity } from '@shared/domain/entity'; -import { EntityId } from '@shared/domain/types'; -import { System, SystemProps } from '../domain'; -import { SystemDomainMapper } from './system-domain.mapper'; - -@Injectable() -export class SystemRepo { - constructor(private readonly em: EntityManager) {} - - public async findById(id: EntityId): Promise { - const entity: SystemEntity | null = await this.em.findOne(SystemEntity, { id }); - - if (!entity) { - return null; - } - - const props: SystemProps = SystemDomainMapper.mapEntityToDomainObjectProperties(entity); - - const domainObject: System = new System(props); - - return domainObject; - } - - public async delete(domainObject: System): Promise { - const entity: SystemEntity | null = await this.em.findOne(SystemEntity, { id: domainObject.id }); - - if (!entity) { - return false; - } - - await this.em.removeAndFlush(entity); - - return true; - } -} diff --git a/apps/server/src/modules/system/service/system.service.spec.ts b/apps/server/src/modules/system/service/system.service.spec.ts index b565a74a2a2..5c1eec8f486 100644 --- a/apps/server/src/modules/system/service/system.service.spec.ts +++ b/apps/server/src/modules/system/service/system.service.spec.ts @@ -2,7 +2,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { systemFactory } from '@shared/testing'; -import { SystemRepo } from '../repo'; +import { SYSTEM_REPO, SystemRepo } from '../domain'; import { SystemService } from './system.service'; describe(SystemService.name, () => { @@ -16,14 +16,14 @@ describe(SystemService.name, () => { providers: [ SystemService, { - provide: SystemRepo, + provide: SYSTEM_REPO, useValue: createMock(), }, ], }).compile(); service = module.get(SystemService); - systemRepo = module.get(SystemRepo); + systemRepo = module.get(SYSTEM_REPO); }); afterAll(async () => { @@ -39,7 +39,7 @@ describe(SystemService.name, () => { const setup = () => { const system = systemFactory.build(); - systemRepo.findById.mockResolvedValueOnce(system); + systemRepo.getSystemById.mockResolvedValueOnce(system); return { system, @@ -57,7 +57,7 @@ describe(SystemService.name, () => { describe('when the system does not exist', () => { const setup = () => { - systemRepo.findById.mockResolvedValueOnce(null); + systemRepo.getSystemById.mockResolvedValueOnce(null); }; it('should return null', async () => { @@ -70,12 +70,64 @@ describe(SystemService.name, () => { }); }); + describe('getSystems', () => { + describe('when systems exist', () => { + const setup = () => { + const systems = systemFactory.buildList(3); + + systemRepo.getSystemsByIds.mockResolvedValueOnce(systems); + + return { + systems, + }; + }; + + it('should return the systems', async () => { + const { systems } = setup(); + + const result = await service.getSystems(systems.map((s) => s.id)); + + expect(result).toEqual(systems); + }); + }); + + describe('when no systems exist', () => { + const setup = () => { + systemRepo.getSystemsByIds.mockResolvedValueOnce([]); + }; + + it('should return empty array', async () => { + setup(); + + const result = await service.getSystems([new ObjectId().toHexString()]); + + expect(result).toEqual([]); + }); + }); + + describe('when throwing an error', () => { + const setup = () => { + const systemIds = [new ObjectId().toHexString()]; + const error = new Error('Connection error'); + systemRepo.getSystemsByIds.mockRejectedValueOnce(error); + + return { systemIds, error }; + }; + + it('should throw an error', async () => { + const { systemIds, error } = setup(); + + await expect(service.getSystems(systemIds)).rejects.toThrow(error); + }); + }); + }); + describe('delete', () => { describe('when the system was deleted', () => { const setup = () => { const system = systemFactory.build(); - systemRepo.delete.mockResolvedValueOnce(true); + systemRepo.delete.mockResolvedValueOnce(); return { system, @@ -95,19 +147,17 @@ describe(SystemService.name, () => { const setup = () => { const system = systemFactory.build(); - systemRepo.delete.mockResolvedValueOnce(false); + systemRepo.delete.mockRejectedValueOnce(new Error('Not found')); return { system, }; }; - it('should return false', async () => { + it('should throw an error', async () => { const { system } = setup(); - const result = await service.delete(system); - - expect(result).toEqual(false); + await expect(service.delete(system)).rejects.toThrowError('Not found'); }); }); }); diff --git a/apps/server/src/modules/system/service/system.service.ts b/apps/server/src/modules/system/service/system.service.ts index 50ad7fadabf..590ad227868 100644 --- a/apps/server/src/modules/system/service/system.service.ts +++ b/apps/server/src/modules/system/service/system.service.ts @@ -1,21 +1,32 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { EntityId } from '@shared/domain/types'; -import { System } from '../domain'; -import { SystemRepo } from '../repo'; +import { SYSTEM_REPO, System, SystemRepo } from '../domain'; @Injectable() export class SystemService { - constructor(private readonly systemRepo: SystemRepo) {} + constructor(@Inject(SYSTEM_REPO) private readonly systemRepo: SystemRepo) {} public async findById(id: EntityId): Promise { - const system: System | null = await this.systemRepo.findById(id); + const system = await this.systemRepo.getSystemById(id); return system; } + public async getSystems(id: EntityId[]): Promise { + const systems = await this.systemRepo.getSystemsByIds(id); + + return systems; + } + + public async findAllForLdapLogin(): Promise { + const systems = await this.systemRepo.findAllForLdapLogin(); + + return systems; + } + public async delete(domainObject: System): Promise { - const deleted: boolean = await this.systemRepo.delete(domainObject); + await this.systemRepo.delete(domainObject); - return deleted; + return true; } } diff --git a/apps/server/src/modules/system/system-api.module.ts b/apps/server/src/modules/system/system-api.module.ts index e9201f376b8..54592a5c4e6 100644 --- a/apps/server/src/modules/system/system-api.module.ts +++ b/apps/server/src/modules/system/system-api.module.ts @@ -1,11 +1,12 @@ import { AuthorizationModule } from '@modules/authorization'; +import { LegacySchoolModule } from '@modules/legacy-school'; import { SystemController } from '@modules/system/controller/system.controller'; import { SystemUc } from '@modules/system/uc/system.uc'; import { Module } from '@nestjs/common'; import { SystemModule } from './system.module'; @Module({ - imports: [SystemModule, AuthorizationModule], + imports: [SystemModule, AuthorizationModule, LegacySchoolModule], providers: [SystemUc], controllers: [SystemController], }) diff --git a/apps/server/src/modules/system/system.module.ts b/apps/server/src/modules/system/system.module.ts index 54c9d51224b..e2d358fb061 100644 --- a/apps/server/src/modules/system/system.module.ts +++ b/apps/server/src/modules/system/system.module.ts @@ -1,13 +1,20 @@ import { IdentityManagementModule } from '@infra/identity-management/identity-management.module'; import { Module } from '@nestjs/common'; import { LegacySystemRepo } from '@shared/repo'; -import { SystemRepo } from './repo'; +import { SYSTEM_REPO } from './domain'; +import { SystemMikroOrmRepo } from './repo/mikro-orm/system.repo'; import { LegacySystemService, SystemService } from './service'; import { SystemOidcService } from './service/system-oidc.service'; @Module({ imports: [IdentityManagementModule], - providers: [LegacySystemRepo, LegacySystemService, SystemOidcService, SystemService, SystemRepo], + providers: [ + LegacySystemRepo, + LegacySystemService, + SystemOidcService, + SystemService, + { provide: SYSTEM_REPO, useClass: SystemMikroOrmRepo }, + ], exports: [LegacySystemService, SystemOidcService, SystemService], }) export class SystemModule {} diff --git a/apps/server/src/modules/system/uc/system.uc.spec.ts b/apps/server/src/modules/system/uc/system.uc.spec.ts index 96ca3518b71..80202175beb 100644 --- a/apps/server/src/modules/system/uc/system.uc.spec.ts +++ b/apps/server/src/modules/system/uc/system.uc.spec.ts @@ -1,15 +1,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; import { SystemDto } from '@modules/system/service/dto/system.dto'; import { SystemUc } from '@modules/system/uc/system.uc'; import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { LegacySchoolDo } from '@shared/domain/domainobject'; import { SystemEntity } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId, SystemTypeEnum } from '@shared/domain/types'; -import { setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; -import { AuthorizationContextBuilder, AuthorizationService } from '../../authorization'; +import { legacySchoolDoFactory, setupEntities, systemEntityFactory, systemFactory, userFactory } from '@shared/testing'; +import { SystemType } from '../domain'; import { SystemMapper } from '../mapper'; import { LegacySystemService, SystemService } from '../service'; @@ -25,6 +28,7 @@ describe('SystemUc', () => { let legacySystemService: DeepMocked; let systemService: DeepMocked; let authorizationService: DeepMocked; + let schoolService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -44,6 +48,10 @@ describe('SystemUc', () => { provide: AuthorizationService, useValue: createMock(), }, + { + provide: LegacySchoolService, + useValue: createMock(), + }, ], }).compile(); @@ -51,6 +59,7 @@ describe('SystemUc', () => { legacySystemService = module.get(LegacySystemService); systemService = module.get(SystemService); authorizationService = module.get(AuthorizationService); + schoolService = module.get(LegacySchoolService); }); afterAll(async () => { @@ -161,20 +170,29 @@ describe('SystemUc', () => { const setup = () => { const user = userFactory.buildWithId(); const system = systemFactory.build(); + const otherSystemId = new ObjectId().toHexString(); + const school = legacySchoolDoFactory.build({ + systems: [system.id, otherSystemId], + ldapLastSync: new Date().toString(), + externalId: 'test', + }); systemService.findById.mockResolvedValueOnce(system); authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); return { user, system, + school, + otherSystemId, }; }; it('should check the permission', async () => { const { user, system } = setup(); - await systemUc.delete(user.id, system.id); + await systemUc.delete(user.id, user.school.id, system.id); expect(authorizationService.checkPermission).toHaveBeenCalledWith( user, @@ -186,10 +204,57 @@ describe('SystemUc', () => { it('should delete the system', async () => { const { user, system } = setup(); - await systemUc.delete(user.id, system.id); + await systemUc.delete(user.id, user.school.id, system.id); expect(systemService.delete).toHaveBeenCalledWith(system); }); + + it('should remove the system from the school', async () => { + const { user, system, school, otherSystemId } = setup(); + + await systemUc.delete(user.id, user.school.id, system.id); + + expect(schoolService.save).toHaveBeenCalledWith( + expect.objectContaining>({ + systems: [otherSystemId], + ldapLastSync: undefined, + externalId: school.externalId, + }) + ); + }); + }); + + describe('when the system is the last ldap system at the school', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const system = systemFactory.build({ type: SystemType.LDAP }); + const school = legacySchoolDoFactory.build({ + systems: [system.id], + ldapLastSync: new Date().toString(), + externalId: 'test', + }); + + systemService.findById.mockResolvedValueOnce(system); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); + + return { + user, + system, + }; + }; + + it('should remove the external id of the school', async () => { + const { user, system } = setup(); + + await systemUc.delete(user.id, user.school.id, system.id); + + expect(schoolService.save).toHaveBeenCalledWith( + expect.objectContaining>({ + externalId: undefined, + }) + ); + }); }); describe('when the system does not exist', () => { @@ -200,15 +265,17 @@ describe('SystemUc', () => { it('should throw a not found exception', async () => { setup(); - await expect(systemUc.delete(new ObjectId().toHexString(), new ObjectId().toHexString())).rejects.toThrow( - NotFoundLoggableException - ); + await expect( + systemUc.delete(new ObjectId().toHexString(), new ObjectId().toHexString(), new ObjectId().toHexString()) + ).rejects.toThrow(NotFoundLoggableException); }); it('should not delete any system', async () => { setup(); - await expect(systemUc.delete(new ObjectId().toHexString(), new ObjectId().toHexString())).rejects.toThrow(); + await expect( + systemUc.delete(new ObjectId().toHexString(), new ObjectId().toHexString(), new ObjectId().toHexString()) + ).rejects.toThrow(); expect(systemService.delete).not.toHaveBeenCalled(); }); @@ -236,13 +303,13 @@ describe('SystemUc', () => { it('should throw an error', async () => { const { user, system, error } = setup(); - await expect(systemUc.delete(user.id, system.id)).rejects.toThrow(error); + await expect(systemUc.delete(user.id, user.school.id, system.id)).rejects.toThrow(error); }); it('should not delete any system', async () => { const { user, system } = setup(); - await expect(systemUc.delete(user.id, system.id)).rejects.toThrow(); + await expect(systemUc.delete(user.id, user.school.id, system.id)).rejects.toThrow(); expect(systemService.delete).not.toHaveBeenCalled(); }); diff --git a/apps/server/src/modules/system/uc/system.uc.ts b/apps/server/src/modules/system/uc/system.uc.ts index 2f28fa6957a..9a13ad77214 100644 --- a/apps/server/src/modules/system/uc/system.uc.ts +++ b/apps/server/src/modules/system/uc/system.uc.ts @@ -1,11 +1,13 @@ import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { LegacySchoolService } from '@modules/legacy-school'; import { Injectable } from '@nestjs/common'; import { EntityNotFoundError } from '@shared/common'; import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { LegacySchoolDo } from '@shared/domain/domainobject'; import { SystemEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; -import { EntityId, SystemType, SystemTypeEnum } from '@shared/domain/types'; -import { System } from '../domain'; +import { EntityId, SystemTypeEnum } from '@shared/domain/types'; +import { System, SystemType } from '../domain'; import { LegacySystemService, SystemDto, SystemService } from '../service'; @Injectable() @@ -13,10 +15,11 @@ export class SystemUc { constructor( private readonly legacySystemService: LegacySystemService, private readonly systemService: SystemService, - private readonly authorizationService: AuthorizationService + private readonly authorizationService: AuthorizationService, + private readonly schoolService: LegacySchoolService ) {} - async findByFilter(type?: SystemType, onlyOauth = false): Promise { + async findByFilter(type?: SystemTypeEnum, onlyOauth = false): Promise { let systems: SystemDto[]; if (onlyOauth) { @@ -40,7 +43,7 @@ export class SystemUc { return system; } - async delete(userId: EntityId, systemId: EntityId): Promise { + async delete(userId: EntityId, schoolId: EntityId, systemId: EntityId): Promise { const system: System | null = await this.systemService.findById(systemId); if (!system) { @@ -55,5 +58,20 @@ export class SystemUc { ); await this.systemService.delete(system); + + await this.removeSystemFromSchool(schoolId, system); + } + + private async removeSystemFromSchool(schoolId: string, system: System) { + const school: LegacySchoolDo = await this.schoolService.getSchoolById(schoolId); + + school.systems = school.systems?.filter((schoolSystemId: string) => schoolSystemId !== system.id); + school.ldapLastSync = undefined; + + if (system.type === SystemType.LDAP && school.systems?.length === 0) { + school.externalId = undefined; + } + + await this.schoolService.save(school); } } diff --git a/apps/server/src/modules/task/controller/task.controller.ts b/apps/server/src/modules/task/controller/task.controller.ts index b8e7231a1b6..cb1bc1ba8b1 100644 --- a/apps/server/src/modules/task/controller/task.controller.ts +++ b/apps/server/src/modules/task/controller/task.controller.ts @@ -4,8 +4,6 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestj import { ApiTags } from '@nestjs/swagger'; import { RequestTimeout } from '@shared/common'; import { PaginationParams } from '@shared/controller/'; -// invalid import can produce dependency cycles -import { serverConfig } from '@modules/server/server.config'; import { TaskMapper } from '../mapper'; import { TaskCopyUC } from '../uc/task-copy.uc'; import { TaskUC } from '../uc/task.uc'; @@ -81,7 +79,7 @@ export class TaskController { } @Post(':taskId/copy') - @RequestTimeout(serverConfig().INCOMING_REQUEST_TIMEOUT_COPY_API) + @RequestTimeout('INCOMING_REQUEST_TIMEOUT_COPY_API') async copyTask( @CurrentUser() currentUser: ICurrentUser, @Param() urlParams: TaskUrlParams, diff --git a/apps/server/src/modules/task/service/submission.service.spec.ts b/apps/server/src/modules/task/service/submission.service.spec.ts index a75ba5d51ac..5500a4bde11 100644 --- a/apps/server/src/modules/task/service/submission.service.spec.ts +++ b/apps/server/src/modules/task/service/submission.service.spec.ts @@ -5,7 +5,18 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Submission } from '@shared/domain/entity'; import { Counted } from '@shared/domain/types'; import { SubmissionRepo } from '@shared/repo'; -import { setupEntities, submissionFactory, taskFactory } from '@shared/testing'; +import { setupEntities, submissionFactory, taskFactory, userFactory } from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { ObjectId } from 'bson'; +import { EventBus } from '@nestjs/cqrs'; +import { + DomainOperationReportBuilder, + OperationType, + DomainDeletionReportBuilder, + DomainName, + DataDeletedEvent, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { SubmissionService } from './submission.service'; describe('Submission Service', () => { @@ -13,6 +24,7 @@ describe('Submission Service', () => { let service: SubmissionService; let submissionRepo: DeepMocked; let filesStorageClientAdapterService: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -29,12 +41,23 @@ describe('Submission Service', () => { provide: FilesStorageClientAdapterService, useValue: createMock(), }, + { + provide: Logger, + useValue: createMock(), + }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); service = module.get(SubmissionService); submissionRepo = module.get(SubmissionRepo); filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); + eventBus = module.get(EventBus); }); afterAll(async () => { @@ -211,4 +234,215 @@ describe('Submission Service', () => { }); }); }); + + describe('deleteSingleSubmissionsOwnedByUser', () => { + describe('when submission with specified userId was not found ', () => { + const setup = () => { + const submission = submissionFactory.buildWithId(); + + submissionRepo.findAllByUserId.mockResolvedValueOnce([[], 0]); + + return { submission }; + }; + + it('should return deletedSubmissions number of 0', async () => { + const { submission } = setup(); + + const result = await service.deleteSingleSubmissionsOwnedByUser(new ObjectId().toString()); + + expect(result.count).toEqual(0); + expect(result.refs.length).toEqual(0); + expect(submission).toBeDefined(); + }); + }); + + describe('when submission with specified userId was found ', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const submission = submissionFactory.buildWithId({ student: user, teamMembers: [user] }); + + submissionRepo.findAllByUserId.mockResolvedValueOnce([[submission], 1]); + submissionRepo.delete.mockResolvedValueOnce(); + + return { submission, user }; + }; + + it('should return deletedSubmissions number of 1', async () => { + const { submission, user } = setup(); + + const result = await service.deleteSingleSubmissionsOwnedByUser(user.id); + + expect(result.count).toEqual(1); + expect(result.refs.length).toEqual(1); + expect(submissionRepo.delete).toBeCalledTimes(1); + expect(submissionRepo.delete).toHaveBeenCalledWith([submission]); + }); + }); + }); + + describe('removeUserReferencesFromSubmissions', () => { + describe('when submission with specified userId was not found ', () => { + const setup = () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const submission = submissionFactory.buildWithId({ student: user1, teamMembers: [user1, user2] }); + + submissionRepo.findAllByUserId.mockResolvedValueOnce([[], 0]); + + return { submission, user1, user2 }; + }; + + it('should return updated submission number of 0', async () => { + const { submission, user1 } = setup(); + + const result = await service.removeUserReferencesFromSubmissions(new ObjectId().toString()); + + expect(result.count).toEqual(0); + expect(result.refs.length).toEqual(0); + expect(submission.student).toEqual(user1); + expect(submission.teamMembers.length).toEqual(2); + }); + }); + + describe('when submission with specified userId was found ', () => { + const setup = () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const submission = submissionFactory.buildWithId({ + student: user1, + teamMembers: [user1, user2], + }); + + submissionRepo.findAllByUserId.mockResolvedValueOnce([[submission], 1]); + submissionRepo.delete.mockResolvedValueOnce(); + + return { submission, user1, user2 }; + }; + + it('should return updated submission number of 1', async () => { + const { submission, user1, user2 } = setup(); + + const result = await service.removeUserReferencesFromSubmissions(user1.id); + + expect(result.count).toEqual(1); + expect(result.refs.length).toEqual(1); + expect(submission.student).toBeUndefined(); + expect(submission.teamMembers.length).toEqual(1); + expect(submission.teamMembers[0]).toEqual(user2); + expect(submissionRepo.save).toBeCalledTimes(1); + expect(submissionRepo.save).toHaveBeenCalledWith([submission]); + }); + }); + }); + + describe('deleteUserData', () => { + const setup = () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const submission1 = submissionFactory.buildWithId({ student: user1, teamMembers: [user1] }); + const submission2 = submissionFactory.buildWithId({ + student: user1, + teamMembers: [user1, user2], + }); + + submissionRepo.findAllByUserId.mockResolvedValueOnce([[submission1, submission2], 2]); + + const expectedResultForOwner = DomainOperationReportBuilder.build(OperationType.DELETE, 1, [submission1.id]); + + const expectedResultForUsersPermission = DomainOperationReportBuilder.build(OperationType.DELETE, 1, [ + submission2.id, + ]); + + const expectedResult = DomainDeletionReportBuilder.build(DomainName.SUBMISSIONS, [ + expectedResultForOwner, + expectedResultForUsersPermission, + ]); + + return { + user1, + expectedResultForOwner, + expectedResultForUsersPermission, + expectedResult, + }; + }; + + describe('when deleteUserData', () => { + it('should call deleteSingleSubmissionsOwnedByUser with userId', async () => { + const { user1, expectedResultForOwner } = setup(); + jest.spyOn(service, 'deleteSingleSubmissionsOwnedByUser').mockResolvedValueOnce(expectedResultForOwner); + + await service.deleteUserData(user1.id); + + expect(service.deleteSingleSubmissionsOwnedByUser).toHaveBeenCalledWith(user1.id); + }); + + it('should call removeUserReferencesFromSubmissions with userId', async () => { + const { user1, expectedResultForUsersPermission } = setup(); + jest + .spyOn(service, 'removeUserReferencesFromSubmissions') + .mockResolvedValueOnce(expectedResultForUsersPermission); + + await service.deleteUserData(user1.id); + + expect(service.removeUserReferencesFromSubmissions).toHaveBeenCalledWith(user1.id); + }); + + it('should return domainOperation object with information about deleted user data', async () => { + const { user1, expectedResultForOwner, expectedResultForUsersPermission, expectedResult } = setup(); + + jest.spyOn(service, 'deleteSingleSubmissionsOwnedByUser').mockResolvedValueOnce(expectedResultForOwner); + jest + .spyOn(service, 'removeUserReferencesFromSubmissions') + .mockResolvedValueOnce(expectedResultForUsersPermission); + + const result = await service.deleteUserData(user1.id); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILERECORDS; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in classService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(service.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); + }); + }); + }); }); diff --git a/apps/server/src/modules/task/service/submission.service.ts b/apps/server/src/modules/task/service/submission.service.ts index b6a545c51c1..17dc1201aa8 100644 --- a/apps/server/src/modules/task/service/submission.service.ts +++ b/apps/server/src/modules/task/service/submission.service.ts @@ -1,15 +1,40 @@ import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { Injectable } from '@nestjs/common'; +import { IEventHandler, EventBus, EventsHandler } from '@nestjs/cqrs'; import { Submission } from '@shared/domain/entity'; import { Counted, EntityId } from '@shared/domain/types'; import { SubmissionRepo } from '@shared/repo'; +import { Logger } from '@src/core/logger'; +import { + UserDeletedEvent, + DeletionService, + DataDeletedEvent, + DomainDeletionReport, + DomainDeletionReportBuilder, + DomainName, + DomainOperationReport, + DataDeletionDomainOperationLoggable, + DomainOperationReportBuilder, + OperationType, + StatusModel, +} from '@modules/deletion'; @Injectable() -export class SubmissionService { +@EventsHandler(UserDeletedEvent) +export class SubmissionService implements DeletionService, IEventHandler { constructor( private readonly submissionRepo: SubmissionRepo, - private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService - ) {} + private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, + private readonly logger: Logger, + private readonly eventBus: EventBus + ) { + this.logger.setContext(SubmissionService.name); + } + + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } async findById(submissionId: EntityId): Promise { return this.submissionRepo.findById(submissionId); @@ -26,4 +51,104 @@ export class SubmissionService { await this.submissionRepo.delete(submission); } + + public async deleteUserData(userId: EntityId): Promise { + const [submissionsDeleted, submissionsModified] = await Promise.all([ + this.deleteSingleSubmissionsOwnedByUser(userId), + this.removeUserReferencesFromSubmissions(userId), + ]); + + const result = DomainDeletionReportBuilder.build(DomainName.SUBMISSIONS, [submissionsDeleted, submissionsModified]); + + return result; + } + + public async deleteSingleSubmissionsOwnedByUser(userId: EntityId): Promise { + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'Deleting single Submissions owned by user', + DomainName.SUBMISSIONS, + userId, + StatusModel.PENDING + ) + ); + let [submissionsEntities, submissionsCount] = await this.submissionRepo.findAllByUserId(userId); + + if (submissionsCount > 0) { + submissionsEntities = submissionsEntities.filter((submission) => submission.isSingleSubmissionOwnedByUser()); + submissionsCount = submissionsEntities.length; + } + + if (submissionsCount > 0) { + await this.submissionRepo.delete(submissionsEntities); + } + + const result = DomainOperationReportBuilder.build( + OperationType.DELETE, + submissionsCount, + this.getSubmissionsId(submissionsEntities) + ); + + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'Successfully deleted single Submissions owned by user', + DomainName.SUBMISSIONS, + userId, + StatusModel.FINISHED, + submissionsCount, + 0 + ) + ); + + return result; + } + + public async removeUserReferencesFromSubmissions(userId: EntityId): Promise { + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'Deleting user references from Submissions', + DomainName.SUBMISSIONS, + userId, + StatusModel.PENDING + ) + ); + + let [submissionsEntities, submissionsCount] = await this.submissionRepo.findAllByUserId(userId); + + if (submissionsCount > 0) { + submissionsEntities = submissionsEntities.filter((submission) => submission.isGroupSubmission()); + submissionsCount = submissionsEntities.length; + } + + if (submissionsCount > 0) { + submissionsEntities.forEach((submission) => { + submission.removeStudentById(userId); + submission.removeUserFromTeamMembers(userId); + }); + + await this.submissionRepo.save(submissionsEntities); + } + + const result = DomainOperationReportBuilder.build( + OperationType.UPDATE, + submissionsCount, + this.getSubmissionsId(submissionsEntities) + ); + + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'Successfully deleted references from Submissions collection', + DomainName.SUBMISSIONS, + userId, + StatusModel.FINISHED, + submissionsCount, + 0 + ) + ); + return result; + } + + private getSubmissionsId(submissions: Submission[]): EntityId[] { + return submissions.map((submission) => submission.id); + } } diff --git a/apps/server/src/modules/task/service/task-copy.service.spec.ts b/apps/server/src/modules/task/service/task-copy.service.spec.ts index ca320ac60d6..4c77b83b1a7 100644 --- a/apps/server/src/modules/task/service/task-copy.service.spec.ts +++ b/apps/server/src/modules/task/service/task-copy.service.spec.ts @@ -1,18 +1,18 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { CopyElementType, CopyHelperService, CopyStatusEnum } from '@modules/copy-helper'; +import { CopyFilesService } from '@modules/files-storage-client'; import { Test, TestingModule } from '@nestjs/testing'; import { Task } from '@shared/domain/entity'; import { TaskRepo } from '@shared/repo'; import { courseFactory, + legacyFileEntityMockFactory, lessonFactory, - schoolFactory, + schoolEntityFactory, setupEntities, taskFactory, userFactory, - legacyFileEntityMockFactory, } from '@shared/testing'; -import { CopyElementType, CopyHelperService, CopyStatusEnum } from '@modules/copy-helper'; -import { CopyFilesService } from '@modules/files-storage-client'; import { TaskCopyService } from './task-copy.service'; describe('task copy service', () => { @@ -61,7 +61,7 @@ describe('task copy service', () => { describe('handleCopyTask', () => { describe('when copying task within original course', () => { const setup = () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); const destinationCourse = courseFactory.buildWithId({ school, teachers: [user] }); const destinationLesson = lessonFactory.buildWithId({ course: destinationCourse }); @@ -292,8 +292,8 @@ describe('task copy service', () => { describe('when copying task into different school', () => { it('should set the school of the copy to the school of the user', async () => { - const originalSchool = schoolFactory.buildWithId(); - const destinationSchool = schoolFactory.buildWithId(); + const originalSchool = schoolEntityFactory.buildWithId(); + const destinationSchool = schoolEntityFactory.buildWithId(); const originalCourse = courseFactory.build({ school: originalSchool }); const originalLesson = lessonFactory.build({ course: originalCourse }); const destinationCourse = courseFactory.buildWithId({ school: destinationSchool }); @@ -409,7 +409,7 @@ describe('task copy service', () => { }; const setupWithFiles = () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const file1 = legacyFileEntityMockFactory.build(); const file2 = legacyFileEntityMockFactory.build(); const imageHTML1 = getImageHTML(file1.id, file1.name); diff --git a/apps/server/src/modules/task/service/task-copy.service.ts b/apps/server/src/modules/task/service/task-copy.service.ts index ee12c5925d3..95a5a83d08b 100644 --- a/apps/server/src/modules/task/service/task-copy.service.ts +++ b/apps/server/src/modules/task/service/task-copy.service.ts @@ -1,6 +1,5 @@ import { CopyElementType, CopyHelperService, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; -import { CopyFilesService } from '@modules/files-storage-client'; -import { FileUrlReplacement } from '@modules/files-storage-client/service/copy-files.service'; +import { CopyFilesService, FileUrlReplacement } from '@modules/files-storage-client'; import { Injectable } from '@nestjs/common'; import { Course, LessonEntity, Task, User } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; diff --git a/apps/server/src/modules/task/service/task.service.spec.ts b/apps/server/src/modules/task/service/task.service.spec.ts index 913ae9745b2..70dedcd12e7 100644 --- a/apps/server/src/modules/task/service/task.service.spec.ts +++ b/apps/server/src/modules/task/service/task.service.spec.ts @@ -3,9 +3,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TaskRepo } from '@shared/repo'; import { courseFactory, setupEntities, submissionFactory, taskFactory, userFactory } from '@shared/testing'; import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; -import { DomainModel } from '@shared/domain/types'; -import { DomainOperationBuilder } from '@shared/domain/builder'; import { Logger } from '@src/core/logger'; +import { EventBus } from '@nestjs/cqrs'; +import { ObjectId } from 'bson'; +import { + DomainOperationReportBuilder, + OperationType, + DomainDeletionReportBuilder, + DomainName, + DataDeletedEvent, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { SubmissionService } from './submission.service'; import { TaskService } from './task.service'; @@ -15,6 +23,7 @@ describe('TaskService', () => { let taskService: TaskService; let submissionService: DeepMocked; let fileStorageClientAdapterService: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -36,6 +45,12 @@ describe('TaskService', () => { provide: Logger, useValue: createMock(), }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); @@ -43,6 +58,7 @@ describe('TaskService', () => { taskService = module.get(TaskService); submissionService = module.get(SubmissionService); fileStorageClientAdapterService = module.get(FilesStorageClientAdapterService); + eventBus = module.get(EventBus); await setupEntities(); }); @@ -118,8 +134,7 @@ describe('TaskService', () => { taskRepo.findByOnlyCreatorId.mockResolvedValue([[taskWithoutCourse], 1]); - const expectedResult = DomainOperationBuilder.build(DomainModel.TASK, 0, 1); - + const expectedResult = DomainOperationReportBuilder.build(OperationType.DELETE, 1, [taskWithoutCourse.id]); return { creator, expectedResult }; }; @@ -150,7 +165,7 @@ describe('TaskService', () => { taskRepo.findByCreatorIdWithCourseAndLesson.mockResolvedValue([[taskWithCourse], 1]); - const expectedResult = DomainOperationBuilder.build(DomainModel.TASK, 1, 0); + const expectedResult = DomainOperationReportBuilder.build(OperationType.UPDATE, 1, [taskWithCourse.id]); const taskWithCourseToUpdate = { ...taskWithCourse, creator: undefined }; return { creator, expectedResult, taskWithCourseToUpdate }; @@ -190,7 +205,7 @@ describe('TaskService', () => { taskRepo.findByUserIdInFinished.mockResolvedValue([[finishedTask], 1]); - const expectedResult = DomainOperationBuilder.build(DomainModel.TASK, 1, 0); + const expectedResult = DomainOperationReportBuilder.build(OperationType.UPDATE, 1, [finishedTask.id]); return { creator, expectedResult }; }; @@ -212,4 +227,134 @@ describe('TaskService', () => { }); }); }); + + describe('deleteUserData', () => { + const setup = () => { + const creator = userFactory.buildWithId(); + const taskWithoutCourse = taskFactory.buildWithId({ creator }); + const course = courseFactory.build(); + const taskWithCourse = taskFactory.buildWithId({ creator, course }); + const finishedTask = taskFactory.finished(creator).buildWithId(); + + taskRepo.findByOnlyCreatorId.mockResolvedValue([[taskWithoutCourse], 1]); + taskRepo.findByCreatorIdWithCourseAndLesson.mockResolvedValue([[taskWithCourse], 1]); + taskRepo.findByUserIdInFinished.mockResolvedValue([[finishedTask], 1]); + + const expectedResultByOnlyCreator = DomainOperationReportBuilder.build(OperationType.DELETE, 1, [ + taskWithoutCourse.id, + ]); + + const expectedResultWithCreatorInTask = DomainOperationReportBuilder.build(OperationType.UPDATE, 1, [ + taskWithCourse.id, + ]); + + const expectedResultForFinishedTask = DomainOperationReportBuilder.build(OperationType.UPDATE, 1, [ + finishedTask.id, + ]); + + const expectedResult = DomainDeletionReportBuilder.build(DomainName.TASK, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [taskWithoutCourse.id]), + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [taskWithCourse.id, finishedTask.id]), + ]); + + return { + creator, + expectedResultByOnlyCreator, + expectedResultWithCreatorInTask, + expectedResultForFinishedTask, + expectedResult, + }; + }; + + describe('when deleteUserData', () => { + it('should call deleteTasksByOnlyCreator with userId', async () => { + const { creator, expectedResultByOnlyCreator } = setup(); + jest.spyOn(taskService, 'deleteTasksByOnlyCreator').mockResolvedValueOnce(expectedResultByOnlyCreator); + + await taskService.deleteUserData(creator.id); + + expect(taskService.deleteTasksByOnlyCreator).toHaveBeenCalledWith(creator.id); + }); + + it('should call removeCreatorIdFromTasks with userId', async () => { + const { creator, expectedResultWithCreatorInTask } = setup(); + jest.spyOn(taskService, 'removeCreatorIdFromTasks').mockResolvedValueOnce(expectedResultWithCreatorInTask); + + await taskService.deleteUserData(creator.id); + + expect(taskService.removeCreatorIdFromTasks).toHaveBeenCalledWith(creator.id); + }); + + it('should call removeUserFromFinished with userId', async () => { + const { creator, expectedResultForFinishedTask } = setup(); + jest.spyOn(taskService, 'removeUserFromFinished').mockResolvedValueOnce(expectedResultForFinishedTask); + + await taskService.deleteUserData(creator.id); + + expect(taskService.removeUserFromFinished).toHaveBeenCalledWith(creator.id); + }); + + it('should return domainOperation object with information about deleted user data', async () => { + const { + creator, + expectedResult, + expectedResultForFinishedTask, + expectedResultWithCreatorInTask, + expectedResultByOnlyCreator, + } = setup(); + + jest.spyOn(taskService, 'removeUserFromFinished').mockResolvedValueOnce(expectedResultForFinishedTask); + jest.spyOn(taskService, 'removeCreatorIdFromTasks').mockResolvedValueOnce(expectedResultWithCreatorInTask); + jest.spyOn(taskService, 'deleteTasksByOnlyCreator').mockResolvedValueOnce(expectedResultByOnlyCreator); + + const result = await taskService.deleteUserData(creator.id); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILERECORDS; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEventis received', () => { + it('should call deleteUserData in classService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(taskService, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await taskService.handle({ deletionRequestId, targetRefId }); + + expect(taskService.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(taskService, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await taskService.handle({ deletionRequestId, targetRefId }); + + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); + }); + }); + }); }); diff --git a/apps/server/src/modules/task/service/task.service.ts b/apps/server/src/modules/task/service/task.service.ts index 337358f10fd..64fc56caf11 100644 --- a/apps/server/src/modules/task/service/task.service.ts +++ b/apps/server/src/modules/task/service/task.service.ts @@ -1,25 +1,44 @@ import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { Injectable } from '@nestjs/common'; import { Task } from '@shared/domain/entity'; -import { DomainOperation, IFindOptions } from '@shared/domain/interface'; -import { Counted, DomainModel, EntityId, StatusModel } from '@shared/domain/types'; +import { IFindOptions } from '@shared/domain/interface'; +import { Counted, EntityId } from '@shared/domain/types'; import { TaskRepo } from '@shared/repo'; -import { DomainOperationBuilder } from '@shared/domain/builder'; import { Logger } from '@src/core/logger'; -import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; +import { IEventHandler, EventBus, EventsHandler } from '@nestjs/cqrs'; +import { + UserDeletedEvent, + DeletionService, + DataDeletedEvent, + DomainDeletionReport, + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DomainOperationReport, + DataDeletionDomainOperationLoggable, + StatusModel, +} from '@modules/deletion'; import { SubmissionService } from './submission.service'; @Injectable() -export class TaskService { +@EventsHandler(UserDeletedEvent) +export class TaskService implements DeletionService, IEventHandler { constructor( private readonly taskRepo: TaskRepo, private readonly submissionService: SubmissionService, private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService, - private readonly logger: Logger + private readonly logger: Logger, + private readonly eventBus: EventBus ) { this.logger.setContext(TaskService.name); } + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + async findBySingleParent( creatorId: EntityId, courseId: EntityId, @@ -48,11 +67,29 @@ export class TaskService { return this.taskRepo.findById(taskId); } - async deleteTasksByOnlyCreator(creatorId: EntityId): Promise { + public async deleteUserData(creatorId: EntityId): Promise { + const [tasksDeleted, tasksModifiedByRemoveCreator, tasksModifiedByRemoveUserFromFinished] = await Promise.all([ + this.deleteTasksByOnlyCreator(creatorId), + this.removeCreatorIdFromTasks(creatorId), + this.removeUserFromFinished(creatorId), + ]); + + const modifiedTasksCount = tasksModifiedByRemoveCreator.count + tasksModifiedByRemoveUserFromFinished.count; + const modifiedTasksRef = [...tasksModifiedByRemoveCreator.refs, ...tasksModifiedByRemoveUserFromFinished.refs]; + + const result = DomainDeletionReportBuilder.build(DomainName.TASK, [ + tasksDeleted, + DomainOperationReportBuilder.build(OperationType.UPDATE, modifiedTasksCount, modifiedTasksRef), + ]); + + return result; + } + + public async deleteTasksByOnlyCreator(creatorId: EntityId): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting data from Task', - DomainModel.TASK, + DomainName.TASK, creatorId, StatusModel.PENDING ) @@ -65,11 +102,16 @@ export class TaskService { await Promise.all(promiseDeletedTasks); } - const result = DomainOperationBuilder.build(DomainModel.TASK, 0, counterOfTasksOnlyWithCreatorId); + const result = DomainOperationReportBuilder.build( + OperationType.DELETE, + counterOfTasksOnlyWithCreatorId, + this.getTasksId(tasksByOnlyCreatorId) + ); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully deleted data from Task', - DomainModel.TASK, + DomainName.TASK, creatorId, StatusModel.FINISHED, counterOfTasksOnlyWithCreatorId, @@ -80,11 +122,11 @@ export class TaskService { return result; } - async removeCreatorIdFromTasks(creatorId: EntityId): Promise { + public async removeCreatorIdFromTasks(creatorId: EntityId): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting user data from Task', - DomainModel.TASK, + DomainName.TASK, creatorId, StatusModel.PENDING ) @@ -97,11 +139,16 @@ export class TaskService { await this.taskRepo.save(tasksByCreatorIdWithCoursesAndLessons); } - const result = DomainOperationBuilder.build(DomainModel.TASK, counterOfTasksWithCoursesorLessons, 0); + const result = DomainOperationReportBuilder.build( + OperationType.UPDATE, + counterOfTasksWithCoursesorLessons, + this.getTasksId(tasksByCreatorIdWithCoursesAndLessons) + ); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully deleted user data from Task', - DomainModel.TASK, + DomainName.TASK, creatorId, StatusModel.FINISHED, counterOfTasksWithCoursesorLessons, @@ -111,11 +158,11 @@ export class TaskService { return result; } - async removeUserFromFinished(userId: EntityId): Promise { + public async removeUserFromFinished(userId: EntityId): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting user data from Task archive collection', - DomainModel.TASK, + DomainName.TASK, userId, StatusModel.PENDING ) @@ -130,11 +177,16 @@ export class TaskService { await this.taskRepo.save(tasksWithUserInFinished); } - const result = DomainOperationBuilder.build(DomainModel.TASK, counterOfTasksWithUserInFinished, 0); + const result = DomainOperationReportBuilder.build( + OperationType.UPDATE, + counterOfTasksWithUserInFinished, + this.getTasksId(tasksWithUserInFinished) + ); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully deleted user data from Task archive collection', - DomainModel.TASK, + DomainName.TASK, userId, StatusModel.FINISHED, counterOfTasksWithUserInFinished, @@ -144,4 +196,8 @@ export class TaskService { return result; } + + private getTasksId(tasks: Task[]): EntityId[] { + return tasks.map((task) => task.id); + } } diff --git a/apps/server/src/modules/task/task.module.ts b/apps/server/src/modules/task/task.module.ts index bd68fa8c5ef..5eeae20b8e8 100644 --- a/apps/server/src/modules/task/task.module.ts +++ b/apps/server/src/modules/task/task.module.ts @@ -3,10 +3,11 @@ import { FilesStorageClientModule } from '@modules/files-storage-client'; import { Module } from '@nestjs/common'; import { CourseRepo, SubmissionRepo, TaskRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; +import { CqrsModule } from '@nestjs/cqrs'; import { SubmissionService, TaskCopyService, TaskService } from './service'; @Module({ - imports: [FilesStorageClientModule, CopyHelperModule, LoggerModule], + imports: [FilesStorageClientModule, CopyHelperModule, CqrsModule, LoggerModule], providers: [TaskService, TaskCopyService, SubmissionService, TaskRepo, CourseRepo, SubmissionRepo], exports: [TaskService, TaskCopyService, SubmissionService], }) diff --git a/apps/server/src/modules/teams/service/team.service.spec.ts b/apps/server/src/modules/teams/service/team.service.spec.ts index d14f9fbfc19..7a9dd6959bf 100644 --- a/apps/server/src/modules/teams/service/team.service.spec.ts +++ b/apps/server/src/modules/teams/service/team.service.spec.ts @@ -3,6 +3,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TeamsRepo } from '@shared/repo'; import { setupEntities, teamFactory, teamUserFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; +import { EventBus } from '@nestjs/cqrs/dist'; +import { ObjectId } from 'bson'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DataDeletedEvent, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; import { TeamService } from './team.service'; describe('TeamService', () => { @@ -10,6 +20,7 @@ describe('TeamService', () => { let service: TeamService; let teamsRepo: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -23,11 +34,18 @@ describe('TeamService', () => { provide: Logger, useValue: createMock(), }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, ], }).compile(); service = module.get(TeamService); teamsRepo = module.get(TeamsRepo); + eventBus = module.get(EventBus); await setupEntities(); }); @@ -81,7 +99,12 @@ describe('TeamService', () => { teamsRepo.findByUserId.mockResolvedValue([team1, team2]); + const expectedResult = DomainDeletionReportBuilder.build(DomainName.TEAMS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [team1.id, team2.id]), + ]); + return { + expectedResult, teamUser, }; }; @@ -89,17 +112,61 @@ describe('TeamService', () => { it('should call teamsRepo.findByUserId', async () => { const { teamUser } = setup(); - await service.deleteUserDataFromTeams(teamUser.user.id); + await service.deleteUserData(teamUser.user.id); expect(teamsRepo.findByUserId).toBeCalledWith(teamUser.user.id); }); it('should update teams without deleted user', async () => { - const { teamUser } = setup(); + const { expectedResult, teamUser } = setup(); + + const result = await service.deleteUserData(teamUser.user.id); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILERECORDS; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in classService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(service.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); - const result = await service.deleteUserDataFromTeams(teamUser.user.id); + await service.handle({ deletionRequestId, targetRefId }); - expect(result).toEqual(2); + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); }); }); }); diff --git a/apps/server/src/modules/teams/service/team.service.ts b/apps/server/src/modules/teams/service/team.service.ts index 2264af9a56c..43cadc8b434 100644 --- a/apps/server/src/modules/teams/service/team.service.ts +++ b/apps/server/src/modules/teams/service/team.service.ts @@ -1,27 +1,49 @@ import { Injectable } from '@nestjs/common'; -import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; +import { EventsHandler, IEventHandler, EventBus } from '@nestjs/cqrs'; import { TeamEntity } from '@shared/domain/entity'; -import { DomainModel, EntityId, StatusModel } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; import { TeamsRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; +import { + UserDeletedEvent, + DeletionService, + DataDeletedEvent, + DomainDeletionReport, + DomainName, + DomainDeletionReportBuilder, + DomainOperationReportBuilder, + OperationType, + DataDeletionDomainOperationLoggable, + StatusModel, +} from '@modules/deletion'; @Injectable() -export class TeamService { - constructor(private readonly teamsRepo: TeamsRepo, private readonly logger: Logger) { +@EventsHandler(UserDeletedEvent) +export class TeamService implements DeletionService, IEventHandler { + constructor( + private readonly teamsRepo: TeamsRepo, + private readonly logger: Logger, + private readonly eventBus: EventBus + ) { this.logger.setContext(TeamService.name); } + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + public async findUserDataFromTeams(userId: EntityId): Promise { const teams = await this.teamsRepo.findByUserId(userId); return teams; } - public async deleteUserDataFromTeams(userId: EntityId): Promise { + public async deleteUserData(userId: EntityId): Promise { this.logger.info( new DataDeletionDomainOperationLoggable( 'Deleting user data from Teams', - DomainModel.TEAMS, + DomainName.TEAMS, userId, StatusModel.PENDING ) @@ -36,17 +58,25 @@ export class TeamService { const numberOfUpdatedTeams = teams.length; + const result = DomainDeletionReportBuilder.build(DomainName.TEAMS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, numberOfUpdatedTeams, this.getTeamsId(teams)), + ]); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully deleted user data from Teams', - DomainModel.TEAMS, + DomainName.TEAMS, userId, - StatusModel.PENDING, + StatusModel.FINISHED, numberOfUpdatedTeams, 0 ) ); - return numberOfUpdatedTeams; + return result; + } + + private getTeamsId(teams: TeamEntity[]): EntityId[] { + return teams.map((team) => team.id); } } diff --git a/apps/server/src/modules/teams/teams.module.ts b/apps/server/src/modules/teams/teams.module.ts index e8d56860d36..3b96d986c0a 100644 --- a/apps/server/src/modules/teams/teams.module.ts +++ b/apps/server/src/modules/teams/teams.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { TeamsRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; +import { CqrsModule } from '@nestjs/cqrs'; import { TeamService } from './service'; @Module({ - imports: [LoggerModule], + imports: [CqrsModule, LoggerModule], providers: [TeamService, TeamsRepo], exports: [TeamService], }) diff --git a/apps/server/src/modules/tldraw-client/index.ts b/apps/server/src/modules/tldraw-client/index.ts index 5b97403deca..40cf6230c34 100644 --- a/apps/server/src/modules/tldraw-client/index.ts +++ b/apps/server/src/modules/tldraw-client/index.ts @@ -1 +1,4 @@ -export * from './tldraw-client.module'; +export { TldrawClientModule } from './tldraw-client.module'; +export { DrawingElementAdapterService } from './service/drawing-element-adapter.service'; +export { TldrawClientConfig } from './interface'; +export { getTldrawClientConfig } from './tldraw-client.config'; diff --git a/apps/server/src/modules/tldraw-client/interface/index.ts b/apps/server/src/modules/tldraw-client/interface/index.ts new file mode 100644 index 00000000000..9dddff4ce51 --- /dev/null +++ b/apps/server/src/modules/tldraw-client/interface/index.ts @@ -0,0 +1 @@ +export * from './tldraw-client-config.interface'; diff --git a/apps/server/src/modules/tldraw-client/interface/tldraw-client-config.interface.ts b/apps/server/src/modules/tldraw-client/interface/tldraw-client-config.interface.ts new file mode 100644 index 00000000000..40564ab2e81 --- /dev/null +++ b/apps/server/src/modules/tldraw-client/interface/tldraw-client-config.interface.ts @@ -0,0 +1,4 @@ +export interface TldrawClientConfig { + TLDRAW_ADMIN_API_CLIENT_BASE_URL: string; + TLDRAW_ADMIN_API_CLIENT_API_KEY: string; +} diff --git a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts index 6d738d8782d..80c383bf545 100644 --- a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts +++ b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.spec.ts @@ -5,6 +5,7 @@ import { axiosResponseFactory, setupEntities } from '@shared/testing'; import { HttpService } from '@nestjs/axios'; import { of } from 'rxjs'; import { LegacyLogger } from '@src/core/logger'; +import { ConfigService } from '@nestjs/config'; import { DrawingElementAdapterService } from './drawing-element-adapter.service'; describe(DrawingElementAdapterService.name, () => { @@ -24,6 +25,19 @@ describe(DrawingElementAdapterService.name, () => { provide: LegacyLogger, useValue: createMock(), }, + { + provide: ConfigService, + useValue: createMock({ + get: jest.fn((key: string) => { + if (key === 'TLDRAW_ADMIN_API_CLIENT_BASE_URL') { + return 'http://localhost:3349'; + } + + // Default is for the Tldraw API Key. + return 'a4a20e6a-8036-4603-aba6-378006fedce2'; + }), + }), + }, ], }).compile(); diff --git a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts index ff3f18abfb6..5987fe6239c 100644 --- a/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts +++ b/apps/server/src/modules/tldraw-client/service/drawing-element-adapter.service.ts @@ -1,22 +1,35 @@ import { Injectable } from '@nestjs/common'; import { LegacyLogger } from '@src/core/logger'; import { firstValueFrom } from 'rxjs'; -import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { TldrawClientConfig } from '../interface'; @Injectable() export class DrawingElementAdapterService { - constructor(private logger: LegacyLogger, private readonly httpService: HttpService) { + constructor( + private logger: LegacyLogger, + private readonly httpService: HttpService, + private readonly configService: ConfigService + ) { this.logger.setContext(DrawingElementAdapterService.name); } async deleteDrawingBinData(docName: string): Promise { - await firstValueFrom( - this.httpService.delete(`${Configuration.get('TLDRAW_URI') as string}/api/v3/tldraw-document/${docName}`, { - headers: { - Accept: 'Application/json', - }, - }) - ); + const baseUrl = this.configService.get('TLDRAW_ADMIN_API_CLIENT_BASE_URL'); + const tldrawDocumentEndpoint = new URL('/api/v3/tldraw-document', baseUrl).toString(); + await firstValueFrom(this.httpService.delete(`${tldrawDocumentEndpoint}/${docName}`, this.defaultHeaders())); + } + + private apiKeyHeader() { + const apiKey = this.configService.get('TLDRAW_ADMIN_API_CLIENT_API_KEY'); + + return { 'X-Api-Key': apiKey, Accept: 'Application/json' }; + } + + private defaultHeaders() { + return { + headers: this.apiKeyHeader(), + }; } } diff --git a/apps/server/src/modules/tldraw-client/service/index.ts b/apps/server/src/modules/tldraw-client/service/index.ts deleted file mode 100644 index 10a16c9972a..00000000000 --- a/apps/server/src/modules/tldraw-client/service/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './drawing-element-adapter.service'; diff --git a/apps/server/src/modules/tldraw-client/tldraw-client.config.spec.ts b/apps/server/src/modules/tldraw-client/tldraw-client.config.spec.ts new file mode 100644 index 00000000000..b0633fdbbe0 --- /dev/null +++ b/apps/server/src/modules/tldraw-client/tldraw-client.config.spec.ts @@ -0,0 +1,40 @@ +import { IConfig } from '@hpi-schul-cloud/commons/lib/interfaces/IConfig'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { getTldrawClientConfig } from './tldraw-client.config'; + +describe(getTldrawClientConfig.name, () => { + let configBefore: IConfig; + + beforeAll(() => { + configBefore = Configuration.toObject({ plainSecrets: true }); + }); + + afterEach(() => { + Configuration.reset(configBefore); + }); + + describe('when called', () => { + const setup = () => { + const baseUrl = 'http://tldraw-server-svc:3349'; + const apiKey = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'; + + Configuration.set('TLDRAW_ADMIN_API_CLIENT__BASE_URL', baseUrl); + Configuration.set('TLDRAW_ADMIN_API_CLIENT__API_KEY', apiKey); + + const expectedConfig = { + TLDRAW_ADMIN_API_CLIENT_BASE_URL: baseUrl, + TLDRAW_ADMIN_API_CLIENT_API_KEY: apiKey, + }; + + return { expectedConfig }; + }; + + it('should return config with proper values', () => { + const { expectedConfig } = setup(); + + const config = getTldrawClientConfig(); + + expect(config).toEqual(expectedConfig); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw-client/tldraw-client.config.ts b/apps/server/src/modules/tldraw-client/tldraw-client.config.ts new file mode 100644 index 00000000000..b778408b0c9 --- /dev/null +++ b/apps/server/src/modules/tldraw-client/tldraw-client.config.ts @@ -0,0 +1,9 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { TldrawClientConfig } from './interface'; + +export const getTldrawClientConfig = (): TldrawClientConfig => { + return { + TLDRAW_ADMIN_API_CLIENT_BASE_URL: Configuration.get('TLDRAW_ADMIN_API_CLIENT__BASE_URL') as string, + TLDRAW_ADMIN_API_CLIENT_API_KEY: Configuration.get('TLDRAW_ADMIN_API_CLIENT__API_KEY') as string, + }; +}; diff --git a/apps/server/src/modules/tldraw-client/tldraw-client.module.ts b/apps/server/src/modules/tldraw-client/tldraw-client.module.ts index e015715b208..58035ea974e 100644 --- a/apps/server/src/modules/tldraw-client/tldraw-client.module.ts +++ b/apps/server/src/modules/tldraw-client/tldraw-client.module.ts @@ -1,10 +1,13 @@ +import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { LoggerModule } from '@src/core/logger'; -import { DrawingElementAdapterService } from './service'; +import { DrawingElementAdapterService } from './service/drawing-element-adapter.service'; +import { getTldrawClientConfig } from './tldraw-client.config'; @Module({ - imports: [LoggerModule], + imports: [LoggerModule, ConfigModule.forFeature(getTldrawClientConfig), HttpModule], providers: [DrawingElementAdapterService], - exports: [], + exports: [DrawingElementAdapterService], }) export class TldrawClientModule {} diff --git a/apps/server/src/modules/tldraw/config.ts b/apps/server/src/modules/tldraw/config.ts index 50219da6516..b0a017dd59c 100644 --- a/apps/server/src/modules/tldraw/config.ts +++ b/apps/server/src/modules/tldraw/config.ts @@ -1,32 +1,44 @@ import { Configuration } from '@hpi-schul-cloud/commons'; export interface TldrawConfig { + TLDRAW_DB_URL: string; NEST_LOG_LEVEL: string; INCOMING_REQUEST_TIMEOUT: number; - TLDRAW_DB_COLLECTION_NAME: string; - TLDRAW_DB_FLUSH_SIZE: string; - TLDRAW_DB_MULTIPLE_COLLECTIONS: boolean; + TLDRAW_DB_COMPRESS_THRESHOLD: string; CONNECTION_STRING: string; FEATURE_TLDRAW_ENABLED: boolean; TLDRAW_PING_TIMEOUT: number; TLDRAW_GC_ENABLED: number; + REDIS_URI: string; + TLDRAW_ASSETS_ENABLED: boolean; + TLDRAW_ASSETS_SYNC_ENABLED: boolean; + TLDRAW_ASSETS_MAX_SIZE: number; + ASSETS_ALLOWED_MIME_TYPES_LIST: string; API_HOST: number; + TLDRAW_MAX_DOCUMENT_SIZE: number; + TLDRAW_FINALIZE_DELAY: number; } -const tldrawConnectionString: string = Configuration.get('TLDRAW_DB_URL') as string; +export const TLDRAW_DB_URL: string = Configuration.get('TLDRAW_DB_URL') as string; +export const TLDRAW_SOCKET_PORT = Configuration.get('TLDRAW__SOCKET_PORT') as number; const tldrawConfig = { + TLDRAW_DB_URL, NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, INCOMING_REQUEST_TIMEOUT: Configuration.get('INCOMING_REQUEST_TIMEOUT_API') as number, - TLDRAW_DB_COLLECTION_NAME: Configuration.get('TLDRAW__DB_COLLECTION_NAME') as string, - TLDRAW_DB_FLUSH_SIZE: Configuration.get('TLDRAW__DB_FLUSH_SIZE') as number, - TLDRAW_DB_MULTIPLE_COLLECTIONS: Configuration.get('TLDRAW__DB_MULTIPLE_COLLECTIONS') as boolean, + TLDRAW_DB_COMPRESS_THRESHOLD: Configuration.get('TLDRAW__DB_COMPRESS_THRESHOLD') as number, FEATURE_TLDRAW_ENABLED: Configuration.get('FEATURE_TLDRAW_ENABLED') as boolean, - CONNECTION_STRING: tldrawConnectionString, + CONNECTION_STRING: Configuration.get('TLDRAW_DB_URL') as string, TLDRAW_PING_TIMEOUT: Configuration.get('TLDRAW__PING_TIMEOUT') as number, TLDRAW_GC_ENABLED: Configuration.get('TLDRAW__GC_ENABLED') as boolean, + REDIS_URI: Configuration.has('REDIS_URI') ? (Configuration.get('REDIS_URI') as string) : null, + TLDRAW_ASSETS_ENABLED: Configuration.get('TLDRAW__ASSETS_ENABLED') as boolean, + TLDRAW_ASSETS_SYNC_ENABLED: Configuration.get('TLDRAW__ASSETS_SYNC_ENABLED') as boolean, + TLDRAW_ASSETS_MAX_SIZE: Configuration.get('TLDRAW__ASSETS_MAX_SIZE') as number, + ASSETS_ALLOWED_MIME_TYPES_LIST: Configuration.get('TLDRAW__ASSETS_ALLOWED_MIME_TYPES_LIST') as string, API_HOST: Configuration.get('API_HOST') as string, + TLDRAW_MAX_DOCUMENT_SIZE: Configuration.get('TLDRAW__MAX_DOCUMENT_SIZE') as number, + TLDRAW_FINALIZE_DELAY: Configuration.get('TLDRAW__FINALIZE_DELAY') as number, }; -export const SOCKET_PORT = Configuration.get('TLDRAW__SOCKET_PORT') as number; export const config = () => tldrawConfig; diff --git a/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.401.api.spec.ts b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.401.api.spec.ts new file mode 100644 index 00000000000..407d04f793d --- /dev/null +++ b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.401.api.spec.ts @@ -0,0 +1,58 @@ +import { INestApplication } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { courseFactory, TestXApiKeyClient, UserAndAccountTestFactory } from '@shared/testing'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ServerTestModule } from '@modules/server'; +import { Logger } from '@src/core/logger'; +import { TldrawService } from '../../service'; +import { TldrawController } from '..'; +import { TldrawRepo } from '../../repo'; +import { tldrawEntityFactory } from '../../testing'; + +const baseRouteName = '/tldraw-document'; +describe('tldraw controller (api)', () => { + let app: INestApplication; + let em: EntityManager; + let testXApiKeyClient: TestXApiKeyClient; + const API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + controllers: [TldrawController], + providers: [Logger, TldrawService, TldrawRepo], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testXApiKeyClient = new TestXApiKeyClient(app, baseRouteName, API_KEY); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('when request does not contain token', () => { + const setup = async () => { + const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); + const course = courseFactory.build({ teachers: [teacherUser] }); + await em.persistAndFlush([teacherAccount, teacherUser, course]); + + const drawingItemData = tldrawEntityFactory.build(); + + await em.persistAndFlush([drawingItemData]); + em.clear(); + + return { teacherUser, drawingItemData }; + }; + + it('should return status 401 for delete', async () => { + const { drawingItemData } = await setup(); + + const response = await testXApiKeyClient.delete(`${drawingItemData.docName}`); + + expect(response.status).toEqual(401); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts index 6d04d1d5871..7d2c6ce3c3b 100644 --- a/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts +++ b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts @@ -1,31 +1,43 @@ -import { INestApplication } from '@nestjs/common'; +import { ExecutionContext, INestApplication } from '@nestjs/common'; import { EntityManager } from '@mikro-orm/mongodb'; -import { cleanupCollections, courseFactory, TestApiClient, UserAndAccountTestFactory } from '@shared/testing'; +import { courseFactory, TestXApiKeyClient, UserAndAccountTestFactory } from '@shared/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { ServerTestModule } from '@modules/server'; import { Logger } from '@src/core/logger'; +import { AuthGuard } from '@nestjs/passport'; +import { Request } from 'express'; import { TldrawService } from '../../service'; import { TldrawController } from '..'; import { TldrawRepo } from '../../repo'; -import { tldrawEntityFactory } from '../../factory'; +import { tldrawEntityFactory } from '../../testing'; const baseRouteName = '/tldraw-document'; describe('tldraw controller (api)', () => { let app: INestApplication; let em: EntityManager; - let testApiClient: TestApiClient; + let testXApiKeyClient: TestXApiKeyClient; + const API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], controllers: [TldrawController], providers: [Logger, TldrawService, TldrawRepo], - }).compile(); + }) + .overrideGuard(AuthGuard('api-key')) + .useValue({ + canActivate(context: ExecutionContext) { + const req: Request = context.switchToHttp().getRequest(); + req.headers['X-API-KEY'] = API_KEY; + return true; + }, + }) + .compile(); app = module.createNestApplication(); await app.init(); em = module.get(EntityManager); - testApiClient = new TestApiClient(app, baseRouteName); + testXApiKeyClient = new TestXApiKeyClient(app, baseRouteName, API_KEY); }); afterAll(async () => { @@ -34,8 +46,6 @@ describe('tldraw controller (api)', () => { describe('with valid user', () => { const setup = async () => { - await cleanupCollections(em); - const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher(); const course = courseFactory.build({ teachers: [teacherUser] }); await em.persistAndFlush([teacherAccount, teacherUser, course]); @@ -45,25 +55,15 @@ describe('tldraw controller (api)', () => { await em.persistAndFlush([drawingItemData]); em.clear(); - const loggedInClient = await testApiClient.login(teacherAccount); - - return { loggedInClient, teacherUser, drawingItemData }; + return { teacherUser, drawingItemData }; }; it('should return status 200 for delete', async () => { - const { loggedInClient, drawingItemData } = await setup(); + const { drawingItemData } = await setup(); - const response = await loggedInClient.delete(`${drawingItemData.docName}`); + const response = await testXApiKeyClient.delete(`${drawingItemData.docName}`); expect(response.status).toEqual(204); }); - - it('should return status 404 for delete with wrong id', async () => { - const { loggedInClient } = await setup(); - - const response = await loggedInClient.delete(`testID123`); - - expect(response.status).toEqual(404); - }); }); }); diff --git a/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts b/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts index 6a0a3cc4fc3..78e5d9b0163 100644 --- a/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts +++ b/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts @@ -2,16 +2,25 @@ import { WsAdapter } from '@nestjs/platform-ws'; import { Test } from '@nestjs/testing'; import WebSocket from 'ws'; import { TextEncoder } from 'util'; -import { INestApplication } from '@nestjs/common'; -import { throwError } from 'rxjs'; +import { INestApplication, NotAcceptableException } from '@nestjs/common'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { createConfigModuleOptions } from '@src/config'; +import { Logger } from '@src/core/logger'; +import { of, throwError } from 'rxjs'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; -import { AxiosError, AxiosRequestHeaders } from 'axios'; -import { WsCloseCodeEnum, WsCloseMessageEnum } from '../../types'; -import { TldrawWsTestModule } from '../../tldraw-ws-test.module'; +import { AxiosError, AxiosHeaders, AxiosResponse } from 'axios'; +import { axiosResponseFactory } from '@shared/testing'; +import { TldrawRedisFactory, TldrawRedisService } from '../../redis'; +import { TldrawDrawing } from '../../entities'; import { TldrawWsService } from '../../service'; -import { TestConnection } from '../../testing/test-connection'; -import { TldrawWs } from '../tldraw.ws'; +import { TldrawBoardRepo, TldrawRepo, YMongodb } from '../../repo'; +import { TestConnection, tldrawTestConfig } from '../../testing'; +import { MetricsService } from '../../metrics'; +import { TldrawWs } from '..'; +import { WsCloseCode, WsCloseMessage } from '../../types'; +import { TldrawConfig } from '../../config'; describe('WebSocketController (WsAdapter)', () => { let app: INestApplication; @@ -19,6 +28,7 @@ describe('WebSocketController (WsAdapter)', () => { let ws: WebSocket; let wsService: TldrawWsService; let httpService: DeepMocked; + let configService: ConfigService; const gatewayPort = 3346; const wsUrl = TestConnection.getWsUrl(gatewayPort); @@ -28,17 +38,37 @@ describe('WebSocketController (WsAdapter)', () => { beforeAll(async () => { const testingModule = await Test.createTestingModule({ - imports: [TldrawWsTestModule], + imports: [ + MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), + ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), + ], providers: [ + TldrawWs, + TldrawWsService, + TldrawBoardRepo, + YMongodb, + MetricsService, + TldrawRedisFactory, + TldrawRedisService, + { + provide: TldrawRepo, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, { provide: HttpService, useValue: createMock(), }, ], }).compile(); - gateway = testingModule.get(TldrawWs); - wsService = testingModule.get(TldrawWsService); + + gateway = testingModule.get(TldrawWs); + wsService = testingModule.get(TldrawWsService); httpService = testingModule.get(HttpService); + configService = testingModule.get(ConfigService); app = testingModule.createNestApplication(); app.useWebSocketAdapter(new WsAdapter(app)); await app.init(); @@ -48,10 +78,6 @@ describe('WebSocketController (WsAdapter)', () => { await app.close(); }); - beforeEach(() => { - jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); - }); - afterEach(() => { jest.clearAllMocks(); }); @@ -62,7 +88,6 @@ describe('WebSocketController (WsAdapter)', () => { jest.spyOn(Uint8Array.prototype, 'reduce').mockReturnValueOnce(1); ws = await TestConnection.setupWs(wsUrl, 'TEST'); - const { buffer } = getMessage(); return { handleConnectionSpy, buffer }; @@ -70,6 +95,7 @@ describe('WebSocketController (WsAdapter)', () => { it(`should handle connection`, async () => { const { handleConnectionSpy, buffer } = await setup(); + ws.send(buffer, () => {}); expect(handleConnectionSpy).toHaveBeenCalledTimes(1); @@ -109,10 +135,10 @@ describe('WebSocketController (WsAdapter)', () => { it(`should handle 2 connections at same doc and data transfer`, async () => { const { handleConnectionSpy, ws2, buffer } = await setup(); + ws.send(buffer); ws2.send(buffer); - expect(handleConnectionSpy).toHaveBeenCalled(); expect(handleConnectionSpy).toHaveBeenCalledTimes(2); handleConnectionSpy.mockRestore(); @@ -137,10 +163,7 @@ describe('WebSocketController (WsAdapter)', () => { ws = await TestConnection.setupWs(wsUrl, 'TEST', {}); - expect(wsCloseSpy).toHaveBeenCalledWith( - WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, - WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE - ); + expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.UNAUTHORIZED, Buffer.from(WsCloseMessage.UNAUTHORIZED)); httpGetCallSpy.mockRestore(); wsCloseSpy.mockRestore(); @@ -152,12 +175,9 @@ describe('WebSocketController (WsAdapter)', () => { const error = new Error('unknown error'); httpGetCallSpy.mockReturnValueOnce(throwError(() => error)); - ws = await TestConnection.setupWs(wsUrl, 'TEST', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + ws = await TestConnection.setupWs(wsUrl, 'TEST', { cookie: 'jwt=jwt-mocked' }); - expect(wsCloseSpy).toHaveBeenCalledWith( - WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, - WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE - ); + expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.UNAUTHORIZED, Buffer.from(WsCloseMessage.UNAUTHORIZED)); httpGetCallSpy.mockRestore(); wsCloseSpy.mockRestore(); @@ -165,14 +185,39 @@ describe('WebSocketController (WsAdapter)', () => { }); }); + describe('when tldraw feature is disabled', () => { + const setup = () => { + const wsCloseSpy = jest.spyOn(WebSocket.prototype, 'close'); + const configSpy = jest.spyOn(configService, 'get').mockReturnValueOnce(false); + + return { + wsCloseSpy, + configSpy, + }; + }; + + it('should close', async () => { + const { wsCloseSpy } = setup(); + + ws = await TestConnection.setupWs(wsUrl, 'test-doc'); + + expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.BAD_REQUEST, Buffer.from(WsCloseMessage.FEATURE_DISABLED)); + + wsCloseSpy.mockRestore(); + ws.close(); + }); + }); + describe('when checking docName and cookie', () => { const setup = () => { - const setupConnectionSpy = jest.spyOn(wsService, 'setupWSConnection'); + const setupConnectionSpy = jest.spyOn(wsService, 'setupWsConnection'); const wsCloseSpy = jest.spyOn(WebSocket.prototype, 'close'); + const closeConnSpy = jest.spyOn(wsService, 'closeConnection').mockRejectedValue(new Error('error')); return { setupConnectionSpy, wsCloseSpy, + closeConnSpy, }; }; @@ -180,13 +225,10 @@ describe('WebSocketController (WsAdapter)', () => { const { setupConnectionSpy, wsCloseSpy } = setup(); const { buffer } = getMessage(); - ws = await TestConnection.setupWs(wsUrl, '', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + ws = await TestConnection.setupWs(wsUrl, '', { cookie: 'jwt=jwt-mocked' }); ws.send(buffer); - expect(wsCloseSpy).toHaveBeenCalledWith( - WsCloseCodeEnum.WS_CLIENT_BAD_REQUEST_CODE, - WsCloseMessageEnum.WS_CLIENT_BAD_REQUEST_MESSAGE - ); + expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.BAD_REQUEST, Buffer.from(WsCloseMessage.BAD_REQUEST)); wsCloseSpy.mockRestore(); setupConnectionSpy.mockRestore(); @@ -195,44 +237,71 @@ describe('WebSocketController (WsAdapter)', () => { it(`should close for not existing docName resource`, async () => { const { setupConnectionSpy, wsCloseSpy } = setup(); - const authorizeConnectionSpy = jest.spyOn(wsService, 'authorizeConnection'); - authorizeConnectionSpy.mockImplementationOnce(() => { - throw new AxiosError('Resource not found', '404', undefined, undefined, { - config: { headers: {} as AxiosRequestHeaders }, - data: undefined, - request: undefined, - statusText: '', - status: 404, - headers: {}, - }); + + const httpGetCallSpy = jest.spyOn(httpService, 'get'); + const error = new AxiosError('unknown error', '404', undefined, undefined, { + config: { headers: new AxiosHeaders() }, + data: undefined, + headers: {}, + statusText: '404', + status: 404, }); - ws = await TestConnection.setupWs(wsUrl, 'GLOBAL', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + httpGetCallSpy.mockReturnValueOnce(throwError(() => error)); - expect(wsCloseSpy).toHaveBeenCalledWith( - WsCloseCodeEnum.WS_CLIENT_NOT_FOUND_CODE, - WsCloseMessageEnum.WS_CLIENT_NOT_FOUND_MESSAGE - ); + ws = await TestConnection.setupWs(wsUrl, 'GLOBAL', { cookie: 'jwt=jwt-mocked' }); + + expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.NOT_FOUND, Buffer.from(WsCloseMessage.NOT_FOUND)); - authorizeConnectionSpy.mockRestore(); wsCloseSpy.mockRestore(); setupConnectionSpy.mockRestore(); ws.close(); }); - it(`should close for not authorizing connection`, async () => { + it(`should close for not authorized connection`, async () => { const { setupConnectionSpy, wsCloseSpy } = setup(); const { buffer } = getMessage(); const httpGetCallSpy = jest.spyOn(httpService, 'get'); - const error = new Error('unknown error'); + const error = new AxiosError('unknown error', '401', undefined, undefined, { + config: { headers: new AxiosHeaders() }, + data: undefined, + headers: {}, + statusText: '401', + status: 401, + }); httpGetCallSpy.mockReturnValueOnce(throwError(() => error)); - ws = await TestConnection.setupWs(wsUrl, 'TEST', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + ws = await TestConnection.setupWs(wsUrl, 'TEST', { cookie: 'jwt=jwt-mocked' }); + ws.send(buffer); + + expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.UNAUTHORIZED, Buffer.from(WsCloseMessage.UNAUTHORIZED)); + + wsCloseSpy.mockRestore(); + setupConnectionSpy.mockRestore(); + httpGetCallSpy.mockRestore(); + ws.close(); + }); + + it(`should close on unexpected error code`, async () => { + const { setupConnectionSpy, wsCloseSpy } = setup(); + const { buffer } = getMessage(); + + const httpGetCallSpy = jest.spyOn(httpService, 'get'); + const error = new AxiosError('unknown error', '418', undefined, undefined, { + config: { headers: new AxiosHeaders() }, + data: undefined, + headers: {}, + statusText: '418', + status: 418, + }); + httpGetCallSpy.mockReturnValueOnce(throwError(() => error)); + + ws = await TestConnection.setupWs(wsUrl, 'TEST', { cookie: 'jwt=jwt-mocked' }); ws.send(buffer); expect(wsCloseSpy).toHaveBeenCalledWith( - WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, - WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE + WsCloseCode.INTERNAL_SERVER_ERROR, + Buffer.from(WsCloseMessage.INTERNAL_SERVER_ERROR) ); wsCloseSpy.mockRestore(); @@ -245,18 +314,20 @@ describe('WebSocketController (WsAdapter)', () => { const { setupConnectionSpy, wsCloseSpy } = setup(); const { buffer } = getMessage(); - const httpGetCallSpy = jest - .spyOn(wsService, 'authorizeConnection') - .mockImplementationOnce(() => Promise.resolve()); + const httpGetCallSpy = jest.spyOn(httpService, 'get'); + const axiosResponse: AxiosResponse = axiosResponseFactory.build({ + data: '', + }); - ws = await TestConnection.setupWs(wsUrl, 'TEST', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + httpGetCallSpy.mockImplementationOnce(() => of(axiosResponse)); + + ws = await TestConnection.setupWs(wsUrl, 'TEST', { cookie: 'jwt=jwt-mocked' }); ws.send(buffer); expect(setupConnectionSpy).toHaveBeenCalledWith(expect.anything(), 'TEST'); wsCloseSpy.mockRestore(); setupConnectionSpy.mockRestore(); - httpGetCallSpy.mockRestore(); ws.close(); }); @@ -264,25 +335,50 @@ describe('WebSocketController (WsAdapter)', () => { const { setupConnectionSpy, wsCloseSpy } = setup(); const { buffer } = getMessage(); - const httpGetCallSpy = jest - .spyOn(wsService, 'authorizeConnection') - .mockImplementationOnce(() => Promise.resolve()); + const httpGetCallSpy = jest.spyOn(httpService, 'get'); + const axiosResponse: AxiosResponse = axiosResponseFactory.build({ + data: '', + }); + httpGetCallSpy.mockReturnValueOnce(of(axiosResponse)); setupConnectionSpy.mockImplementationOnce(() => { throw new Error('unknown error'); }); - ws = await TestConnection.setupWs(wsUrl, 'TEST', { headers: { cookie: { jwt: 'jwt-mocked' } } }); + ws = await TestConnection.setupWs(wsUrl, 'TEST', { cookie: 'jwt=jwt-mocked' }); ws.send(buffer); expect(setupConnectionSpy).toHaveBeenCalledWith(expect.anything(), 'TEST'); expect(wsCloseSpy).toHaveBeenCalledWith( - WsCloseCodeEnum.WS_CLIENT_ESTABLISHING_CONNECTION_CODE, - WsCloseMessageEnum.WS_CLIENT_ESTABLISHING_CONNECTION_MESSAGE + WsCloseCode.INTERNAL_SERVER_ERROR, + Buffer.from(WsCloseMessage.INTERNAL_SERVER_ERROR) ); wsCloseSpy.mockRestore(); setupConnectionSpy.mockRestore(); - httpGetCallSpy.mockRestore(); + ws.close(); + }); + + it('should close after setup connection throws NotAcceptableException', async () => { + const { setupConnectionSpy, wsCloseSpy } = setup(); + const { buffer } = getMessage(); + + const httpGetCallSpy = jest.spyOn(httpService, 'get'); + const axiosResponse: AxiosResponse = axiosResponseFactory.build({ + data: '', + }); + httpGetCallSpy.mockReturnValueOnce(of(axiosResponse)); + setupConnectionSpy.mockImplementationOnce(() => { + throw new NotAcceptableException(); + }); + + ws = await TestConnection.setupWs(wsUrl, 'TEST', { cookie: 'jwt=jwt-mocked' }); + ws.send(buffer); + + expect(setupConnectionSpy).toHaveBeenCalledWith(expect.anything(), 'TEST'); + expect(wsCloseSpy).toHaveBeenCalledWith(WsCloseCode.NOT_ACCEPTABLE, Buffer.from(WsCloseMessage.NOT_ACCEPTABLE)); + + wsCloseSpy.mockRestore(); + setupConnectionSpy.mockRestore(); ws.close(); }); }); diff --git a/apps/server/src/modules/tldraw/controller/tldraw.controller.ts b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts index 3bc7137f5ec..7ae5ece2048 100644 --- a/apps/server/src/modules/tldraw/controller/tldraw.controller.ts +++ b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts @@ -1,10 +1,12 @@ import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { Controller, Delete, ForbiddenException, HttpCode, NotFoundException, Param } from '@nestjs/common'; +import { Controller, Delete, ForbiddenException, HttpCode, NotFoundException, Param, UseGuards } from '@nestjs/common'; import { ApiValidationError } from '@shared/common'; -import { TldrawService } from '../service/tldraw.service'; +import { AuthGuard } from '@nestjs/passport'; +import { TldrawService } from '../service'; import { TldrawDeleteParams } from './tldraw.params'; @ApiTags('Tldraw Document') +@UseGuards(AuthGuard('api-key')) @Controller('tldraw-document') export class TldrawController { constructor(private readonly tldrawService: TldrawService) {} diff --git a/apps/server/src/modules/tldraw/controller/tldraw.ws.ts b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts index 4aad2c404b3..feefed9127f 100644 --- a/apps/server/src/modules/tldraw/controller/tldraw.ws.ts +++ b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts @@ -1,17 +1,24 @@ import { WebSocketGateway, WebSocketServer, OnGatewayInit, OnGatewayConnection } from '@nestjs/websockets'; -import { Server, WebSocket } from 'ws'; +import WebSocket, { Server } from 'ws'; import { Request } from 'express'; import { ConfigService } from '@nestjs/config'; import cookie from 'cookie'; -import { BadRequestException } from '@nestjs/common'; +import { + InternalServerErrorException, + UnauthorizedException, + NotFoundException, + NotAcceptableException, +} from '@nestjs/common'; import { Logger } from '@src/core/logger'; -import { AxiosError } from 'axios'; -import { WebsocketCloseErrorLoggable } from '../loggable/websocket-close-error.loggable'; -import { TldrawConfig, SOCKET_PORT } from '../config'; -import { WsCloseCodeEnum, WsCloseMessageEnum } from '../types'; +import { isAxiosError } from 'axios'; +import { firstValueFrom } from 'rxjs'; +import { HttpService } from '@nestjs/axios'; +import { WebsocketInitErrorLoggable } from '../loggable'; +import { TldrawConfig, TLDRAW_SOCKET_PORT } from '../config'; +import { WsCloseCode, WsCloseMessage } from '../types'; import { TldrawWsService } from '../service'; -@WebSocketGateway(SOCKET_PORT) +@WebSocketGateway(TLDRAW_SOCKET_PORT) export class TldrawWs implements OnGatewayInit, OnGatewayConnection { @WebSocketServer() server!: Server; @@ -19,64 +26,33 @@ export class TldrawWs implements OnGatewayInit, OnGatewayConnection { constructor( private readonly configService: ConfigService, private readonly tldrawWsService: TldrawWsService, + private readonly httpService: HttpService, private readonly logger: Logger ) {} - async handleConnection(client: WebSocket, request: Request): Promise { + public async handleConnection(client: WebSocket, request: Request): Promise { + if (!this.configService.get('FEATURE_TLDRAW_ENABLED')) { + client.close(WsCloseCode.BAD_REQUEST, WsCloseMessage.FEATURE_DISABLED); + return; + } + const docName = this.getDocNameFromRequest(request); - if (docName.length > 0 && this.configService.get('FEATURE_TLDRAW_ENABLED')) { + if (!docName) { + client.close(WsCloseCode.BAD_REQUEST, WsCloseMessage.BAD_REQUEST); + return; + } + + try { const cookies = this.parseCookiesFromHeader(request); - try { - await this.tldrawWsService.authorizeConnection(docName, cookies?.jwt); - } catch (err) { - if ((err as AxiosError).response?.status === 404 || (err as AxiosError).response?.status === 400) { - this.closeClientAndLogError( - client, - WsCloseCodeEnum.WS_CLIENT_NOT_FOUND_CODE, - WsCloseMessageEnum.WS_CLIENT_NOT_FOUND_MESSAGE, - err as Error - ); - } else { - this.closeClientAndLogError( - client, - WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, - WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE, - err as Error - ); - } - return; - } - try { - this.tldrawWsService.setupWSConnection(client, docName); - } catch (err) { - this.closeClientAndLogError( - client, - WsCloseCodeEnum.WS_CLIENT_ESTABLISHING_CONNECTION_CODE, - WsCloseMessageEnum.WS_CLIENT_ESTABLISHING_CONNECTION_MESSAGE, - err as Error - ); - } - } else { - this.closeClientAndLogError( - client, - WsCloseCodeEnum.WS_CLIENT_BAD_REQUEST_CODE, - WsCloseMessageEnum.WS_CLIENT_BAD_REQUEST_MESSAGE, - new BadRequestException() - ); + await this.authorizeConnection(docName, cookies?.jwt); + await this.tldrawWsService.setupWsConnection(client, docName); + } catch (err) { + this.handleError(err, client, docName); } } - public afterInit(): void { - this.tldrawWsService.setPersistence({ - bindState: async (docName, ydoc) => { - await this.tldrawWsService.updateDocument(docName, ydoc); - }, - writeState: async (docName) => { - // This is called when all connections to the document are closed. - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access - await this.tldrawWsService.flushDocument(docName); - }, - }); + public async afterInit(): Promise { + await this.tldrawWsService.createDbIndex(); } private getDocNameFromRequest(request: Request): string { @@ -89,8 +65,72 @@ export class TldrawWs implements OnGatewayInit, OnGatewayConnection { return parsedCookies; } - private closeClientAndLogError(client: WebSocket, code: WsCloseCodeEnum, data: string, err: Error): void { - client.close(code, data); - this.logger.warning(new WebsocketCloseErrorLoggable(err, `(${code}) ${data}`)); + private async authorizeConnection(drawingName: string, token: string): Promise { + if (!token) { + throw new UnauthorizedException('Token was not given'); + } + + try { + const apiHostUrl = this.configService.get('API_HOST'); + await firstValueFrom( + this.httpService.get(`${apiHostUrl}/v3/elements/${drawingName}/permission`, { + headers: { + Accept: 'Application/json', + Authorization: `Bearer ${token}`, + }, + }) + ); + } catch (err) { + if (isAxiosError(err)) { + switch (err.response?.status) { + case 400: + case 404: + throw new NotFoundException(); + case 401: + case 403: + throw new UnauthorizedException(); + default: + throw new InternalServerErrorException(); + } + } + + throw new InternalServerErrorException(); + } + } + + private closeClientAndLog( + client: WebSocket, + code: WsCloseCode, + message: WsCloseMessage, + docName: string, + err?: unknown + ): void { + client.close(code, message); + this.logger.warning(new WebsocketInitErrorLoggable(code, message, docName, err)); + } + + private handleError(err: unknown, client: WebSocket, docName: string): void { + if (err instanceof NotFoundException) { + this.closeClientAndLog(client, WsCloseCode.NOT_FOUND, WsCloseMessage.NOT_FOUND, docName); + return; + } + + if (err instanceof UnauthorizedException) { + this.closeClientAndLog(client, WsCloseCode.UNAUTHORIZED, WsCloseMessage.UNAUTHORIZED, docName); + return; + } + + if (err instanceof NotAcceptableException) { + this.closeClientAndLog(client, WsCloseCode.NOT_ACCEPTABLE, WsCloseMessage.NOT_ACCEPTABLE, docName); + return; + } + + this.closeClientAndLog( + client, + WsCloseCode.INTERNAL_SERVER_ERROR, + WsCloseMessage.INTERNAL_SERVER_ERROR, + docName, + err + ); } } diff --git a/apps/server/src/modules/tldraw/domain/index.ts b/apps/server/src/modules/tldraw/domain/index.ts new file mode 100644 index 00000000000..6e30b3fa99e --- /dev/null +++ b/apps/server/src/modules/tldraw/domain/index.ts @@ -0,0 +1 @@ +export * from './ws-shared-doc.do'; diff --git a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts index 7b0b0d8c60c..791e4108f8e 100644 --- a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts +++ b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts @@ -1,164 +1,19 @@ -import { Test } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import WebSocket from 'ws'; -import { WsAdapter } from '@nestjs/platform-ws'; -import { CoreModule } from '@src/core'; -import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions } from '@src/config'; -import { createMock } from '@golevelup/ts-jest'; -import * as AwarenessProtocol from 'y-protocols/awareness'; -import { config } from '../config'; -import { TldrawBoardRepo } from '../repo/tldraw-board.repo'; -import { TldrawWsService } from '../service'; import { WsSharedDocDo } from './ws-shared-doc.do'; -import { TldrawWs } from '../controller'; -import { TestConnection } from '../testing/test-connection'; describe('WsSharedDocDo', () => { - let app: INestApplication; - let ws: WebSocket; - let service: TldrawWsService; - - const gatewayPort = 3346; - const wsUrl = TestConnection.getWsUrl(gatewayPort); - - jest.useFakeTimers(); - - beforeAll(async () => { - const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; - const testingModule = await Test.createTestingModule({ - imports, - providers: [ - TldrawWs, - TldrawBoardRepo, - { - provide: TldrawWsService, - useValue: createMock(), - }, - ], - }).compile(); - - service = testingModule.get(TldrawWsService); - app = testingModule.createNestApplication(); - app.useWebSocketAdapter(new WsAdapter(app)); - jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); - await app.init(); + beforeAll(() => { + jest.useFakeTimers(); }); - afterAll(async () => { - await app.close(); - }); - - describe('ydoc client awareness change handler', () => { - const setup = async () => { - ws = await TestConnection.setupWs(wsUrl, 'TEST'); - - class MockAwareness { - on = jest.fn(); - } - const doc = new WsSharedDocDo('TEST', service); - doc.awareness = new MockAwareness() as unknown as AwarenessProtocol.Awareness; - const awarenessMetaMock = new Map(); - awarenessMetaMock.set(1, { clock: 11, lastUpdated: 21 }); - awarenessMetaMock.set(2, { clock: 12, lastUpdated: 22 }); - awarenessMetaMock.set(3, { clock: 13, lastUpdated: 23 }); - const awarenessStatesMock = new Map(); - awarenessStatesMock.set(1, { updating: '21' }); - awarenessStatesMock.set(2, { updating: '22' }); - awarenessStatesMock.set(3, { updating: '23' }); - doc.awareness.states = awarenessStatesMock; - doc.awareness.meta = awarenessMetaMock; - - const sendSpy = jest.spyOn(service, 'send').mockReturnValue(); - - const mockIDs = new Set(); - const mockConns = new Map>(); - mockConns.set(ws, mockIDs); - doc.conns = mockConns; - - return { - sendSpy, - doc, - mockIDs, - mockConns, - }; - }; - - describe('when adding two clients states', () => { - it('should have two registered clients states', async () => { - const { sendSpy, doc, mockIDs } = await setup(); - const awarenessUpdate = { - added: [1, 3], - updated: [], - removed: [], - }; - doc.awarenessChangeHandler(awarenessUpdate, ws); - - expect(mockIDs.size).toBe(2); - expect(mockIDs.has(1)).toBe(true); - expect(mockIDs.has(3)).toBe(true); - expect(mockIDs.has(2)).toBe(false); - expect(sendSpy).toBeCalled(); - - ws.close(); - sendSpy.mockRestore(); - }); - }); - - describe('when removing one of two existing clients states', () => { - it('should have one registered client state', async () => { - const { sendSpy, doc, mockIDs } = await setup(); - let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { - added: [1, 3], - updated: [], - removed: [], - }; - - doc.awarenessChangeHandler(awarenessUpdate, ws); - - awarenessUpdate = { - added: [], - updated: [], - removed: [1], - }; - - doc.awarenessChangeHandler(awarenessUpdate, ws); - - expect(mockIDs.size).toBe(1); - expect(mockIDs.has(1)).toBe(false); - expect(mockIDs.has(3)).toBe(true); - expect(sendSpy).toBeCalled(); - - ws.close(); - sendSpy.mockRestore(); - }); - }); - - describe('when updating client state', () => { - it('should not change number of states', async () => { - const { sendSpy, doc, mockIDs } = await setup(); - let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { - added: [1], - updated: [], - removed: [], - }; - - doc.awarenessChangeHandler(awarenessUpdate, ws); - - awarenessUpdate = { - added: [], - updated: [1], - removed: [], - }; - - doc.awarenessChangeHandler(awarenessUpdate, ws); - - expect(mockIDs.size).toBe(1); - expect(mockIDs.has(1)).toBe(true); - expect(sendSpy).toBeCalled(); + describe('constructor', () => { + describe('when constructor is called', () => { + it('should create a new object with correct properties', () => { + const doc = new WsSharedDocDo('docname'); - ws.close(); - sendSpy.mockRestore(); + expect(doc).toBeInstanceOf(WsSharedDocDo); + expect(doc.name).toEqual('docname'); + expect(doc.awarenessChannel).toEqual('docname-awareness'); + expect(doc.awareness).toBeDefined(); }); }); }); diff --git a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts index a7084ada0da..2ceec1962c2 100644 --- a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts +++ b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts @@ -1,88 +1,24 @@ -import { Doc } from 'yjs'; import WebSocket from 'ws'; -import { Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness'; -import { encoding } from 'lib0'; -import { TldrawWsService } from '@modules/tldraw/service'; -import { WSMessageType } from '../types/connection-enum'; +import { Doc } from 'yjs'; +import { Awareness } from 'y-protocols/awareness'; export class WsSharedDocDo extends Doc { public name: string; - public conns: Map>; + public connections: Map>; public awareness: Awareness; - /** - * @param {string} name - * @param {TldrawWsService} tldrawService - * @param {boolean} gcEnabled - */ - constructor(name: string, private tldrawService: TldrawWsService, gcEnabled = true) { + public awarenessChannel: string; + + public isFinalizing = false; + + constructor(name: string, gcEnabled = true) { super({ gc: gcEnabled }); this.name = name; - this.conns = new Map(); + this.connections = new Map(); this.awareness = new Awareness(this); this.awareness.setLocalState(null); - - this.awareness.on('update', this.awarenessChangeHandler); - this.on('update', (update: Uint8Array, origin, doc: WsSharedDocDo) => { - this.tldrawService.updateHandler(update, origin, doc); - }); - } - - /** - * @param {{ added: Array, updated: Array, removed: Array }} changes - * @param {WebSocket | null} wsConnection Origin is the connection that made the change - */ - public awarenessChangeHandler = ( - { added, updated, removed }: { added: Array; updated: Array; removed: Array }, - wsConnection: WebSocket | null - ): void => { - const changedClients = this.manageClientsConnections({ added, updated, removed }, wsConnection); - const buff = this.prepareAwarenessMessage(changedClients); - this.sendAwarenessMessage(buff); - }; - - /** - * @param {{ added: Array, updated: Array, removed: Array }} changes - * @param {WebSocket | null} wsConnection Origin is the connection that made the change - */ - private manageClientsConnections( - { added, updated, removed }: { added: Array; updated: Array; removed: Array }, - wsConnection: WebSocket | null - ): number[] { - const changedClients = added.concat(updated, removed); - if (wsConnection !== null) { - const connControlledIDs = this.conns.get(wsConnection); - if (connControlledIDs !== undefined) { - added.forEach((clientID) => { - connControlledIDs.add(clientID); - }); - removed.forEach((clientID) => { - connControlledIDs.delete(clientID); - }); - } - } - return changedClients; - } - - /** - * @param changedClients array of changed clients - */ - private prepareAwarenessMessage(changedClients: number[]): Uint8Array { - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, WSMessageType.AWARENESS); - encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(this.awareness, changedClients)); - const message = encoding.toUint8Array(encoder); - return message; - } - - /** - * @param {{ Uint8Array }} buff encoded message about changes - */ - private sendAwarenessMessage(buff: Uint8Array): void { - this.conns.forEach((_, c) => { - this.tldrawService.send(this, c, buff); - }); + this.awarenessChannel = `${name}-awareness`; } } diff --git a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts index a85ae26319c..2698056a0ef 100644 --- a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts +++ b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts @@ -1,4 +1,5 @@ import { setupEntities } from '@shared/testing'; +import { tldrawEntityFactory } from '../testing'; import { TldrawDrawing } from './tldraw-drawing.entity'; describe('tldraw entity', () => { @@ -9,20 +10,9 @@ describe('tldraw entity', () => { describe('constructor', () => { describe('when creating a tldraw doc', () => { it('should create drawing', () => { - const tldraw = new TldrawDrawing({ - docName: 'test', - version: 'v1_tst', - value: 'bindatamock', - _id: 'test-id', - clock: 0, - action: 'update', - }); - expect(tldraw).toBeInstanceOf(TldrawDrawing); - }); + const tldraw = tldrawEntityFactory.build(); - it('should throw with empty docName', () => { - const call = () => new TldrawDrawing({ docName: '', version: 'v1_tst', value: 'bindatamock', _id: 'test-id' }); - expect(call).toThrow(); + expect(tldraw).toBeInstanceOf(TldrawDrawing); }); }); }); diff --git a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts index b6db76a3f2e..daaa93090e5 100644 --- a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts +++ b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts @@ -1,25 +1,19 @@ -import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; -import { BadRequestException } from '@nestjs/common'; -import { ObjectId } from '@mikro-orm/mongodb'; +import { Entity, Index, Property } from '@mikro-orm/core'; +import { BaseEntity } from '@shared/domain/entity/base.entity'; -@Entity({ tableName: 'drawings' }) -export class TldrawDrawing { - constructor(props: TldrawDrawingProps) { - if (!props.docName) throw new BadRequestException('Tldraw element should have name.'); - this.docName = props.docName; - this.version = props.version; - this.value = props.value; - if (typeof props.clock === 'number') { - this.clock = props.clock; - } - if (props.action) { - this.action = props.action; - } - } - - @PrimaryKey() - _id!: ObjectId; +export interface TldrawDrawingProps { + id?: string; + docName: string; + version: string; + clock?: number; + action?: string; + value: Buffer; + part?: number; +} +@Entity({ tableName: 'drawings' }) +@Index({ properties: ['version', 'docName', 'action', 'clock', 'part'] }) +export class TldrawDrawing extends BaseEntity { @Property({ nullable: false }) docName: string; @@ -27,20 +21,24 @@ export class TldrawDrawing { version: string; @Property({ nullable: false }) - value: string; + value: Buffer; @Property({ nullable: true }) clock?: number; @Property({ nullable: true }) action?: string; -} -export interface TldrawDrawingProps { - _id?: string; - docName: string; - version: string; - clock?: number; - action?: string; - value: string; + @Property({ nullable: true }) + part?: number; + + constructor(props: TldrawDrawingProps) { + super(); + this.docName = props.docName; + this.version = props.version; + this.value = props.value; + this.clock = props.clock; + this.action = props.action; + this.part = props.part; + } } diff --git a/apps/server/src/modules/tldraw/factory/index.ts b/apps/server/src/modules/tldraw/factory/index.ts deleted file mode 100644 index 7a5f39169bf..00000000000 --- a/apps/server/src/modules/tldraw/factory/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './tldraw.factory'; diff --git a/apps/server/src/modules/tldraw/index.ts b/apps/server/src/modules/tldraw/index.ts deleted file mode 100644 index 8966e72549e..00000000000 --- a/apps/server/src/modules/tldraw/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './tldraw.module'; -export * from './tldraw-test.module'; -export * from './tldraw-ws.module'; diff --git a/apps/server/src/modules/tldraw/job/index.ts b/apps/server/src/modules/tldraw/job/index.ts new file mode 100644 index 00000000000..7ab9c738039 --- /dev/null +++ b/apps/server/src/modules/tldraw/job/index.ts @@ -0,0 +1 @@ +export * from './tldraw-files.console'; diff --git a/apps/server/src/modules/tldraw/job/tldraw-files.console.spec.ts b/apps/server/src/modules/tldraw/job/tldraw-files.console.spec.ts new file mode 100644 index 00000000000..e4379a7e1c2 --- /dev/null +++ b/apps/server/src/modules/tldraw/job/tldraw-files.console.spec.ts @@ -0,0 +1,50 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LegacyLogger } from '@src/core/logger'; +import { TldrawDeleteFilesUc } from '../uc'; +import { TldrawFilesConsole } from './tldraw-files.console'; + +describe('TldrawFilesConsole', () => { + let console: TldrawFilesConsole; + let deleteFilesUc: TldrawDeleteFilesUc; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TldrawFilesConsole, + { + provide: TldrawDeleteFilesUc, + useValue: createMock(), + }, + { + provide: LegacyLogger, + useValue: createMock(), + }, + ], + }).compile(); + + console = module.get(TldrawFilesConsole); + deleteFilesUc = module.get(TldrawDeleteFilesUc); + + // Set fake system time. Otherwise, dates constructed in the test and the + // console can differ because of the short time elapsing between the calls. + jest.useFakeTimers(); + jest.setSystemTime(new Date(2022, 1, 22)); + }); + + it('should be defined', () => { + expect(console).toBeDefined(); + }); + + describe('deleteUnusedFiles', () => { + it('should call UC with threshold date', async () => { + const minimumFileAgeInHours = 1; + const thresholdDate = new Date(); + thresholdDate.setHours(thresholdDate.getHours() - minimumFileAgeInHours); + + await console.deleteUnusedFiles(minimumFileAgeInHours); + + expect(deleteFilesUc.deleteUnusedFiles).toHaveBeenCalledWith(thresholdDate); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/job/tldraw-files.console.ts b/apps/server/src/modules/tldraw/job/tldraw-files.console.ts new file mode 100644 index 00000000000..e200efe21f3 --- /dev/null +++ b/apps/server/src/modules/tldraw/job/tldraw-files.console.ts @@ -0,0 +1,26 @@ +import { Command, Console } from 'nestjs-console'; +import { LegacyLogger } from '@src/core/logger'; +import { TldrawDeleteFilesUc } from '../uc'; + +@Console({ command: 'files', description: 'tldraw file deletion console' }) +export class TldrawFilesConsole { + constructor(private deleteFilesUc: TldrawDeleteFilesUc, private logger: LegacyLogger) { + this.logger.setContext(TldrawFilesConsole.name); + } + + @Command({ + command: 'deletion-job ', + description: + 'tldraw file deletion job to delete files no longer used in board - only files older than hours will be marked for deletion', + }) + async deleteUnusedFiles(minimumFileAgeInHours: number): Promise { + this.logger.log( + `Start tldraw file deletion job: marking files for deletion that are no longer used in whiteboard but only older than ${minimumFileAgeInHours} hours to prevent deletion of files that may still be used in an open whiteboard` + ); + const thresholdDate = new Date(); + thresholdDate.setHours(thresholdDate.getHours() - minimumFileAgeInHours); + + await this.deleteFilesUc.deleteUnusedFiles(thresholdDate); + this.logger.log('deletion job finished'); + } +} diff --git a/apps/server/src/modules/tldraw/loggable/close-connection.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/close-connection.loggable.spec.ts new file mode 100644 index 00000000000..df2fbec507f --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/close-connection.loggable.spec.ts @@ -0,0 +1,24 @@ +import { CloseConnectionLoggable } from './close-connection.loggable'; + +describe('CloseConnectionLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const error = new Error('test'); + const loggable = new CloseConnectionLoggable('functionName', error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Close web socket error in functionName', + type: 'CLOSE_WEB_SOCKET_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/close-connection.loggable.ts b/apps/server/src/modules/tldraw/loggable/close-connection.loggable.ts new file mode 100644 index 00000000000..e1d2c90e0bd --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/close-connection.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class CloseConnectionLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly errorLocation: string, private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Close web socket error in ${this.errorLocation}`, + type: `CLOSE_WEB_SOCKET_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/file-storage-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/file-storage-error.loggable.spec.ts new file mode 100644 index 00000000000..817edd10f5e --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/file-storage-error.loggable.spec.ts @@ -0,0 +1,24 @@ +import { FileStorageErrorLoggable } from './file-storage-error.loggable'; + +describe('FileStorageErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const error = new Error('test'); + const loggable = new FileStorageErrorLoggable('doc1', error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Error in document doc1: assets could not be synchronized with file storage.', + type: 'FILE_STORAGE_GENERAL_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/file-storage-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/file-storage-error.loggable.ts new file mode 100644 index 00000000000..3654b608a17 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/file-storage-error.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class FileStorageErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly docName: string, private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Error in document ${this.docName}: assets could not be synchronized with file storage.`, + type: `FILE_STORAGE_GENERAL_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/index.ts b/apps/server/src/modules/tldraw/loggable/index.ts new file mode 100644 index 00000000000..00bfbc2fa7b --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/index.ts @@ -0,0 +1,9 @@ +export * from './mongo-transaction-error.loggable'; +export * from './redis-error.loggable'; +export * from './redis-publish-error.loggable'; +export * from './websocket-error.loggable'; +export * from './websocket-init-error.loggable'; +export * from './websocket-message-error.loggable'; +export * from './ws-shared-doc-error.loggable'; +export * from './close-connection.loggable'; +export * from './file-storage-error.loggable'; diff --git a/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.spec.ts new file mode 100644 index 00000000000..e109ece222f --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.spec.ts @@ -0,0 +1,24 @@ +import { MongoTransactionErrorLoggable } from './mongo-transaction-error.loggable'; + +describe('MongoTransactionErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const error = new Error('test'); + const loggable = new MongoTransactionErrorLoggable(error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Error while saving transaction', + type: 'MONGO_TRANSACTION_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.ts new file mode 100644 index 00000000000..15153388f3c --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class MongoTransactionErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Error while saving transaction`, + type: `MONGO_TRANSACTION_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/redis-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/redis-error.loggable.spec.ts new file mode 100644 index 00000000000..1208015c2a8 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/redis-error.loggable.spec.ts @@ -0,0 +1,25 @@ +import { RedisErrorLoggable } from './redis-error.loggable'; + +describe('RedisGeneralErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const type = 'SUB'; + const error = new Error('test'); + const loggable = new RedisErrorLoggable(type, error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Redis SUB error', + type: 'REDIS_SUB_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/redis-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/redis-error.loggable.ts new file mode 100644 index 00000000000..3ef9e3bbcfe --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/redis-error.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class RedisErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly connectionType: 'PUB' | 'SUB', private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Redis ${this.connectionType} error`, + type: `REDIS_${this.connectionType}_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.spec.ts new file mode 100644 index 00000000000..915b1596dd5 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.spec.ts @@ -0,0 +1,26 @@ +import { RedisPublishErrorLoggable } from './redis-publish-error.loggable'; +import { UpdateType } from '../types'; + +describe('RedisPublishErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const type = UpdateType.DOCUMENT; + const error = new Error('test'); + const loggable = new RedisPublishErrorLoggable(type, error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Error while publishing document state to Redis', + type: 'REDIS_PUBLISH_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.ts new file mode 100644 index 00000000000..2e3d6b1559e --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.ts @@ -0,0 +1,20 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { UpdateType } from '../types'; + +export class RedisPublishErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly type: UpdateType, private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Error while publishing ${this.type} state to Redis`, + type: `REDIS_PUBLISH_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts deleted file mode 100644 index ba0b21c9714..00000000000 --- a/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { WebsocketCloseErrorLoggable } from './websocket-close-error.loggable'; - -describe('WebsocketCloseErrorLoggable', () => { - describe('getLogMessage', () => { - const setup = () => { - const error = new Error('test'); - const errorMessage = 'message'; - - const loggable = new WebsocketCloseErrorLoggable(error, errorMessage); - - return { loggable, error, errorMessage }; - }; - - it('should return a loggable message', () => { - const { loggable, error, errorMessage } = setup(); - - const message = loggable.getLogMessage(); - - expect(message).toEqual({ message: errorMessage, error, type: 'WEBSOCKET_CLOSE_ERROR' }); - }); - }); -}); diff --git a/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.ts deleted file mode 100644 index 6da84c3699f..00000000000 --- a/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; - -export class WebsocketCloseErrorLoggable implements Loggable { - constructor(private readonly error: Error, private readonly message: string) {} - - getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { - return { - message: this.message, - type: 'WEBSOCKET_CLOSE_ERROR', - error: this.error, - }; - } -} diff --git a/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.spec.ts new file mode 100644 index 00000000000..4e129376cc3 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.spec.ts @@ -0,0 +1,20 @@ +import { WebsocketErrorLoggable } from './websocket-error.loggable'; + +describe('WebsocketErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const error = new Error('test'); + + const loggable = new WebsocketErrorLoggable(error); + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ message: 'Websocket error event', error, type: 'WEBSOCKET_ERROR' }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.ts new file mode 100644 index 00000000000..1da725b3518 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class WebsocketErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'Websocket error event', + type: 'WEBSOCKET_ERROR', + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/websocket-init-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/websocket-init-error.loggable.spec.ts new file mode 100644 index 00000000000..faada42a29d --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-init-error.loggable.spec.ts @@ -0,0 +1,28 @@ +import { WebsocketInitErrorLoggable } from './websocket-init-error.loggable'; +import { WsCloseCode, WsCloseMessage } from '../types'; + +describe('WebsocketInitErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const error = new Error('test'); + const errorCode = WsCloseCode.BAD_REQUEST; + const errorMessage = WsCloseMessage.BAD_REQUEST; + const docName = 'test'; + + const loggable = new WebsocketInitErrorLoggable(errorCode, errorMessage, docName, error); + return { loggable, error, errorCode, errorMessage, docName }; + }; + + it('should return a loggable message', () => { + const { loggable, error, errorMessage, errorCode, docName } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: `[${docName}] [${errorCode}] ${errorMessage}`, + type: 'WEBSOCKET_CONNECTION_INIT_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/websocket-init-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/websocket-init-error.loggable.ts new file mode 100644 index 00000000000..d82760290b8 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-init-error.loggable.ts @@ -0,0 +1,25 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { WsCloseCode, WsCloseMessage } from '../types'; + +export class WebsocketInitErrorLoggable implements Loggable { + private readonly error: Error | undefined; + + constructor( + private readonly code: WsCloseCode, + private readonly message: WsCloseMessage, + private readonly docName: string, + private readonly err?: unknown + ) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `[${this.docName}] [${this.code}] ${this.message}`, + type: 'WEBSOCKET_CONNECTION_INIT_ERROR', + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.spec.ts new file mode 100644 index 00000000000..272efcf618b --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.spec.ts @@ -0,0 +1,24 @@ +import { WebsocketMessageErrorLoggable } from './websocket-message-error.loggable'; + +describe('WebsocketMessageErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const error = new Error('test'); + const loggable = new WebsocketMessageErrorLoggable(error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Error while handling websocket message', + type: 'WEBSOCKET_MESSAGE_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.ts new file mode 100644 index 00000000000..0309c5aa21b --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class WebsocketMessageErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Error while handling websocket message`, + type: `WEBSOCKET_MESSAGE_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.spec.ts new file mode 100644 index 00000000000..d18fcff8e9a --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.spec.ts @@ -0,0 +1,26 @@ +import { WsSharedDocErrorLoggable } from './ws-shared-doc-error.loggable'; + +describe('WsSharedDocErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const docName = 'docname'; + const message = 'error message'; + const error = new Error('test'); + const loggable = new WsSharedDocErrorLoggable(docName, message, error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Error in document docname: error message', + type: 'WSSHAREDDOC_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.ts new file mode 100644 index 00000000000..4ddd8102ed0 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class WsSharedDocErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly docName: string, private readonly message: string, private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Error in document ${this.docName}: ${this.message}`, + type: `WSSHAREDDOC_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/redis/index.ts b/apps/server/src/modules/tldraw/redis/index.ts new file mode 100644 index 00000000000..8b4354dcc39 --- /dev/null +++ b/apps/server/src/modules/tldraw/redis/index.ts @@ -0,0 +1,2 @@ +export * from './tldraw-redis.factory'; +export * from './tldraw-redis.service'; diff --git a/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.spec.ts b/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.spec.ts new file mode 100644 index 00000000000..c24fec60514 --- /dev/null +++ b/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.spec.ts @@ -0,0 +1,61 @@ +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { createConfigModuleOptions } from '@src/config'; +import { INestApplication } from '@nestjs/common'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { createMock } from '@golevelup/ts-jest'; +import { Logger } from '@src/core/logger'; +import { RedisConnectionTypeEnum } from '../types'; +import { TldrawConfig } from '../config'; +import { tldrawTestConfig } from '../testing'; +import { TldrawRedisFactory } from './tldraw-redis.factory'; + +describe('TldrawRedisFactory', () => { + let app: INestApplication; + let configService: ConfigService; + let redisFactory: TldrawRedisFactory; + + beforeAll(async () => { + const testingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig))], + providers: [ + TldrawRedisFactory, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + + configService = testingModule.get(ConfigService); + redisFactory = testingModule.get(TldrawRedisFactory); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should check if factory was created', () => { + expect(redisFactory).toBeDefined(); + }); + + describe('build', () => { + it('should throw if REDIS_URI is not set', () => { + const configSpy = jest.spyOn(configService, 'get').mockReturnValueOnce(null); + + expect(() => redisFactory.build(RedisConnectionTypeEnum.PUBLISH)).toThrow('REDIS_URI is not set'); + configSpy.mockRestore(); + }); + + it('should return redis connection', () => { + const configSpy = jest.spyOn(configService, 'get').mockReturnValueOnce('redis://localhost:6379'); + const redis = redisFactory.build(RedisConnectionTypeEnum.PUBLISH); + + expect(redis).toBeDefined(); + configSpy.mockRestore(); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.ts b/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.ts new file mode 100644 index 00000000000..b71a6b401f8 --- /dev/null +++ b/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.ts @@ -0,0 +1,29 @@ +import { Redis } from 'ioredis'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Logger } from '@src/core/logger'; +import { TldrawConfig } from '../config'; +import { RedisErrorLoggable } from '../loggable'; +import { RedisConnectionTypeEnum } from '../types'; + +@Injectable() +export class TldrawRedisFactory { + constructor(private readonly configService: ConfigService, private readonly logger: Logger) { + this.logger.setContext(TldrawRedisFactory.name); + } + + public build(connectionType: RedisConnectionTypeEnum) { + const redisUri = this.configService.get('REDIS_URI'); + if (!redisUri) { + throw new Error('REDIS_URI is not set'); + } + + const redis = new Redis(redisUri, { + maxRetriesPerRequest: null, + }); + + redis.on('error', (err) => this.logger.warning(new RedisErrorLoggable(connectionType, err))); + + return redis; + } +} diff --git a/apps/server/src/modules/tldraw/redis/tldraw-redis.service.spec.ts b/apps/server/src/modules/tldraw/redis/tldraw-redis.service.spec.ts new file mode 100644 index 00000000000..79c15dc2854 --- /dev/null +++ b/apps/server/src/modules/tldraw/redis/tldraw-redis.service.spec.ts @@ -0,0 +1,122 @@ +import { INestApplication } from '@nestjs/common'; +import { createMock } from '@golevelup/ts-jest'; +import { Logger } from '@src/core/logger'; +import { Test } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import * as Yjs from 'yjs'; +import * as AwarenessProtocol from 'y-protocols/awareness'; +import { HttpService } from '@nestjs/axios'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { TldrawWs } from '../controller'; +import { TldrawWsService } from '../service'; +import { TldrawBoardRepo, TldrawRepo, YMongodb } from '../repo'; +import { MetricsService } from '../metrics'; +import { WsSharedDocDo } from '../domain'; +import { TldrawRedisFactory, TldrawRedisService } from '.'; +import { tldrawTestConfig } from '../testing'; + +jest.mock('yjs', () => { + const moduleMock: unknown = { + __esModule: true, + ...jest.requireActual('yjs'), + }; + return moduleMock; +}); +jest.mock('y-protocols/awareness', () => { + const moduleMock: unknown = { + __esModule: true, + ...jest.requireActual('y-protocols/awareness'), + }; + return moduleMock; +}); +jest.mock('y-protocols/sync', () => { + const moduleMock: unknown = { + __esModule: true, + ...jest.requireActual('y-protocols/sync'), + }; + return moduleMock; +}); + +describe('TldrawRedisService', () => { + let app: INestApplication; + let service: TldrawRedisService; + + beforeAll(async () => { + const testingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig))], + providers: [ + TldrawWs, + TldrawWsService, + YMongodb, + MetricsService, + TldrawRedisFactory, + TldrawRedisService, + { + provide: TldrawBoardRepo, + useValue: createMock(), + }, + { + provide: TldrawRepo, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, + { + provide: HttpService, + useValue: createMock(), + }, + ], + }).compile(); + + service = testingModule.get(TldrawRedisService); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('redisMessageHandler', () => { + const setup = () => { + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValueOnce(); + const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate').mockReturnValueOnce(); + + const doc = new WsSharedDocDo('TEST'); + doc.awarenessChannel = 'TEST-awareness'; + + return { + doc, + applyUpdateSpy, + applyAwarenessUpdateSpy, + }; + }; + + describe('when channel name is the same as docName', () => { + it('should call applyUpdate', () => { + const { doc, applyUpdateSpy } = setup(); + service.handleMessage('TEST', Buffer.from('message'), doc); + + expect(applyUpdateSpy).toHaveBeenCalled(); + }); + }); + + describe('when channel name is the same as docAwarenessChannel name', () => { + it('should call applyAwarenessUpdate', () => { + const { doc, applyAwarenessUpdateSpy } = setup(); + service.handleMessage('TEST-awareness', Buffer.from('message'), doc); + + expect(applyAwarenessUpdateSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/redis/tldraw-redis.service.ts b/apps/server/src/modules/tldraw/redis/tldraw-redis.service.ts new file mode 100644 index 00000000000..59b2a277bee --- /dev/null +++ b/apps/server/src/modules/tldraw/redis/tldraw-redis.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { Redis } from 'ioredis'; +import { Logger } from '@src/core/logger'; +import { Buffer } from 'node:buffer'; +import { applyAwarenessUpdate } from 'y-protocols/awareness'; +import { applyUpdate } from 'yjs'; +import { WsSharedDocDo } from '../domain'; +import { RedisConnectionTypeEnum, UpdateOrigin, UpdateType } from '../types'; +import { RedisPublishErrorLoggable, WsSharedDocErrorLoggable } from '../loggable'; +import { TldrawRedisFactory } from './tldraw-redis.factory'; + +@Injectable() +export class TldrawRedisService { + public readonly sub: Redis; + + private readonly pub: Redis; + + constructor(private readonly logger: Logger, private readonly tldrawRedisFactory: TldrawRedisFactory) { + this.logger.setContext(TldrawRedisService.name); + + this.sub = this.tldrawRedisFactory.build(RedisConnectionTypeEnum.SUBSCRIBE); + this.pub = this.tldrawRedisFactory.build(RedisConnectionTypeEnum.PUBLISH); + } + + public handleMessage = (channelId: string, update: Buffer, doc: WsSharedDocDo): void => { + if (channelId.includes(UpdateType.AWARENESS)) { + applyAwarenessUpdate(doc.awareness, update, UpdateOrigin.REDIS); + } else { + applyUpdate(doc, update, UpdateOrigin.REDIS); + } + }; + + public subscribeToRedisChannels(doc: WsSharedDocDo) { + this.sub.subscribe(doc.name, doc.awarenessChannel).catch((err) => { + this.logger.warning(new WsSharedDocErrorLoggable(doc.name, 'Error while subscribing to Redis channels', err)); + }); + } + + public unsubscribeFromRedisChannels(doc: WsSharedDocDo) { + this.sub.unsubscribe(doc.name, doc.awarenessChannel).catch((err) => { + this.logger.warning(new WsSharedDocErrorLoggable(doc.name, 'Error while unsubscribing from Redis channels', err)); + }); + } + + public publishUpdateToRedis(doc: WsSharedDocDo, update: Uint8Array, type: UpdateType) { + const channel = type === UpdateType.AWARENESS ? doc.awarenessChannel : doc.name; + this.pub.publish(channel, Buffer.from(update)).catch((err) => { + this.logger.warning(new RedisPublishErrorLoggable(type, err)); + }); + } +} diff --git a/apps/server/src/modules/tldraw/repo/index.ts b/apps/server/src/modules/tldraw/repo/index.ts index 3cc9ad02bf7..0552c6c0191 100644 --- a/apps/server/src/modules/tldraw/repo/index.ts +++ b/apps/server/src/modules/tldraw/repo/index.ts @@ -1,2 +1,3 @@ export * from './tldraw-board.repo'; export * from './tldraw.repo'; +export * from './y-mongodb'; diff --git a/apps/server/src/modules/tldraw/repo/key.factory.spec.ts b/apps/server/src/modules/tldraw/repo/key.factory.spec.ts new file mode 100644 index 00000000000..f33fac4a3f8 --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/key.factory.spec.ts @@ -0,0 +1,106 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { KeyFactory } from './key.factory'; + +describe('KeyFactory', () => { + describe('createForUpdate', () => { + describe('when clock is not passed', () => { + const setup = () => { + const params = { docName: new ObjectId().toHexString() }; + + return { params }; + }; + + it('should return a object that support the interface UniqueKey and clock is not defined', () => { + const { params } = setup(); + + const result = KeyFactory.createForUpdate(params.docName); + + expect(result).toEqual({ + docName: params.docName, + version: 'v1', + action: 'update', + clock: undefined, + }); + }); + }); + + describe('when positive clock number is passed', () => { + const setup = () => { + const params = { docName: new ObjectId().toHexString(), clock: 2 }; + + return { params }; + }; + + it('should return a object that support the interface UniqueKey and pass the clock number', () => { + const { params } = setup(); + + const result = KeyFactory.createForUpdate(params.docName, params.clock); + + expect(result).toEqual({ + docName: params.docName, + version: 'v1', + action: 'update', + clock: params.clock, + }); + }); + }); + + describe('when clock number -1 is passed', () => { + const setup = () => { + const params = { docName: new ObjectId().toHexString(), clock: -1 }; + + return { params }; + }; + + it('should return a object that support the interface UniqueKey and pass the clock number', () => { + const { params } = setup(); + + const result = KeyFactory.createForUpdate(params.docName, params.clock); + + expect(result).toEqual({ + docName: params.docName, + version: 'v1', + action: 'update', + clock: params.clock, + }); + }); + }); + + describe('when clock lower then -1 is passed', () => { + const setup = () => { + const params = { docName: new ObjectId().toHexString(), clock: -2 }; + + return { params }; + }; + + it('should throw an invalid clock number error', () => { + const { params } = setup(); + + expect(() => KeyFactory.createForUpdate(params.docName, params.clock)).toThrowError(); + }); + }); + }); + + describe('createForInsert', () => { + describe('when docName passed', () => { + const setup = () => { + const params = { docName: new ObjectId().toHexString() }; + + return { params }; + }; + + it('should return a object that support the interface UniqueKey', () => { + const { params } = setup(); + + const result = KeyFactory.createForInsert(params.docName); + + expect(result).toEqual({ + docName: params.docName, + version: 'v1_sv', + action: undefined, + clock: undefined, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/repo/key.factory.ts b/apps/server/src/modules/tldraw/repo/key.factory.ts new file mode 100644 index 00000000000..83f1ef84233 --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/key.factory.ts @@ -0,0 +1,54 @@ +enum DatabaseAction { + UPDATE = 'update', +} + +export enum Version { + V1_SV = 'v1_sv', + V1 = 'v1', +} + +interface UniqueKey { + version: Version; + action?: DatabaseAction; + docName: string; + clock?: number; +} + +export class KeyFactory { + static checkValidClock(clock?: number): void { + if (clock && clock < -1) { + throw new Error('Invalid clock value is passed to KeyFactory'); + } + } + + static createForUpdate(docName: string, clock?: number): UniqueKey { + KeyFactory.checkValidClock(clock); + let uniqueKey: UniqueKey; + + if (clock !== undefined) { + uniqueKey = { + docName, + version: Version.V1, + action: DatabaseAction.UPDATE, + clock, + }; + } else { + uniqueKey = { + docName, + version: Version.V1, + action: DatabaseAction.UPDATE, + }; + } + + return uniqueKey; + } + + static createForInsert(docName: string): UniqueKey { + const uniqueKey = { + docName, + version: Version.V1_SV, + }; + + return uniqueKey; + } +} diff --git a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts index 6d9b3c799bb..ab6c81d117f 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts @@ -1,51 +1,67 @@ import { Test } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; -import WebSocket from 'ws'; import { WsAdapter } from '@nestjs/platform-ws'; -import { CoreModule } from '@src/core'; +import { Doc } from 'yjs'; +import { createMock } from '@golevelup/ts-jest'; +import { HttpService } from '@nestjs/axios'; +import { Logger } from '@src/core/logger'; import { ConfigModule } from '@nestjs/config'; +import { MongoMemoryDatabaseModule } from '@infra/database'; import { createConfigModuleOptions } from '@src/config'; -import { createMock } from '@golevelup/ts-jest'; -import { Doc } from 'yjs'; -import * as YjsUtils from '../utils/ydoc-utils'; -import { config } from '../config'; import { TldrawBoardRepo } from './tldraw-board.repo'; -import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { WsSharedDocDo } from '../domain'; import { TldrawWsService } from '../service'; +import { tldrawTestConfig } from '../testing'; +import { TldrawDrawing } from '../entities'; import { TldrawWs } from '../controller'; -import { TestConnection } from '../testing/test-connection'; +import { MetricsService } from '../metrics'; +import { TldrawRepo } from './tldraw.repo'; +import { YMongodb } from './y-mongodb'; +import { TldrawRedisFactory, TldrawRedisService } from '../redis'; describe('TldrawBoardRepo', () => { let app: INestApplication; let repo: TldrawBoardRepo; - let ws: WebSocket; - let service: TldrawWsService; - - const gatewayPort = 3346; - const wsUrl = TestConnection.getWsUrl(gatewayPort); - - jest.useFakeTimers(); beforeAll(async () => { - const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; const testingModule = await Test.createTestingModule({ - imports, + imports: [ + MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), + ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), + ], providers: [ TldrawWs, + TldrawWsService, TldrawBoardRepo, + YMongodb, + MetricsService, + TldrawRedisFactory, + TldrawRedisService, + { + provide: TldrawRepo, + useValue: createMock(), + }, { - provide: TldrawWsService, - useValue: createMock(), + provide: Logger, + useValue: createMock(), + }, + { + provide: HttpService, + useValue: createMock(), }, ], }).compile(); - service = testingModule.get(TldrawWsService); - repo = testingModule.get(TldrawBoardRepo); + repo = testingModule.get(TldrawBoardRepo); app = testingModule.createNestApplication(); app.useWebSocketAdapter(new WsAdapter(app)); - jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); await app.init(); + + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.resetAllMocks(); }); afterAll(async () => { @@ -55,90 +71,12 @@ describe('TldrawBoardRepo', () => { it('should check if repo and its properties are set correctly', () => { expect(repo).toBeDefined(); expect(repo.mdb).toBeDefined(); - expect(repo.configService).toBeDefined(); - expect(repo.flushSize).toBeDefined(); - expect(repo.multipleCollections).toBeDefined(); - expect(repo.connectionString).toBeDefined(); - expect(repo.collectionName).toBeDefined(); - }); - - describe('updateDocument', () => { - describe('when document receives empty update', () => { - const setup = async () => { - const doc = new WsSharedDocDo('TEST2', service); - ws = await TestConnection.setupWs(wsUrl, 'TEST2'); - const wsSet = new Set(); - wsSet.add(ws); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - doc.conns.set(ws, wsSet); - const storeGetYDocSpy = jest - .spyOn(repo.mdb, 'getYDoc') - .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); - const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockImplementation(() => Promise.resolve(1)); - - return { - doc, - storeUpdateSpy, - storeGetYDocSpy, - }; - }; - - it('should not update db with diff', async () => { - const { doc, storeUpdateSpy, storeGetYDocSpy } = await setup(); - - await repo.updateDocument('TEST2', doc); - expect(storeUpdateSpy).toHaveBeenCalledTimes(0); - storeUpdateSpy.mockRestore(); - storeGetYDocSpy.mockRestore(); - ws.close(); - }); - }); - - describe('when document receive update', () => { - const setup = async () => { - const clientMessageMock = 'test-message'; - const doc = new WsSharedDocDo('TEST', service); - ws = await TestConnection.setupWs(wsUrl, 'TEST'); - const wsSet = new Set(); - wsSet.add(ws); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - doc.conns.set(ws, wsSet); - const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockImplementation(() => Promise.resolve(1)); - const storeGetYDocSpy = jest - .spyOn(repo.mdb, 'getYDoc') - .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); - const byteArray = new TextEncoder().encode(clientMessageMock); - - return { - doc, - byteArray, - storeUpdateSpy, - storeGetYDocSpy, - }; - }; - - it('should update db with diff', async () => { - const { doc, byteArray, storeUpdateSpy, storeGetYDocSpy } = await setup(); - - await repo.updateDocument('TEST', doc); - doc.emit('update', [byteArray, undefined, doc]); - expect(storeUpdateSpy).toHaveBeenCalled(); - expect(storeUpdateSpy).toHaveBeenCalledTimes(1); - storeUpdateSpy.mockRestore(); - storeGetYDocSpy.mockRestore(); - ws.close(); - }); - }); }); describe('getYDocFromMdb', () => { describe('when taking doc data from db', () => { const setup = () => { - const storeGetYDocSpy = jest - .spyOn(repo.mdb, 'getYDoc') - .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); + const storeGetYDocSpy = jest.spyOn(repo.mdb, 'getDocument').mockResolvedValueOnce(new WsSharedDocDo('TEST')); return { storeGetYDocSpy, @@ -147,75 +85,60 @@ describe('TldrawBoardRepo', () => { it('should return ydoc', async () => { const { storeGetYDocSpy } = setup(); - expect(await repo.getYDocFromMdb('test')).toBeInstanceOf(Doc); + const result = await repo.getDocumentFromDb('test'); + + expect(result).toBeInstanceOf(Doc); storeGetYDocSpy.mockRestore(); }); }); }); - describe('updateStoredDocWithDiff', () => { - describe('when the difference between update and current drawing is more than 0', () => { - const setup = () => { - const calculateDiffSpy = jest.spyOn(YjsUtils, 'calculateDiff').mockImplementationOnce(() => 1); - const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockResolvedValueOnce(Promise.resolve(1)); + describe('compressDocument', () => { + const setup = () => { + const flushDocumentSpy = jest.spyOn(repo.mdb, 'compressDocumentTransactional').mockResolvedValueOnce(); - return { - calculateDiffSpy, - storeUpdateSpy, - }; - }; + return { flushDocumentSpy }; + }; - it('should call store update method', () => { - const { storeUpdateSpy, calculateDiffSpy } = setup(); - const diffArray = new Uint8Array(); - repo.updateStoredDocWithDiff('test', diffArray); + it('should call compress method on YMongo', async () => { + const { flushDocumentSpy } = setup(); - expect(storeUpdateSpy).toHaveBeenCalled(); + await repo.compressDocument('test'); - calculateDiffSpy.mockRestore(); - storeUpdateSpy.mockRestore(); - }); + expect(flushDocumentSpy).toHaveBeenCalled(); + flushDocumentSpy.mockRestore(); }); + }); - describe('when the difference between update and current drawing is 0', () => { - const setup = () => { - const calculateDiffSpy = jest.spyOn(YjsUtils, 'calculateDiff').mockImplementationOnce(() => 0); - const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate'); + describe('storeUpdate', () => { + const setup = () => { + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdateTransactional').mockResolvedValue(2); + const compressDocumentSpy = jest.spyOn(repo.mdb, 'compressDocumentTransactional').mockResolvedValueOnce(); - return { - calculateDiffSpy, - storeUpdateSpy, - }; + return { + storeUpdateSpy, + compressDocumentSpy, }; + }; - it('should not call store update method', () => { - const { storeUpdateSpy, calculateDiffSpy } = setup(); - const diffArray = new Uint8Array(); - repo.updateStoredDocWithDiff('test', diffArray); + it('should call store update method on YMongo', async () => { + const { storeUpdateSpy } = setup(); - expect(storeUpdateSpy).not.toHaveBeenCalled(); + await repo.storeUpdate('test', new Uint8Array()); - calculateDiffSpy.mockRestore(); - storeUpdateSpy.mockRestore(); - }); + expect(storeUpdateSpy).toHaveBeenCalled(); + storeUpdateSpy.mockRestore(); }); - }); - describe('flushDocument', () => { - const setup = () => { - const flushDocumentSpy = jest.spyOn(repo.mdb, 'flushDocument').mockResolvedValueOnce(Promise.resolve()); - - return { flushDocumentSpy }; - }; + it('should call compressDocument if compress threshold was reached', async () => { + const { storeUpdateSpy, compressDocumentSpy } = setup(); - it('should call flush method on mdbPersistence', async () => { - const { flushDocumentSpy } = setup(); - await repo.flushDocument('test'); - - expect(flushDocumentSpy).toHaveBeenCalled(); + await repo.storeUpdate('test', new Uint8Array()); - flushDocumentSpy.mockRestore(); + expect(storeUpdateSpy).toHaveBeenCalled(); + expect(compressDocumentSpy).toHaveBeenCalled(); + storeUpdateSpy.mockRestore(); }); }); }); diff --git a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts index 8f3b3187158..7d3887feb68 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts @@ -1,72 +1,39 @@ import { Injectable } from '@nestjs/common'; -import { MongodbPersistence } from 'y-mongodb-provider'; +import { Logger } from '@src/core/logger'; import { ConfigService } from '@nestjs/config'; -import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'; import { TldrawConfig } from '../config'; -import { calculateDiff } from '../utils'; -import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { WsSharedDocDo } from '../domain'; +import { YMongodb } from './y-mongodb'; @Injectable() export class TldrawBoardRepo { - public connectionString: string; - - public collectionName: string; - - public flushSize: number; - - public multipleCollections: boolean; - - public mdb: MongodbPersistence; - - constructor(public readonly configService: ConfigService) { - this.connectionString = this.configService.get('CONNECTION_STRING'); - this.collectionName = this.configService.get('TLDRAW_DB_COLLECTION_NAME') ?? 'drawings'; - this.flushSize = this.configService.get('TLDRAW_DB_FLUSH_SIZE') ?? 400; - this.multipleCollections = this.configService.get('TLDRAW_DB_MULTIPLE_COLLECTIONS'); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment - this.mdb = new MongodbPersistence(this.connectionString, { - collectionName: 'drawings', - flushSize: 400, - multipleCollections: false, - }); + constructor( + private readonly configService: ConfigService, + readonly mdb: YMongodb, + private readonly logger: Logger + ) { + this.logger.setContext(TldrawBoardRepo.name); } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // eslint-disable-next-line consistent-return - public async getYDocFromMdb(docName: string): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access - const yDoc = await this.mdb.getYDoc(docName); - if (yDoc instanceof Doc) { - return yDoc; - } + public async createDbIndex(): Promise { + await this.mdb.createIndex(); } - public updateStoredDocWithDiff(docName: string, diff: Uint8Array): void { - const calc = calculateDiff(diff); - if (calc > 0) { - void this.mdb.storeUpdate(docName, diff); - } + public async getDocumentFromDb(docName: string): Promise { + const yDoc = await this.mdb.getDocument(docName); + return yDoc; } - public async updateDocument(docName: string, ydoc: WsSharedDocDo): Promise { - const persistedYdoc = await this.getYDocFromMdb(docName); - const persistedStateVector = encodeStateVector(persistedYdoc); - const diff = encodeStateAsUpdate(ydoc, persistedStateVector); - this.updateStoredDocWithDiff(docName, diff); - - applyUpdate(ydoc, encodeStateAsUpdate(persistedYdoc)); - - ydoc.on('update', (update: Uint8Array) => { - void this.mdb.storeUpdate(docName, update); - }); - - persistedYdoc.destroy(); + public async compressDocument(docName: string): Promise { + await this.mdb.compressDocumentTransactional(docName); } - public async flushDocument(docName: string): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access - await this.mdb.flushDocument(docName); + public async storeUpdate(docName: string, update: Uint8Array): Promise { + const compressThreshold = this.configService.get('TLDRAW_DB_COMPRESS_THRESHOLD'); + const currentClock = await this.mdb.storeUpdateTransactional(docName, update); + + if (currentClock % compressThreshold === 0) { + await this.compressDocument(docName); + } } } diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts index 5c075669431..86c0ce7345a 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts @@ -1,28 +1,32 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { cleanupCollections } from '@shared/testing'; -import { MongoMemoryDatabaseModule } from '@infra/database'; -import { NotFoundException } from '@nestjs/common'; -import { tldrawEntityFactory } from '../factory'; +import { MikroORM } from '@mikro-orm/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { tldrawEntityFactory, tldrawTestConfig } from '../testing'; import { TldrawDrawing } from '../entities'; import { TldrawRepo } from './tldraw.repo'; +import { TldrawWsTestModule } from '../tldraw-ws-test.module'; -describe(TldrawRepo.name, () => { - let module: TestingModule; +describe('TldrawRepo', () => { + let testingModule: TestingModule; let repo: TldrawRepo; let em: EntityManager; + let orm: MikroORM; beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] })], - providers: [TldrawRepo], + testingModule = await Test.createTestingModule({ + imports: [TldrawWsTestModule, ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig))], }).compile(); - repo = module.get(TldrawRepo); - em = module.get(EntityManager); + + repo = testingModule.get(TldrawRepo); + em = testingModule.get(EntityManager); + orm = testingModule.get(MikroORM); }); afterAll(async () => { - await module.close(); + await testingModule.close(); }); afterEach(async () => { @@ -31,13 +35,22 @@ describe(TldrawRepo.name, () => { describe('create', () => { describe('when called', () => { - it('should create new drawing node', async () => { + const setup = async () => { const drawing = tldrawEntityFactory.build(); await repo.create(drawing); em.clear(); + return { + drawing, + }; + }; + + it('should create new drawing node', async () => { + const { drawing } = await setup(); + const result = await em.find(TldrawDrawing, {}); + expect(result[0]._id).toEqual(drawing._id); }); @@ -64,29 +77,43 @@ describe(TldrawRepo.name, () => { it('should return the object', async () => { const { drawing } = await setup(); + const result = await repo.findByDocName(drawing.docName); + expect(result[0].docName).toEqual(drawing.docName); expect(result[0]._id).toEqual(drawing._id); }); - - it('should throw NotFoundException for wrong docName', async () => { - await expect(repo.findByDocName('invalid-name')).rejects.toThrow(NotFoundException); - }); }); }); describe('delete', () => { - describe('when finding by docName and deleting all records', () => { - it('should delete all records', async () => { + describe('when drawings exist', () => { + const setup = async () => { const drawing = tldrawEntityFactory.build(); + await repo.create(drawing); - const results = await repo.findByDocName(drawing.docName); - await repo.delete(results); + return { drawing }; + }; + + it('should delete the specified drawing', async () => { + const { drawing } = await setup(); + + await repo.delete([drawing]); - expect(results.length).not.toEqual(0); - await expect(repo.findByDocName(drawing.docName)).rejects.toThrow(NotFoundException); + const results = await repo.findByDocName(drawing.docName); + expect(results.length).toEqual(0); }); }); }); + + describe('ensureIndexes', () => { + it('should call getSchemaGenerator().ensureIndexes()', async () => { + const ormSpy = jest.spyOn(orm, 'getSchemaGenerator'); + + await repo.ensureIndexes(); + + expect(ormSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts index d8eb4330bd2..c4c934fb540 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts @@ -1,24 +1,59 @@ +import { MikroORM } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/mongodb'; -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BulkWriteResult, Collection, Sort } from '@mikro-orm/mongodb/node_modules/mongodb'; +import { Injectable } from '@nestjs/common'; import { TldrawDrawing } from '../entities'; @Injectable() export class TldrawRepo { - constructor(private readonly _em: EntityManager) {} + constructor(private readonly em: EntityManager, private readonly orm: MikroORM) {} - async create(entity: TldrawDrawing): Promise { - await this._em.persistAndFlush(entity); + public async create(entity: TldrawDrawing): Promise { + await this.em.persistAndFlush(entity); } - async findByDocName(docName: string): Promise { - const domainObject = await this._em.find(TldrawDrawing, { docName }); - if (domainObject.length === 0) { - throw new NotFoundException(`There is no '${docName}' for this docName`); - } - return domainObject; + public async findByDocName(docName: string): Promise { + const drawings = await this.em.find(TldrawDrawing, { docName }); + return drawings; } - async delete(entity: TldrawDrawing | TldrawDrawing[]): Promise { - await this._em.removeAndFlush(entity); + public async delete(entity: TldrawDrawing | TldrawDrawing[]): Promise { + await this.em.removeAndFlush(entity); + } + + public get(query: object): Promise { + const collection = this.getCollection(); + return collection.findOne(query, { allowDiskUse: true }); + } + + public async put(query: object, values: object): Promise { + const collection = this.getCollection(); + await collection.updateOne(query, { $set: values }, { upsert: true }); + return this.get(query); + } + + public del(query: object): Promise { + const collection = this.getCollection(); + const bulk = collection.initializeOrderedBulkOp(); + bulk.find(query).delete(); + return bulk.execute(); + } + + public readAsCursor(query: object, opts: { limit?: number; reverse?: boolean } = {}): Promise { + const { limit = 0, reverse = false } = opts; + + const collection = this.getCollection(); + const sortQuery: Sort = reverse ? { clock: -1, part: 1 } : { clock: 1, part: 1 }; + const curs = collection.find(query, { allowDiskUse: true }).sort(sortQuery).limit(limit); + + return curs.toArray(); + } + + public getCollection(): Collection { + return this.em.getCollection(TldrawDrawing); + } + + public async ensureIndexes(): Promise { + await this.orm.getSchemaGenerator().ensureIndexes(); } } diff --git a/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts b/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts new file mode 100644 index 00000000000..cf901cbf565 --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts @@ -0,0 +1,282 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections } from '@shared/testing'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { ConfigModule } from '@nestjs/config'; +import { Logger } from '@src/core/logger'; +import { createMock } from '@golevelup/ts-jest'; +import * as Yjs from 'yjs'; +import { createConfigModuleOptions } from '@src/config'; +import { HttpService } from '@nestjs/axios'; +import { TldrawRedisFactory, TldrawRedisService } from '../redis'; +import { tldrawEntityFactory, tldrawTestConfig } from '../testing'; +import { TldrawDrawing } from '../entities'; +import { TldrawWs } from '../controller'; +import { TldrawWsService } from '../service'; +import { MetricsService } from '../metrics'; +import { TldrawBoardRepo } from './tldraw-board.repo'; +import { TldrawRepo } from './tldraw.repo'; +import { YMongodb } from './y-mongodb'; + +jest.mock('yjs', () => { + const moduleMock: unknown = { + __esModule: true, + ...jest.requireActual('yjs'), + }; + return moduleMock; +}); + +describe('YMongoDb', () => { + let testingModule: TestingModule; + let mdb: YMongodb; + let repo: TldrawRepo; + let em: EntityManager; + + beforeAll(async () => { + testingModule = await Test.createTestingModule({ + imports: [ + MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), + ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), + ], + providers: [ + TldrawWs, + TldrawWsService, + TldrawBoardRepo, + TldrawRepo, + YMongodb, + MetricsService, + TldrawRedisFactory, + TldrawRedisService, + { + provide: Logger, + useValue: createMock(), + }, + { + provide: HttpService, + useValue: createMock(), + }, + ], + }).compile(); + + mdb = testingModule.get(YMongodb); + repo = testingModule.get(TldrawRepo); + em = testingModule.get(EntityManager); + }); + + afterAll(async () => { + await testingModule.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('storeUpdateTransactional', () => { + describe('when clock is defined', () => { + const setup = async () => { + const drawing = tldrawEntityFactory.build({ clock: 1 }); + await em.persistAndFlush(drawing); + em.clear(); + + return { drawing }; + }; + + it('should create new document with updates in the database', async () => { + const { drawing } = await setup(); + + await mdb.storeUpdateTransactional(drawing.docName, new Uint8Array([])); + const docs = await em.findAndCount(TldrawDrawing, { docName: drawing.docName }); + + expect(docs.length).toEqual(2); + }); + }); + + describe('when clock is undefined', () => { + const setup = async () => { + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValueOnce(); + const drawing = tldrawEntityFactory.build({ clock: undefined }); + + await em.persistAndFlush(drawing); + em.clear(); + + return { + applyUpdateSpy, + drawing, + }; + }; + + it('should call applyUpdate and create new document with updates in the database', async () => { + const { applyUpdateSpy, drawing } = await setup(); + + await mdb.storeUpdateTransactional(drawing.docName, new Uint8Array([2, 2])); + const docs = await em.findAndCount(TldrawDrawing, { docName: drawing.docName }); + + expect(applyUpdateSpy).toHaveBeenCalled(); + expect(docs.length).toEqual(2); + }); + }); + }); + + describe('compressDocumentTransactional', () => { + const setup = async () => { + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValueOnce(); + const mergeUpdatesSpy = jest.spyOn(Yjs, 'mergeUpdates').mockReturnValueOnce(new Uint8Array([])); + + const drawing1 = tldrawEntityFactory.build({ clock: 1, part: undefined }); + const drawing2 = tldrawEntityFactory.build({ clock: 2, part: undefined }); + const drawing3 = tldrawEntityFactory.build({ clock: 3, part: undefined }); + const drawing4 = tldrawEntityFactory.build({ clock: 4, part: undefined }); + + await em.persistAndFlush([drawing1, drawing2, drawing3, drawing4]); + em.clear(); + + return { + applyUpdateSpy, + mergeUpdatesSpy, + drawing1, + }; + }; + + it('should merge multiple documents with the same name in the database into two (one main document and one with update)', async () => { + const { applyUpdateSpy, drawing1 } = await setup(); + + await mdb.compressDocumentTransactional(drawing1.docName); + const docs = await em.findAndCount(TldrawDrawing, { docName: drawing1.docName }); + + expect(docs.length).toEqual(2); + applyUpdateSpy.mockRestore(); + }); + }); + + describe('createIndex', () => { + const setup = () => { + const ensureIndexesSpy = jest.spyOn(repo, 'ensureIndexes').mockResolvedValueOnce(); + + return { + ensureIndexesSpy, + }; + }; + + it('should create index', async () => { + const { ensureIndexesSpy } = setup(); + + await mdb.createIndex(); + + expect(ensureIndexesSpy).toHaveBeenCalled(); + }); + }); + + describe('getAllDocumentNames', () => { + const setup = async () => { + const drawing1 = tldrawEntityFactory.build({ docName: 'test-name1', version: 'v1_sv' }); + const drawing2 = tldrawEntityFactory.build({ docName: 'test-name2', version: 'v1_sv' }); + const drawing3 = tldrawEntityFactory.build({ docName: 'test-name3', version: 'v1_sv' }); + + await em.persistAndFlush([drawing1, drawing2, drawing3]); + em.clear(); + }; + + it('should return all document names', async () => { + await setup(); + + const docNames = await mdb.getAllDocumentNames(); + + expect(docNames).toEqual(['test-name1', 'test-name2', 'test-name3']); + }); + }); + + describe('getYDoc', () => { + describe('when getting document with well defined parts', () => { + const setup = async () => { + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValue(); + const mergeUpdatesSpy = jest.spyOn(Yjs, 'mergeUpdates').mockReturnValue(new Uint8Array([])); + + const drawing1 = tldrawEntityFactory.build({ clock: 1, part: 1 }); + const drawing2 = tldrawEntityFactory.build({ clock: 1, part: 2 }); + const drawing3 = tldrawEntityFactory.build({ clock: 2, part: 1 }); + + await em.persistAndFlush([drawing1, drawing2, drawing3]); + em.clear(); + + return { + applyUpdateSpy, + mergeUpdatesSpy, + drawing1, + drawing2, + drawing3, + }; + }; + + it('should return ydoc', async () => { + const { applyUpdateSpy } = await setup(); + + const doc = await mdb.getDocument('test-name'); + + expect(doc).toBeDefined(); + applyUpdateSpy.mockRestore(); + }); + }); + + describe('when getting document with missing parts', () => { + const setup = async () => { + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValue(); + + const drawing1 = tldrawEntityFactory.build({ clock: 1, part: 1 }); + const drawing4 = tldrawEntityFactory.build({ clock: 1, part: 3 }); + const drawing5 = tldrawEntityFactory.build({ clock: 1, part: 4 }); + + await em.persistAndFlush([drawing1, drawing4, drawing5]); + em.clear(); + + return { + applyUpdateSpy, + }; + }; + + it('should not return ydoc', async () => { + const { applyUpdateSpy } = await setup(); + + const doc = await mdb.getDocument('test-name'); + + expect(doc).toBeUndefined(); + applyUpdateSpy.mockRestore(); + }); + }); + + describe('when getting document with part undefined', () => { + const setup = async () => { + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValue(); + const drawing1 = tldrawEntityFactory.build({ part: undefined }); + const drawing2 = tldrawEntityFactory.build({ part: undefined }); + const drawing3 = tldrawEntityFactory.build({ part: undefined }); + + await em.persistAndFlush([drawing1, drawing2, drawing3]); + em.clear(); + + return { + applyUpdateSpy, + }; + }; + + it('should return ydoc from the database', async () => { + const { applyUpdateSpy } = await setup(); + + const doc = await mdb.getDocument('test-name'); + + expect(doc).toBeDefined(); + applyUpdateSpy.mockRestore(); + }); + + describe('when single entity size is greater than MAX_DOCUMENT_SIZE', () => { + it('should return ydoc from the database', async () => { + const { applyUpdateSpy } = await setup(); + + const doc = await mdb.getDocument('test-name'); + + expect(doc).toBeDefined(); + applyUpdateSpy.mockRestore(); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/repo/y-mongodb.ts b/apps/server/src/modules/tldraw/repo/y-mongodb.ts new file mode 100644 index 00000000000..0fcca950f41 --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/y-mongodb.ts @@ -0,0 +1,264 @@ +import { BulkWriteResult } from '@mikro-orm/mongodb/node_modules/mongodb'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Logger } from '@src/core/logger'; +import { Buffer } from 'buffer'; +import * as binary from 'lib0/binary'; +import * as encoding from 'lib0/encoding'; +import * as promise from 'lib0/promise'; +import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector, mergeUpdates } from 'yjs'; +import { TldrawConfig } from '../config'; +import { WsSharedDocDo } from '../domain'; +import { TldrawDrawing } from '../entities'; +import { MongoTransactionErrorLoggable } from '../loggable'; +import { YTransaction } from '../types'; +import { KeyFactory, Version } from './key.factory'; +import { TldrawRepo } from './tldraw.repo'; + +@Injectable() +export class YMongodb { + private readonly _transact: >(docName: string, fn: () => T) => T; + + // scope the queue of the transaction to each docName + // this should allow concurrency for different rooms + private tr: { docName?: Promise } = {}; + + constructor( + private readonly configService: ConfigService, + private readonly repo: TldrawRepo, + private readonly logger: Logger + ) { + this.logger.setContext(YMongodb.name); + + // execute a transaction on a database + // this will ensure that other processes are currently not writing + this._transact = >(docName: string, fn: () => T): T => { + if (!this.tr[docName]) { + this.tr[docName] = promise.resolve(); + } + + const currTr = this.tr[docName] as T; + let nextTr: Promise = promise.resolve(null); + + nextTr = (async () => { + await currTr; + + let res: YTransaction | null; + try { + res = await fn(); + } catch (err) { + this.logger.warning(new MongoTransactionErrorLoggable(err)); + } + + // once the last transaction for a given docName resolves, remove it from the queue + if (this.tr[docName] === nextTr) { + delete this.tr[docName]; + } + + return res; + })(); + + this.tr[docName] = nextTr; + + return this.tr[docName] as T; + }; + } + + public async createIndex(): Promise { + await this.repo.ensureIndexes(); + } + + public async getAllDocumentNames(): Promise { + const docs = await this.repo.readAsCursor({ version: Version.V1_SV }); + const docNames = docs.map((doc) => doc.docName); + + return docNames; + } + + public getDocument(docName: string): Promise { + return this._transact(docName, async (): Promise => { + const updates = await this.getMongoUpdates(docName); + const mergedUpdates = mergeUpdates(updates); + + const gcEnabled = this.configService.get('TLDRAW_GC_ENABLED'); + const ydoc = new WsSharedDocDo(docName, gcEnabled); + applyUpdate(ydoc, mergedUpdates); + + return ydoc; + }); + } + + public storeUpdateTransactional(docName: string, update: Uint8Array): Promise { + return this._transact(docName, () => this.storeUpdate(docName, update)); + } + + public compressDocumentTransactional(docName: string): Promise { + return this._transact(docName, async () => { + const updates = await this.getMongoUpdates(docName); + const mergedUpdates = mergeUpdates(updates); + + const ydoc = new Doc(); + applyUpdate(ydoc, mergedUpdates); + + const stateAsUpdate = encodeStateAsUpdate(ydoc); + const sv = encodeStateVector(ydoc); + const clock = await this.storeUpdate(docName, stateAsUpdate); + await this.writeStateVector(docName, sv, clock); + await this.clearUpdatesRange(docName, 0, clock); + + ydoc.destroy(); + }); + } + + public async getCurrentUpdateClock(docName: string): Promise { + const updates = await this.getMongoBulkData( + { + ...KeyFactory.createForUpdate(docName, 0), + clock: { + $gte: 0, + $lt: binary.BITS32, + }, + }, + { reverse: true, limit: 1 } + ); + + const clock = this.extractClock(updates); + + return clock; + } + + private async clearUpdatesRange(docName: string, from: number, to: number): Promise { + return this.repo.del({ + docName, + clock: { + $gte: from, + $lt: to, + }, + }); + } + + private getMongoBulkData(query: object, opts: object): Promise { + return this.repo.readAsCursor(query, opts); + } + + private mergeDocsTogether(doc: TldrawDrawing, docs: TldrawDrawing[], docIndex: number): Buffer[] { + const parts = [Buffer.from(doc.value.buffer)]; + let currentPartId: number | undefined = doc.part; + for (let i = docIndex + 1; i < docs.length; i += 1) { + const part = docs[i]; + + if (!this.isSameClock(part, doc)) { + break; + } + + this.checkIfPartIsNextPartAfterCurrent(part, currentPartId); + + parts.push(Buffer.from(part.value.buffer)); + currentPartId = part.part; + } + + return parts; + } + + /** + * Convert the mongo document array to an array of values (as buffers) + */ + private convertMongoUpdates(docs: TldrawDrawing[]): Buffer[] { + if (!Array.isArray(docs) || !docs.length) return []; + + const updates: Buffer[] = []; + for (let i = 0; i < docs.length; i += 1) { + const doc = docs[i]; + + if (!doc.part) { + updates.push(Buffer.from(doc.value.buffer)); + } + + if (doc.part === 1) { + // merge the docs together that got split because of mongodb size limits + const parts = this.mergeDocsTogether(doc, docs, i); + updates.push(Buffer.concat(parts)); + } + } + return updates; + } + + /** + * Get all document updates for a specific document. + */ + private async getMongoUpdates(docName: string, opts = {}): Promise { + const uniqueKey = KeyFactory.createForUpdate(docName); + const docs = await this.getMongoBulkData(uniqueKey, opts); + + return this.convertMongoUpdates(docs); + } + + private async writeStateVector(docName: string, sv: Uint8Array, clock: number): Promise { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, clock); + encoding.writeVarUint8Array(encoder, sv); + const uniqueKey = KeyFactory.createForInsert(docName); + + await this.repo.put(uniqueKey, { + value: Buffer.from(encoding.toUint8Array(encoder)), + }); + } + + private async storeUpdate(docName: string, update: Uint8Array): Promise { + const clock: number = await this.getCurrentUpdateClock(docName); + + if (clock === -1) { + // make sure that a state vector is always written, so we can search for available documents + const ydoc = new Doc(); + applyUpdate(ydoc, update); + const sv = encodeStateVector(ydoc); + + await this.writeStateVector(docName, sv, 0); + } + + const maxDocumentSize = this.configService.get('TLDRAW_MAX_DOCUMENT_SIZE'); + const value = Buffer.from(update); + // if our buffer exceeds maxDocumentSize, we store the update in multiple documents + if (value.length <= maxDocumentSize) { + const uniqueKey = KeyFactory.createForUpdate(docName, clock + 1); + + await this.repo.put(uniqueKey, { + value, + }); + } else { + const totalChunks = Math.ceil(value.length / maxDocumentSize); + + const putPromises: Promise[] = []; + for (let i = 0; i < totalChunks; i += 1) { + const start = i * maxDocumentSize; + const end = Math.min(start + maxDocumentSize, value.length); + const chunk = value.subarray(start, end); + + putPromises.push( + this.repo.put({ ...KeyFactory.createForUpdate(docName, clock + 1), part: i + 1 }, { value: chunk }) + ); + } + + await Promise.all(putPromises); + } + + return clock + 1; + } + + private isSameClock(doc1: TldrawDrawing, doc2: TldrawDrawing): boolean { + return doc1.clock === doc2.clock; + } + + private checkIfPartIsNextPartAfterCurrent(part: TldrawDrawing, currentPartId: number | undefined): void { + if (part.part === undefined || currentPartId !== part.part - 1) { + throw new Error('Could not merge updates together because a part is missing'); + } + } + + private extractClock(updates: TldrawDrawing[]): number { + if (updates.length === 0 || updates[0].clock == null) { + return -1; + } + return updates[0].clock; + } +} diff --git a/apps/server/src/modules/tldraw/service/index.ts b/apps/server/src/modules/tldraw/service/index.ts index 2bc9f981432..23b3adf2ee4 100644 --- a/apps/server/src/modules/tldraw/service/index.ts +++ b/apps/server/src/modules/tldraw/service/index.ts @@ -1,2 +1,3 @@ -export * from './tldraw.ws.service'; +export * from './tldraw-files-storage.service'; export * from './tldraw.service'; +export * from './tldraw.ws.service'; diff --git a/apps/server/src/modules/tldraw/service/tldraw-files-storage.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw-files-storage.service.spec.ts new file mode 100644 index 00000000000..09352805248 --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw-files-storage.service.spec.ts @@ -0,0 +1,102 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { FilesStorageClientAdapterService } from '@modules/files-storage-client'; +import { tldrawFileDtoFactory } from '@shared/testing/factory'; +import { TldrawFilesStorageAdapterService } from './tldraw-files-storage.service'; +import { tldrawAssetFactory } from '../testing'; + +describe('TldrawFilesStorageAdapterService', () => { + let module: TestingModule; + let tldrawFilesStorageAdapterService: TldrawFilesStorageAdapterService; + let filesStorageClientAdapterService: FilesStorageClientAdapterService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + TldrawFilesStorageAdapterService, + { + provide: FilesStorageClientAdapterService, + useValue: createMock(), + }, + ], + }).compile(); + + tldrawFilesStorageAdapterService = module.get(TldrawFilesStorageAdapterService); + filesStorageClientAdapterService = module.get(FilesStorageClientAdapterService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('deleteUnusedFilesForDocument', () => { + describe('when there are files found for this document', () => { + const setup = () => { + const asset = tldrawAssetFactory.build(); + const usedAssets = [asset]; + + const fileDtos = tldrawFileDtoFactory.buildListWithId(2); + const fileWithWrongDate = tldrawFileDtoFactory.build({ createdAt: undefined }); + fileDtos.push(fileWithWrongDate); + + const listFilesOfParentSpy = jest + .spyOn(filesStorageClientAdapterService, 'listFilesOfParent') + .mockResolvedValueOnce(fileDtos); + const deleteFilesSpy = jest.spyOn(filesStorageClientAdapterService, 'deleteFiles'); + + return { + usedAssets, + listFilesOfParentSpy, + deleteFilesSpy, + }; + }; + + it('should call deleteFiles on filesStorageClientAdapterService', async () => { + const { usedAssets, listFilesOfParentSpy, deleteFilesSpy } = setup(); + + await tldrawFilesStorageAdapterService.deleteUnusedFilesForDocument('docname', usedAssets, new Date()); + + expect(listFilesOfParentSpy).toHaveBeenCalled(); + expect(deleteFilesSpy).toHaveBeenCalled(); + }); + + describe('when no files are older than the threshold date', () => { + it('should not call deleteFiles on filesStorageClientAdapterService', async () => { + const { usedAssets, listFilesOfParentSpy, deleteFilesSpy } = setup(); + + await tldrawFilesStorageAdapterService.deleteUnusedFilesForDocument( + 'docname', + usedAssets, + new Date(2019, 1, 1, 0, 0) + ); + + expect(listFilesOfParentSpy).toHaveBeenCalled(); + expect(deleteFilesSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when there are no files found for this document', () => { + const setup = () => { + const listFilesOfParentSpy = jest + .spyOn(filesStorageClientAdapterService, 'listFilesOfParent') + .mockResolvedValueOnce([]); + const deleteFilesSpy = jest.spyOn(filesStorageClientAdapterService, 'deleteFiles'); + + return { + listFilesOfParentSpy, + deleteFilesSpy, + }; + }; + + it('should not call deleteFiles on filesStorageClientAdapterService', async () => { + const { listFilesOfParentSpy, deleteFilesSpy } = setup(); + + await tldrawFilesStorageAdapterService.deleteUnusedFilesForDocument('docname', [], new Date()); + + expect(listFilesOfParentSpy).toHaveBeenCalled(); + expect(deleteFilesSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/service/tldraw-files-storage.service.ts b/apps/server/src/modules/tldraw/service/tldraw-files-storage.service.ts new file mode 100644 index 00000000000..fef272813fc --- /dev/null +++ b/apps/server/src/modules/tldraw/service/tldraw-files-storage.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { FileDto, FilesStorageClientAdapterService } from '@modules/files-storage-client'; +import { TldrawAsset } from '../types'; + +@Injectable() +export class TldrawFilesStorageAdapterService { + constructor(private readonly filesStorageClientAdapterService: FilesStorageClientAdapterService) {} + + public async deleteUnusedFilesForDocument( + docName: string, + usedAssets: TldrawAsset[], + createdBeforeDate: Date + ): Promise { + const fileRecords = await this.filesStorageClientAdapterService.listFilesOfParent(docName); + const fileRecordIdsForDeletion = this.foundAssetsForDeletion(fileRecords, usedAssets, createdBeforeDate); + + if (fileRecordIdsForDeletion.length === 0) { + return; + } + + await this.filesStorageClientAdapterService.deleteFiles(fileRecordIdsForDeletion); + } + + private foundAssetsForDeletion(fileRecords: FileDto[], usedAssets: TldrawAsset[], createdBeforeDate: Date): string[] { + const fileRecordIdsForDeletion: string[] = []; + + for (const fileRecord of fileRecords) { + if (this.isOlderThanRequiredDate(fileRecord, createdBeforeDate)) { + this.addFileRecordIdToDeletionList(fileRecord, fileRecordIdsForDeletion, usedAssets); + } + } + + return fileRecordIdsForDeletion; + } + + private addFileRecordIdToDeletionList( + fileRecord: FileDto, + fileRecordIdsForDeletion: string[], + usedAssets: TldrawAsset[] + ) { + const foundAsset = usedAssets.some((asset) => this.matchAssetWithFileRecord(asset, fileRecord)); + if (!foundAsset) { + fileRecordIdsForDeletion.push(fileRecord.id); + } + } + + private isOlderThanRequiredDate(fileRecord: FileDto, createdBeforeDate: Date) { + if (!fileRecord.createdAt) { + return false; + } + + const isOlder = new Date(fileRecord.createdAt) < createdBeforeDate; + return isOlder; + } + + private matchAssetWithFileRecord(asset: TldrawAsset, fileRecord: FileDto) { + const srcArr = asset.src.split('/'); + const fileRecordId = srcArr[srcArr.length - 2]; + + return fileRecordId === fileRecord.id; + } +} diff --git a/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts index 546ab739bb0..8febc5f1f88 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts @@ -2,13 +2,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityManager } from '@mikro-orm/mongodb'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections } from '@shared/testing'; -import { NotFoundException } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; import { TldrawDrawing } from '../entities'; -import { tldrawEntityFactory } from '../factory'; +import { tldrawEntityFactory, tldrawTestConfig } from '../testing'; import { TldrawRepo } from '../repo/tldraw.repo'; import { TldrawService } from './tldraw.service'; -describe(TldrawService.name, () => { +describe('TldrawService', () => { let module: TestingModule; let service: TldrawService; let repo: TldrawRepo; @@ -16,7 +17,10 @@ describe(TldrawService.name, () => { beforeAll(async () => { module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] })], + imports: [ + MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), + ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), + ], providers: [TldrawService, TldrawRepo], }).compile(); @@ -31,26 +35,19 @@ describe(TldrawService.name, () => { afterEach(async () => { await cleanupCollections(em); - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('delete', () => { describe('when deleting all collection connected to one drawing', () => { it('should remove all collections giving drawing name', async () => { const drawing = tldrawEntityFactory.build(); - await repo.create(drawing); - const result = await repo.findByDocName(drawing.docName); - - expect(result.length).toEqual(1); await service.deleteByDocName(drawing.docName); - await expect(repo.findByDocName(drawing.docName)).rejects.toThrow(NotFoundException); - }); - - it('should throw when cannot find drawing', async () => { - await expect(service.deleteByDocName('nonExistingName')).rejects.toThrow(NotFoundException); + const result = await repo.findByDocName(drawing.docName); + expect(result.length).toEqual(0); }); }); }); diff --git a/apps/server/src/modules/tldraw/service/tldraw.service.ts b/apps/server/src/modules/tldraw/service/tldraw.service.ts index 4e0aa3db8db..8001a72ed0f 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.service.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { TldrawRepo } from '../repo/tldraw.repo'; +import { TldrawRepo } from '../repo'; @Injectable() export class TldrawService { diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts index 04ac871d428..3fada8c0c29 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts @@ -2,27 +2,36 @@ import { Test } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import WebSocket from 'ws'; import { WsAdapter } from '@nestjs/platform-ws'; -import { CoreModule } from '@src/core'; -import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions } from '@src/config'; import { TextEncoder } from 'util'; +import * as Yjs from 'yjs'; import * as SyncProtocols from 'y-protocols/sync'; import * as AwarenessProtocol from 'y-protocols/awareness'; +import * as Ioredis from 'ioredis'; import { encoding } from 'lib0'; import { TldrawWsFactory } from '@shared/testing/factory/tldraw.ws.factory'; import { HttpService } from '@nestjs/axios'; +import { WebSocketReadyStateEnum } from '@shared/testing'; +import { Logger } from '@src/core/logger'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { of, throwError } from 'rxjs'; -import { AxiosResponse } from 'axios'; -import { axiosResponseFactory } from '@shared/testing'; -import { MetricsService } from '@modules/tldraw/metrics'; -import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; -import { config } from '../config'; -import { TldrawBoardRepo } from '../repo'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { TldrawRedisFactory, TldrawRedisService } from '../redis'; import { TldrawWs } from '../controller'; +import { TldrawDrawing } from '../entities'; +import { TldrawBoardRepo, TldrawRepo, YMongodb } from '../repo'; +import { TestConnection, tldrawTestConfig } from '../testing'; +import { WsSharedDocDo } from '../domain'; +import { MetricsService } from '../metrics'; import { TldrawWsService } from '.'; -import { TestConnection } from '../testing/test-connection'; +jest.mock('yjs', () => { + const moduleMock: unknown = { + __esModule: true, + ...jest.requireActual('yjs'), + }; + return moduleMock; +}); jest.mock('y-protocols/awareness', () => { const moduleMock: unknown = { __esModule: true, @@ -42,7 +51,8 @@ describe('TldrawWSService', () => { let app: INestApplication; let ws: WebSocket; let service: TldrawWsService; - let httpService: DeepMocked; + let boardRepo: DeepMocked; + let logger: DeepMocked; const gatewayPort = 3346; const wsUrl = TestConnection.getWsUrl(gatewayPort); @@ -52,17 +62,31 @@ describe('TldrawWSService', () => { setTimeout(resolve, ms); }); - jest.useFakeTimers(); - beforeAll(async () => { - const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; const testingModule = await Test.createTestingModule({ - imports, + imports: [ + MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), + ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), + ], providers: [ TldrawWs, - TldrawBoardRepo, TldrawWsService, + YMongodb, MetricsService, + TldrawRedisFactory, + TldrawRedisService, + { + provide: TldrawBoardRepo, + useValue: createMock(), + }, + { + provide: TldrawRepo, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, { provide: HttpService, useValue: createMock(), @@ -70,11 +94,11 @@ describe('TldrawWSService', () => { ], }).compile(); - service = testingModule.get(TldrawWsService); - httpService = testingModule.get(HttpService); + service = testingModule.get(TldrawWsService); + boardRepo = testingModule.get(TldrawBoardRepo); + logger = testingModule.get(Logger); app = testingModule.createNestApplication(); app.useWebSocketAdapter(new WsAdapter(app)); - jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); await app.init(); }); @@ -82,6 +106,11 @@ describe('TldrawWSService', () => { await app.close(); }); + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + const createMessage = (values: number[]) => { const encoder = encoding.createEncoder(); values.forEach((val) => { @@ -90,15 +119,14 @@ describe('TldrawWSService', () => { encoding.writeVarUint(encoder, 0); encoding.writeVarUint(encoder, 1); const msg = encoding.toUint8Array(encoder); + return { msg, }; }; - it('should chcek if service properties are set correctly', () => { + it('should check if service properties are set correctly', () => { expect(service).toBeDefined(); - expect(service.pingTimeout).toBeDefined(); - expect(service.persistence).toBeDefined(); }); describe('send', () => { @@ -107,7 +135,7 @@ describe('TldrawWSService', () => { ws = await TestConnection.setupWs(wsUrl, 'TEST'); const clientMessageMock = 'test-message'; - const closeConSpy = jest.spyOn(service, 'closeConn').mockImplementationOnce(() => {}); + const closeConSpy = jest.spyOn(service, 'closeConnection').mockResolvedValueOnce(); const sendSpy = jest.spyOn(service, 'send'); const doc = TldrawWsFactory.createWsSharedDocDo(); const byteArray = new TextEncoder().encode(clientMessageMock); @@ -128,19 +156,97 @@ describe('TldrawWSService', () => { expect(sendSpy).toThrow(); expect(sendSpy).toHaveBeenCalledWith(doc, ws, byteArray); expect(closeConSpy).toHaveBeenCalled(); - ws.close(); sendSpy.mockRestore(); }); }); - describe('when websocket has ready state different than 0 or 1', () => { + describe('when client is not connected to WS and close connection throws error', () => { const setup = () => { + const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.OPEN); const clientMessageMock = 'test-message'; - const closeConSpy = jest.spyOn(service, 'closeConn'); + + const closeConSpy = jest.spyOn(service, 'closeConnection').mockRejectedValue(new Error('error')); + jest.spyOn(socketMock, 'send').mockImplementation((...args: unknown[]) => { + args.forEach((arg) => { + if (typeof arg === 'function') { + arg(new Error('error')); + } + }); + }); const sendSpy = jest.spyOn(service, 'send'); + const errorLogSpy = jest.spyOn(logger, 'warning'); const doc = TldrawWsFactory.createWsSharedDocDo(); - const socketMock = TldrawWsFactory.createWebsocket(3); + const byteArray = new TextEncoder().encode(clientMessageMock); + + return { + socketMock, + closeConSpy, + errorLogSpy, + sendSpy, + doc, + byteArray, + }; + }; + + it('should log error', async () => { + const { socketMock, closeConSpy, errorLogSpy, sendSpy, doc, byteArray } = setup(); + + service.send(doc, socketMock, byteArray); + + await delay(100); + + expect(sendSpy).toHaveBeenCalledWith(doc, socketMock, byteArray); + expect(closeConSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); + closeConSpy.mockRestore(); + sendSpy.mockRestore(); + }); + }); + + describe('when web socket has ready state CLOSED and close connection throws error', () => { + const setup = () => { + const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.CLOSED); + const clientMessageMock = 'test-message'; + + const closeConSpy = jest.spyOn(service, 'closeConnection').mockRejectedValue(new Error('error')); + const sendSpy = jest.spyOn(service, 'send'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const byteArray = new TextEncoder().encode(clientMessageMock); + + return { + socketMock, + closeConSpy, + errorLogSpy, + sendSpy, + doc, + byteArray, + }; + }; + + it('should log error', async () => { + const { socketMock, closeConSpy, errorLogSpy, sendSpy, doc, byteArray } = setup(); + + service.send(doc, socketMock, byteArray); + + await delay(100); + + expect(sendSpy).toHaveBeenCalledWith(doc, socketMock, byteArray); + expect(closeConSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); + closeConSpy.mockRestore(); + sendSpy.mockRestore(); + }); + }); + + describe('when websocket has ready state different than Open (1) or Connecting (0)', () => { + const setup = () => { + const clientMessageMock = 'test-message'; + const closeConSpy = jest.spyOn(service, 'closeConnection'); + const sendSpy = jest.spyOn(service, 'send'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.CLOSED); const byteArray = new TextEncoder().encode(clientMessageMock); return { @@ -160,62 +266,67 @@ describe('TldrawWSService', () => { expect(sendSpy).toHaveBeenCalledWith(doc, socketMock, byteArray); expect(sendSpy).toHaveBeenCalledTimes(1); expect(closeConSpy).toHaveBeenCalled(); - closeConSpy.mockRestore(); sendSpy.mockRestore(); }); }); - describe('when websocket has ready state 0', () => { + describe('when websocket has ready state Open (0)', () => { const setup = async () => { ws = await TestConnection.setupWs(wsUrl, 'TEST'); const clientMessageMock = 'test-message'; const sendSpy = jest.spyOn(service, 'send'); + jest.spyOn(Ioredis.Redis.prototype, 'publish').mockResolvedValueOnce(1); const doc = TldrawWsFactory.createWsSharedDocDo(); - const socketMock = TldrawWsFactory.createWebsocket(0); - doc.conns.set(socketMock, new Set()); + const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.OPEN); + doc.connections.set(socketMock, new Set()); const encoder = encoding.createEncoder(); encoding.writeVarUint(encoder, 2); const updateByteArray = new TextEncoder().encode(clientMessageMock); encoding.writeVarUint8Array(encoder, updateByteArray); const msg = encoding.toUint8Array(encoder); + return { sendSpy, doc, msg, + socketMock, }; }; it('should call send in updateHandler', async () => { - const { sendSpy, doc, msg } = await setup(); + const { sendSpy, doc, msg, socketMock } = await setup(); - service.updateHandler(msg, {}, doc); + service.updateHandler(msg, socketMock, doc); expect(sendSpy).toHaveBeenCalled(); - ws.close(); sendSpy.mockRestore(); }); }); - describe('when received message of specific type', () => { + describe('when received message of type specific type', () => { const setup = async (messageValues: number[]) => { ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish'); const sendSpy = jest.spyOn(service, 'send'); const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate'); const syncProtocolUpdateSpy = jest .spyOn(SyncProtocols, 'readSyncMessage') - .mockImplementationOnce((dec, enc) => { + .mockImplementationOnce((_dec, enc) => { enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; return 1; }); - const doc = new WsSharedDocDo('TEST', service); + const doc = new WsSharedDocDo('TEST'); const { msg } = createMessage(messageValues); return { sendSpy, + errorLogSpy, + publishSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, @@ -229,7 +340,6 @@ describe('TldrawWSService', () => { service.messageHandler(ws, doc, msg); expect(sendSpy).toHaveBeenCalledTimes(1); - ws.close(); sendSpy.mockRestore(); applyAwarenessUpdateSpy.mockRestore(); @@ -238,11 +348,10 @@ describe('TldrawWSService', () => { it('should not call send method when received message of type AWARENESS', async () => { const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([1, 1, 0]); - service.messageHandler(ws, doc, msg); - expect(sendSpy).toHaveBeenCalledTimes(0); - expect(applyAwarenessUpdateSpy).toHaveBeenCalledTimes(1); + service.messageHandler(ws, doc, msg); + expect(sendSpy).not.toHaveBeenCalled(); ws.close(); sendSpy.mockRestore(); applyAwarenessUpdateSpy.mockRestore(); @@ -251,15 +360,66 @@ describe('TldrawWSService', () => { it('should do nothing when received message unknown type', async () => { const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([2]); + service.messageHandler(ws, doc, msg); expect(sendSpy).toHaveBeenCalledTimes(0); expect(applyAwarenessUpdateSpy).toHaveBeenCalledTimes(0); + ws.close(); + sendSpy.mockRestore(); + applyAwarenessUpdateSpy.mockRestore(); + syncProtocolUpdateSpy.mockRestore(); + }); + }); + + describe('when publishing AWARENESS has errors', () => { + const setup = async (messageValues: number[]) => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const errorLogSpy = jest.spyOn(logger, 'warning'); + const publishSpy = jest + .spyOn(Ioredis.Redis.prototype, 'publish') + .mockImplementationOnce((_channel, _message, cb) => { + if (cb) { + cb(new Error('error')); + } + return Promise.resolve(0); + }); + const sendSpy = jest.spyOn(service, 'send'); + const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate'); + const syncProtocolUpdateSpy = jest + .spyOn(SyncProtocols, 'readSyncMessage') + .mockImplementationOnce((_dec, enc) => { + enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; + return 1; + }); + const doc = new WsSharedDocDo('TEST'); + const { msg } = createMessage(messageValues); + + return { + sendSpy, + errorLogSpy, + publishSpy, + applyAwarenessUpdateSpy, + syncProtocolUpdateSpy, + doc, + msg, + }; + }; + + it('should log error', async () => { + const { publishSpy, errorLogSpy, sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = + await setup([1, 1, 0]); + service.messageHandler(ws, doc, msg); + + expect(sendSpy).not.toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); ws.close(); sendSpy.mockRestore(); applyAwarenessUpdateSpy.mockRestore(); syncProtocolUpdateSpy.mockRestore(); + publishSpy.mockRestore(); }); }); @@ -271,7 +431,7 @@ describe('TldrawWSService', () => { jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce(() => { throw new Error('error'); }); - const doc = new WsSharedDocDo('TEST', service); + const doc = new WsSharedDocDo('TEST'); const { msg } = createMessage([0]); return { @@ -284,10 +444,9 @@ describe('TldrawWSService', () => { it('should not call send method', async () => { const { sendSpy, doc, msg } = await setup(); - service.messageHandler(ws, doc, msg); + expect(() => service.messageHandler(ws, doc, msg)).toThrow('error'); expect(sendSpy).toHaveBeenCalledTimes(0); - ws.close(); sendSpy.mockRestore(); }); @@ -297,220 +456,743 @@ describe('TldrawWSService', () => { const setup = async () => { ws = await TestConnection.setupWs(wsUrl, 'TEST'); - const doc = new WsSharedDocDo('TEST', service); + const doc = new WsSharedDocDo('TEST'); doc.awareness.states = new Map(); doc.awareness.states.set(1, ['test1']); doc.awareness.states.set(2, ['test2']); - const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockImplementationOnce(() => {}); - const sendSpy = jest.spyOn(service, 'send'); - const getYDocSpy = jest.spyOn(service, 'getYDoc').mockImplementationOnce(() => doc); + const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockReturnValueOnce(); + const sendSpy = jest.spyOn(service, 'send').mockImplementation(() => {}); + const getYDocSpy = jest.spyOn(service, 'getDocument').mockResolvedValueOnce(doc); + const closeConnSpy = jest.spyOn(service, 'closeConnection').mockResolvedValue(); const { msg } = createMessage([0]); - jest.spyOn(AwarenessProtocol, 'encodeAwarenessUpdate').mockImplementationOnce(() => msg); + jest.spyOn(AwarenessProtocol, 'encodeAwarenessUpdate').mockReturnValueOnce(msg); return { messageHandlerSpy, sendSpy, getYDocSpy, + closeConnSpy, }; }; it('should send to every client', async () => { - const { messageHandlerSpy, sendSpy, getYDocSpy } = await setup(); + const { messageHandlerSpy, sendSpy, getYDocSpy, closeConnSpy } = await setup(); - service.setupWSConnection(ws); + await service.setupWsConnection(ws, 'TEST'); + await delay(20); + ws.emit('pong'); - expect(sendSpy).toHaveBeenCalledTimes(2); + await delay(20); + expect(sendSpy).toHaveBeenCalledTimes(3); ws.close(); messageHandlerSpy.mockRestore(); sendSpy.mockRestore(); getYDocSpy.mockRestore(); + closeConnSpy.mockRestore(); }); }); }); + describe('on websocket error', () => { + const setup = async () => { + boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('TEST')); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + + return { + errorLogSpy, + }; + }; + + it('should log error', async () => { + const { errorLogSpy } = await setup(); + await service.setupWsConnection(ws, 'TEST'); + ws.emit('error', new Error('error')); + + expect(errorLogSpy).toHaveBeenCalled(); + ws.close(); + }); + }); + describe('closeConn', () => { - describe('when trying to close already closed connection', () => { + describe('when there is no error', () => { const setup = async () => { + boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('TEST')); ws = await TestConnection.setupWs(wsUrl); - jest.spyOn(ws, 'close').mockImplementationOnce(() => { - throw new Error('some error'); - }); + const redisUnsubscribeSpy = jest.spyOn(Ioredis.Redis.prototype, 'unsubscribe').mockResolvedValueOnce(1); + const closeConnSpy = jest.spyOn(service, 'closeConnection'); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); + + return { + redisUnsubscribeSpy, + closeConnSpy, + }; + }; + + it('should close connection', async () => { + const { redisUnsubscribeSpy, closeConnSpy } = await setup(); + + await service.setupWsConnection(ws, 'TEST'); + + expect(closeConnSpy).toHaveBeenCalled(); + ws.close(); + closeConnSpy.mockRestore(); + redisUnsubscribeSpy.mockRestore(); + }); + }); + + describe('when there are active connections', () => { + const setup = async () => { + const doc = new WsSharedDocDo('TEST'); + ws = await TestConnection.setupWs(wsUrl); + const ws2 = await TestConnection.setupWs(wsUrl); + doc.connections.set(ws, new Set()); + doc.connections.set(ws2, new Set()); + boardRepo.compressDocument.mockRestore(); + + return { + doc, + }; + }; + + it('should not call compressDocument', async () => { + const { doc } = await setup(); + + await service.closeConnection(doc, ws); + + expect(boardRepo.compressDocument).not.toHaveBeenCalled(); + ws.close(); + }); + }); + + describe('when close connection fails', () => { + const setup = async () => { + boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('TEST')); + ws = await TestConnection.setupWs(wsUrl); + + boardRepo.compressDocument.mockResolvedValueOnce(); + const redisUnsubscribeSpy = jest.spyOn(Ioredis.Redis.prototype, 'unsubscribe').mockResolvedValueOnce(1); + const closeConnSpy = jest.spyOn(service, 'closeConnection').mockRejectedValueOnce(new Error('error')); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const sendSpyError = jest.spyOn(service, 'send').mockReturnValue(); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); + + return { + redisUnsubscribeSpy, + closeConnSpy, + errorLogSpy, + sendSpyError, + }; }; - it('should throw error', async () => { - await setup(); - try { - const doc = TldrawWsFactory.createWsSharedDocDo(); - service.closeConn(doc, ws); - } catch (err) { - expect(err).toBeDefined(); - } + it('should log error', async () => { + const { redisUnsubscribeSpy, closeConnSpy, errorLogSpy, sendSpyError } = await setup(); + + await service.setupWsConnection(ws, 'TEST'); + + await delay(100); + + expect(closeConnSpy).toHaveBeenCalled(); ws.close(); + await delay(100); + expect(errorLogSpy).toHaveBeenCalled(); + redisUnsubscribeSpy.mockRestore(); + closeConnSpy.mockRestore(); + sendSpyError.mockRestore(); }); }); - describe('when ping failed', () => { + describe('when unsubscribing from Redis throw error', () => { const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + const doc = TldrawWsFactory.createWsSharedDocDo(); + doc.connections.set(ws, new Set()); + + boardRepo.compressDocument.mockResolvedValueOnce(); + const redisUnsubscribeSpy = jest + .spyOn(Ioredis.Redis.prototype, 'unsubscribe') + .mockRejectedValue(new Error('error')); + const closeConnSpy = jest.spyOn(service, 'closeConnection'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); + + return { + doc, + redisUnsubscribeSpy, + closeConnSpy, + errorLogSpy, + }; + }; + + it('should log error', async () => { + const { doc, errorLogSpy, redisUnsubscribeSpy, closeConnSpy } = await setup(); + + await service.closeConnection(doc, ws); + await delay(200); + + expect(redisUnsubscribeSpy).toHaveBeenCalled(); + expect(closeConnSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); + closeConnSpy.mockRestore(); + redisUnsubscribeSpy.mockRestore(); + }); + }); + + describe('when pong not received', () => { + const setup = async () => { + boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('TEST')); ws = await TestConnection.setupWs(wsUrl, 'TEST'); - const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockImplementationOnce(() => {}); - const closeConnSpy = jest.spyOn(service, 'closeConn'); - jest.spyOn(ws, 'ping').mockImplementationOnce(() => { - throw new Error('error'); - }); + const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockReturnValueOnce(); + const closeConnSpy = jest.spyOn(service, 'closeConnection').mockImplementation(() => Promise.resolve()); + const pingSpy = jest.spyOn(ws, 'ping').mockImplementationOnce(() => {}); + const sendSpy = jest.spyOn(service, 'send').mockImplementation(() => {}); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); return { messageHandlerSpy, closeConnSpy, + pingSpy, + sendSpy, + clearIntervalSpy, }; }; it('should close connection', async () => { - const { messageHandlerSpy, closeConnSpy } = await setup(); + const { messageHandlerSpy, closeConnSpy, pingSpy, sendSpy, clearIntervalSpy } = await setup(); - service.setupWSConnection(ws); + await service.setupWsConnection(ws, 'TEST'); - await delay(10); + await delay(20); expect(closeConnSpy).toHaveBeenCalled(); + expect(clearIntervalSpy).toHaveBeenCalled(); + ws.close(); + messageHandlerSpy.mockRestore(); + pingSpy.mockRestore(); + closeConnSpy.mockRestore(); + sendSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + }); + }); + + describe('when pong not received and close connection fails', () => { + const setup = async () => { + boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('TEST')); + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockReturnValueOnce(); + const closeConnSpy = jest.spyOn(service, 'closeConnection').mockRejectedValue(new Error('error')); + const pingSpy = jest.spyOn(ws, 'ping').mockImplementation(() => {}); + const sendSpy = jest.spyOn(service, 'send').mockImplementation(() => {}); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); + + return { + messageHandlerSpy, + closeConnSpy, + pingSpy, + sendSpy, + clearIntervalSpy, + errorLogSpy, + }; + }; + + it('should log error', async () => { + const { messageHandlerSpy, closeConnSpy, pingSpy, sendSpy, clearIntervalSpy, errorLogSpy } = await setup(); + + await service.setupWsConnection(ws, 'TEST'); + await delay(200); + + expect(closeConnSpy).toHaveBeenCalled(); + expect(clearIntervalSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); ws.close(); messageHandlerSpy.mockRestore(); + pingSpy.mockRestore(); closeConnSpy.mockRestore(); + sendSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + }); + }); + + describe('when compressDocument failed', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + doc.connections.set(ws, new Set()); + + boardRepo.compressDocument.mockRejectedValueOnce(new Error('error')); + const errorLogSpy = jest.spyOn(logger, 'warning'); + + return { + doc, + errorLogSpy, + }; + }; + + it('should log error', async () => { + const { doc, errorLogSpy } = await setup(); + + await service.closeConnection(doc, ws); + + expect(boardRepo.compressDocument).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); + ws.close(); }); }); }); + describe('updateHandler', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + const sendSpy = jest.spyOn(service, 'send').mockReturnValueOnce(); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish').mockResolvedValueOnce(1); + + const doc = TldrawWsFactory.createWsSharedDocDo(); + const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.OPEN); + doc.connections.set(socketMock, new Set()); + const msg = new Uint8Array([0]); + + return { + doc, + sendSpy, + socketMock, + msg, + errorLogSpy, + publishSpy, + }; + }; + + it('should call send method', async () => { + const { sendSpy, doc, socketMock, msg } = await setup(); + + service.updateHandler(msg, socketMock, doc); + + expect(sendSpy).toHaveBeenCalled(); + ws.close(); + }); + }); + + describe('databaseUpdateHandler', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + boardRepo.storeUpdate.mockResolvedValueOnce(); + }; + + it('should call storeUpdate method', async () => { + await setup(); + + await service.databaseUpdateHandler('test', new Uint8Array(), 'test'); + + expect(boardRepo.storeUpdate).toHaveBeenCalled(); + ws.close(); + }); + + it('should not call storeUpdate when origin is redis', async () => { + await setup(); + + await service.databaseUpdateHandler('test', new Uint8Array(), 'redis'); + + expect(boardRepo.storeUpdate).not.toHaveBeenCalled(); + ws.close(); + }); + }); + + describe('when publish to Redis throws errors', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + const sendSpy = jest.spyOn(service, 'send').mockReturnValueOnce(); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish').mockRejectedValueOnce(new Error('error')); + + const doc = TldrawWsFactory.createWsSharedDocDo(); + doc.connections.set(ws, new Set()); + const msg = new Uint8Array([0]); + + return { + doc, + sendSpy, + msg, + errorLogSpy, + publishSpy, + }; + }; + + it('should log error', async () => { + const { doc, msg, errorLogSpy, publishSpy } = await setup(); + + service.updateHandler(msg, ws, doc); + + await delay(20); + + expect(errorLogSpy).toHaveBeenCalled(); + ws.close(); + publishSpy.mockRestore(); + }); + }); + describe('messageHandler', () => { describe('when message is received', () => { const setup = async (messageValues: number[]) => { + boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('TEST')); ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const errorLogSpy = jest.spyOn(logger, 'warning'); const messageHandlerSpy = jest.spyOn(service, 'messageHandler'); - const readSyncMessageSpy = jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce((dec, enc) => { + const readSyncMessageSpy = jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce((_dec, enc) => { enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; return 1; }); + const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish'); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); const { msg } = createMessage(messageValues); return { - messageHandlerSpy, msg, + messageHandlerSpy, readSyncMessageSpy, + errorLogSpy, + publishSpy, }; }; it('should handle message', async () => { - const { messageHandlerSpy, msg, readSyncMessageSpy } = await setup([0, 1]); + const { messageHandlerSpy, msg, readSyncMessageSpy, publishSpy } = await setup([0, 1]); + publishSpy.mockResolvedValueOnce(1); - service.setupWSConnection(ws); + await service.setupWsConnection(ws, 'TEST'); ws.emit('message', msg); - expect(messageHandlerSpy).toHaveBeenCalledTimes(1); + await delay(20); + expect(messageHandlerSpy).toHaveBeenCalledTimes(1); ws.close(); messageHandlerSpy.mockRestore(); readSyncMessageSpy.mockRestore(); + publishSpy.mockRestore(); + }); + + it('should log error when messageHandler throws', async () => { + const { messageHandlerSpy, msg, errorLogSpy } = await setup([0, 1]); + messageHandlerSpy.mockImplementationOnce(() => { + throw new Error('error'); + }); + + await service.setupWsConnection(ws, 'TEST'); + ws.emit('message', msg); + + await delay(20); + + expect(errorLogSpy).toHaveBeenCalled(); + ws.close(); + messageHandlerSpy.mockRestore(); + errorLogSpy.mockRestore(); + }); + + it('should log error when publish to Redis throws', async () => { + const { errorLogSpy, publishSpy } = await setup([1, 1]); + publishSpy.mockRejectedValueOnce(new Error('error')); + + await service.setupWsConnection(ws, 'TEST'); + + expect(errorLogSpy).toHaveBeenCalled(); + ws.close(); }); }); }); - describe('getYDoc', () => { + describe('getDocument', () => { describe('when getting yDoc by name', () => { - it('should assign to service.doc and return instance', () => { + it('should assign to service docs map and return instance', async () => { + boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('get-test')); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); const docName = 'get-test'; - const doc = service.getYDoc(docName); + const doc = await service.getDocument(docName); + expect(doc).toBeInstanceOf(WsSharedDocDo); expect(service.docs.get(docName)).not.toBeUndefined(); }); + + describe('when subscribing to redis channel', () => { + const setup = () => { + boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('test-redis')); + const doc = new WsSharedDocDo('test-redis'); + + const redisSubscribeSpy = jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce(1); + const redisOnSpy = jest.spyOn(Ioredis.Redis.prototype, 'on'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + boardRepo.getDocumentFromDb.mockResolvedValueOnce(doc); + + return { + redisOnSpy, + redisSubscribeSpy, + errorLogSpy, + }; + }; + + it('should subscribe', async () => { + const { redisOnSpy, redisSubscribeSpy } = setup(); + + const doc = await service.getDocument('test-redis'); + + expect(doc).toBeDefined(); + expect(redisSubscribeSpy).toHaveBeenCalled(); + redisSubscribeSpy.mockRestore(); + redisOnSpy.mockRestore(); + }); + }); }); - }); - describe('updateDocument', () => { - const setup = () => { - const updateDocumentSpy = jest.spyOn(service, 'updateDocument').mockImplementation(() => Promise.resolve()); + describe('when subscribing to redis channel throws error', () => { + const setup = () => { + boardRepo.getDocumentFromDb.mockResolvedValueOnce(new WsSharedDocDo('test-redis-fail-2')); + const redisSubscribeSpy = jest + .spyOn(Ioredis.Redis.prototype, 'subscribe') + .mockRejectedValue(new Error('error')); + const redisOnSpy = jest.spyOn(Ioredis.Redis.prototype, 'on'); + const errorLogSpy = jest.spyOn(logger, 'warning'); - return { updateDocumentSpy }; - }; + return { + redisOnSpy, + redisSubscribeSpy, + errorLogSpy, + }; + }; + + it('should log error', async () => { + const { errorLogSpy, redisSubscribeSpy, redisOnSpy } = setup(); - it('should call update method', async () => { - const { updateDocumentSpy } = setup(); - await service.updateDocument('test', TldrawWsFactory.createWsSharedDocDo()); + await service.getDocument('test-redis-fail-2'); - expect(updateDocumentSpy).toHaveBeenCalled(); + await delay(500); - updateDocumentSpy.mockRestore(); + expect(redisSubscribeSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); + redisSubscribeSpy.mockRestore(); + redisOnSpy.mockRestore(); + }); + }); + + describe('when found document is still finalizing', () => { + const setup = () => { + const doc = new WsSharedDocDo('test-finalizing'); + doc.isFinalizing = true; + service.docs.set('test-finalizing', doc); + boardRepo.getDocumentFromDb.mockResolvedValueOnce(doc); + }; + + it('should throw', async () => { + setup(); + + await expect(service.getDocument('test-finalizing')).rejects.toThrow(); + service.docs.delete('test-finalizing'); + }); }); }); - describe('flushDocument', () => { + describe('redisMessageHandler', () => { const setup = () => { - const flushDocumentSpy = jest.spyOn(service, 'flushDocument').mockResolvedValueOnce(Promise.resolve()); + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValueOnce(); + const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate').mockReturnValueOnce(); + + const doc = new WsSharedDocDo('TEST'); + doc.awarenessChannel = 'TEST-awareness'; - return { flushDocumentSpy }; + return { + doc, + applyUpdateSpy, + applyAwarenessUpdateSpy, + }; }; - it('should call flush method', async () => { - const { flushDocumentSpy } = setup(); - await service.flushDocument('test'); + describe('when channel name is the same as docName', () => { + it('should call applyUpdate', () => { + const { doc, applyUpdateSpy } = setup(); + service.docs.set('TEST', doc); + service.redisMessageHandler(Buffer.from('TEST'), Buffer.from('message')); + + expect(applyUpdateSpy).toHaveBeenCalled(); + }); + }); - expect(flushDocumentSpy).toHaveBeenCalled(); + describe('when channel name is the same as docAwarenessChannel name', () => { + it('should call applyAwarenessUpdate', () => { + const { doc, applyAwarenessUpdateSpy } = setup(); + service.docs.set('TEST', doc); + service.redisMessageHandler(Buffer.from('TEST-awareness'), Buffer.from('message')); - flushDocumentSpy.mockRestore(); + expect(applyAwarenessUpdateSpy).toHaveBeenCalled(); + }); + }); + + describe('when channel name is not found as document name', () => { + it('should not call applyUpdate or applyAwarenessUpdate', () => { + const { doc, applyUpdateSpy, applyAwarenessUpdateSpy } = setup(); + service.docs.set('TEST', doc); + service.redisMessageHandler(Buffer.from('NOTFOUND'), Buffer.from('message')); + + expect(applyUpdateSpy).not.toHaveBeenCalled(); + expect(applyAwarenessUpdateSpy).not.toHaveBeenCalled(); + }); }); }); - describe('authorizeConnection', () => { - it('should call properly method', async () => { - const params = { drawingName: 'drawingName', token: 'token' }; - const response: AxiosResponse = axiosResponseFactory.build({ - status: 200, + describe('updateHandler', () => { + describe('when update comes from connected websocket', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const doc = new WsSharedDocDo('TEST'); + doc.connections.set(ws, new Set()); + const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + + return { + doc, + publishSpy, + errorLogSpy, + }; + }; + + it('should publish update to redis', async () => { + const { doc, publishSpy } = await setup(); + + service.updateHandler(new Uint8Array([]), ws, doc); + + expect(publishSpy).toHaveBeenCalled(); + ws.close(); }); - httpService.get.mockReturnValueOnce(of(response)); + it('should log error on failed publish', async () => { + const { doc, publishSpy, errorLogSpy } = await setup(); + publishSpy.mockRejectedValueOnce(new Error('error')); - await expect(service.authorizeConnection(params.drawingName, params.token)).resolves.not.toThrow(); - httpService.get.mockRestore(); - }); + service.updateHandler(new Uint8Array([]), ws, doc); - it('should properly setup REST GET call params', async () => { - const params = { drawingName: 'drawingName', token: 'token' }; - const response: AxiosResponse = axiosResponseFactory.build({ - status: 200, + expect(errorLogSpy).toHaveBeenCalled(); + ws.close(); }); - const expectedUrl = 'http://localhost:3030/api/v3/elements/drawingName/permission'; - const expectedHeaders = { - headers: { - Accept: 'Application/json', - Authorization: `Bearer ${params.token}`, - }, + }); + }); + + describe('awarenessUpdateHandler', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + class MockAwareness { + on = jest.fn(); + } + + const doc = new WsSharedDocDo('TEST-AUH'); + doc.awareness = new MockAwareness() as unknown as AwarenessProtocol.Awareness; + const awarenessMetaMock = new Map(); + awarenessMetaMock.set(1, { clock: 11, lastUpdated: 21 }); + awarenessMetaMock.set(2, { clock: 12, lastUpdated: 22 }); + awarenessMetaMock.set(3, { clock: 13, lastUpdated: 23 }); + const awarenessStatesMock = new Map(); + awarenessStatesMock.set(1, { updating: '21' }); + awarenessStatesMock.set(2, { updating: '22' }); + awarenessStatesMock.set(3, { updating: '23' }); + doc.awareness.states = awarenessStatesMock; + doc.awareness.meta = awarenessMetaMock; + + const sendSpy = jest.spyOn(service, 'send').mockReturnValue(); + + const mockIDs = new Set(); + const mockConns = new Map>(); + mockConns.set(ws, mockIDs); + doc.connections = mockConns; + + return { + sendSpy, + doc, + mockIDs, + mockConns, }; - httpService.get.mockReturnValueOnce(of(response)); + }; + + describe('when adding two clients states', () => { + it('should have two registered clients states', async () => { + const { sendSpy, doc, mockIDs } = await setup(); + const awarenessUpdate = { + added: [1, 3], + updated: [], + removed: [], + }; - await service.authorizeConnection(params.drawingName, params.token); + service.awarenessUpdateHandler(awarenessUpdate, ws, doc); - expect(httpService.get).toHaveBeenCalledWith(expectedUrl, expectedHeaders); - httpService.get.mockRestore(); + expect(mockIDs.size).toBe(2); + expect(mockIDs.has(1)).toBe(true); + expect(mockIDs.has(3)).toBe(true); + expect(mockIDs.has(2)).toBe(false); + expect(sendSpy).toBeCalled(); + ws.close(); + sendSpy.mockRestore(); + }); }); - it('should throw error for http response', async () => { - const params = { drawingName: 'drawingName', token: 'token' }; - const error = new Error('unknown error'); - httpService.get.mockReturnValueOnce(throwError(() => error)); + describe('when removing one of two existing clients states', () => { + it('should have one registered client state', async () => { + const { sendSpy, doc, mockIDs } = await setup(); + let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { + added: [1, 3], + updated: [], + removed: [], + }; + + service.awarenessUpdateHandler(awarenessUpdate, ws, doc); + awarenessUpdate = { + added: [], + updated: [], + removed: [1], + }; + service.awarenessUpdateHandler(awarenessUpdate, ws, doc); - await expect(service.authorizeConnection(params.drawingName, params.token)).rejects.toThrow(); - httpService.get.mockRestore(); + expect(mockIDs.size).toBe(1); + expect(mockIDs.has(1)).toBe(false); + expect(mockIDs.has(3)).toBe(true); + expect(sendSpy).toBeCalled(); + ws.close(); + sendSpy.mockRestore(); + }); }); - it('should throw error for lack of token', async () => { - const params = { drawingName: 'drawingName', token: 'token' }; + describe('when updating client state', () => { + it('should not change number of states', async () => { + const { sendSpy, doc, mockIDs } = await setup(); + let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { + added: [1], + updated: [], + removed: [], + }; - await expect(service.authorizeConnection(params.drawingName, '')).rejects.toThrow(); - httpService.get.mockRestore(); + service.awarenessUpdateHandler(awarenessUpdate, ws, doc); + awarenessUpdate = { + added: [], + updated: [1], + removed: [], + }; + service.awarenessUpdateHandler(awarenessUpdate, ws, doc); + + expect(mockIDs.size).toBe(1); + expect(mockIDs.has(1)).toBe(true); + expect(sendSpy).toBeCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); }); }); }); diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts index f1ef8744c44..70034e192c0 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts @@ -1,234 +1,400 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable, NotAcceptableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import WebSocket from 'ws'; -import { applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'; -import { encoding, decoding, map } from 'lib0'; -import { readSyncMessage, writeSyncStep1, writeUpdate } from 'y-protocols/sync'; -import { firstValueFrom } from 'rxjs'; -import { HttpService } from '@nestjs/axios'; -import { Persitence, WSConnectionState, WSMessageType } from '../types'; +import { encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'; +import { decoding, encoding } from 'lib0'; +import { readSyncMessage, writeSyncStep1, writeSyncStep2, writeUpdate } from 'y-protocols/sync'; +import { Buffer } from 'node:buffer'; +import { Logger } from '@src/core/logger'; +import { YMap } from 'yjs/dist/src/types/YMap'; +import { TldrawRedisService } from '../redis'; +import { + CloseConnectionLoggable, + WebsocketErrorLoggable, + WebsocketMessageErrorLoggable, + WsSharedDocErrorLoggable, +} from '../loggable'; import { TldrawConfig } from '../config'; -import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { + AwarenessConnectionsUpdate, + TldrawAsset, + TldrawShape, + UpdateOrigin, + UpdateType, + WSMessageType, +} from '../types'; +import { WsSharedDocDo } from '../domain'; import { TldrawBoardRepo } from '../repo'; import { MetricsService } from '../metrics'; @Injectable() export class TldrawWsService { - public pingTimeout: number; - - public persistence: Persitence | null = null; - - public docs = new Map(); + public docs = new Map(); constructor( private readonly configService: ConfigService, private readonly tldrawBoardRepo: TldrawBoardRepo, - private readonly httpService: HttpService, - private readonly metricsService: MetricsService + private readonly logger: Logger, + private readonly metricsService: MetricsService, + private readonly tldrawRedisService: TldrawRedisService ) { - this.pingTimeout = this.configService.get('TLDRAW_PING_TIMEOUT'); - } - - public setPersistence(persistence_: Persitence): void { - this.persistence = persistence_; - } - - /** - * @param {WsSharedDocDo} doc - * @param {WebSocket} ws - */ - public closeConn(doc: WsSharedDocDo, ws: WebSocket): void { - if (doc.conns.has(ws)) { - const controlledIds = doc.conns.get(ws) as Set; - doc.conns.delete(ws); - removeAwarenessStates(doc.awareness, Array.from(controlledIds), null); - if (doc.conns.size === 0 && this.persistence !== null) { - // if persisted, we store state and destroy ydocument - this.persistence - .writeState(doc.name, doc) - .then(() => { - doc.destroy(); - return null; - }) - .catch(() => {}); - this.docs.delete(doc.name); - this.metricsService.decrementNumberOfBoardsOnServerCounter(); - } + this.logger.setContext(TldrawWsService.name); + + this.tldrawRedisService.sub.on('messageBuffer', (channel, message) => this.redisMessageHandler(channel, message)); + } + + public async closeConnection(doc: WsSharedDocDo, ws: WebSocket): Promise { + if (doc.connections.has(ws)) { + const controlledIds = doc.connections.get(ws); + doc.connections.delete(ws); + removeAwarenessStates(doc.awareness, this.forceToArray(controlledIds), null); + this.metricsService.decrementNumberOfUsersOnServerCounter(); } - try { - ws.close(); - } catch (err) { - throw new Error('Cannot close the connection. It is possible that connection is already closed.'); - } + ws.close(); + await this.finalizeIfNoConnections(doc); } - /** - * @param {WsSharedDocDo} doc - * @param {WebSocket} conn - * @param {Uint8Array} message - */ - public send(doc: WsSharedDocDo, conn: WebSocket, message: Uint8Array): void { - if (conn.readyState !== WSConnectionState.CONNECTING && conn.readyState !== WSConnectionState.OPEN) { - this.closeConn(doc, conn); - } - try { - conn.send(message, (err: Error | undefined) => { - if (err != null) { - this.closeConn(doc, conn); - } + public send(doc: WsSharedDocDo, ws: WebSocket, message: Uint8Array): void { + if (this.isClosedOrClosing(ws)) { + this.closeConnection(doc, ws).catch((err) => { + this.logger.warning(new CloseConnectionLoggable('send | isClosedOrClosing', err)); }); - } catch (e) { - this.closeConn(doc, conn); } + + ws.send(message, (err) => { + if (err) { + this.closeConnection(doc, ws).catch((e) => { + this.logger.warning(new CloseConnectionLoggable('send', e)); + }); + } + }); } - /** - * @param {Uint8Array} update - * @param {any} origin - * @param {WsSharedDocDo} doc - */ public updateHandler(update: Uint8Array, origin, doc: WsSharedDocDo): void { - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, WSMessageType.SYNC); - writeUpdate(encoder, update); - const message = encoding.toUint8Array(encoder); - doc.conns.forEach((_, conn) => { - this.send(doc, conn, message); - }); + if (this.isFromConnectedWebSocket(doc, origin)) { + this.tldrawRedisService.publishUpdateToRedis(doc, update, UpdateType.DOCUMENT); + } + + this.sendUpdateToConnectedClients(update, doc); } - /** - * Gets a Y.Doc by name, whether in memory or on disk - * - * @param {string} docName - the name of the Y.Doc to find or create - * @param {boolean} gc - whether to allow gc on the doc (applies only when created) - * @return {WsSharedDocDo} - */ - getYDoc(docName: string, gc = true): WsSharedDocDo { - return map.setIfUndefined(this.docs, docName, () => { - const doc = new WsSharedDocDo(docName, this, gc); - if (this.persistence !== null) { - this.persistence.bindState(docName, doc).catch(() => {}); - } - this.docs.set(docName, doc); - this.metricsService.incrementNumberOfBoardsOnServerCounter(); - return doc; - }); + public async databaseUpdateHandler(docName: string, update: Uint8Array, origin) { + if (this.isFromRedis(origin)) { + return; + } + await this.tldrawBoardRepo.storeUpdate(docName, update); } - public messageHandler(conn: WebSocket, doc: WsSharedDocDo, message: Uint8Array): void { - try { - const encoder = encoding.createEncoder(); - const decoder = decoding.createDecoder(message); - const messageType = decoding.readVarUint(decoder); - switch (messageType) { - case WSMessageType.SYNC: - encoding.writeVarUint(encoder, WSMessageType.SYNC); - readSyncMessage(decoder, encoder, doc, conn); - - // If the `encoder` only contains the type of reply message and no - // message, there is no need to send the message. When `encoder` only - // contains the type of reply, its length is 1. - if (encoding.length(encoder) > 1) { - this.send(doc, conn, encoding.toUint8Array(encoder)); - } - break; - case WSMessageType.AWARENESS: { - applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn); - break; - } - default: - break; + public awarenessUpdateHandler = ( + connectionsUpdate: AwarenessConnectionsUpdate, + wsConnection: WebSocket | null, + doc: WsSharedDocDo + ): void => { + const changedClients = this.manageClientsConnections(connectionsUpdate, wsConnection, doc); + const buff = this.prepareAwarenessMessage(changedClients, doc); + this.sendAwarenessMessage(buff, doc); + }; + + public async getDocument(docName: string) { + const existingDoc = this.docs.get(docName); + + if (this.isFinalizingOrNotYetLoaded(existingDoc)) { + // drop the connection, the client will have to reconnect + // and check again if the finalizing or loading has finished + throw new NotAcceptableException(); + } + + if (existingDoc) { + return existingDoc; + } + + const doc = await this.tldrawBoardRepo.getDocumentFromDb(docName); + doc.isLoaded = false; + + this.registerAwarenessUpdateHandler(doc); + this.registerUpdateHandler(doc); + this.tldrawRedisService.subscribeToRedisChannels(doc); + this.registerDatabaseUpdateHandler(doc); + + this.docs.set(docName, doc); + this.metricsService.incrementNumberOfBoardsOnServerCounter(); + doc.isLoaded = true; + return doc; + } + + public async createDbIndex(): Promise { + await this.tldrawBoardRepo.createDbIndex(); + } + + public messageHandler(ws: WebSocket, doc: WsSharedDocDo, message: Uint8Array): void { + const encoder = encoding.createEncoder(); + const decoder = decoding.createDecoder(message); + const messageType = decoding.readVarUint(decoder); + switch (messageType) { + case WSMessageType.SYNC: + this.handleSyncMessage(doc, encoder, decoder, ws); + break; + case WSMessageType.AWARENESS: { + this.handleAwarenessMessage(doc, decoder); + break; } - } catch (err) { - doc.emit('error', [err]); + default: + break; + } + } + + private handleSyncMessage( + doc: WsSharedDocDo, + encoder: encoding.Encoder, + decoder: decoding.Decoder, + ws: WebSocket + ): void { + encoding.writeVarUint(encoder, WSMessageType.SYNC); + readSyncMessage(decoder, encoder, doc, ws); + + // If the `encoder` only contains the type of reply message and no + // message, there is no need to send the message. When `encoder` only + // contains the type of reply, its length is 1. + if (encoding.length(encoder) > 1) { + this.send(doc, ws, encoding.toUint8Array(encoder)); } } - /** - * @param {WebSocket} ws - * @param {string} docName - */ - public setupWSConnection(ws: WebSocket, docName = 'GLOBAL'): void { + private handleAwarenessMessage(doc: WsSharedDocDo, decoder: decoding.Decoder) { + const update = decoding.readVarUint8Array(decoder); + this.tldrawRedisService.publishUpdateToRedis(doc, update, UpdateType.AWARENESS); + } + + public redisMessageHandler = (channel: Buffer, update: Buffer): void => { + const channelId = channel.toString(); + const docName = channel.toString().split('-')[0]; + const doc = this.docs.get(docName); + if (!doc) { + return; + } + + this.tldrawRedisService.handleMessage(channelId, update, doc); + }; + + public async setupWsConnection(ws: WebSocket, docName: string) { ws.binaryType = 'arraybuffer'; + // get doc, initialize if it does not exist yet - const doc = this.getYDoc(docName, true); - doc.conns.set(ws, new Set()); + const doc = await this.getDocument(docName); + doc.connections.set(ws, new Set()); + + ws.on('error', (err) => { + this.logger.warning(new WebsocketErrorLoggable(err)); + }); - // listen and reply to events ws.on('message', (message: ArrayBufferLike) => { - this.messageHandler(ws, doc, new Uint8Array(message)); + try { + this.messageHandler(ws, doc, new Uint8Array(message)); + } catch (err) { + this.logger.warning(new WebsocketMessageErrorLoggable(err)); + } }); - // Check if connection is still alive + // check if connection is still alive + const pingTimeout = this.configService.get('TLDRAW_PING_TIMEOUT'); let pongReceived = true; const pingInterval = setInterval(() => { - const hasConn = doc.conns.has(ws); - - if (pongReceived) { - if (!hasConn) return; + if (pongReceived && doc.connections.has(ws)) { pongReceived = false; - - try { - ws.ping(); - } catch (e) { - this.closeConn(doc, ws); - clearInterval(pingInterval); - } + ws.ping(); return; } - if (hasConn) { - this.closeConn(doc, ws); - } - + this.closeConnection(doc, ws).catch((err) => { + this.logger.warning(new CloseConnectionLoggable('pingInterval', err)); + }); clearInterval(pingInterval); - }, this.pingTimeout); + }, pingTimeout); + ws.on('close', () => { - this.closeConn(doc, ws); + this.closeConnection(doc, ws).catch((err) => { + this.logger.warning(new CloseConnectionLoggable('websocket close', err)); + }); clearInterval(pingInterval); }); + ws.on('pong', () => { pongReceived = true; }); + { - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, WSMessageType.SYNC); - writeSyncStep1(encoder, doc); - this.send(doc, ws, encoding.toUint8Array(encoder)); + // send initial doc state to client as update + this.sendInitialState(ws, doc); + + const syncEncoder = encoding.createEncoder(); + encoding.writeVarUint(syncEncoder, WSMessageType.SYNC); + writeSyncStep1(syncEncoder, doc); + this.send(doc, ws, encoding.toUint8Array(syncEncoder)); + const awarenessStates = doc.awareness.getStates(); if (awarenessStates.size > 0) { - encoding.writeVarUint(encoder, WSMessageType.AWARENESS); - encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys()))); - this.send(doc, ws, encoding.toUint8Array(encoder)); + const awarenessEncoder = encoding.createEncoder(); + encoding.writeVarUint(awarenessEncoder, WSMessageType.AWARENESS); + encoding.writeVarUint8Array( + awarenessEncoder, + encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys())) + ); + this.send(doc, ws, encoding.toUint8Array(awarenessEncoder)); } } this.metricsService.incrementNumberOfUsersOnServerCounter(); } - public async updateDocument(docName: string, ydoc: WsSharedDocDo): Promise { - await this.tldrawBoardRepo.updateDocument(docName, ydoc); + private async finalizeIfNoConnections(doc: WsSharedDocDo) { + // wait before doing the check + // the only user on the pod might have lost connection for a moment + // or simply refreshed the page + await this.delay(this.configService.get('TLDRAW_FINALIZE_DELAY')); + + if (doc.connections.size > 0) { + return; + } + + if (doc.isFinalizing) { + return; + } + doc.isFinalizing = true; + + try { + this.tldrawRedisService.unsubscribeFromRedisChannels(doc); + await this.tldrawBoardRepo.compressDocument(doc.name); + } catch (err) { + this.logger.warning(new WsSharedDocErrorLoggable(doc.name, 'Error while finalizing document', err)); + } finally { + doc.destroy(); + this.docs.delete(doc.name); + this.metricsService.decrementNumberOfBoardsOnServerCounter(); + } } - public async flushDocument(docName: string): Promise { - await this.tldrawBoardRepo.flushDocument(docName); + private syncDocumentAssetsWithShapes(doc: WsSharedDocDo): TldrawAsset[] { + // clean up assets that are not used as shapes anymore + // which can happen when users do undo/redo operations on assets + const assets: YMap = doc.getMap('assets'); + const shapes: YMap = doc.getMap('shapes'); + const usedShapesAsAssets: TldrawShape[] = []; + const usedAssets: TldrawAsset[] = []; + + for (const [, shape] of shapes) { + if (shape.assetId) { + usedShapesAsAssets.push(shape); + } + } + + doc.transact(() => { + for (const [, asset] of assets) { + const foundAsset = usedShapesAsAssets.some((shape) => shape.assetId === asset.id); + if (!foundAsset) { + assets.delete(asset.id); + } else { + usedAssets.push(asset); + } + } + }); + + return usedAssets; } - public async authorizeConnection(drawingName: string, token: string) { - if (!token) { - throw new UnauthorizedException('Token was not given'); + private sendUpdateToConnectedClients(update: Uint8Array, doc: WsSharedDocDo): void { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.SYNC); + writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + + for (const [conn] of doc.connections) { + this.send(doc, conn, message); } - const headers = { - Accept: 'Application/json', - Authorization: `Bearer ${token}`, - }; + } + + private prepareAwarenessMessage(changedClients: number[], doc: WsSharedDocDo): Uint8Array { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.AWARENESS); + encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(doc.awareness, changedClients)); + const message = encoding.toUint8Array(encoder); + return message; + } - await firstValueFrom( - this.httpService.get(`${this.configService.get('API_HOST')}/v3/elements/${drawingName}/permission`, { - headers, - }) + private sendAwarenessMessage(message: Uint8Array, doc: WsSharedDocDo): void { + for (const [conn] of doc.connections) { + this.send(doc, conn, message); + } + } + + private manageClientsConnections( + connectionsUpdate: AwarenessConnectionsUpdate, + ws: WebSocket | null, + doc: WsSharedDocDo + ): number[] { + const changedClients = connectionsUpdate.added.concat(connectionsUpdate.updated, connectionsUpdate.removed); + if (ws !== null) { + const connControlledIDs = doc.connections.get(ws); + if (connControlledIDs !== undefined) { + for (const clientID of connectionsUpdate.added) { + connControlledIDs.add(clientID); + } + + for (const clientID of connectionsUpdate.removed) { + connControlledIDs.delete(clientID); + } + } + } + + return changedClients; + } + + private registerAwarenessUpdateHandler(doc: WsSharedDocDo) { + doc.awareness.on('update', (connectionsUpdate: AwarenessConnectionsUpdate, wsConnection: WebSocket | null) => + this.awarenessUpdateHandler(connectionsUpdate, wsConnection, doc) ); } + + private registerUpdateHandler(doc: WsSharedDocDo) { + doc.on('update', (update: Uint8Array, origin) => this.updateHandler(update, origin, doc)); + } + + private registerDatabaseUpdateHandler(doc: WsSharedDocDo) { + doc.on('update', (update: Uint8Array, origin) => this.databaseUpdateHandler(doc.name, update, origin)); + } + + private sendInitialState(ws: WebSocket, doc: WsSharedDocDo): void { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.SYNC); + writeSyncStep2(encoder, doc); + this.send(doc, ws, encoding.toUint8Array(encoder)); + } + + private isFinalizingOrNotYetLoaded(doc: WsSharedDocDo | undefined): boolean { + const isFinalizing = doc !== undefined && doc.isFinalizing; + const isNotLoaded = doc !== undefined && !doc.isLoaded; + return isFinalizing || isNotLoaded; + } + + private delay(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } + + private isClosedOrClosing(ws: WebSocket): boolean { + return ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED; + } + + private forceToArray(connections: Set | undefined): number[] { + return connections ? Array.from(connections) : []; + } + + private isFromConnectedWebSocket(doc: WsSharedDocDo, origin: unknown) { + return origin instanceof WebSocket && doc.connections.has(origin); + } + + private isFromRedis(origin: unknown): boolean { + return typeof origin === 'string' && origin === UpdateOrigin.REDIS; + } } diff --git a/apps/server/src/modules/tldraw/testing/index.ts b/apps/server/src/modules/tldraw/testing/index.ts new file mode 100644 index 00000000000..e240b1fb117 --- /dev/null +++ b/apps/server/src/modules/tldraw/testing/index.ts @@ -0,0 +1,5 @@ +export * from './tldraw.factory'; +export * from './test-connection'; +export * from './testConfig'; +export * from './tldraw-asset.factory'; +export * from './tldraw-shape.factory'; diff --git a/apps/server/src/modules/tldraw/testing/test-connection.ts b/apps/server/src/modules/tldraw/testing/test-connection.ts index 4231acbb286..248d8144b7a 100644 --- a/apps/server/src/modules/tldraw/testing/test-connection.ts +++ b/apps/server/src/modules/tldraw/testing/test-connection.ts @@ -1,4 +1,5 @@ import WebSocket from 'ws'; +import { HttpHeaders } from 'aws-sdk/clients/iot'; export class TestConnection { public static getWsUrl = (gatewayPort: number): string => { @@ -6,12 +7,12 @@ export class TestConnection { return wsUrl; }; - public static setupWs = async (wsUrl: string, docName?: string, headers?: object): Promise => { + public static setupWs = async (wsUrl: string, docName?: string, headers?: HttpHeaders): Promise => { let ws: WebSocket; if (docName) { - ws = new WebSocket(`${wsUrl}/${docName}`, headers); + ws = new WebSocket(`${wsUrl}/${docName}`, { headers }); } else { - ws = new WebSocket(`${wsUrl}`, headers); + ws = new WebSocket(`${wsUrl}`, { headers }); } await new Promise((resolve) => { ws.on('open', resolve); diff --git a/apps/server/src/modules/tldraw/testing/testConfig.ts b/apps/server/src/modules/tldraw/testing/testConfig.ts new file mode 100644 index 00000000000..61f244d1a27 --- /dev/null +++ b/apps/server/src/modules/tldraw/testing/testConfig.ts @@ -0,0 +1,13 @@ +import { config } from '../config'; + +export const tldrawTestConfig = () => { + const conf = config(); + if (!conf.REDIS_URI) { + conf.REDIS_URI = 'redis://127.0.0.1:6379'; + } + conf.TLDRAW_DB_COMPRESS_THRESHOLD = 2; + conf.TLDRAW_PING_TIMEOUT = 0; + conf.TLDRAW_FINALIZE_DELAY = 0; + conf.TLDRAW_MAX_DOCUMENT_SIZE = 1; + return conf; +}; diff --git a/apps/server/src/modules/tldraw/testing/tldraw-asset.factory.ts b/apps/server/src/modules/tldraw/testing/tldraw-asset.factory.ts new file mode 100644 index 00000000000..9791d5f5155 --- /dev/null +++ b/apps/server/src/modules/tldraw/testing/tldraw-asset.factory.ts @@ -0,0 +1,11 @@ +import { Factory } from 'fishery'; +import { TldrawAsset, TldrawShapeType } from '../types'; + +export const tldrawAssetFactory = Factory.define(({ sequence }) => { + return { + id: `asset-${sequence}`, + type: TldrawShapeType.Image, + name: 'img.png', + src: `/filerecordid-${sequence}/file1.jpg`, + }; +}); diff --git a/apps/server/src/modules/tldraw/testing/tldraw-shape.factory.ts b/apps/server/src/modules/tldraw/testing/tldraw-shape.factory.ts new file mode 100644 index 00000000000..368a6ca74a2 --- /dev/null +++ b/apps/server/src/modules/tldraw/testing/tldraw-shape.factory.ts @@ -0,0 +1,10 @@ +import { Factory } from 'fishery'; +import { TldrawShape, TldrawShapeType } from '../types'; + +export const tldrawShapeFactory = Factory.define(({ sequence }) => { + return { + id: `shape-${sequence}`, + type: TldrawShapeType.Image, + assetId: `asset-${sequence}`, + }; +}); diff --git a/apps/server/src/modules/tldraw/factory/tldraw.factory.ts b/apps/server/src/modules/tldraw/testing/tldraw.factory.ts similarity index 61% rename from apps/server/src/modules/tldraw/factory/tldraw.factory.ts rename to apps/server/src/modules/tldraw/testing/tldraw.factory.ts index c6e80ec2329..33d869f0017 100644 --- a/apps/server/src/modules/tldraw/factory/tldraw.factory.ts +++ b/apps/server/src/modules/tldraw/testing/tldraw.factory.ts @@ -1,15 +1,18 @@ import { BaseFactory } from '@shared/testing/factory/base.factory'; +import { ObjectId } from '@mikro-orm/mongodb'; import { TldrawDrawing, TldrawDrawingProps } from '../entities'; export const tldrawEntityFactory = BaseFactory.define( TldrawDrawing, ({ sequence }) => { return { - _id: 'test-id', - id: 'test-id', + id: new ObjectId().toHexString(), docName: 'test-name', - value: 'test-value', - version: `test-version-${sequence}`, + value: Buffer.from('test'), + version: `v1`, + action: 'update', + clock: sequence, + part: sequence, }; } ); diff --git a/apps/server/src/modules/tldraw/tldraw-test.module.ts b/apps/server/src/modules/tldraw/tldraw-api-test.module.ts similarity index 71% rename from apps/server/src/modules/tldraw/tldraw-test.module.ts rename to apps/server/src/modules/tldraw/tldraw-api-test.module.ts index 59c8af72f74..6603823f80e 100644 --- a/apps/server/src/modules/tldraw/tldraw-test.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-api-test.module.ts @@ -1,33 +1,32 @@ +import { MongoDatabaseModuleOptions, MongoMemoryDatabaseModule } from '@infra/database'; +import { defaultMikroOrmOptions } from '@modules/server'; +import { HttpModule } from '@nestjs/axios'; import { DynamicModule, Module } from '@nestjs/common'; -import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/database'; -import { Logger, LoggerModule } from '@src/core/logger'; import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; -import { RedisModule } from '@infra/redis'; -import { defaultMikroOrmOptions } from '@modules/server'; -import { HttpModule } from '@nestjs/axios'; -import { MetricsService } from './metrics'; +import { Logger, LoggerModule } from '@src/core/logger'; +import { TldrawRedisFactory, TldrawRedisService } from './redis'; import { config } from './config'; -import { TldrawController } from './controller/tldraw.controller'; -import { TldrawService } from './service/tldraw.service'; -import { TldrawRepo } from './repo/tldraw.repo'; +import { TldrawController } from './controller'; +import { MetricsService } from './metrics'; +import { TldrawRepo } from './repo'; +import { TldrawService } from './service'; const imports = [ MongoMemoryDatabaseModule.forRoot({ ...defaultMikroOrmOptions }), LoggerModule, ConfigModule.forRoot(createConfigModuleOptions(config)), - RedisModule, HttpModule, ]; -const providers = [Logger, TldrawService, TldrawRepo, MetricsService]; +const providers = [Logger, TldrawService, TldrawRepo, MetricsService, TldrawRedisFactory, TldrawRedisService]; @Module({ imports, providers, }) -export class TldrawTestModule { +export class TldrawApiTestModule { static forRoot(options?: MongoDatabaseModuleOptions): DynamicModule { return { - module: TldrawTestModule, + module: TldrawApiTestModule, imports: [...imports, MongoMemoryDatabaseModule.forRoot({ ...defaultMikroOrmOptions, ...options })], controllers: [TldrawController], providers, diff --git a/apps/server/src/modules/tldraw/tldraw.module.ts b/apps/server/src/modules/tldraw/tldraw-api.module.ts similarity index 55% rename from apps/server/src/modules/tldraw/tldraw.module.ts rename to apps/server/src/modules/tldraw/tldraw-api.module.ts index fa5ebf59d02..fbba4bef505 100644 --- a/apps/server/src/modules/tldraw/tldraw.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-api.module.ts @@ -1,19 +1,17 @@ import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME, TLDRAW_DB_URL } from '@src/config'; -import { CoreModule } from '@src/core'; -import { Logger } from '@src/core/logger'; +import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME } from '@src/config'; +import { LoggerModule } from '@src/core/logger'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; -import { AuthenticationModule } from '@modules/authentication/authentication.module'; -import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; -import { AuthorizationModule } from '@modules/authorization'; +import { CoreModule } from '@src/core'; +import { config, TLDRAW_DB_URL } from './config'; import { TldrawDrawing } from './entities'; -import { config } from './config'; -import { TldrawService } from './service/tldraw.service'; -import { TldrawBoardRepo } from './repo'; -import { TldrawController } from './controller/tldraw.controller'; -import { TldrawRepo } from './repo/tldraw.repo'; +import { TldrawController } from './controller'; +import { TldrawService } from './service'; +import { TldrawBoardRepo, TldrawRepo, YMongodb } from './repo'; +// TODO must be fixed, direct import of a file from another module in not allowed +import { XApiKeyStrategy } from '../authentication/strategy/x-api-key.strategy'; const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => @@ -23,10 +21,8 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { @Module({ imports: [ - AuthorizationModule, - AuthenticationModule, + LoggerModule, CoreModule, - RabbitMQWrapperTestModule, MikroOrmModule.forRoot({ ...defaultMikroOrmOptions, type: 'mongo', @@ -37,7 +33,7 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { }), ConfigModule.forRoot(createConfigModuleOptions(config)), ], - providers: [Logger, TldrawService, TldrawBoardRepo, TldrawRepo], + providers: [TldrawService, TldrawBoardRepo, TldrawRepo, YMongodb, XApiKeyStrategy], controllers: [TldrawController], }) -export class TldrawModule {} +export class TldrawApiModule {} diff --git a/apps/server/src/modules/tldraw/tldraw-console.module.ts b/apps/server/src/modules/tldraw/tldraw-console.module.ts new file mode 100644 index 00000000000..171505bd510 --- /dev/null +++ b/apps/server/src/modules/tldraw/tldraw-console.module.ts @@ -0,0 +1,43 @@ +import { Module, NotFoundException } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME } from '@src/config'; +import { LoggerModule } from '@src/core/logger'; +import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; +import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; +import { RabbitMQWrapperModule } from '@infra/rabbitmq'; +import { ConsoleWriterModule } from '@infra/console'; +import { ConsoleModule } from 'nestjs-console'; +import { FilesStorageClientModule } from '../files-storage-client'; +import { config, TLDRAW_DB_URL } from './config'; +import { TldrawDrawing } from './entities'; +import { TldrawFilesStorageAdapterService } from './service'; +import { TldrawRepo, YMongodb } from './repo'; +import { TldrawFilesConsole } from './job'; +import { TldrawDeleteFilesUc } from './uc'; + +const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { + findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + new NotFoundException(`The requested ${entityName}: ${where} has not been found.`), +}; + +@Module({ + imports: [ + ConsoleModule, + ConsoleWriterModule, + RabbitMQWrapperModule, + FilesStorageClientModule, + LoggerModule, + MikroOrmModule.forRoot({ + ...defaultMikroOrmOptions, + type: 'mongo', + clientUrl: TLDRAW_DB_URL, + password: DB_PASSWORD, + user: DB_USERNAME, + entities: [TldrawDrawing], + }), + ConfigModule.forRoot(createConfigModuleOptions(config)), + ], + providers: [TldrawRepo, YMongodb, TldrawFilesConsole, TldrawFilesStorageAdapterService, TldrawDeleteFilesUc], +}) +export class TldrawConsoleModule {} diff --git a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts index 7a80aac20de..e5e4e192e83 100644 --- a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts @@ -3,15 +3,33 @@ import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/da import { CoreModule } from '@src/core'; import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; +import { LoggerModule } from '@src/core/logger'; import { HttpModule } from '@nestjs/axios'; import { MetricsService } from './metrics'; -import { TldrawBoardRepo } from './repo'; +import { TldrawBoardRepo, TldrawRepo, YMongodb } from './repo'; import { TldrawWsService } from './service'; import { config } from './config'; import { TldrawWs } from './controller'; +import { TldrawDrawing } from './entities'; +import { TldrawRedisFactory, TldrawRedisService } from './redis'; -const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config)), HttpModule]; -const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService, MetricsService]; +const imports = [ + HttpModule, + LoggerModule, + CoreModule, + MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), + ConfigModule.forRoot(createConfigModuleOptions(config)), +]; +const providers = [ + TldrawWs, + TldrawWsService, + TldrawBoardRepo, + TldrawRepo, + YMongodb, + MetricsService, + TldrawRedisFactory, + TldrawRedisService, +]; @Module({ imports, providers, diff --git a/apps/server/src/modules/tldraw/tldraw-ws.module.ts b/apps/server/src/modules/tldraw/tldraw-ws.module.ts index 8ed614a510e..3f096352a4a 100644 --- a/apps/server/src/modules/tldraw/tldraw-ws.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-ws.module.ts @@ -1,17 +1,48 @@ -import { Module } from '@nestjs/common'; +import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions } from '@src/config'; +import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME } from '@src/config'; import { CoreModule } from '@src/core'; -import { Logger } from '@src/core/logger'; +import { LoggerModule } from '@src/core/logger'; +import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; +import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { HttpModule } from '@nestjs/axios'; +import { TldrawDrawing } from './entities'; import { MetricsService } from './metrics'; -import { TldrawBoardRepo } from './repo'; +import { TldrawBoardRepo, TldrawRepo, YMongodb } from './repo'; import { TldrawWsService } from './service'; import { TldrawWs } from './controller'; -import { config } from './config'; +import { config, TLDRAW_DB_URL } from './config'; +import { TldrawRedisFactory, TldrawRedisService } from './redis'; +const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { + findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + new NotFoundException(`The requested ${entityName}: ${where} has not been found.`), +}; @Module({ - imports: [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config)), HttpModule], - providers: [Logger, TldrawWs, TldrawWsService, TldrawBoardRepo, MetricsService], + imports: [ + HttpModule, + LoggerModule, + CoreModule, + MikroOrmModule.forRoot({ + ...defaultMikroOrmOptions, + type: 'mongo', + clientUrl: TLDRAW_DB_URL, + password: DB_PASSWORD, + user: DB_USERNAME, + entities: [TldrawDrawing], + }), + ConfigModule.forRoot(createConfigModuleOptions(config)), + ], + providers: [ + TldrawWs, + TldrawWsService, + TldrawBoardRepo, + TldrawRepo, + YMongodb, + MetricsService, + TldrawRedisFactory, + TldrawRedisService, + ], }) export class TldrawWsModule {} diff --git a/apps/server/src/modules/tldraw/types/awareness-connections-update-type.ts b/apps/server/src/modules/tldraw/types/awareness-connections-update-type.ts new file mode 100644 index 00000000000..77e5ab1b99e --- /dev/null +++ b/apps/server/src/modules/tldraw/types/awareness-connections-update-type.ts @@ -0,0 +1,5 @@ +export type AwarenessConnectionsUpdate = { + added: Array; + updated: Array; + removed: Array; +}; diff --git a/apps/server/src/modules/tldraw/types/connection-enum.ts b/apps/server/src/modules/tldraw/types/connection-enum.ts index 6a9a4692e03..c8c0cfdd2c3 100644 --- a/apps/server/src/modules/tldraw/types/connection-enum.ts +++ b/apps/server/src/modules/tldraw/types/connection-enum.ts @@ -1,8 +1,3 @@ -export enum WSConnectionState { - CONNECTING = 0, - OPEN = 1, -} - export enum WSMessageType { SYNC = 0, AWARENESS = 1, diff --git a/apps/server/src/modules/tldraw/types/index.ts b/apps/server/src/modules/tldraw/types/index.ts index 957e55aab3f..ed1bf3d3226 100644 --- a/apps/server/src/modules/tldraw/types/index.ts +++ b/apps/server/src/modules/tldraw/types/index.ts @@ -1,3 +1,7 @@ +export * from './tldraw-types'; export * from './connection-enum'; +export * from './y-transaction-type'; export * from './ws-close-enum'; -export * from './persistence-type'; +export * from './awareness-connections-update-type'; +export * from './redis-connection-type-enum'; +export * from './update-enums'; diff --git a/apps/server/src/modules/tldraw/types/persistence-type.ts b/apps/server/src/modules/tldraw/types/persistence-type.ts deleted file mode 100644 index ee8d4510275..00000000000 --- a/apps/server/src/modules/tldraw/types/persistence-type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; - -export type Persitence = { - bindState: (docName: string, ydoc: WsSharedDocDo) => Promise; - writeState: (docName: string, ydoc: WsSharedDocDo) => Promise; -}; diff --git a/apps/server/src/modules/tldraw/types/redis-connection-type-enum.ts b/apps/server/src/modules/tldraw/types/redis-connection-type-enum.ts new file mode 100644 index 00000000000..a0e34661a98 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/redis-connection-type-enum.ts @@ -0,0 +1,4 @@ +export enum RedisConnectionTypeEnum { + PUBLISH = 'PUB', + SUBSCRIBE = 'SUB', +} diff --git a/apps/server/src/modules/tldraw/types/tldraw-types.ts b/apps/server/src/modules/tldraw/types/tldraw-types.ts new file mode 100644 index 00000000000..be566290ae2 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/tldraw-types.ts @@ -0,0 +1,26 @@ +export enum TldrawShapeType { + Sticky = 'sticky', + Ellipse = 'ellipse', + Rectangle = 'rectangle', + Triangle = 'triangle', + Draw = 'draw', + Arrow = 'arrow', + Line = 'line', + Text = 'text', + Group = 'group', + Image = 'image', + Video = 'video', +} + +export type TldrawShape = { + id: string; + type: TldrawShapeType; + assetId?: string; +}; + +export type TldrawAsset = { + id: string; + type: TldrawShapeType; + name: string; + src: string; +}; diff --git a/apps/server/src/modules/tldraw/types/update-enums.ts b/apps/server/src/modules/tldraw/types/update-enums.ts new file mode 100644 index 00000000000..826bfe7039c --- /dev/null +++ b/apps/server/src/modules/tldraw/types/update-enums.ts @@ -0,0 +1,8 @@ +export enum UpdateOrigin { + REDIS = 'redis', +} + +export enum UpdateType { + AWARENESS = 'awareness', + DOCUMENT = 'document', +} diff --git a/apps/server/src/modules/tldraw/types/ws-close-enum.ts b/apps/server/src/modules/tldraw/types/ws-close-enum.ts index 0cbf8021e84..0e3333c46ab 100644 --- a/apps/server/src/modules/tldraw/types/ws-close-enum.ts +++ b/apps/server/src/modules/tldraw/types/ws-close-enum.ts @@ -1,12 +1,15 @@ -export enum WsCloseCodeEnum { - WS_CLIENT_BAD_REQUEST_CODE = 4400, - WS_CLIENT_UNAUTHORISED_CONNECTION_CODE = 4401, - WS_CLIENT_NOT_FOUND_CODE = 4404, - WS_CLIENT_ESTABLISHING_CONNECTION_CODE = 4500, +export enum WsCloseCode { + BAD_REQUEST = 4400, + UNAUTHORIZED = 4401, + NOT_FOUND = 4404, + NOT_ACCEPTABLE = 4406, + INTERNAL_SERVER_ERROR = 4500, } -export enum WsCloseMessageEnum { - WS_CLIENT_BAD_REQUEST_MESSAGE = 'Document name is mandatory in url or Tldraw Tool is turned off.', - WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE = "Unauthorised connection - you don't have permission to this drawing.", - WS_CLIENT_NOT_FOUND_MESSAGE = 'Drawing not found.', - WS_CLIENT_ESTABLISHING_CONNECTION_MESSAGE = 'Unable to establish websocket connection. Try again later.', +export enum WsCloseMessage { + FEATURE_DISABLED = 'Tldraw feature is disabled.', + BAD_REQUEST = 'Room name param not found in url.', + UNAUTHORIZED = "You don't have permission to this drawing.", + NOT_FOUND = 'Drawing not found.', + NOT_ACCEPTABLE = 'Could not get document, still finalizing or not yet loaded.', + INTERNAL_SERVER_ERROR = 'Unable to establish websocket connection.', } diff --git a/apps/server/src/modules/tldraw/types/y-transaction-type.ts b/apps/server/src/modules/tldraw/types/y-transaction-type.ts new file mode 100644 index 00000000000..cee97047960 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/y-transaction-type.ts @@ -0,0 +1,3 @@ +import { Doc } from 'yjs'; + +export type YTransaction = Doc | number | void; diff --git a/apps/server/src/modules/tldraw/uc/index.ts b/apps/server/src/modules/tldraw/uc/index.ts new file mode 100644 index 00000000000..0b585097608 --- /dev/null +++ b/apps/server/src/modules/tldraw/uc/index.ts @@ -0,0 +1 @@ +export * from './tldraw-delete-files.uc'; diff --git a/apps/server/src/modules/tldraw/uc/tldraw-delete-files.uc.spec.ts b/apps/server/src/modules/tldraw/uc/tldraw-delete-files.uc.spec.ts new file mode 100644 index 00000000000..4cbed61fdfd --- /dev/null +++ b/apps/server/src/modules/tldraw/uc/tldraw-delete-files.uc.spec.ts @@ -0,0 +1,68 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { YMap } from 'yjs/dist/src/types/YMap'; +import { TldrawFilesStorageAdapterService } from '../service'; +import { YMongodb } from '../repo'; +import { TldrawDeleteFilesUc } from './tldraw-delete-files.uc'; +import { WsSharedDocDo } from '../domain'; +import { TldrawAsset, TldrawShape, TldrawShapeType } from '../types'; +import { tldrawShapeFactory, tldrawAssetFactory } from '../testing'; + +describe('TldrawDeleteFilesUc', () => { + let uc: TldrawDeleteFilesUc; + let mdb: DeepMocked; + let filesStorageAdapterService: DeepMocked; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TldrawDeleteFilesUc, + { + provide: YMongodb, + useValue: createMock(), + }, + { + provide: TldrawFilesStorageAdapterService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(TldrawDeleteFilesUc); + mdb = module.get(YMongodb); + filesStorageAdapterService = module.get(TldrawFilesStorageAdapterService); + }); + + it('should be defined', () => { + expect(uc).toBeDefined(); + }); + + describe('deleteUnusedFiles', () => { + const setup = () => { + mdb.getAllDocumentNames.mockResolvedValueOnce(['doc1']); + const doc = new WsSharedDocDo('doc1'); + + const shapes: YMap = doc.getMap('shapes'); + const shape1 = tldrawShapeFactory.build(); + const shape2 = tldrawShapeFactory.build({ type: TldrawShapeType.Draw, assetId: undefined }); + shapes.set('shape1', shape1); + shapes.set('shape2', shape2); + + const assets: YMap = doc.getMap('assets'); + const asset1 = tldrawAssetFactory.build(); + const asset2 = tldrawAssetFactory.build(); + assets.set('asset1', asset1); + assets.set('asset2', asset2); + + mdb.getDocument.mockResolvedValueOnce(doc); + }; + + it('should call deleteUnusedFilesForDocument on TldrawFilesStorageAdapterService correct number of times', async () => { + setup(); + + await uc.deleteUnusedFiles(new Date()); + + expect(filesStorageAdapterService.deleteUnusedFilesForDocument).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/uc/tldraw-delete-files.uc.ts b/apps/server/src/modules/tldraw/uc/tldraw-delete-files.uc.ts new file mode 100644 index 00000000000..fe2037eb44d --- /dev/null +++ b/apps/server/src/modules/tldraw/uc/tldraw-delete-files.uc.ts @@ -0,0 +1,46 @@ +/* eslint-disable no-await-in-loop */ +import { Injectable } from '@nestjs/common'; +import { YMap } from 'yjs/dist/src/types/YMap'; +import { YMongodb } from '../repo'; +import { TldrawFilesStorageAdapterService } from '../service'; +import { WsSharedDocDo } from '../domain'; +import { TldrawAsset, TldrawShape } from '../types'; + +@Injectable() +export class TldrawDeleteFilesUc { + constructor(private mdb: YMongodb, private filesStorageTldrawAdapterService: TldrawFilesStorageAdapterService) {} + + public async deleteUnusedFiles(thresholdDate: Date): Promise { + const docNames = await this.mdb.getAllDocumentNames(); + + for (const docName of docNames) { + const doc = await this.mdb.getDocument(docName); + const usedAssets = this.getUsedAssetsFromDocument(doc); + + await this.filesStorageTldrawAdapterService.deleteUnusedFilesForDocument(docName, usedAssets, thresholdDate); + doc.destroy(); + } + } + + private getUsedAssetsFromDocument(doc: WsSharedDocDo): TldrawAsset[] { + const assets: YMap = doc.getMap('assets'); + const shapes: YMap = doc.getMap('shapes'); + const usedShapesAsAssets: TldrawShape[] = []; + const usedAssets: TldrawAsset[] = []; + + for (const [, shape] of shapes) { + if (shape.assetId) { + usedShapesAsAssets.push(shape); + } + } + + for (const [, asset] of assets) { + const foundAsset = usedShapesAsAssets.some((shape) => shape.assetId === asset.id); + if (foundAsset) { + usedAssets.push(asset); + } + } + + return usedAssets; + } +} diff --git a/apps/server/src/modules/tldraw/utils/index.ts b/apps/server/src/modules/tldraw/utils/index.ts deleted file mode 100644 index a51b9059bc1..00000000000 --- a/apps/server/src/modules/tldraw/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ydoc-utils'; diff --git a/apps/server/src/modules/tldraw/utils/ydoc-utils.ts b/apps/server/src/modules/tldraw/utils/ydoc-utils.ts deleted file mode 100644 index 6d0817ecc9d..00000000000 --- a/apps/server/src/modules/tldraw/utils/ydoc-utils.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const calculateDiff = (diff: Uint8Array): number => - diff.reduce((previousValue, currentValue) => previousValue + currentValue, 0); diff --git a/apps/server/src/modules/tool/common/common-tool.module.ts b/apps/server/src/modules/tool/common/common-tool.module.ts index 57375c67e96..535e97f448e 100644 --- a/apps/server/src/modules/tool/common/common-tool.module.ts +++ b/apps/server/src/modules/tool/common/common-tool.module.ts @@ -1,13 +1,27 @@ -import { LegacySchoolModule } from '@modules/legacy-school'; -import { Module } from '@nestjs/common'; +import { BoardModule } from '@modules/board'; +import { forwardRef, Module } from '@nestjs/common'; import { ContextExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; +import { SchoolModule } from '@src/modules/school'; import { CommonToolService, CommonToolValidationService } from './service'; +import { CommonToolMetadataService } from './service/common-tool-metadata.service'; @Module({ - imports: [LoggerModule, LegacySchoolModule], + imports: [LoggerModule, SchoolModule, forwardRef(() => BoardModule)], // TODO: make deletion of entities cascading, adjust ExternalToolService.deleteExternalTool and remove the repos from here - providers: [CommonToolService, CommonToolValidationService, SchoolExternalToolRepo, ContextExternalToolRepo], - exports: [CommonToolService, CommonToolValidationService, SchoolExternalToolRepo, ContextExternalToolRepo], + providers: [ + CommonToolService, + CommonToolValidationService, + SchoolExternalToolRepo, + ContextExternalToolRepo, + CommonToolMetadataService, + ], + exports: [ + CommonToolService, + CommonToolValidationService, + SchoolExternalToolRepo, + ContextExternalToolRepo, + CommonToolMetadataService, + ], }) export class CommonToolModule {} diff --git a/apps/server/src/modules/tool/common/enum/custom-parameter-type.enum.ts b/apps/server/src/modules/tool/common/enum/custom-parameter-type.enum.ts index b9f07101baf..7165a6a5750 100644 --- a/apps/server/src/modules/tool/common/enum/custom-parameter-type.enum.ts +++ b/apps/server/src/modules/tool/common/enum/custom-parameter-type.enum.ts @@ -6,6 +6,7 @@ export enum CustomParameterType { AUTO_CONTEXTNAME = 'auto_contextname', AUTO_SCHOOLID = 'auto_schoolid', AUTO_SCHOOLNUMBER = 'auto_schoolnumber', + AUTO_MEDIUMID = 'auto_mediumid', } export const autoParameters: CustomParameterType[] = [ @@ -13,4 +14,5 @@ export const autoParameters: CustomParameterType[] = [ CustomParameterType.AUTO_CONTEXTNAME, CustomParameterType.AUTO_SCHOOLID, CustomParameterType.AUTO_SCHOOLNUMBER, + CustomParameterType.AUTO_MEDIUMID, ]; diff --git a/apps/server/src/modules/tool/common/enum/request-response/custom-parameter-type.enum.ts b/apps/server/src/modules/tool/common/enum/request-response/custom-parameter-type.enum.ts index 5e06f5a130f..7771941ee9a 100644 --- a/apps/server/src/modules/tool/common/enum/request-response/custom-parameter-type.enum.ts +++ b/apps/server/src/modules/tool/common/enum/request-response/custom-parameter-type.enum.ts @@ -6,4 +6,5 @@ export enum CustomParameterTypeParams { AUTO_CONTEXTNAME = 'auto_contextname', AUTO_SCHOOLID = 'auto_schoolid', AUTO_SCHOOLNUMBER = 'auto_schoolnumber', + AUTO_MEDIUMID = 'auto_mediumid', } diff --git a/apps/server/src/modules/tool/common/service/common-tool-metadata.service.spec.ts b/apps/server/src/modules/tool/common/service/common-tool-metadata.service.spec.ts new file mode 100644 index 00000000000..e308da50600 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/common-tool-metadata.service.spec.ts @@ -0,0 +1,150 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { ContentElementService } from '@modules/board'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ContextExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; +import { contextExternalToolFactory, schoolExternalToolFactory } from '@shared/testing'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ExternalToolMetadata } from '../../external-tool/domain'; +import { SchoolExternalTool, SchoolExternalToolMetadata } from '../../school-external-tool/domain'; +import { CommonToolMetadataService } from './common-tool-metadata.service'; + +describe(CommonToolMetadataService.name, () => { + let module: TestingModule; + let service: CommonToolMetadataService; + + let schoolExternalToolRepo: DeepMocked; + let contextExternalToolRepo: DeepMocked; + let contentElementService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + CommonToolMetadataService, + { + provide: SchoolExternalToolRepo, + useValue: createMock(), + }, + { + provide: ContextExternalToolRepo, + useValue: createMock(), + }, + { + provide: ContentElementService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(CommonToolMetadataService); + schoolExternalToolRepo = module.get(SchoolExternalToolRepo); + contextExternalToolRepo = module.get(ContextExternalToolRepo); + contentElementService = module.get(ContentElementService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getMetadataForExternalTool', () => { + describe('when the tool has no usages', () => { + const setup = () => { + schoolExternalToolRepo.findByExternalToolId.mockResolvedValueOnce([]); + }; + + it('should return 0 usages for all contexts', async () => { + setup(); + + const result: ExternalToolMetadata = await service.getMetadataForExternalTool(new ObjectId().toHexString()); + + expect(result).toEqual({ + schoolExternalToolCount: 0, + contextExternalToolCountPerContext: { + course: 0, + boardElement: 0, + }, + }); + }); + }); + + describe('when the tool has usages in all contexts', () => { + const setup = () => { + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextExternalTools: ContextExternalTool[] = contextExternalToolFactory.buildListWithId(2); + + schoolExternalToolRepo.findByExternalToolId.mockResolvedValueOnce([schoolExternalTool]); + contextExternalToolRepo.findBySchoolToolIdsAndContextType.mockResolvedValueOnce(contextExternalTools); + contextExternalToolRepo.findBySchoolToolIdsAndContextType.mockResolvedValueOnce(contextExternalTools); + contentElementService.countBoardUsageForExternalTools.mockResolvedValueOnce(3); + }; + + it('should return the amount of usages for all contexts', async () => { + setup(); + + const result: ExternalToolMetadata = await service.getMetadataForExternalTool(new ObjectId().toHexString()); + + expect(result).toEqual({ + schoolExternalToolCount: 1, + contextExternalToolCountPerContext: { + course: 2, + boardElement: 3, + }, + }); + }); + }); + }); + + describe('getMetadataForSchoolExternalTool', () => { + describe('when the tool has no usages', () => { + const setup = () => { + contextExternalToolRepo.findBySchoolToolIdsAndContextType.mockResolvedValueOnce([]); + contextExternalToolRepo.findBySchoolToolIdsAndContextType.mockResolvedValueOnce([]); + contentElementService.countBoardUsageForExternalTools.mockResolvedValueOnce(0); + }; + + it('should return 0 usages for all contexts', async () => { + setup(); + + const result: SchoolExternalToolMetadata = await service.getMetadataForSchoolExternalTool( + new ObjectId().toHexString() + ); + + expect(result).toEqual({ + contextExternalToolCountPerContext: { + course: 0, + boardElement: 0, + }, + }); + }); + }); + + describe('when the tool has usages in all contexts', () => { + const setup = () => { + const contextExternalTools: ContextExternalTool[] = contextExternalToolFactory.buildListWithId(2); + + contextExternalToolRepo.findBySchoolToolIdsAndContextType.mockResolvedValueOnce(contextExternalTools); + contextExternalToolRepo.findBySchoolToolIdsAndContextType.mockResolvedValueOnce(contextExternalTools); + contentElementService.countBoardUsageForExternalTools.mockResolvedValueOnce(3); + }; + + it('should return the amount of usages for all contexts', async () => { + setup(); + + const result: SchoolExternalToolMetadata = await service.getMetadataForSchoolExternalTool( + new ObjectId().toHexString() + ); + + expect(result).toEqual({ + contextExternalToolCountPerContext: { + course: 2, + boardElement: 3, + }, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/common/service/common-tool-metadata.service.ts b/apps/server/src/modules/tool/common/service/common-tool-metadata.service.ts new file mode 100644 index 00000000000..8c1a2fca637 --- /dev/null +++ b/apps/server/src/modules/tool/common/service/common-tool-metadata.service.ts @@ -0,0 +1,89 @@ +import { ContentElementService } from '@modules/board'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { ContextExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; +import { ContextExternalTool } from '../../context-external-tool/domain'; +import { ContextExternalToolType } from '../../context-external-tool/entity'; +import { ExternalToolMetadata } from '../../external-tool/domain'; +import { SchoolExternalTool, SchoolExternalToolMetadata } from '../../school-external-tool/domain'; +import { ToolContextType } from '../enum'; +import { ToolContextMapper } from '../mapper/tool-context.mapper'; + +@Injectable() +export class CommonToolMetadataService { + constructor( + private readonly schoolToolRepo: SchoolExternalToolRepo, + private readonly contextToolRepo: ContextExternalToolRepo, + @Inject(forwardRef(() => ContentElementService)) + private readonly contentElementService: ContentElementService + ) {} + + async getMetadataForExternalTool(toolId: EntityId): Promise { + const schoolExternalTools: SchoolExternalTool[] = await this.schoolToolRepo.findByExternalToolId(toolId); + + const schoolExternalToolIds: string[] = schoolExternalTools.map( + (schoolExternalTool: SchoolExternalTool): string => + // We can be sure that the repo returns the id + schoolExternalTool.id as string + ); + + const externalToolMetadata: ExternalToolMetadata = await this.getMetadata(schoolExternalToolIds); + + return externalToolMetadata; + } + + async getMetadataForSchoolExternalTool(schoolExternalToolId: EntityId): Promise { + const externalToolMetadata: ExternalToolMetadata = await this.getMetadata([schoolExternalToolId]); + + const schoolExternalToolMetadata: SchoolExternalToolMetadata = new SchoolExternalToolMetadata({ + contextExternalToolCountPerContext: externalToolMetadata.contextExternalToolCountPerContext, + }); + + return schoolExternalToolMetadata; + } + + private async getMetadata(schoolExternalToolIds: EntityId[]): Promise { + const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ + schoolExternalToolCount: schoolExternalToolIds.length, + contextExternalToolCountPerContext: { + [ContextExternalToolType.BOARD_ELEMENT]: 0, + [ContextExternalToolType.COURSE]: 0, + }, + }); + + if (schoolExternalToolIds.length) { + await Promise.all( + Object.values(ToolContextType).map(async (contextType: ToolContextType): Promise => { + const type: ContextExternalToolType = ToolContextMapper.contextMapping[contextType]; + + const contextExternalTools: ContextExternalTool[] = + await this.contextToolRepo.findBySchoolToolIdsAndContextType(schoolExternalToolIds, type); + + const count: number = await this.countUsageForType(contextExternalTools, type); + + externalToolMetadata.contextExternalToolCountPerContext[type] = count; + }) + ); + } + + return externalToolMetadata; + } + + private async countUsageForType( + contextExternalTools: ContextExternalTool[], + contextType: ContextExternalToolType + ): Promise { + let count = 0; + if (contextType === ContextExternalToolType.BOARD_ELEMENT) { + count = await this.contentElementService.countBoardUsageForExternalTools(contextExternalTools); + } else { + const contextIds: EntityId[] = contextExternalTools.map( + (contextExternalTool: ContextExternalTool): EntityId => contextExternalTool.contextRef.id + ); + + count = new Set(contextIds).size; + } + + return count; + } +} diff --git a/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.ts b/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.ts index 1ba0740d0dd..6c260c5f0aa 100644 --- a/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.ts +++ b/apps/server/src/modules/tool/common/service/validation/common-tool-validation.service.ts @@ -22,13 +22,12 @@ export class CommonToolValidationService { new ParameterArrayEntryValidator(), ]; - public validateParameters(loadedExternalTool: ExternalTool, validatableTool: ValidatableTool): ValidationError[] { + public validateParameters(externalTool: ExternalTool, validatableTool: ValidatableTool): ValidationError[] { const errors: ValidationError[] = []; - const parametersForScope: CustomParameter[] = (loadedExternalTool.parameters ?? []).filter( - (param: CustomParameter) => - (validatableTool instanceof SchoolExternalTool && param.scope === CustomParameterScope.SCHOOL) || - (validatableTool instanceof ContextExternalTool && param.scope === CustomParameterScope.CONTEXT) + const parametersForScope: CustomParameter[] = this.filterParametersForScope( + externalTool.parameters, + validatableTool ); this.arrayValidators.forEach((validator: ParameterArrayValidator) => { @@ -39,4 +38,17 @@ export class CommonToolValidationService { return errors; } + + private filterParametersForScope( + params: CustomParameter[] | undefined, + validatableTool: ValidatableTool + ): CustomParameter[] { + const parametersForScope: CustomParameter[] = (params ?? []).filter( + (param: CustomParameter) => + (validatableTool instanceof SchoolExternalTool && param.scope === CustomParameterScope.SCHOOL) || + (validatableTool instanceof ContextExternalTool && param.scope === CustomParameterScope.CONTEXT) + ); + + return parametersForScope; + } } diff --git a/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.spec.ts b/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.spec.ts index 4949a840f11..79cba0dd459 100644 --- a/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.spec.ts +++ b/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.spec.ts @@ -95,5 +95,16 @@ describe(ToolParameterTypeValidationUtil.name, () => { expect(result).toEqual(false); }); }); + + describe('when the type is AUTO_MEDIUMID', () => { + it('should return false', () => { + const result: boolean = ToolParameterTypeValidationUtil.isValueValidForType( + CustomParameterType.AUTO_MEDIUMID, + 'any value' + ); + + expect(result).toEqual(false); + }); + }); }); }); diff --git a/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.ts b/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.ts index f765cb68782..7c2b09cac5b 100644 --- a/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.ts +++ b/apps/server/src/modules/tool/common/service/validation/tool-parameter-type-validation.util.ts @@ -10,6 +10,7 @@ export class ToolParameterTypeValidationUtil { [CustomParameterType.AUTO_CONTEXTNAME]: () => false, [CustomParameterType.AUTO_SCHOOLID]: () => false, [CustomParameterType.AUTO_SCHOOLNUMBER]: () => false, + [CustomParameterType.AUTO_MEDIUMID]: () => false, }; public static isValueValidForType(type: CustomParameterType, val: string): boolean { diff --git a/apps/server/src/modules/tool/common/uc/tool-permissions-helper.spec.ts b/apps/server/src/modules/tool/common/uc/tool-permission-helper.spec.ts similarity index 74% rename from apps/server/src/modules/tool/common/uc/tool-permissions-helper.spec.ts rename to apps/server/src/modules/tool/common/uc/tool-permission-helper.spec.ts index d0578cb2442..68272ff1b2a 100644 --- a/apps/server/src/modules/tool/common/uc/tool-permissions-helper.spec.ts +++ b/apps/server/src/modules/tool/common/uc/tool-permission-helper.spec.ts @@ -8,24 +8,20 @@ import { import { AuthorizableReferenceType } from '@modules/authorization/domain'; import { BoardDoAuthorizableService, ContentElementService } from '@modules/board'; import { CourseService } from '@modules/learnroom'; -import { LegacySchoolService } from '@modules/legacy-school'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { BoardDoAuthorizable, ExternalToolElement, LegacySchoolDo } from '@shared/domain/domainobject'; +import { BoardDoAuthorizable, ExternalToolElement } from '@shared/domain/domainobject'; import { Permission } from '@shared/domain/interface'; import { contextExternalToolFactory, courseFactory, externalToolElementFactory, - legacySchoolDoFactory, - schoolExternalToolFactory, setupEntities, userFactory, } from '@shared/testing'; import { boardDoAuthorizableFactory } from '@shared/testing/factory/domainobject/board/board-do-authorizable.factory'; import { ContextExternalTool, ContextRef } from '../../context-external-tool/domain'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; import { ToolContextType } from '../enum'; import { ToolPermissionHelper } from './tool-permission-helper'; @@ -34,7 +30,6 @@ describe('ToolPermissionHelper', () => { let helper: ToolPermissionHelper; let authorizationService: DeepMocked; - let schoolService: DeepMocked; let courseService: DeepMocked; let contentElementService: DeepMocked; let boardDoAuthorizableService: DeepMocked; @@ -48,10 +43,6 @@ describe('ToolPermissionHelper', () => { provide: AuthorizationService, useValue: createMock(), }, - { - provide: LegacySchoolService, - useValue: createMock(), - }, { provide: CourseService, useValue: createMock(), @@ -69,7 +60,6 @@ describe('ToolPermissionHelper', () => { helper = module.get(ToolPermissionHelper); authorizationService = module.get(AuthorizationService); - schoolService = module.get(LegacySchoolService); courseService = module.get(CourseService); contentElementService = module.get(ContentElementService); boardDoAuthorizableService = module.get(BoardDoAuthorizableService); @@ -110,7 +100,7 @@ describe('ToolPermissionHelper', () => { it('should check permission for context external tool', async () => { const { user, course, contextExternalTool, context } = setup(); - await helper.ensureContextPermissions(user.id, contextExternalTool, context); + await helper.ensureContextPermissions(user, contextExternalTool, context); expect(authorizationService.checkPermission).toHaveBeenCalledTimes(2); expect(authorizationService.checkPermission).toHaveBeenNthCalledWith(1, user, contextExternalTool, context); @@ -146,7 +136,7 @@ describe('ToolPermissionHelper', () => { it('should check permission for context external tool', async () => { const { user, board, contextExternalTool, context } = setup(); - await helper.ensureContextPermissions(user.id, contextExternalTool, context); + await helper.ensureContextPermissions(user, contextExternalTool, context); expect(authorizationService.checkPermission).toHaveBeenCalledTimes(2); expect(authorizationService.checkPermission).toHaveBeenNthCalledWith(1, user, contextExternalTool, context); @@ -176,7 +166,7 @@ describe('ToolPermissionHelper', () => { it('should throw a forbidden loggable exception', async () => { const { user, contextExternalTool, context } = setup(); - await expect(helper.ensureContextPermissions(user.id, contextExternalTool, context)).rejects.toThrowError( + await expect(helper.ensureContextPermissions(user, contextExternalTool, context)).rejects.toThrowError( new ForbiddenLoggableException(user.id, AuthorizableReferenceType.ContextExternalToolEntity, context) ); }); @@ -205,47 +195,7 @@ describe('ToolPermissionHelper', () => { it('should check permission for context external tool and fail', async () => { const { user, contextExternalTool, context, error } = setup(); - await expect(helper.ensureContextPermissions(user.id, contextExternalTool, context)).rejects.toThrowError( - error - ); - }); - }); - }); - - describe('ensureSchoolPermissions', () => { - describe('when school external tool is given', () => { - const setup = () => { - const user = userFactory.buildWithId(); - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); - const school: LegacySchoolDo = legacySchoolDoFactory.build({ id: schoolExternalTool.schoolId }); - - schoolService.getSchoolById.mockResolvedValue(school); - authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); - - return { - user, - schoolExternalTool, - school, - context, - }; - }; - - it('should check permission for school external tool', async () => { - const { user, schoolExternalTool, context, school } = setup(); - - await helper.ensureSchoolPermissions(user.id, schoolExternalTool, context); - - expect(authorizationService.checkPermission).toHaveBeenCalledTimes(1); - expect(authorizationService.checkPermission).toHaveBeenCalledWith(user, school, context); - }); - - it('should return undefined', async () => { - const { user, schoolExternalTool, context } = setup(); - - const result = await helper.ensureSchoolPermissions(user.id, schoolExternalTool, context); - - expect(result).toBeUndefined(); + await expect(helper.ensureContextPermissions(user, contextExternalTool, context)).rejects.toThrowError(error); }); }); }); diff --git a/apps/server/src/modules/tool/common/uc/tool-permission-helper.ts b/apps/server/src/modules/tool/common/uc/tool-permission-helper.ts index d43de5d84f0..ddb5493afc6 100644 --- a/apps/server/src/modules/tool/common/uc/tool-permission-helper.ts +++ b/apps/server/src/modules/tool/common/uc/tool-permission-helper.ts @@ -2,20 +2,16 @@ import { AuthorizationContext, AuthorizationService, ForbiddenLoggableException import { AuthorizableReferenceType } from '@modules/authorization/domain'; import { BoardDoAuthorizableService, ContentElementService } from '@modules/board'; import { CourseService } from '@modules/learnroom'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { Inject, Injectable, forwardRef } from '@nestjs/common'; -import { BoardDoAuthorizable, LegacySchoolDo } from '@shared/domain/domainobject'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { BoardDoAuthorizable } from '@shared/domain/domainobject'; import { Course, User } from '@shared/domain/entity'; -import { EntityId } from '@shared/domain/types'; import { ContextExternalTool } from '../../context-external-tool/domain'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; import { ToolContextType } from '../enum'; @Injectable() export class ToolPermissionHelper { constructor( @Inject(forwardRef(() => AuthorizationService)) private readonly authorizationService: AuthorizationService, - private readonly schoolService: LegacySchoolService, // invalid dependency on this place it is in UC layer in a other module // loading of ressources should be part of service layer // if it must resolve different loadings based on the request it can be added in own service and use in UC @@ -26,41 +22,25 @@ export class ToolPermissionHelper { // TODO build interface to get contextDO by contextType public async ensureContextPermissions( - userId: EntityId, + user: User, contextExternalTool: ContextExternalTool, context: AuthorizationContext ): Promise { - const authorizableUser = await this.authorizationService.getUserWithPermissions(userId); - - this.authorizationService.checkPermission(authorizableUser, contextExternalTool, context); + this.authorizationService.checkPermission(user, contextExternalTool, context); if (contextExternalTool.contextRef.type === ToolContextType.COURSE) { // loading of ressources should be part of the UC -> unnessasary awaits const course: Course = await this.courseService.findById(contextExternalTool.contextRef.id); - this.authorizationService.checkPermission(authorizableUser, course, context); + this.authorizationService.checkPermission(user, course, context); } else if (contextExternalTool.contextRef.type === ToolContextType.BOARD_ELEMENT) { const boardElement = await this.boardElementService.findById(contextExternalTool.contextRef.id); const board: BoardDoAuthorizable = await this.boardService.getBoardAuthorizable(boardElement); - this.authorizationService.checkPermission(authorizableUser, board, context); + this.authorizationService.checkPermission(user, board, context); } else { - throw new ForbiddenLoggableException(userId, AuthorizableReferenceType.ContextExternalToolEntity, context); + throw new ForbiddenLoggableException(user.id, AuthorizableReferenceType.ContextExternalToolEntity, context); } } - - public async ensureSchoolPermissions( - userId: EntityId, - schoolExternalTool: SchoolExternalTool, - context: AuthorizationContext - ): Promise { - // loading of ressources should be part of the UC -> unnessasary awaits - const [user, school]: [User, LegacySchoolDo] = await Promise.all([ - this.authorizationService.getUserWithPermissions(userId), - this.schoolService.getSchoolById(schoolExternalTool.schoolId), - ]); - - this.authorizationService.checkPermission(user, school, context); - } } diff --git a/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts b/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts index d73e8a25171..102e033cb6b 100644 --- a/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts +++ b/apps/server/src/modules/tool/context-external-tool/context-external-tool.module.ts @@ -1,15 +1,21 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { LoggerModule } from '@src/core/logger'; import { CommonToolModule } from '../common'; import { ExternalToolModule } from '../external-tool'; import { SchoolExternalToolModule } from '../school-external-tool'; +import { ToolConfigModule } from '../tool-config.module'; import { ContextExternalToolAuthorizableService, ContextExternalToolService, ToolReferenceService } from './service'; import { ContextExternalToolValidationService } from './service/context-external-tool-validation.service'; -import { ToolConfigModule } from '../tool-config.module'; import { ToolVersionService } from './service/tool-version-service'; @Module({ - imports: [CommonToolModule, ExternalToolModule, SchoolExternalToolModule, LoggerModule, ToolConfigModule], + imports: [ + forwardRef(() => CommonToolModule), + forwardRef(() => ExternalToolModule), + SchoolExternalToolModule, + LoggerModule, + ToolConfigModule, + ], providers: [ ContextExternalToolService, ContextExternalToolValidationService, diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts index 2561db5489a..27e68052b7c 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-context.api.spec.ts @@ -2,26 +2,26 @@ import { EntityManager, MikroORM } from '@mikro-orm/core'; import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Account, Course, SchoolEntity, User } from '@shared/domain/entity'; +import { Course, SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { - TestApiClient, - UserAndAccountTestFactory, accountFactory, - contextExternalToolEntityFactory, courseFactory, - customParameterEntityFactory, - externalToolEntityFactory, roleFactory, - schoolExternalToolEntityFactory, - schoolFactory, + schoolEntityFactory, + TestApiClient, + UserAndAccountTestFactory, userFactory, } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { AccountEntity } from '@modules/account/entity/account.entity'; +import { ObjectId } from '@mikro-orm/mongodb'; import { CustomParameterScope, CustomParameterType, ToolContextType } from '../../../common/enum'; import { ExternalToolEntity } from '../../../external-tool/entity'; +import { customParameterEntityFactory, externalToolEntityFactory } from '../../../external-tool/testing'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; +import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../entity'; +import { contextExternalToolEntityFactory } from '../../testing'; import { ContextExternalToolPostParams, ContextExternalToolResponse, @@ -59,7 +59,7 @@ describe('ToolContextController (API)', () => { describe('[POST] tools/context-external-tools', () => { describe('when creation of contextExternalTool is successfully', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_ADMIN, ]); @@ -137,7 +137,7 @@ describe('ToolContextController (API)', () => { describe('when user is not authorized for the requested context', () => { const setup = async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }); const course = courseFactory.build({ teachers: [teacherUser] }); const otherCourse = courseFactory.build(); @@ -178,7 +178,7 @@ describe('ToolContextController (API)', () => { describe('when external tool has no restrictions ', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_ADMIN, ]); @@ -237,7 +237,7 @@ describe('ToolContextController (API)', () => { describe('when external tool restricts to wrong context ', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_ADMIN, ]); @@ -289,7 +289,7 @@ describe('ToolContextController (API)', () => { describe('[DELETE] tools/context-external-tools/:contextExternalToolId', () => { describe('when deletion of contextExternalTool is successfully', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_ADMIN, ]); @@ -375,14 +375,14 @@ describe('ToolContextController (API)', () => { describe('[GET] tools/context-external-tools/:contextType/:contextId', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); - const otherSchool: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const otherSchool: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_ADMIN, ]); const otherTeacherUser: User = userFactory.buildWithId({ roles: [], school: otherSchool }); - const otherTeacherAccount: Account = accountFactory.buildWithId({ userId: otherTeacherUser.id }); + const otherTeacherAccount: AccountEntity = accountFactory.buildWithId({ userId: otherTeacherUser.id }); const course: Course = courseFactory.buildWithId({ students: [teacherUser], @@ -545,7 +545,7 @@ describe('ToolContextController (API)', () => { describe('[GET] tools/context-external-tools/:contextExternalToolId', () => { describe('when the tool exists', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_ADMIN, @@ -609,7 +609,7 @@ describe('ToolContextController (API)', () => { describe('when the tool does not exist', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_ADMIN, @@ -639,7 +639,7 @@ describe('ToolContextController (API)', () => { describe('when user is not authorized', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const course: Course = courseFactory.buildWithId({ school, @@ -678,7 +678,7 @@ describe('ToolContextController (API)', () => { describe('when user has not the required permission', () => { const setup = async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({ school }); const course = courseFactory.build({ teachers: [studentUser], @@ -726,7 +726,7 @@ describe('ToolContextController (API)', () => { describe('[PUT] tools/context-external-tools/:contextExternalToolId', () => { describe('when update of contextExternalTool is successfully', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_ADMIN, @@ -820,7 +820,7 @@ describe('ToolContextController (API)', () => { const roleWithoutPermission = roleFactory.build(); teacherUser.roles.set([roleWithoutPermission]); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const course = courseFactory.build({ teachers: [teacherUser], school }); const contextParameter = customParameterEntityFactory.build({ scope: CustomParameterScope.CONTEXT, @@ -891,7 +891,7 @@ describe('ToolContextController (API)', () => { describe('when the user is not authenticated', () => { const setup = async () => { const { teacherUser } = UserAndAccountTestFactory.buildTeacher(); - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); const contextParameter = customParameterEntityFactory.build({ diff --git a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts index 11952df60bc..e234c6c5fff 100644 --- a/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/controller/api-test/tool-reference.api.spec.ts @@ -5,23 +5,23 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Course, SchoolEntity } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { - TestApiClient, - UserAndAccountTestFactory, cleanupCollections, - contextExternalToolEntityFactory, + contextExternalToolConfigurationStatusResponseFactory, courseFactory, customParameterFactory, - externalToolEntityFactory, - schoolExternalToolEntityFactory, - schoolFactory, - contextExternalToolConfigurationStatusResponseFactory, + schoolEntityFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; import { Response } from 'supertest'; import { CustomParameterLocation, CustomParameterScope, ToolContextType } from '../../../common/enum'; import { ExternalToolEntity } from '../../../external-tool/entity'; +import { externalToolEntityFactory } from '../../../external-tool/testing'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; +import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../entity'; +import { contextExternalToolEntityFactory } from '../../testing'; import { ContextExternalToolContextParams, ToolReferenceListResponse, ToolReferenceResponse } from '../dto'; describe('ToolReferenceController (API)', () => { @@ -62,8 +62,8 @@ describe('ToolReferenceController (API)', () => { describe('when user has no access to a tool', () => { const setup = async () => { - const schoolWithoutTool: SchoolEntity = schoolFactory.buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId(); + const schoolWithoutTool: SchoolEntity = schoolEntityFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school: schoolWithoutTool }); const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); @@ -110,7 +110,7 @@ describe('ToolReferenceController (API)', () => { describe('when user has access for a tool', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ Permission.CONTEXT_TOOL_USER, ]); @@ -199,8 +199,8 @@ describe('ToolReferenceController (API)', () => { describe('when user has no access to a tool', () => { const setup = async () => { - const schoolWithoutTool: SchoolEntity = schoolFactory.buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId(); + const schoolWithoutTool: SchoolEntity = schoolEntityFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school: schoolWithoutTool }); const course: Course = courseFactory.buildWithId({ school, teachers: [adminUser] }); const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); @@ -244,7 +244,7 @@ describe('ToolReferenceController (API)', () => { describe('when user has access for a tool', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ Permission.CONTEXT_TOOL_USER, ]); diff --git a/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts b/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts index 3f796b97001..7046648ce2b 100644 --- a/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts +++ b/apps/server/src/modules/tool/context-external-tool/domain/context-external-tool.do.ts @@ -42,3 +42,5 @@ export class ContextExternalTool extends BaseDO implements ToolVersion { return this.toolVersion; } } + +export type ContextExternalToolWithId = ContextExternalTool & { id: string }; diff --git a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.spec.ts b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.spec.ts index b41519bbef5..5bf07d1f105 100644 --- a/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/entity/context-external-tool.entity.spec.ts @@ -1,19 +1,17 @@ -import { - contextExternalToolEntityFactory, - externalToolEntityFactory, - schoolExternalToolEntityFactory, - schoolFactory, - setupEntities, -} from '@shared/testing'; +import { schoolEntityFactory, setupEntities } from '@shared/testing'; + import { CustomParameterLocation, CustomParameterScope, CustomParameterType, ToolConfigType } from '../../common/enum'; import { BasicToolConfigEntity, CustomParameterEntity, - ExternalToolEntity, ExternalToolConfigEntity, + ExternalToolEntity, } from '../../external-tool/entity'; +import { externalToolEntityFactory } from '../../external-tool/testing'; import { SchoolExternalToolEntity } from '../../school-external-tool/entity'; +import { schoolExternalToolEntityFactory } from '../../school-external-tool/testing'; +import { contextExternalToolEntityFactory } from '../testing'; import { ContextExternalToolEntity } from './context-external-tool.entity'; describe('ExternalToolEntity', () => { @@ -57,7 +55,7 @@ describe('ExternalToolEntity', () => { }); const schoolTool: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ tool: externalToolEntity, - school: schoolFactory.buildWithId(), + school: schoolEntityFactory.buildWithId(), schoolParameters: [], toolVersion: 1, }); diff --git a/apps/server/src/shared/testing/factory/context-external-tool-entity.factory.ts b/apps/server/src/modules/tool/context-external-tool/testing/context-external-tool-entity.factory.ts similarity index 79% rename from apps/server/src/shared/testing/factory/context-external-tool-entity.factory.ts rename to apps/server/src/modules/tool/context-external-tool/testing/context-external-tool-entity.factory.ts index 2d421ab99c8..5addd661578 100644 --- a/apps/server/src/shared/testing/factory/context-external-tool-entity.factory.ts +++ b/apps/server/src/modules/tool/context-external-tool/testing/context-external-tool-entity.factory.ts @@ -4,9 +4,9 @@ import { ContextExternalToolProperties, ContextExternalToolType, } from '@modules/tool/context-external-tool/entity'; +import { schoolExternalToolEntityFactory } from '@modules/tool/school-external-tool/testing/school-external-tool-entity.factory'; import { BaseFactory } from '@shared/testing/factory/base.factory'; -import { courseFactory } from './course.factory'; -import { schoolExternalToolEntityFactory } from './school-external-tool-entity.factory'; +import { courseFactory } from '@shared/testing/factory/course.factory'; export const contextExternalToolEntityFactory = BaseFactory.define< ContextExternalToolEntity, diff --git a/apps/server/src/modules/tool/context-external-tool/testing/index.ts b/apps/server/src/modules/tool/context-external-tool/testing/index.ts new file mode 100644 index 00000000000..4c763729962 --- /dev/null +++ b/apps/server/src/modules/tool/context-external-tool/testing/index.ts @@ -0,0 +1 @@ +export { contextExternalToolEntityFactory } from './context-external-tool-entity.factory'; diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts index 5078aca1b14..b2189788da7 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.spec.ts @@ -80,7 +80,7 @@ describe('ContextExternalToolUc', () => { describe('createContextExternalTool', () => { describe('when contextExternalTool is given and user has permission ', () => { const setup = () => { - const userId: EntityId = 'userId'; + const user: User = userFactory.buildWithId(); const schoolId: EntityId = new ObjectId().toHexString(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ @@ -101,54 +101,55 @@ describe('ContextExternalToolUc', () => { schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); contextExternalToolService.saveContextExternalTool.mockResolvedValue(contextExternalTool); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); return { contextExternalTool, - userId, + user, schoolId, }; }; it('should call contextExternalToolService', async () => { - const { contextExternalTool, userId, schoolId } = setup(); + const { contextExternalTool, user, schoolId } = setup(); - await uc.createContextExternalTool(userId, schoolId, contextExternalTool); + await uc.createContextExternalTool(user.id, schoolId, contextExternalTool); expect(contextExternalToolService.saveContextExternalTool).toHaveBeenCalledWith(contextExternalTool); }); it('should call contextExternalToolService to ensure permissions', async () => { - const { contextExternalTool, userId, schoolId } = setup(); + const { contextExternalTool, user, schoolId } = setup(); - await uc.createContextExternalTool(userId, schoolId, contextExternalTool); + await uc.createContextExternalTool(user.id, schoolId, contextExternalTool); expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( - userId, + user, contextExternalTool, AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]) ); }); it('should check for context restrictions', async () => { - const { contextExternalTool, userId, schoolId } = setup(); + const { contextExternalTool, user, schoolId } = setup(); - await uc.createContextExternalTool(userId, schoolId, contextExternalTool); + await uc.createContextExternalTool(user.id, schoolId, contextExternalTool); expect(contextExternalToolService.checkContextRestrictions).toHaveBeenCalledWith(contextExternalTool); }); it('should call contextExternalToolValidationService', async () => { - const { contextExternalTool, userId, schoolId } = setup(); + const { contextExternalTool, user, schoolId } = setup(); - await uc.createContextExternalTool(userId, schoolId, contextExternalTool); + await uc.createContextExternalTool(user.id, schoolId, contextExternalTool); expect(contextExternalToolValidationService.validate).toHaveBeenCalledWith(contextExternalTool); }); it('should return the saved object', async () => { - const { contextExternalTool, userId, schoolId } = setup(); + const { contextExternalTool, user, schoolId } = setup(); - const result = await uc.createContextExternalTool(userId, schoolId, contextExternalTool); + const result = await uc.createContextExternalTool(user.id, schoolId, contextExternalTool); expect(result).toEqual(contextExternalTool); }); @@ -338,7 +339,7 @@ describe('ContextExternalToolUc', () => { describe('updateContextExternalTool', () => { describe('when contextExternalTool is given and user has permission ', () => { const setup = () => { - const userId: EntityId = 'userId'; + const user: User = userFactory.buildWithId(); const schoolId: EntityId = new ObjectId().toHexString(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ @@ -360,47 +361,53 @@ describe('ContextExternalToolUc', () => { schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); contextExternalToolService.saveContextExternalTool.mockResolvedValue(contextExternalTool); contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); return { contextExternalTool, contextExternalToolId: contextExternalTool.id as string, - userId, + user, schoolId, }; }; it('should call contextExternalToolService', async () => { - const { contextExternalTool, userId, schoolId, contextExternalToolId } = setup(); + const { contextExternalTool, user, schoolId, contextExternalToolId } = setup(); - await uc.updateContextExternalTool(userId, schoolId, contextExternalToolId, contextExternalTool); + await uc.updateContextExternalTool(user.id, schoolId, contextExternalToolId, contextExternalTool); expect(contextExternalToolService.saveContextExternalTool).toHaveBeenCalledWith(contextExternalTool); }); it('should call contextExternalToolService to ensure permissions', async () => { - const { contextExternalTool, userId, schoolId, contextExternalToolId } = setup(); + const { contextExternalTool, user, schoolId, contextExternalToolId } = setup(); - await uc.updateContextExternalTool(userId, schoolId, contextExternalToolId, contextExternalTool); + await uc.updateContextExternalTool(user.id, schoolId, contextExternalToolId, contextExternalTool); expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( - userId, + user, contextExternalTool, AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]) ); }); it('should call contextExternalToolValidationService', async () => { - const { contextExternalTool, userId, schoolId, contextExternalToolId } = setup(); + const { contextExternalTool, user, schoolId, contextExternalToolId } = setup(); - await uc.updateContextExternalTool(userId, schoolId, contextExternalToolId, contextExternalTool); + await uc.updateContextExternalTool(user.id, schoolId, contextExternalToolId, contextExternalTool); expect(contextExternalToolValidationService.validate).toHaveBeenCalledWith(contextExternalTool); }); it('should return the saved object', async () => { - const { contextExternalTool, userId, schoolId, contextExternalToolId } = setup(); + const { contextExternalTool, user, schoolId, contextExternalToolId } = setup(); - const result = await uc.updateContextExternalTool(userId, schoolId, contextExternalToolId, contextExternalTool); + const result = await uc.updateContextExternalTool( + user.id, + schoolId, + contextExternalToolId, + contextExternalTool + ); expect(result).toEqual(contextExternalTool); }); @@ -554,35 +561,36 @@ describe('ContextExternalToolUc', () => { describe('deleteContextExternalTool', () => { describe('when contextExternalTool is given and user has permission ', () => { const setup = () => { - const userId: EntityId = 'userId'; + const user: User = userFactory.buildWithId(); const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); toolPermissionHelper.ensureContextPermissions.mockResolvedValue(); contextExternalToolService.findByIdOrFail.mockResolvedValue(contextExternalTool); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); return { contextExternalTool, contextExternalToolId: contextExternalTool.id as string, - userId, + user, }; }; it('should call contextExternalToolService', async () => { - const { contextExternalTool, contextExternalToolId, userId } = setup(); + const { contextExternalTool, contextExternalToolId, user } = setup(); - await uc.deleteContextExternalTool(userId, contextExternalToolId); + await uc.deleteContextExternalTool(user.id, contextExternalToolId); expect(contextExternalToolService.deleteContextExternalTool).toHaveBeenCalledWith(contextExternalTool); }); it('should call contextExternalToolService to ensure permissions', async () => { - const { contextExternalTool, contextExternalToolId, userId } = setup(); + const { contextExternalTool, contextExternalToolId, user } = setup(); - await uc.deleteContextExternalTool(userId, contextExternalToolId); + await uc.deleteContextExternalTool(user.id, contextExternalToolId); expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( - userId, + user, contextExternalTool, AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]) ); @@ -607,7 +615,7 @@ describe('ContextExternalToolUc', () => { }); contextExternalToolService.findAllByContext.mockResolvedValue([contextExternalTool]); - authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); authorizationService.hasPermission.mockReturnValue(true); return { @@ -704,7 +712,7 @@ describe('ContextExternalToolUc', () => { describe('getContextExternalTool', () => { describe('when right permission, context and id is given', () => { const setup = () => { - const userId: EntityId = 'userId'; + const user: User = userFactory.buildWithId(); const contextId: EntityId = 'contextId'; const contextType: ToolContextType = ToolContextType.COURSE; @@ -718,31 +726,32 @@ describe('ContextExternalToolUc', () => { contextExternalToolService.findByIdOrFail.mockResolvedValue(contextExternalTool); toolPermissionHelper.ensureContextPermissions.mockResolvedValue(Promise.resolve()); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); return { contextExternalTool, - userId, + user, contextId, contextType, }; }; it('should call contextExternalToolService to ensure permission ', async () => { - const { contextExternalTool, userId } = setup(); + const { contextExternalTool, user } = setup(); - await uc.getContextExternalTool(userId, contextExternalTool.id as string); + await uc.getContextExternalTool(user.id, contextExternalTool.id as string); expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( - userId, + user, contextExternalTool, AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]) ); }); it('should call contextExternalToolService to get contextExternalTool ', async () => { - const { contextExternalTool, userId } = setup(); + const { contextExternalTool, user } = setup(); - await uc.getContextExternalTool(userId, contextExternalTool.id as string); + await uc.getContextExternalTool(user.id, contextExternalTool.id as string); expect(contextExternalToolService.findByIdOrFail).toHaveBeenCalledWith(contextExternalTool.id); }); diff --git a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts index 590361b09cf..dbea96596d3 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/context-external-tool.uc.ts @@ -45,7 +45,8 @@ export class ContextExternalToolUc { contextExternalToolDto.schoolToolRef.schoolId = schoolId; const contextExternalTool = new ContextExternalTool(contextExternalToolDto); - await this.toolPermissionHelper.ensureContextPermissions(userId, contextExternalTool, context); + const user: User = await this.authorizationService.getUserWithPermissions(userId); + await this.toolPermissionHelper.ensureContextPermissions(user, contextExternalTool, context); await this.contextExternalToolService.checkContextRestrictions(contextExternalTool); @@ -83,7 +84,8 @@ export class ContextExternalToolUc { }); contextExternalTool.schoolToolRef.schoolId = schoolId; - await this.toolPermissionHelper.ensureContextPermissions(userId, contextExternalTool, context); + const user: User = await this.authorizationService.getUserWithPermissions(userId); + await this.toolPermissionHelper.ensureContextPermissions(user, contextExternalTool, context); await this.contextExternalToolValidationService.validate(contextExternalTool); @@ -97,8 +99,9 @@ export class ContextExternalToolUc { public async deleteContextExternalTool(userId: EntityId, contextExternalToolId: EntityId): Promise { const tool: ContextExternalTool = await this.contextExternalToolService.findByIdOrFail(contextExternalToolId); + const user: User = await this.authorizationService.getUserWithPermissions(userId); const context = AuthorizationContextBuilder.write([Permission.CONTEXT_TOOL_ADMIN]); - await this.toolPermissionHelper.ensureContextPermissions(userId, tool, context); + await this.toolPermissionHelper.ensureContextPermissions(user, tool, context); await this.contextExternalToolService.deleteContextExternalTool(tool); } @@ -120,8 +123,9 @@ export class ContextExternalToolUc { async getContextExternalTool(userId: EntityId, contextToolId: EntityId) { const tool: ContextExternalTool = await this.contextExternalToolService.findByIdOrFail(contextToolId); const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]); + const user: User = await this.authorizationService.getUserWithPermissions(userId); - await this.toolPermissionHelper.ensureContextPermissions(userId, tool, context); + await this.toolPermissionHelper.ensureContextPermissions(user, tool, context); return tool; } diff --git a/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts b/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts index 23072469b80..439a5b671ab 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.spec.ts @@ -1,5 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { AuthorizationContextBuilder } from '@modules/authorization'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Permission } from '@shared/domain/interface'; @@ -35,6 +35,10 @@ describe('ToolReferenceUc', () => { provide: ToolPermissionHelper, useValue: createMock(), }, + { + provide: AuthorizationService, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.ts b/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.ts index b0ed14c5b75..ca3263917d8 100644 --- a/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.ts +++ b/apps/server/src/modules/tool/context-external-tool/uc/tool-reference.uc.ts @@ -1,5 +1,6 @@ -import { AuthorizationContext, AuthorizationContextBuilder } from '@modules/authorization'; +import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { Injectable } from '@nestjs/common'; +import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { ToolContextType } from '../../common/enum'; @@ -12,7 +13,8 @@ export class ToolReferenceUc { constructor( private readonly contextExternalToolService: ContextExternalToolService, private readonly toolReferenceService: ToolReferenceService, - private readonly toolPermissionHelper: ToolPermissionHelper + private readonly toolPermissionHelper: ToolPermissionHelper, + private readonly authorizationService: AuthorizationService ) {} async getToolReferencesForContext( @@ -26,8 +28,9 @@ export class ToolReferenceUc { contextRef ); + const user: User = await this.authorizationService.getUserWithPermissions(userId); const toolReferencesPromises: Promise[] = contextExternalTools.map( - async (contextExternalTool: ContextExternalTool) => this.tryBuildToolReference(userId, contextExternalTool) + async (contextExternalTool: ContextExternalTool) => this.tryBuildToolReference(user, contextExternalTool) ); const toolReferencesWithNull: (ToolReference | null)[] = await Promise.all(toolReferencesPromises); @@ -39,11 +42,11 @@ export class ToolReferenceUc { } private async tryBuildToolReference( - userId: EntityId, + user: User, contextExternalTool: ContextExternalTool ): Promise { try { - await this.ensureToolPermissions(userId, contextExternalTool); + await this.ensureToolPermissions(user, contextExternalTool); const toolReference: ToolReference = await this.toolReferenceService.getToolReference( contextExternalTool.id as string @@ -60,7 +63,8 @@ export class ToolReferenceUc { contextExternalToolId ); - await this.ensureToolPermissions(userId, contextExternalTool); + const user: User = await this.authorizationService.getUserWithPermissions(userId); + await this.ensureToolPermissions(user, contextExternalTool); const toolReference: ToolReference = await this.toolReferenceService.getToolReference( contextExternalTool.id as string @@ -69,11 +73,11 @@ export class ToolReferenceUc { return toolReference; } - private async ensureToolPermissions(userId: EntityId, contextExternalTool: ContextExternalTool): Promise { + private async ensureToolPermissions(user: User, contextExternalTool: ContextExternalTool): Promise { const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); const promise: Promise = this.toolPermissionHelper.ensureContextPermissions( - userId, + user, contextExternalTool, context ); diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts index 812836a99da..6c68519fda5 100644 --- a/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/tool-configuration.api.spec.ts @@ -3,22 +3,20 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Account, Board, Course, SchoolEntity, User } from '@shared/domain/entity'; +import { LegacyBoard, Course, SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { - TestApiClient, - UserAndAccountTestFactory, accountFactory, boardFactory, - contextExternalToolEntityFactory, courseFactory, customParameterFactory, - externalToolEntityFactory, - schoolExternalToolEntityFactory, - schoolFactory, + schoolEntityFactory, + TestApiClient, + UserAndAccountTestFactory, userFactory, } from '@shared/testing'; import { Response } from 'supertest'; +import { AccountEntity } from '@modules/account/entity/account.entity'; import { CustomParameterLocationParams, CustomParameterScopeTypeParams, @@ -26,8 +24,11 @@ import { ToolContextType, } from '../../../common/enum'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; +import { contextExternalToolEntityFactory } from '../../../context-external-tool/testing'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; +import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; import { ExternalToolEntity } from '../../entity'; +import { externalToolEntityFactory } from '../../testing'; import { ContextExternalToolConfigurationTemplateListResponse, ContextExternalToolConfigurationTemplateResponse, @@ -65,7 +66,7 @@ describe('ToolConfigurationController (API)', () => { describe('[GET] tools/:contextType/:contextId/available-tools', () => { describe('when the user is not authorized', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({ school }); @@ -120,14 +121,14 @@ describe('ToolConfigurationController (API)', () => { describe('when tools are available for a context', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_ADMIN, ]); const course: Course = courseFactory.buildWithId({ teachers: [teacherUser], school }); - const board: Board = boardFactory.buildWithId({ course }); + const board: LegacyBoard = boardFactory.buildWithId({ course }); const [globalParameter, schoolParameter, contextParameter] = customParameterFactory.buildListWithEachType(); const externalTool: ExternalToolEntity = externalToolEntityFactory.buildWithId({ @@ -247,7 +248,7 @@ describe('ToolConfigurationController (API)', () => { describe('when no tools are available for a course', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({}, [ Permission.CONTEXT_TOOL_ADMIN, @@ -283,10 +284,10 @@ describe('ToolConfigurationController (API)', () => { describe('[GET] tools/school/:schoolId/available-tools', () => { describe('when the user is not authorized', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const user: User = userFactory.buildWithId({ school, roles: [] }); - const account: Account = accountFactory.buildWithId({ userId: user.id }); + const account: AccountEntity = accountFactory.buildWithId({ userId: user.id }); const course: Course = courseFactory.buildWithId({ teachers: [user], school }); @@ -322,7 +323,7 @@ describe('ToolConfigurationController (API)', () => { describe('when tools are available for a school', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.TOOL_ADMIN]); @@ -381,7 +382,7 @@ describe('ToolConfigurationController (API)', () => { describe('when no tools are available for a school', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.SCHOOL_TOOL_ADMIN]); @@ -411,7 +412,7 @@ describe('ToolConfigurationController (API)', () => { describe('GET tools/school-external-tools/:schoolExternalToolId/configuration-template', () => { describe('when the user is not authorized', () => { const setup = async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); // not on same school like the tool const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({}, []); const externalTool: ExternalToolEntity = externalToolEntityFactory.build(); @@ -444,7 +445,7 @@ describe('ToolConfigurationController (API)', () => { describe('when tool is not hidden', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ Permission.SCHOOL_TOOL_ADMIN, @@ -507,7 +508,7 @@ describe('ToolConfigurationController (API)', () => { describe('when tool is hidden', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.SCHOOL_TOOL_ADMIN]); @@ -540,7 +541,7 @@ describe('ToolConfigurationController (API)', () => { describe('GET tools/context-external-tools/:contextExternalToolId/configuration-template', () => { describe('when the user is not authorized', () => { const setup = async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.SCHOOL_TOOL_ADMIN]); // user is not part of the course const course = courseFactory.build(); @@ -582,7 +583,7 @@ describe('ToolConfigurationController (API)', () => { describe('when tool is not hidden', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_ADMIN, @@ -663,7 +664,7 @@ describe('ToolConfigurationController (API)', () => { describe('when tool is hidden', () => { const setup = async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_ADMIN, ]); diff --git a/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts b/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts index e41be01e880..91e0bfff6d5 100644 --- a/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts +++ b/apps/server/src/modules/tool/external-tool/controller/api-test/tool.api.spec.ts @@ -1,19 +1,19 @@ import { Loaded } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server'; +import { schoolExternalToolEntityFactory } from '@modules/tool/school-external-tool/testing'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { SchoolEntity } from '@shared/domain/entity'; +import { ColumnBoardNode, ExternalToolElementNodeEntity, SchoolEntity } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { - TestApiClient, - UserAndAccountTestFactory, cleanupCollections, - contextExternalToolEntityFactory, - externalToolEntityFactory, + columnBoardNodeFactory, + externalToolElementNodeFactory, externalToolFactory, - schoolExternalToolEntityFactory, - schoolFactory, + schoolEntityFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; @@ -25,9 +25,10 @@ import { ToolConfigType, } from '../../../common/enum'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; +import { contextExternalToolEntityFactory } from '../../../context-external-tool/testing'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; -import { ExternalToolMetadata } from '../../domain'; import { ExternalToolEntity } from '../../entity'; +import { externalToolEntityFactory } from '../../testing'; import { ExternalToolCreateParams, ExternalToolMetadataResponse, @@ -364,6 +365,7 @@ describe('ToolController (API)', () => { describe('[POST] tools/external-tools/:externalToolId', () => { const postParams: ExternalToolCreateParams = { name: 'Tool 1', + description: 'This is a tool description', parameters: [ { name: 'key', @@ -388,6 +390,10 @@ describe('ToolController (API)', () => { logoUrl: 'https://link.to-my-logo.com', url: 'https://link.to-my-tool.com', openNewTab: true, + medium: { + mediumId: 'mediumId', + publisher: 'publisher', + }, }; describe('when valid data is given', () => { @@ -396,6 +402,7 @@ describe('ToolController (API)', () => { const params = { ...postParams, id: toolId }; const externalToolEntity: ExternalToolEntity = externalToolEntityFactory .withBase64Logo() + .withMedium() .buildWithId({ version: 1 }, toolId); const base64Logo: string = externalToolEntity.logoBase64 as string; @@ -431,7 +438,8 @@ describe('ToolController (API)', () => { expect(body.id).toBeDefined(); expect(body).toEqual({ id: body.id, - name: 'Tool 1', + name: params.name, + description: params.description, parameters: [ { name: 'key', @@ -457,6 +465,10 @@ describe('ToolController (API)', () => { url: 'https://link.to-my-tool.com', openNewTab: true, version: 2, + medium: { + mediumId: params.medium?.mediumId ?? '', + publisher: params.medium?.publisher, + }, }); }); }); @@ -660,7 +672,7 @@ describe('ToolController (API)', () => { const toolId: string = new ObjectId().toHexString(); const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(undefined, toolId); - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const schoolExternalToolEntitys: SchoolExternalToolEntity[] = schoolExternalToolEntityFactory.buildList(2, { tool: externalToolEntity, school, @@ -669,6 +681,7 @@ describe('ToolController (API)', () => { const courseTools: ContextExternalToolEntity[] = contextExternalToolEntityFactory.buildList(3, { schoolTool: schoolExternalToolEntitys[0], contextType: ContextExternalToolType.COURSE, + contextId: new ObjectId().toHexString(), }); const boardTools: ContextExternalToolEntity[] = contextExternalToolEntityFactory.buildList(2, { @@ -677,10 +690,14 @@ describe('ToolController (API)', () => { contextId: new ObjectId().toHexString(), }); - const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ - schoolExternalToolCount: 2, - contextExternalToolCountPerContext: { course: 3, boardElement: 2 }, - }); + const board: ColumnBoardNode = columnBoardNodeFactory.buildWithId(); + const externalToolElements: ExternalToolElementNodeEntity[] = externalToolElementNodeFactory.buildListWithId( + 2, + { + contextExternalTool: boardTools[0], + parent: board, + } + ); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.TOOL_ADMIN]); await em.persistAndFlush([ @@ -691,12 +708,14 @@ describe('ToolController (API)', () => { ...schoolExternalToolEntitys, ...courseTools, ...boardTools, + board, + ...externalToolElements, ]); em.clear(); const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); - return { loggedInClient, toolId, externalToolEntity, externalToolMetadata }; + return { loggedInClient, toolId, externalToolEntity }; }; it('should return the metadata of externalTool', async () => { @@ -708,11 +727,88 @@ describe('ToolController (API)', () => { expect(response.body).toEqual({ schoolExternalToolCount: 2, contextExternalToolCountPerContext: { - course: 3, - boardElement: 2, + course: 1, + boardElement: 1, }, }); }); }); }); + + describe('[GET] tools/external-tools/:externalToolId/datasheet', () => { + describe('when user is not authenticated', () => { + const setup = () => { + const toolId: string = new ObjectId().toHexString(); + + return { toolId }; + }; + + it('should return unauthorized', async () => { + const { toolId } = setup(); + + const response: Response = await testApiClient.get(`${toolId}/datasheet`); + + expect(response.statusCode).toEqual(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when externalToolId is given', () => { + const setup = async () => { + const toolId: string = new ObjectId().toHexString(); + const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(undefined, toolId); + + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.TOOL_ADMIN]); + await em.persistAndFlush([adminAccount, adminUser, externalToolEntity]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + + // this date will only have a daily precision, which should not impact successful tests + const date = new Date(); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const dateString = `${year}-${month}-${day}`; + + return { loggedInClient, externalToolEntity, dateString }; + }; + + it('should return the datasheet of the externalTool', async () => { + const { loggedInClient, externalToolEntity, dateString } = await setup(); + + const response: Response = await loggedInClient.get(`${externalToolEntity.id}/datasheet`); + + expect(response.statusCode).toEqual(HttpStatus.OK); + expect(response.header).toEqual( + expect.objectContaining({ + 'content-type': 'application/pdf', + 'content-disposition': `inline; filename=CTL-Datenblatt-${externalToolEntity.name}-${dateString}.pdf`, + }) + ); + expect(response.body).toEqual(expect.any(Buffer)); + }); + }); + + describe('when external tool cannot be found', () => { + const setup = async () => { + const toolId: string = new ObjectId().toHexString(); + + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({}, [Permission.TOOL_ADMIN]); + await em.persistAndFlush([adminAccount, adminUser]); + em.clear(); + + const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); + + return { loggedInClient, toolId }; + }; + + it('should return a not found exception', async () => { + const { loggedInClient, toolId } = await setup(); + + const response: Response = await loggedInClient.get(`${toolId}/datasheet`); + + expect(response.statusCode).toEqual(HttpStatus.NOT_FOUND); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-create.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-create.params.ts index 5e3d41f20f9..86887a9ab42 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-create.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-create.params.ts @@ -1,5 +1,5 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsLocale, IsOptional, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsLocale, IsString } from 'class-validator'; import { LtiMessageType, LtiPrivacyPermission, ToolConfigType } from '../../../../../common/enum'; import { ExternalToolConfigCreateParams } from './external-tool-config.params'; @@ -20,11 +20,6 @@ export class Lti11ToolConfigCreateParams extends ExternalToolConfigCreateParams @ApiProperty() secret!: string; - @IsString() - @IsOptional() - @ApiPropertyOptional() - resource_link_id?: string; - @IsEnum(LtiMessageType) @ApiProperty() lti_message_type!: LtiMessageType; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-update.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-update.params.ts index 87de384fb2d..7b238690f58 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-update.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/config/lti11-tool-config-update.params.ts @@ -21,11 +21,6 @@ export class Lti11ToolConfigUpdateParams extends ExternalToolConfigCreateParams @ApiPropertyOptional() secret?: string; - @IsString() - @IsOptional() - @ApiPropertyOptional() - resource_link_id?: string; - @IsEnum(LtiMessageType) @ApiProperty() lti_message_type!: LtiMessageType; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts index b3e54a14d9b..b6e9c360e0f 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-create.params.ts @@ -9,25 +9,31 @@ import { Oauth2ToolConfigCreateParams, } from './config'; import { CustomParameterPostParams } from './custom-parameter.params'; +import { ExternalToolMediumParams } from './external-tool-medium.params'; @ApiExtraModels(Lti11ToolConfigCreateParams, Oauth2ToolConfigCreateParams, BasicToolConfigParams) export class ExternalToolCreateParams { @IsString() - @ApiProperty() + @ApiProperty({ type: String, description: 'Name of the external tool' }) name!: string; @IsString() @IsOptional() - @ApiPropertyOptional() + @ApiPropertyOptional({ type: String, description: 'Description of the external tool' }) + description?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ type: String, description: 'URL of the external tool' }) url?: string; @IsString() @IsOptional() - @ApiPropertyOptional() + @ApiPropertyOptional({ type: String, description: 'URL of the logo of the external tool' }) logoUrl?: string; @ValidateNested() - @Type(/* istanbul ignore next */ () => ExternalToolConfigCreateParams, { + @Type(() => ExternalToolConfigCreateParams, { keepDiscriminatorProperty: true, discriminator: { property: 'type', @@ -39,6 +45,7 @@ export class ExternalToolCreateParams { }, }) @ApiProperty({ + description: 'Configuration of the external tool', oneOf: [ { $ref: getSchemaPath(BasicToolConfigParams) }, { $ref: getSchemaPath(Lti11ToolConfigCreateParams) }, @@ -50,12 +57,12 @@ export class ExternalToolCreateParams { @ValidateNested({ each: true }) @IsArray() @IsOptional() - @ApiPropertyOptional({ type: [CustomParameterPostParams] }) - @Type(/* istanbul ignore next */ () => CustomParameterPostParams) + @ApiPropertyOptional({ type: [CustomParameterPostParams], description: 'Custom parameters of the external tool' }) + @Type(() => CustomParameterPostParams) parameters?: CustomParameterPostParams[]; @IsBoolean() - @ApiProperty() + @ApiProperty({ description: 'Tool can be hidden, those tools cant be added to e.g. school, course or board' }) isHidden!: boolean; @IsBoolean() @@ -67,12 +74,22 @@ export class ExternalToolCreateParams { isDeactivated!: boolean; @IsBoolean() - @ApiProperty() + @ApiProperty({ description: 'Tool should be opened in a new tab' }) openNewTab!: boolean; @IsArray() @IsOptional() @IsEnum(ToolContextType, { each: true }) - @ApiPropertyOptional({ enum: ToolContextType, enumName: 'ToolContextType', isArray: true }) + @ApiPropertyOptional({ + enum: ToolContextType, + enumName: 'ToolContextType', + isArray: true, + description: 'Restrict tools to specific contexts', + }) restrictToContexts?: ToolContextType[]; + + @ValidateNested() + @IsOptional() + @ApiPropertyOptional({ type: ExternalToolMediumParams, description: 'Medium of the external tool' }) + medium?: ExternalToolMediumParams; } diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-medium.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-medium.params.ts new file mode 100644 index 00000000000..d097b2f5aa1 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-medium.params.ts @@ -0,0 +1,15 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class ExternalToolMediumParams { + @IsString() + @IsNotEmpty() + @ApiProperty({ type: String, description: 'Id of the medium' }) + mediumId!: string; + + @IsString() + @IsOptional() + @IsNotEmpty() + @ApiPropertyOptional({ type: String, description: 'Publisher of the medium' }) + publisher?: string; +} diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts index a3b642f496e..6ad052c0bc8 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/external-tool-update.params.ts @@ -9,11 +9,12 @@ import { Oauth2ToolConfigUpdateParams, } from './config'; import { CustomParameterPostParams } from './custom-parameter.params'; +import { ExternalToolMediumParams } from './external-tool-medium.params'; @ApiExtraModels(Lti11ToolConfigUpdateParams, Oauth2ToolConfigUpdateParams, BasicToolConfigParams) export class ExternalToolUpdateParams { @IsString() - @ApiProperty() + @ApiProperty({ type: String, description: 'ID of the external tool' }) id!: string; @IsString() @@ -22,16 +23,21 @@ export class ExternalToolUpdateParams { @IsString() @IsOptional() - @ApiPropertyOptional() + @ApiPropertyOptional({ type: String, description: 'Description of the external tool' }) + description?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional({ type: String, description: 'URL of the external tool' }) url?: string; @IsString() @IsOptional() - @ApiPropertyOptional() + @ApiPropertyOptional({ type: String, description: 'URL of the logo of the external tool' }) logoUrl?: string; @ValidateNested() - @Type(/* istanbul ignore next */ () => ExternalToolConfigCreateParams, { + @Type(() => ExternalToolConfigCreateParams, { keepDiscriminatorProperty: true, discriminator: { property: 'type', @@ -43,6 +49,7 @@ export class ExternalToolUpdateParams { }, }) @ApiProperty({ + description: 'Configuration of the external tool', oneOf: [ { $ref: getSchemaPath(BasicToolConfigParams) }, { $ref: getSchemaPath(Lti11ToolConfigUpdateParams) }, @@ -54,28 +61,39 @@ export class ExternalToolUpdateParams { @ValidateNested({ each: true }) @IsArray() @IsOptional() - @ApiPropertyOptional({ type: [CustomParameterPostParams] }) - @Type(/* istanbul ignore next */ () => CustomParameterPostParams) + @ApiPropertyOptional({ type: [CustomParameterPostParams], description: 'Custom parameters of the external tool' }) + @Type(() => CustomParameterPostParams) parameters?: CustomParameterPostParams[]; @IsBoolean() - @ApiProperty() + @ApiProperty({ type: Boolean, default: false }) isHidden!: boolean; @IsBoolean() @ApiProperty({ type: Boolean, default: false, + description: 'Tool can be deactivated, related tools can not be added to e.g. school, course or board anymore', }) isDeactivated!: boolean; @IsBoolean() - @ApiProperty() + @ApiProperty({ type: Boolean, default: false, description: 'Open the tool in a new tab' }) openNewTab!: boolean; @IsArray() @IsOptional() @IsEnum(ToolContextType, { each: true }) - @ApiPropertyOptional({ enum: ToolContextType, enumName: 'ToolContextType', isArray: true }) + @ApiPropertyOptional({ + enum: ToolContextType, + enumName: 'ToolContextType', + isArray: true, + description: 'Restrict the tool to certain contexts', + }) restrictToContexts?: ToolContextType[]; + + @ValidateNested() + @IsOptional() + @ApiPropertyOptional({ type: ExternalToolMediumParams, description: 'Medium of the external tool' }) + medium?: ExternalToolMediumParams; } diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/request/index.ts b/apps/server/src/modules/tool/external-tool/controller/dto/request/index.ts index e366a9df2e9..994c473e233 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/request/index.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/request/index.ts @@ -9,3 +9,4 @@ export * from './school-id.params'; export * from './context-ref.params'; export * from './school-external-tool-id.params'; export * from './context-external-tool-id.params'; +export { ExternalToolMediumParams } from './external-tool-medium.params'; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/config/lti11-tool-config.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/config/lti11-tool-config.response.ts index a38a62542fe..a3c68ba5f43 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/config/lti11-tool-config.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/config/lti11-tool-config.response.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { LtiMessageType, LtiPrivacyPermission, ToolConfigType } from '../../../../../common/enum'; import { ExternalToolConfigResponse } from './external-tool-config.response'; @@ -12,9 +12,6 @@ export class Lti11ToolConfigResponse extends ExternalToolConfigResponse { @ApiProperty() key: string; - @ApiPropertyOptional() - resource_link_id?: string; - @ApiProperty() lti_message_type: LtiMessageType; @@ -29,7 +26,6 @@ export class Lti11ToolConfigResponse extends ExternalToolConfigResponse { this.type = ToolConfigType.LTI11; this.baseUrl = props.baseUrl; this.key = props.key; - this.resource_link_id = props.resource_link_id; this.lti_message_type = props.lti_message_type; this.privacy_permission = props.privacy_permission; this.launch_presentation_locale = props.launch_presentation_locale; diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-medium.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-medium.response.ts new file mode 100644 index 00000000000..a56f8291daf --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool-medium.response.ts @@ -0,0 +1,14 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ExternalToolMediumResponse { + @ApiProperty({ type: String, description: 'Id of the medium' }) + mediumId!: string; + + @ApiPropertyOptional({ type: String, description: 'Publisher of the medium' }) + publisher?: string; + + constructor(props: ExternalToolMediumResponse) { + this.mediumId = props.mediumId; + this.publisher = props.publisher; + } +} diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts index b77d170a876..554bccc984e 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/external-tool.response.ts @@ -1,45 +1,66 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; +import { BasicToolConfigParams, Lti11ToolConfigCreateParams, Oauth2ToolConfigCreateParams } from '../request'; import { BasicToolConfigResponse, Oauth2ToolConfigResponse, Lti11ToolConfigResponse } from './config'; import { CustomParameterResponse } from './custom-parameter.response'; import { ToolContextType } from '../../../../common/enum'; +import { ExternalToolMediumResponse } from './external-tool-medium.response'; export class ExternalToolResponse { - @ApiProperty() + @ApiProperty({ type: String, description: 'Id of the external tool' }) id: string; - @ApiProperty() + @ApiProperty({ type: String, description: 'Name of the external tool' }) name: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ type: String, description: 'Description of the external tool' }) + description?: string; + + @ApiPropertyOptional({ type: String, description: 'URL of the external tool' }) url?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ type: String, description: 'URL of the logo of the external tool' }) logoUrl?: string; - @ApiProperty() + @ApiProperty({ + description: 'Configuration of the external tool', + oneOf: [ + { $ref: getSchemaPath(BasicToolConfigParams) }, + { $ref: getSchemaPath(Lti11ToolConfigCreateParams) }, + { $ref: getSchemaPath(Oauth2ToolConfigCreateParams) }, + ], + }) config: BasicToolConfigResponse | Oauth2ToolConfigResponse | Lti11ToolConfigResponse; - @ApiProperty() + @ApiProperty({ type: [CustomParameterResponse], description: 'Custom parameters of the external tool' }) parameters: CustomParameterResponse[]; - @ApiProperty() + @ApiProperty({ type: Boolean, description: 'Is the external tool hidden' }) isHidden: boolean; - @ApiProperty() + @ApiProperty({ type: Boolean, description: 'Is the external tool deactivated' }) isDeactivated: boolean; - @ApiProperty() + @ApiProperty({ type: Boolean, description: 'Should the external tool be opened in a new tab' }) openNewTab: boolean; - @ApiProperty() + @ApiProperty({ type: Number, description: 'Version of the external tool' }) version: number; - @ApiPropertyOptional({ enum: ToolContextType, enumName: 'ToolContextType', isArray: true }) + @ApiPropertyOptional({ + enum: ToolContextType, + enumName: 'ToolContextType', + isArray: true, + description: 'Contexts in which the external tool is restricted', + }) restrictToContexts?: ToolContextType[]; + @ApiPropertyOptional({ type: ExternalToolMediumResponse, description: 'Medium of the external tool' }) + medium?: ExternalToolMediumResponse; + constructor(response: ExternalToolResponse) { this.id = response.id; this.name = response.name; + this.description = response.description; this.url = response.url; this.logoUrl = response.logoUrl; this.config = response.config; @@ -49,5 +70,6 @@ export class ExternalToolResponse { this.openNewTab = response.openNewTab; this.version = response.version; this.restrictToContexts = response.restrictToContexts; + this.medium = response.medium; } } diff --git a/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts b/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts index e621ade2020..08fb3761317 100644 --- a/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts +++ b/apps/server/src/modules/tool/external-tool/controller/dto/response/index.ts @@ -8,3 +8,4 @@ export * from './school-external-tool-configuration-template.response'; export * from './school-external-tool-configuration-template-list.response'; export * from './external-tool-metadata.response'; export * from './tool-context-types-list.response'; +export { ExternalToolMediumResponse } from './external-tool-medium.response'; diff --git a/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts b/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts index 1cf0ad54269..febf1d459f3 100644 --- a/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts +++ b/apps/server/src/modules/tool/external-tool/controller/tool.controller.ts @@ -1,9 +1,22 @@ import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query, Res } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Query, + Res, + StreamableFile, +} from '@nestjs/common'; import { ApiCreatedResponse, ApiForbiddenResponse, ApiFoundResponse, + ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiResponse, @@ -190,4 +203,24 @@ export class ToolController { return mapped; } + + @Get(':externalToolId/datasheet') + @ApiOperation({ summary: 'Returns a pdf of the external tool information' }) + @ApiUnauthorizedResponse({ description: 'User is not logged in.' }) + @ApiNotFoundResponse({ description: 'The external tool has not been found' }) + async getDatasheet( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: ExternalToolIdParams, + @Res({ passthrough: true }) res: Response + ): Promise { + const datasheetBuffer: Buffer = await this.externalToolUc.getDatasheet(currentUser.userId, params.externalToolId); + + const myFilename = await this.externalToolUc.createDatasheetFilename(params.externalToolId); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename=${myFilename}`); + + const streamableFile: StreamableFile = new StreamableFile(datasheetBuffer); + return streamableFile; + } } diff --git a/apps/server/src/modules/tool/external-tool/domain/config/lti11-tool-config.do.ts b/apps/server/src/modules/tool/external-tool/domain/config/lti11-tool-config.do.ts index faf9ae41f66..b17e6fd5979 100644 --- a/apps/server/src/modules/tool/external-tool/domain/config/lti11-tool-config.do.ts +++ b/apps/server/src/modules/tool/external-tool/domain/config/lti11-tool-config.do.ts @@ -6,8 +6,6 @@ export class Lti11ToolConfig extends ExternalToolConfig { secret: string; - resource_link_id?: string; - lti_message_type: LtiMessageType; privacy_permission: LtiPrivacyPermission; @@ -21,7 +19,6 @@ export class Lti11ToolConfig extends ExternalToolConfig { }); this.key = props.key; this.secret = props.secret; - this.resource_link_id = props.resource_link_id; this.lti_message_type = props.lti_message_type; this.privacy_permission = props.privacy_permission; this.launch_presentation_locale = props.launch_presentation_locale; diff --git a/apps/server/src/modules/tool/external-tool/domain/datasheet/external-tool-datasheet-template-data.ts b/apps/server/src/modules/tool/external-tool/domain/datasheet/external-tool-datasheet-template-data.ts new file mode 100644 index 00000000000..c170eb5f673 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/domain/datasheet/external-tool-datasheet-template-data.ts @@ -0,0 +1,45 @@ +import { ExternalToolParameterDatasheetTemplateData } from './external-tool-parameter-datasheet-template-data'; + +export class ExternalToolDatasheetTemplateData { + createdAt: string; + + creatorName: string; + + instance: string; + + schoolName?: string; + + toolName: string; + + toolUrl: string; + + isDeactivated?: string; + + restrictToContexts?: string; + + toolType: string; + + skipConsent?: string; + + messageType?: string; + + privacy?: string; + + parameters?: ExternalToolParameterDatasheetTemplateData[]; + + constructor(externalToolData: ExternalToolDatasheetTemplateData) { + this.createdAt = externalToolData.createdAt; + this.creatorName = externalToolData.creatorName; + this.instance = externalToolData.instance; + this.schoolName = externalToolData.schoolName; + this.toolName = externalToolData.toolName; + this.toolUrl = externalToolData.toolUrl; + this.isDeactivated = externalToolData.isDeactivated; + this.restrictToContexts = externalToolData.restrictToContexts; + this.toolType = externalToolData.toolType; + this.skipConsent = externalToolData.skipConsent; + this.messageType = externalToolData.messageType; + this.privacy = externalToolData.privacy; + this.parameters = externalToolData.parameters; + } +} diff --git a/apps/server/src/modules/tool/external-tool/domain/datasheet/external-tool-parameter-datasheet-template-data.ts b/apps/server/src/modules/tool/external-tool/domain/datasheet/external-tool-parameter-datasheet-template-data.ts new file mode 100644 index 00000000000..80eee452771 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/domain/datasheet/external-tool-parameter-datasheet-template-data.ts @@ -0,0 +1,21 @@ +import { CustomParameterLocation } from '../../../common/enum'; + +export class ExternalToolParameterDatasheetTemplateData { + name: string; + + properties: string; + + scope: string; + + type: string; + + location: CustomParameterLocation; + + constructor(parameterData: ExternalToolParameterDatasheetTemplateData) { + this.name = parameterData.name; + this.properties = parameterData.properties; + this.scope = parameterData.scope; + this.type = parameterData.type; + this.location = parameterData.location; + } +} diff --git a/apps/server/src/modules/tool/external-tool/domain/datasheet/external-tool-parameter-datasheet-template-property.ts b/apps/server/src/modules/tool/external-tool/domain/datasheet/external-tool-parameter-datasheet-template-property.ts new file mode 100644 index 00000000000..b9bf55f28e3 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/domain/datasheet/external-tool-parameter-datasheet-template-property.ts @@ -0,0 +1,8 @@ +export enum ExternalToolParameterDatasheetTemplateProperty { + HIDDEN = 'versteckt', + DEACTIVATED = 'deaktiviert', + OPTIONAL = 'optional', + PROTECTED = 'geschÃŧtzt', + SKIP_CONSENT = 'Zustimmung Ãŧberspringen', + MANDATORY = 'verpflichtend', +} diff --git a/apps/server/src/modules/tool/external-tool/domain/datasheet/index.ts b/apps/server/src/modules/tool/external-tool/domain/datasheet/index.ts new file mode 100644 index 00000000000..c78ca2facd4 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/domain/datasheet/index.ts @@ -0,0 +1,3 @@ +export { ExternalToolDatasheetTemplateData } from './external-tool-datasheet-template-data'; +export { ExternalToolParameterDatasheetTemplateData } from './external-tool-parameter-datasheet-template-data'; +export { ExternalToolParameterDatasheetTemplateProperty } from './external-tool-parameter-datasheet-template-property'; diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool-medium.do.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool-medium.do.ts new file mode 100644 index 00000000000..8bfeeab4abc --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool-medium.do.ts @@ -0,0 +1,16 @@ +export interface ExternalToolMediumProps { + mediumId: string; + + publisher?: string; +} + +export class ExternalToolMedium { + mediumId: string; + + publisher?: string; + + constructor(props: ExternalToolMediumProps) { + this.mediumId = props.mediumId; + this.publisher = props.publisher; + } +} diff --git a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts index 4bb48e21ab1..f21113dacfa 100644 --- a/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts +++ b/apps/server/src/modules/tool/external-tool/domain/external-tool.do.ts @@ -4,12 +4,15 @@ import { ToolVersion } from '../../common/interface'; import { Oauth2ToolConfig, BasicToolConfig, Lti11ToolConfig, ExternalToolConfig } from './config'; import { CustomParameter } from '../../common/domain'; import { ToolConfigType, ToolContextType } from '../../common/enum'; +import { ExternalToolMedium } from './external-tool-medium.do'; export interface ExternalToolProps { id?: string; name: string; + description?: string; + url?: string; logoUrl?: string; @@ -29,11 +32,15 @@ export interface ExternalToolProps { version: number; restrictToContexts?: ToolContextType[]; + + medium?: ExternalToolMedium; } export class ExternalTool extends BaseDO implements ToolVersion { name: string; + description?: string; + url?: string; logoUrl?: string; @@ -54,10 +61,13 @@ export class ExternalTool extends BaseDO implements ToolVersion { restrictToContexts?: ToolContextType[]; + medium?: ExternalToolMedium; + constructor(props: ExternalToolProps) { super(props.id); this.name = props.name; + this.description = props.description; this.url = props.url; this.logoUrl = props.logoUrl; this.logo = props.logo; @@ -76,6 +86,7 @@ export class ExternalTool extends BaseDO implements ToolVersion { this.openNewTab = props.openNewTab; this.version = props.version; this.restrictToContexts = props.restrictToContexts; + this.medium = props.medium; } getVersion(): number { diff --git a/apps/server/src/modules/tool/external-tool/domain/index.ts b/apps/server/src/modules/tool/external-tool/domain/index.ts index 61fca0d2bfe..8ad3b86b315 100644 --- a/apps/server/src/modules/tool/external-tool/domain/index.ts +++ b/apps/server/src/modules/tool/external-tool/domain/index.ts @@ -1,3 +1,9 @@ export * from './external-tool.do'; export * from './config'; export * from './external-tool-metadata'; +export { + ExternalToolParameterDatasheetTemplateProperty, + ExternalToolDatasheetTemplateData, + ExternalToolParameterDatasheetTemplateData, +} from './datasheet'; +export { ExternalToolMedium, ExternalToolMediumProps } from './external-tool-medium.do'; diff --git a/apps/server/src/modules/tool/external-tool/entity/config/lti11-tool-config.entity.ts b/apps/server/src/modules/tool/external-tool/entity/config/lti11-tool-config.entity.ts index c5edfd09900..7ebd6b2ca67 100644 --- a/apps/server/src/modules/tool/external-tool/entity/config/lti11-tool-config.entity.ts +++ b/apps/server/src/modules/tool/external-tool/entity/config/lti11-tool-config.entity.ts @@ -11,9 +11,6 @@ export class Lti11ToolConfigEntity extends ExternalToolConfigEntity { @Property() secret: string; - @Property({ nullable: true }) - resource_link_id?: string; - @Enum() lti_message_type: LtiMessageType; @@ -28,7 +25,6 @@ export class Lti11ToolConfigEntity extends ExternalToolConfigEntity { this.type = ToolConfigType.LTI11; this.key = props.key; this.secret = props.secret; - this.resource_link_id = props.resource_link_id; this.lti_message_type = props.lti_message_type; this.privacy_permission = props.privacy_permission; this.launch_presentation_locale = props.launch_presentation_locale; diff --git a/apps/server/src/modules/tool/external-tool/entity/external-tool-medium.entity.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool-medium.entity.ts new file mode 100644 index 00000000000..acb2094cf8e --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool-medium.entity.ts @@ -0,0 +1,15 @@ +import { Embeddable, Property } from '@mikro-orm/core'; + +@Embeddable() +export class ExternalToolMediumEntity { + @Property({ nullable: false }) + mediumId: string; + + @Property({ nullable: true }) + publisher?: string; + + constructor(props: ExternalToolMediumEntity) { + this.mediumId = props.mediumId; + this.publisher = props.publisher; + } +} diff --git a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts index 9b144b2c725..0ab767552c8 100644 --- a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.spec.ts @@ -33,7 +33,6 @@ describe('ExternalToolEntity', () => { baseUrl: 'mockBaseUrl', key: 'mockKey', secret: 'mockSecret', - resource_link_id: 'mockLink', lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.ANONYMOUS, launch_presentation_locale: 'de-DE', diff --git a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts index bc79a891392..d5d9eefe3ef 100644 --- a/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts +++ b/apps/server/src/modules/tool/external-tool/entity/external-tool.entity.ts @@ -4,6 +4,7 @@ import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; import { CustomParameterEntity } from './custom-parameter'; import { BasicToolConfigEntity, Lti11ToolConfigEntity, Oauth2ToolConfigEntity } from './config'; import { ToolContextType } from '../../common/enum'; +import { ExternalToolMediumEntity } from './external-tool-medium.entity'; export type IExternalToolProperties = Readonly>; @@ -13,6 +14,9 @@ export class ExternalToolEntity extends BaseEntityWithTimestamps { @Property() name: string; + @Property({ nullable: true }) + description?: string; + @Property({ nullable: true }) url?: string; @@ -43,9 +47,13 @@ export class ExternalToolEntity extends BaseEntityWithTimestamps { @Property({ nullable: true }) restrictToContexts?: ToolContextType[]; + @Embedded(() => ExternalToolMediumEntity, { nullable: true, object: true }) + medium?: ExternalToolMediumEntity; + constructor(props: IExternalToolProperties) { super(); this.name = props.name; + this.description = props.description; this.url = props.url; this.logoUrl = props.logoUrl; this.logoBase64 = props.logoBase64; @@ -56,5 +64,6 @@ export class ExternalToolEntity extends BaseEntityWithTimestamps { this.openNewTab = props.openNewTab; this.version = props.version; this.restrictToContexts = props.restrictToContexts; + this.medium = props.medium; } } diff --git a/apps/server/src/modules/tool/external-tool/entity/index.ts b/apps/server/src/modules/tool/external-tool/entity/index.ts index da017a1e2c6..22a329f7204 100644 --- a/apps/server/src/modules/tool/external-tool/entity/index.ts +++ b/apps/server/src/modules/tool/external-tool/entity/index.ts @@ -1,3 +1,4 @@ export * from './external-tool.entity'; export * from './config'; export * from './custom-parameter'; +export { ExternalToolMediumEntity } from './external-tool-medium.entity'; diff --git a/apps/server/src/modules/tool/external-tool/external-tool.module.ts b/apps/server/src/modules/tool/external-tool/external-tool.module.ts index a84734fecb2..407326bb5a9 100644 --- a/apps/server/src/modules/tool/external-tool/external-tool.module.ts +++ b/apps/server/src/modules/tool/external-tool/external-tool.module.ts @@ -1,13 +1,15 @@ +import { EncryptionModule } from '@infra/encryption'; +import { OauthProviderServiceModule } from '@infra/oauth-provider'; import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { LoggerModule } from '@src/core/logger'; -import { OauthProviderServiceModule } from '@infra/oauth-provider'; -import { EncryptionModule } from '@infra/encryption'; import { ExternalToolRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; +import { CommonToolModule } from '../common'; +import { ToolContextMapper } from '../common/mapper/tool-context.mapper'; import { ToolConfigModule } from '../tool-config.module'; import { ExternalToolMetadataMapper } from './mapper'; -import { ToolContextMapper } from '../common/mapper/tool-context.mapper'; import { + DatasheetPdfService, ExternalToolConfigurationService, ExternalToolLogoService, ExternalToolParameterValidationService, @@ -15,9 +17,7 @@ import { ExternalToolServiceMapper, ExternalToolValidationService, ExternalToolVersionIncrementService, - ExternalToolMetadataService, } from './service'; -import { CommonToolModule } from '../common'; @Module({ imports: [CommonToolModule, ToolConfigModule, LoggerModule, OauthProviderServiceModule, EncryptionModule, HttpModule], @@ -30,9 +30,9 @@ import { CommonToolModule } from '../common'; ExternalToolConfigurationService, ExternalToolLogoService, ExternalToolRepo, - ExternalToolMetadataService, ExternalToolMetadataMapper, ToolContextMapper, + DatasheetPdfService, ], exports: [ ExternalToolService, @@ -40,7 +40,7 @@ import { CommonToolModule } from '../common'; ExternalToolVersionIncrementService, ExternalToolConfigurationService, ExternalToolLogoService, - ExternalToolMetadataService, + DatasheetPdfService, ], }) export class ExternalToolModule {} diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.spec.ts new file mode 100644 index 00000000000..6014c236210 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.spec.ts @@ -0,0 +1,363 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { School } from '@modules/school'; +import { schoolFactory } from '@modules/school/testing'; +import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; +import { UserDO } from '@shared/domain/domainobject'; +import { + customParameterFactory, + externalToolDatasheetTemplateDataFactory, + externalToolFactory, + externalToolParameterDatasheetTemplateDataFactory, + schoolExternalToolFactory, + userDoFactory, +} from '@shared/testing'; +import { CustomParameter } from '../../common/domain'; +import { CustomParameterScope, CustomParameterType, ToolContextType } from '../../common/enum'; +import { + ExternalToolDatasheetTemplateData, + ExternalToolParameterDatasheetTemplateData, + ExternalToolParameterDatasheetTemplateProperty, +} from '../domain'; +import { ExternalToolDatasheetMapper } from './external-tool-datasheet.mapper'; + +describe(ExternalToolDatasheetMapper.name, () => { + beforeEach(() => { + Configuration.set('SC_THEME', 'default'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when optional parameters are given', () => { + const setup = () => { + const user: UserDO = userDoFactory.build(); + const externalTool = externalToolFactory.withCustomParameters(1, { isOptional: true, isProtected: true }).build({ + isDeactivated: true, + restrictToContexts: [ToolContextType.COURSE, ToolContextType.BOARD_ELEMENT], + }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + status: { isDeactivated: true }, + }); + const expectDatasheet: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory + .withOptionalProperties() + .withParameters(1, { properties: 'optional, geschÃŧtzt' }) + .build({ instance: 'dBildungscloud' }); + + return { user, externalTool, schoolExternalTool, expectDatasheet }; + }; + + it('should map all parameters correctly', () => { + const { user, externalTool, schoolExternalTool, expectDatasheet } = setup(); + + const mappedData: ExternalToolDatasheetTemplateData = + ExternalToolDatasheetMapper.mapToExternalToolDatasheetTemplateData( + externalTool, + user.firstName, + user.lastName, + schoolExternalTool + ); + + expect(mappedData).toEqual(expectDatasheet); + }); + }); + + describe('when tool is deactivated on school level', () => { + const setup = () => { + const user: UserDO = userDoFactory.build(); + const school: School = schoolFactory.build(); + const externalTool = externalToolFactory.build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build({ + status: { isDeactivated: true }, + }); + const expectDatasheet: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory.build({ + instance: 'dBildungscloud', + isDeactivated: 'Das Tool ist deaktiviert', + schoolName: school.getInfo().name, + }); + + return { user, externalTool, schoolExternalTool, expectDatasheet, school }; + }; + + it('should map all parameters correctly', () => { + const { user, externalTool, schoolExternalTool, expectDatasheet, school } = setup(); + + const mappedData: ExternalToolDatasheetTemplateData = + ExternalToolDatasheetMapper.mapToExternalToolDatasheetTemplateData( + externalTool, + user.firstName, + user.lastName, + schoolExternalTool, + school.getInfo().name + ); + + expect(mappedData).toEqual(expectDatasheet); + }); + }); + + describe('when an oauth2 tool is given', () => { + const setup = () => { + const user: UserDO = userDoFactory.build(); + const externalTool = externalToolFactory.withOauth2Config({ skipConsent: true }).build(); + const expectDatasheet: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory + .asOauth2Tool() + .build({ instance: 'dBildungscloud' }); + + return { user, externalTool, expectDatasheet }; + }; + it('should map oauth2 parameters', () => { + const { user, externalTool, expectDatasheet } = setup(); + + const mappedData: ExternalToolDatasheetTemplateData = + ExternalToolDatasheetMapper.mapToExternalToolDatasheetTemplateData( + externalTool, + user.firstName, + user.lastName, + undefined + ); + + expect(mappedData).toEqual(expectDatasheet); + }); + }); + + describe('when an lti11 tool is given', () => { + const setup = () => { + const user: UserDO = userDoFactory.build(); + const externalTool = externalToolFactory.withLti11Config().build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const expectDatasheet: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory + .asLti11Tool() + .build({ instance: 'dBildungscloud' }); + + return { user, externalTool, schoolExternalTool, expectDatasheet }; + }; + it('should map lti11 parameters', () => { + const { user, externalTool, schoolExternalTool, expectDatasheet } = setup(); + + const mappedData: ExternalToolDatasheetTemplateData = + ExternalToolDatasheetMapper.mapToExternalToolDatasheetTemplateData( + externalTool, + user.firstName, + user.lastName, + schoolExternalTool + ); + + expect(mappedData).toEqual(expectDatasheet); + }); + }); + + describe('when instance is unknown', () => { + const setup = () => { + Configuration.set('SC_THEME', 'mockInstance'); + const user: UserDO = userDoFactory.build(); + const externalTool = externalToolFactory.build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const expectDatasheet: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory.build({ + instance: 'unbekannt', + }); + + return { user, externalTool, schoolExternalTool, expectDatasheet }; + }; + it('should map correct instance', () => { + const { user, externalTool, schoolExternalTool, expectDatasheet } = setup(); + + const mappedData: ExternalToolDatasheetTemplateData = + ExternalToolDatasheetMapper.mapToExternalToolDatasheetTemplateData( + externalTool, + user.firstName, + user.lastName, + schoolExternalTool + ); + + expect(mappedData).toEqual(expectDatasheet); + }); + }); + + describe('when instance is brb', () => { + const setup = () => { + Configuration.set('SC_THEME', 'brb'); + const user: UserDO = userDoFactory.build(); + const externalTool = externalToolFactory.build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const expectDatasheet: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory.build({ + instance: 'Schul-Cloud Brandenburg', + }); + + return { user, externalTool, schoolExternalTool, expectDatasheet }; + }; + it('should map correct instance', () => { + const { user, externalTool, schoolExternalTool, expectDatasheet } = setup(); + + const mappedData: ExternalToolDatasheetTemplateData = + ExternalToolDatasheetMapper.mapToExternalToolDatasheetTemplateData( + externalTool, + user.firstName, + user.lastName, + schoolExternalTool + ); + + expect(mappedData).toEqual(expectDatasheet); + }); + }); + + describe('when instance is thr', () => { + const setup = () => { + Configuration.set('SC_THEME', 'thr'); + const user: UserDO = userDoFactory.build(); + const externalTool = externalToolFactory.build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const expectDatasheet: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory.build({ + instance: 'ThÃŧringer Schulcloud', + }); + + return { user, externalTool, schoolExternalTool, expectDatasheet }; + }; + it('should map correct instance', () => { + const { user, externalTool, schoolExternalTool, expectDatasheet } = setup(); + + const mappedData: ExternalToolDatasheetTemplateData = + ExternalToolDatasheetMapper.mapToExternalToolDatasheetTemplateData( + externalTool, + user.firstName, + user.lastName, + schoolExternalTool + ); + + expect(mappedData).toEqual(expectDatasheet); + }); + }); + + describe('when instance is dbc', () => { + const setup = () => { + Configuration.set('SC_THEME', 'default'); + const user: UserDO = userDoFactory.build(); + const externalTool = externalToolFactory.build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const expectDatasheet: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory.build({ + instance: 'dBildungscloud', + }); + + return { user, externalTool, schoolExternalTool, expectDatasheet }; + }; + it('should map correct instance', () => { + const { user, externalTool, schoolExternalTool, expectDatasheet } = setup(); + + const mappedData: ExternalToolDatasheetTemplateData = + ExternalToolDatasheetMapper.mapToExternalToolDatasheetTemplateData( + externalTool, + user.firstName, + user.lastName, + schoolExternalTool + ); + + expect(mappedData).toEqual(expectDatasheet); + }); + }); + + describe('when instance is nbc', () => { + const setup = () => { + Configuration.set('SC_THEME', 'n21'); + const user: UserDO = userDoFactory.build(); + const externalTool = externalToolFactory.build(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const expectDatasheet: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory.build({ + instance: 'Niedersächsische Bildungscloud', + }); + + return { user, externalTool, schoolExternalTool, expectDatasheet }; + }; + it('should map correct instance', () => { + const { user, externalTool, schoolExternalTool, expectDatasheet } = setup(); + + const mappedData: ExternalToolDatasheetTemplateData = + ExternalToolDatasheetMapper.mapToExternalToolDatasheetTemplateData( + externalTool, + user.firstName, + user.lastName, + schoolExternalTool + ); + + expect(mappedData).toEqual(expectDatasheet); + }); + }); + + describe('when custom parameters have types and scopes', () => { + const setup = () => { + const user: UserDO = userDoFactory.build(); + const params: CustomParameter[] = [ + customParameterFactory.build({ type: CustomParameterType.STRING, scope: CustomParameterScope.CONTEXT }), + customParameterFactory.build({ type: CustomParameterType.BOOLEAN, scope: CustomParameterScope.SCHOOL }), + customParameterFactory.build({ type: CustomParameterType.NUMBER, scope: CustomParameterScope.GLOBAL }), + customParameterFactory.build({ type: CustomParameterType.AUTO_SCHOOLNUMBER }), + customParameterFactory.build({ type: CustomParameterType.AUTO_SCHOOLID }), + customParameterFactory.build({ type: CustomParameterType.AUTO_CONTEXTID }), + customParameterFactory.build({ type: CustomParameterType.AUTO_CONTEXTNAME }), + customParameterFactory.build({ type: CustomParameterType.AUTO_MEDIUMID }), + customParameterFactory.build({ type: undefined, scope: undefined }), + ]; + const externalTool = externalToolFactory.build({ parameters: params }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + + const parameters: ExternalToolParameterDatasheetTemplateData[] = [ + externalToolParameterDatasheetTemplateDataFactory.build({ + type: 'Zeichenkette', + scope: 'Kontext', + properties: ExternalToolParameterDatasheetTemplateProperty.MANDATORY, + }), + externalToolParameterDatasheetTemplateDataFactory.build({ + type: 'Wahrheitswert', + scope: 'Schule', + properties: ExternalToolParameterDatasheetTemplateProperty.MANDATORY, + }), + externalToolParameterDatasheetTemplateDataFactory.build({ + type: 'Zahl', + scope: 'Global', + properties: ExternalToolParameterDatasheetTemplateProperty.MANDATORY, + }), + externalToolParameterDatasheetTemplateDataFactory.build({ + type: 'Auto Schulnummer', + properties: ExternalToolParameterDatasheetTemplateProperty.MANDATORY, + }), + externalToolParameterDatasheetTemplateDataFactory.build({ + type: 'Auto Schul-ID', + properties: ExternalToolParameterDatasheetTemplateProperty.MANDATORY, + }), + externalToolParameterDatasheetTemplateDataFactory.build({ + type: 'Auto Kontext-ID', + properties: ExternalToolParameterDatasheetTemplateProperty.MANDATORY, + }), + externalToolParameterDatasheetTemplateDataFactory.build({ + type: 'Auto Kontext-Name', + properties: ExternalToolParameterDatasheetTemplateProperty.MANDATORY, + }), + externalToolParameterDatasheetTemplateDataFactory.build({ + type: 'Auto Medium-ID', + properties: ExternalToolParameterDatasheetTemplateProperty.MANDATORY, + }), + externalToolParameterDatasheetTemplateDataFactory.build({ + type: 'unbekannt', + scope: 'unbekannt', + properties: ExternalToolParameterDatasheetTemplateProperty.MANDATORY, + }), + ]; + const expectDatasheet: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory.build({ + parameters, + }); + + return { user, externalTool, schoolExternalTool, expectDatasheet }; + }; + it('should map all parameters correctly', () => { + const { user, externalTool, schoolExternalTool, expectDatasheet } = setup(); + + const mappedData: ExternalToolDatasheetTemplateData = + ExternalToolDatasheetMapper.mapToExternalToolDatasheetTemplateData( + externalTool, + user.firstName, + user.lastName, + schoolExternalTool + ); + + expect(mappedData).toEqual(expectDatasheet); + }); + }); +}); diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.ts new file mode 100644 index 00000000000..d908925c761 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-datasheet.mapper.ts @@ -0,0 +1,185 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; +import { CustomParameter } from '../../common/domain'; +import { CustomParameterScope, CustomParameterType, ToolConfigType, ToolContextType } from '../../common/enum'; +import { + ExternalTool, + ExternalToolDatasheetTemplateData, + ExternalToolParameterDatasheetTemplateData, + ExternalToolParameterDatasheetTemplateProperty, +} from '../domain'; + +export class ExternalToolDatasheetMapper { + public static mapToExternalToolDatasheetTemplateData( + externalTool: ExternalTool, + firstName: string, + lastname: string, + schoolExternalTool?: SchoolExternalTool, + schoolName?: string + ): ExternalToolDatasheetTemplateData { + const externalToolData: ExternalToolDatasheetTemplateData = new ExternalToolDatasheetTemplateData({ + createdAt: new Date().toLocaleDateString('de-DE'), + creatorName: `${firstName} ${lastname}`, + instance: ExternalToolDatasheetMapper.mapToInstanceName(), + schoolName, + toolName: externalTool.name, + toolUrl: externalTool.config.baseUrl, + isDeactivated: ExternalToolDatasheetMapper.mapToIsDeactivated(externalTool, schoolExternalTool), + restrictToContexts: externalTool.restrictToContexts + ? ExternalToolDatasheetMapper.mapToLimitedContexts(externalTool) + : undefined, + toolType: ExternalToolDatasheetMapper.mapToToolType(externalTool), + }); + + if (externalTool.parameters) { + externalToolData.parameters = ExternalToolDatasheetMapper.mapToParameterDataList(externalTool); + } + + if (ExternalTool.isOauth2Config(externalTool.config) && externalTool.config.skipConsent) { + externalToolData.skipConsent = 'Zustimmung Ãŧberspringen: ja'; + } + + if (ExternalTool.isLti11Config(externalTool.config)) { + externalToolData.messageType = externalTool.config.lti_message_type.toString(); + externalToolData.privacy = externalTool.config.privacy_permission.toString(); + } + + return externalToolData; + } + + private static mapToInstanceName(): string { + const instance: string = Configuration.get('SC_THEME') as string; + switch (instance) { + case 'n21': + return 'Niedersächsische Bildungscloud'; + case 'brb': + return 'Schul-Cloud Brandenburg'; + case 'thr': + return 'ThÃŧringer Schulcloud'; + case 'default': + return 'dBildungscloud'; + default: + return 'unbekannt'; + } + } + + private static mapToIsDeactivated( + externalTool: ExternalTool, + schoolExternalTool?: SchoolExternalTool + ): string | undefined { + if (externalTool.isDeactivated) { + return 'Das Tool ist instanzweit deaktiviert'; + } + + if (schoolExternalTool?.status?.isDeactivated) { + return 'Das Tool ist deaktiviert'; + } + + return undefined; + } + + private static mapToLimitedContexts(externalTool: ExternalTool): string { + const restrictToContexts: string[] = []; + if (externalTool.restrictToContexts?.includes(ToolContextType.COURSE)) { + restrictToContexts.push('Kurs'); + } + if (externalTool.restrictToContexts?.includes(ToolContextType.BOARD_ELEMENT)) { + restrictToContexts.push('Kurs-Board'); + } + + const restrictToContextsString = restrictToContexts.join(', '); + + return restrictToContextsString; + } + + private static mapToToolType(externalTool: ExternalTool): string { + const toolType: string = externalTool.config.type; + switch (toolType) { + case ToolConfigType.OAUTH2: + return 'OAuth 2.0'; + case ToolConfigType.LTI11: + return 'LTI 1.1'; + default: + return 'Basic'; + } + } + + private static mapToParameterDataList( + externalTool: ExternalTool + ): ExternalToolParameterDatasheetTemplateData[] | undefined { + const parameterData: ExternalToolParameterDatasheetTemplateData[] | undefined = externalTool.parameters?.map( + (parameter: CustomParameter) => { + const paramData: ExternalToolParameterDatasheetTemplateData = + ExternalToolDatasheetMapper.mapToParameterData(parameter); + + return paramData; + } + ); + + return parameterData; + } + + private static mapToParameterData(parameter: CustomParameter): ExternalToolParameterDatasheetTemplateData { + const parameterData: ExternalToolParameterDatasheetTemplateData = new ExternalToolParameterDatasheetTemplateData({ + name: parameter.name, + type: ExternalToolDatasheetMapper.mapToType(parameter), + properties: ExternalToolDatasheetMapper.mapToProperties(parameter), + scope: ExternalToolDatasheetMapper.mapToScope(parameter), + location: parameter.location, + }); + + return parameterData; + } + + private static mapToProperties(parameter: CustomParameter): string { + const properties: ExternalToolParameterDatasheetTemplateProperty[] = []; + if (parameter.isOptional) { + properties.push(ExternalToolParameterDatasheetTemplateProperty.OPTIONAL); + } else { + properties.push(ExternalToolParameterDatasheetTemplateProperty.MANDATORY); + } + + if (parameter.isProtected) { + properties.push(ExternalToolParameterDatasheetTemplateProperty.PROTECTED); + } + + const propertyString = properties.join(', '); + return propertyString; + } + + private static mapToType(parameter: CustomParameter): string { + switch (parameter.type) { + case CustomParameterType.STRING: + return 'Zeichenkette'; + case CustomParameterType.BOOLEAN: + return 'Wahrheitswert'; + case CustomParameterType.NUMBER: + return 'Zahl'; + case CustomParameterType.AUTO_CONTEXTID: + return 'Auto Kontext-ID'; + case CustomParameterType.AUTO_CONTEXTNAME: + return 'Auto Kontext-Name'; + case CustomParameterType.AUTO_SCHOOLID: + return 'Auto Schul-ID'; + case CustomParameterType.AUTO_SCHOOLNUMBER: + return 'Auto Schulnummer'; + case CustomParameterType.AUTO_MEDIUMID: + return 'Auto Medium-ID'; + default: + return 'unbekannt'; + } + } + + private static mapToScope(parameter: CustomParameter): string { + switch (parameter.scope) { + case CustomParameterScope.CONTEXT: + return 'Kontext'; + case CustomParameterScope.SCHOOL: + return 'Schule'; + case CustomParameterScope.GLOBAL: + return 'Global'; + default: + return 'unbekannt'; + } + } +} diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts index 8089b5b4f87..fb372b73ae4 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.spec.ts @@ -135,7 +135,6 @@ describe('ExternalToolRequestMapper', () => { lti11ConfigParams.baseUrl = 'mockUrl'; lti11ConfigParams.key = 'mockKey'; lti11ConfigParams.secret = 'mockSecret'; - lti11ConfigParams.resource_link_id = 'mockLink'; lti11ConfigParams.lti_message_type = LtiMessageType.BASIC_LTI_LAUNCH_REQUEST; lti11ConfigParams.privacy_permission = LtiPrivacyPermission.NAME; lti11ConfigParams.launch_presentation_locale = 'de-DE'; @@ -144,7 +143,6 @@ describe('ExternalToolRequestMapper', () => { privacy_permission: LtiPrivacyPermission.NAME, secret: 'mockSecret', key: 'mockKey', - resource_link_id: 'mockLink', lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, type: ToolConfigType.LTI11, baseUrl: 'mockUrl', @@ -389,7 +387,6 @@ describe('ExternalToolRequestMapper', () => { lti11ConfigParams.baseUrl = 'mockUrl'; lti11ConfigParams.key = 'mockKey'; lti11ConfigParams.secret = 'mockSecret'; - lti11ConfigParams.resource_link_id = 'mockLink'; lti11ConfigParams.lti_message_type = LtiMessageType.BASIC_LTI_LAUNCH_REQUEST; lti11ConfigParams.privacy_permission = LtiPrivacyPermission.NAME; lti11ConfigParams.launch_presentation_locale = 'de-DE'; @@ -398,7 +395,6 @@ describe('ExternalToolRequestMapper', () => { privacy_permission: LtiPrivacyPermission.NAME, secret: 'mockSecret', key: 'mockKey', - resource_link_id: 'mockLink', lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, type: ToolConfigType.LTI11, baseUrl: 'mockUrl', diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts index 3ba9d04db37..ca98f701300 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-request.mapper.ts @@ -20,12 +20,14 @@ import { Oauth2ToolConfigCreateParams, Oauth2ToolConfigUpdateParams, SortExternalToolParams, + ExternalToolMediumParams, } from '../controller/dto'; import { ExternalTool } from '../domain'; import { BasicToolConfigDto, CustomParameterDto, ExternalToolCreate, + ExternalToolMediumDto, ExternalToolUpdate, Lti11ToolConfigCreate, Lti11ToolConfigUpdate, @@ -53,6 +55,7 @@ const typeMapping: Record = { [CustomParameterTypeParams.AUTO_CONTEXTNAME]: CustomParameterType.AUTO_CONTEXTNAME, [CustomParameterTypeParams.AUTO_SCHOOLID]: CustomParameterType.AUTO_SCHOOLID, [CustomParameterTypeParams.AUTO_SCHOOLNUMBER]: CustomParameterType.AUTO_SCHOOLNUMBER, + [CustomParameterTypeParams.AUTO_MEDIUMID]: CustomParameterType.AUTO_MEDIUMID, }; @Injectable() @@ -74,6 +77,7 @@ export class ExternalToolRequestMapper { return { id: externalToolUpdateParams.id, name: externalToolUpdateParams.name, + description: externalToolUpdateParams.description, url: externalToolUpdateParams.url, logoUrl: externalToolUpdateParams.logoUrl, config: mappedConfig, @@ -83,6 +87,7 @@ export class ExternalToolRequestMapper { openNewTab: externalToolUpdateParams.openNewTab, version, restrictToContexts: externalToolUpdateParams.restrictToContexts, + medium: this.mapRequestToExternalToolMedium(externalToolUpdateParams.medium), }; } @@ -111,9 +116,19 @@ export class ExternalToolRequestMapper { openNewTab: externalToolCreateParams.openNewTab, version, restrictToContexts: externalToolCreateParams.restrictToContexts, + medium: this.mapRequestToExternalToolMedium(externalToolCreateParams.medium), }; } + private mapRequestToExternalToolMedium( + externalToolMediumParams: ExternalToolMediumParams | undefined + ): ExternalToolMediumDto | undefined { + if (!externalToolMediumParams) { + return undefined; + } + return { ...externalToolMediumParams }; + } + private mapRequestToBasicToolConfig(externalToolConfigParams: BasicToolConfigParams): BasicToolConfigDto { return { ...externalToolConfigParams }; } diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts index e87502a5d21..f388ef810ca 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.spec.ts @@ -222,7 +222,6 @@ describe('ExternalToolResponseMapper', () => { type: ToolConfigType.LTI11, baseUrl: 'mockUrl', launch_presentation_locale: 'de-DE', - resource_link_id: 'linkId', }); const customParameterResponse: CustomParameterResponse = new CustomParameterResponse({ diff --git a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts index 283f49906cf..790c8b7944c 100644 --- a/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts +++ b/apps/server/src/modules/tool/external-tool/mapper/external-tool-response.mapper.ts @@ -11,11 +11,12 @@ import { import { BasicToolConfigResponse, CustomParameterResponse, + ExternalToolMediumResponse, ExternalToolResponse, Lti11ToolConfigResponse, Oauth2ToolConfigResponse, } from '../controller/dto'; -import { BasicToolConfig, ExternalTool, Lti11ToolConfig, Oauth2ToolConfig } from '../domain'; +import { BasicToolConfig, ExternalTool, ExternalToolMedium, Lti11ToolConfig, Oauth2ToolConfig } from '../domain'; const scopeMapping: Record = { [CustomParameterScope.GLOBAL]: CustomParameterScopeTypeParams.GLOBAL, @@ -37,6 +38,7 @@ const typeMapping: Record = { [CustomParameterType.AUTO_CONTEXTNAME]: CustomParameterTypeParams.AUTO_CONTEXTNAME, [CustomParameterType.AUTO_SCHOOLID]: CustomParameterTypeParams.AUTO_SCHOOLID, [CustomParameterType.AUTO_SCHOOLNUMBER]: CustomParameterTypeParams.AUTO_SCHOOLNUMBER, + [CustomParameterType.AUTO_MEDIUMID]: CustomParameterTypeParams.AUTO_MEDIUMID, }; @Injectable() @@ -58,6 +60,7 @@ export class ExternalToolResponseMapper { return new ExternalToolResponse({ id: externalTool.id ?? '', name: externalTool.name, + description: externalTool.description, url: externalTool.url, logoUrl: externalTool.logoUrl, config: mappedConfig, @@ -67,9 +70,18 @@ export class ExternalToolResponseMapper { openNewTab: externalTool.openNewTab, version: externalTool.version, restrictToContexts: externalTool.restrictToContexts, + medium: this.mapMediumToResponse(externalTool.medium), }); } + private static mapMediumToResponse(medium?: ExternalToolMedium): ExternalToolMediumResponse | undefined { + if (!medium) { + return undefined; + } + + return new ExternalToolMediumResponse({ ...medium }); + } + private static mapBasicToolConfigDOToResponse(externalToolConfigDO: BasicToolConfig): BasicToolConfigResponse { return new BasicToolConfigResponse({ ...externalToolConfigDO }); } diff --git a/apps/server/src/modules/tool/external-tool/service/datasheet-pdf.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/datasheet-pdf.service.spec.ts new file mode 100644 index 00000000000..05de876f21c --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/service/datasheet-pdf.service.spec.ts @@ -0,0 +1,143 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UserDO } from '@shared/domain/domainobject'; +import { + customParameterFactory, + externalToolDatasheetTemplateDataFactory, + externalToolFactory, + userDoFactory, +} from '@shared/testing'; +import { createPdf, TCreatedPdf } from 'pdfmake/build/pdfmake'; +import { TDocumentDefinitions } from 'pdfmake/interfaces'; +import { CustomParameter } from '../../common/domain'; +import { ExternalTool, ExternalToolDatasheetTemplateData } from '../domain'; +import { DatasheetPdfService } from './datasheet-pdf.service'; + +jest.mock('pdfmake/build/pdfmake'); + +const mockCreatePdf = createPdf as jest.MockedFunction< + (documentDefinitions: TDocumentDefinitions) => Partial +>; + +const setupMockCreatePdf = (error?: boolean) => { + if (error) { + mockCreatePdf.mockImplementation(() => { + throw new Error('error from createPdf'); + }); + } else { + mockCreatePdf.mockImplementation((): Partial => { + return { + getBuffer: jest.fn().mockImplementation((callback: (buffer: Buffer) => void) => { + callback(Buffer.from('fake-pdf-content')); + }), + }; + }); + } +}; + +describe(DatasheetPdfService.name, () => { + let module: TestingModule; + let service: DatasheetPdfService; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [DatasheetPdfService], + }).compile(); + + service = module.get(DatasheetPdfService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('generatePdf', () => { + describe('when tool is oauth2 tool with optional properties and custom parameters', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + + const param: CustomParameter = customParameterFactory.build(); + const externalTool: ExternalTool = externalToolFactory.build({ parameters: [param] }); + const datasheetData: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory + .withParameters(1, { name: param.name }) + .withOptionalProperties() + .asOauth2Tool() + .build({ + toolName: externalTool.name, + instance: 'dBildungscloud', + creatorName: `${user.firstName} ${user.lastName}`, + schoolName: 'schoolName', + }); + + setupMockCreatePdf(false); + + return { datasheetData }; + }; + + it('should return Buffer', async () => { + const { datasheetData } = setup(); + + const result = await service.generatePdf(datasheetData); + + expect(result).toEqual(expect.any(Uint8Array)); + }); + }); + + describe('when tool is lti tool without custom parameters', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + + const param: CustomParameter = customParameterFactory.build(); + const externalTool: ExternalTool = externalToolFactory.build({ parameters: [param] }); + const datasheetData: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory + .asLti11Tool() + .build({ + toolName: externalTool.name, + instance: 'dBildungscloud', + creatorName: `${user.firstName} ${user.lastName}`, + }); + + setupMockCreatePdf(false); + + return { datasheetData }; + }; + + it('should return Buffer', async () => { + const { datasheetData } = setup(); + + const result = await service.generatePdf(datasheetData); + + expect(result).toEqual(expect.any(Uint8Array)); + }); + }); + + describe('when an error occurs', () => { + const setup = () => { + const user: UserDO = userDoFactory.buildWithId(); + + const param: CustomParameter = customParameterFactory.build(); + const externalTool: ExternalTool = externalToolFactory.build({ parameters: [param] }); + const datasheetData: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory + .withParameters(1, { name: param.name }) + .build({ + toolName: externalTool.name, + instance: 'dBildungscloud', + creatorName: `${user.firstName} ${user.lastName}`, + }); + + setupMockCreatePdf(true); + + return { datasheetData }; + }; + + it('should throw an error', async () => { + const { datasheetData } = setup(); + + await expect(service.generatePdf(datasheetData)).rejects.toThrowError('error from createPdf'); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/external-tool/service/datasheet-pdf.service.ts b/apps/server/src/modules/tool/external-tool/service/datasheet-pdf.service.ts new file mode 100644 index 00000000000..8deeb391ec5 --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/service/datasheet-pdf.service.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; +import { createPdf, TCreatedPdf } from 'pdfmake/build/pdfmake'; +import pdfFonts from 'pdfmake/build/vfs_fonts'; +import { Content, TDocumentDefinitions } from 'pdfmake/interfaces'; +import { ExternalToolDatasheetTemplateData, ExternalToolParameterDatasheetTemplateData } from '../domain'; + +@Injectable() +export class DatasheetPdfService { + public generatePdf(templateData: ExternalToolDatasheetTemplateData): Promise { + const content: Content = []; + + content.push( + { text: `Erstellt am ${templateData.createdAt} von ${templateData.creatorName}`, style: 'right-aligned' }, + '\n', + { text: templateData.instance, style: 'right-aligned' }, + '\n' + ); + + if (templateData.schoolName) { + content.push({ text: templateData.schoolName, style: 'right-aligned' }, '\n'); + } + + content.push( + { text: 'Datenblatt', style: ['center-aligned', 'h1'] }, + { text: templateData.toolName, style: ['center-aligned', 'h2'] }, + '\n', + { text: templateData.toolUrl, style: ['center-aligned', 'link'], link: templateData.toolUrl }, + '\n\n' + ); + + if (templateData.isDeactivated) { + content.push(templateData.isDeactivated); + } + + if (templateData.restrictToContexts?.length) { + content.push(`Dieses Tool ist auf folgende Kontexte beschränkt: ${templateData.restrictToContexts}`); + } + + content.push('\n', `Typ des Tools: ${templateData.toolType}`); + + if (templateData.toolType === 'OAuth 2.0' && templateData.skipConsent) { + content.push(templateData.skipConsent); + } + + if (templateData.toolType === 'LTI 1.1' && templateData.messageType && templateData.privacy) { + content.push(`Message Type: ${templateData.messageType}`, `Privatsphäre: ${templateData.privacy}`); + } + + if (templateData.parameters?.length) { + content.push({ text: 'An den Dienst Ãŧbermittelte Parameter', style: 'h4' }, '\n', { + table: { + headerRows: 1, + widths: [200, 'auto', 'auto', 'auto', 'auto'], + body: [ + ['Name', 'Typ', 'Eigenschaften', 'Geltungsbereich', 'Ort '], + ...templateData.parameters.map((param: ExternalToolParameterDatasheetTemplateData) => [ + param.name, + param.type, + param.properties, + param.scope, + param.location, + ]), + ], + }, + }); + } else { + content.push('\n', 'Die Konfiguration dieses Tools enthält keine benutzerspezifischen Parameter.'); + } + + return new Promise((resolve, reject) => { + try { + const documentDefinition: TDocumentDefinitions = { + content, + styles: { + 'right-aligned': { alignment: 'right' }, + 'center-aligned': { alignment: 'center' }, + h4: { fontSize: 14, bold: true, margin: [0, 10, 0, 5] }, + h1: { fontSize: 22, bold: true, margin: [0, 10, 0, 5] }, + h2: { fontSize: 18, bold: true, margin: [0, 10, 0, 5] }, + link: { color: 'blue', decoration: 'underline' }, + }, + }; + + const pdfDoc: TCreatedPdf = createPdf(documentDefinition, {}, undefined, pdfFonts.pdfMake.vfs); + pdfDoc.getBuffer((buffer: Buffer): void => { + resolve(buffer); + }); + } catch (error) { + reject(error); + } + }); + } +} diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.spec.ts deleted file mode 100644 index 4753cc805f4..00000000000 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ObjectId } from '@mikro-orm/mongodb'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ContextExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; -import { externalToolFactory, legacySchoolDoFactory, schoolExternalToolFactory } from '@shared/testing'; -import { ContextExternalToolType } from '../../context-external-tool/entity'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { ExternalTool, ExternalToolMetadata } from '../domain'; -import { ExternalToolMetadataService } from './external-tool-metadata.service'; - -describe('ExternalToolMetadataService', () => { - let module: TestingModule; - let service: ExternalToolMetadataService; - - let schoolExternalToolRepo: DeepMocked; - let contextExternalToolRepo: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - ExternalToolMetadataService, - { - provide: SchoolExternalToolRepo, - useValue: createMock(), - }, - { - provide: ContextExternalToolRepo, - useValue: createMock(), - }, - ], - }).compile(); - - service = module.get(ExternalToolMetadataService); - schoolExternalToolRepo = module.get(SchoolExternalToolRepo); - contextExternalToolRepo = module.get(ContextExternalToolRepo); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('getMetadata', () => { - describe('when externalToolId is given', () => { - const setup = () => { - const toolId: string = new ObjectId().toHexString(); - - const school = legacySchoolDoFactory.buildWithId(); - const school1 = legacySchoolDoFactory.buildWithId(); - - const schoolToolId: string = new ObjectId().toHexString(); - const schoolToolId1: string = new ObjectId().toHexString(); - - const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ - toolId, - schoolId: school.id, - id: schoolToolId, - }); - const schoolExternalTool1: SchoolExternalTool = schoolExternalToolFactory.buildWithId( - { toolId, schoolId: school1.id, id: schoolToolId1 }, - schoolToolId1 - ); - - const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ - schoolExternalToolCount: 2, - contextExternalToolCountPerContext: { course: 3, boardElement: 3 }, - }); - - schoolExternalToolRepo.findByExternalToolId.mockResolvedValue([schoolExternalTool, schoolExternalTool1]); - contextExternalToolRepo.countBySchoolToolIdsAndContextType.mockResolvedValue(3); - - return { - toolId, - externalToolMetadata, - schoolExternalTool, - schoolExternalTool1, - }; - }; - - it('should call the repo to get schoolExternalTools by externalToolId', async () => { - const { toolId } = setup(); - - await service.getMetadata(toolId); - - expect(schoolExternalToolRepo.findByExternalToolId).toHaveBeenCalledWith(toolId); - }); - - it('should call the repo to count contextExternalTools by schoolExternalToolId and context', async () => { - const { toolId, schoolExternalTool, schoolExternalTool1 } = setup(); - - await service.getMetadata(toolId); - - expect(contextExternalToolRepo.countBySchoolToolIdsAndContextType).toHaveBeenCalledWith( - ContextExternalToolType.COURSE, - [schoolExternalTool.id, schoolExternalTool1.id] - ); - expect(contextExternalToolRepo.countBySchoolToolIdsAndContextType).toHaveBeenCalledWith( - ContextExternalToolType.BOARD_ELEMENT, - [schoolExternalTool.id, schoolExternalTool1.id] - ); - expect(contextExternalToolRepo.countBySchoolToolIdsAndContextType).toHaveBeenCalledTimes(2); - }); - - it('should return externalToolMetadata', async () => { - const { toolId, externalToolMetadata } = setup(); - - const result: ExternalToolMetadata = await service.getMetadata(toolId); - - expect(result).toEqual(externalToolMetadata); - }); - }); - - describe('when no related school external tool was found', () => { - const setup = () => { - const toolId: string = new ObjectId().toHexString(); - const externalToolEntity: ExternalTool = externalToolFactory.buildWithId(undefined, toolId); - - const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ - schoolExternalToolCount: 0, - contextExternalToolCountPerContext: { course: 0, boardElement: 0 }, - }); - - schoolExternalToolRepo.findByExternalToolId.mockResolvedValue([]); - contextExternalToolRepo.countBySchoolToolIdsAndContextType.mockResolvedValue(0); - - return { - toolId, - externalToolEntity, - externalToolMetadata, - }; - }; - - it('should return empty externalToolMetadata', async () => { - const { toolId, externalToolMetadata } = setup(); - - const result: ExternalToolMetadata = await service.getMetadata(toolId); - - expect(result).toEqual(externalToolMetadata); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts deleted file mode 100644 index 5d99ca73935..00000000000 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-metadata.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; -import { ContextExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; -import { ToolContextType } from '../../common/enum'; -import { ToolContextMapper } from '../../common/mapper/tool-context.mapper'; -import { ContextExternalToolType } from '../../context-external-tool/entity'; -import { SchoolExternalTool } from '../../school-external-tool/domain'; -import { ExternalToolMetadata } from '../domain'; - -@Injectable() -export class ExternalToolMetadataService { - constructor( - private readonly schoolToolRepo: SchoolExternalToolRepo, - private readonly contextToolRepo: ContextExternalToolRepo - ) {} - - async getMetadata(toolId: EntityId): Promise { - const schoolExternalTools: SchoolExternalTool[] = await this.schoolToolRepo.findByExternalToolId(toolId); - - const schoolExternalToolIds: string[] = schoolExternalTools.map( - (schoolExternalTool: SchoolExternalTool): string => - // We can be sure that the repo returns the id - schoolExternalTool.id as string - ); - const contextExternalToolCount: Record = { - [ContextExternalToolType.BOARD_ELEMENT]: 0, - [ContextExternalToolType.COURSE]: 0, - }; - if (schoolExternalTools.length >= 1) { - await Promise.all( - Object.values(ToolContextType).map(async (contextType: ToolContextType): Promise => { - const type: ContextExternalToolType = ToolContextMapper.contextMapping[contextType]; - - const countPerContext: number = await this.contextToolRepo.countBySchoolToolIdsAndContextType( - type, - schoolExternalToolIds - ); - contextExternalToolCount[type] = countPerContext; - }) - ); - } - - const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ - schoolExternalToolCount: schoolExternalTools.length, - contextExternalToolCountPerContext: contextExternalToolCount, - }); - - return externalToolMetadata; - } -} diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.spec.ts index fc02e6d4426..ceef0aeb178 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.spec.ts @@ -357,5 +357,66 @@ describe('ExternalToolParameterValidationService', () => { ); }); }); + + describe('when auto parameter is auto medium id', () => { + describe('when medium id is not set', () => { + const setup = () => { + const parameter = customParameterFactory.buildWithId({ + type: CustomParameterType.AUTO_MEDIUMID, + scope: CustomParameterScope.GLOBAL, + }); + const externalTool: ExternalTool = externalToolFactory.buildWithId({ + parameters: [parameter], + medium: undefined, + }); + + externalToolService.findExternalToolByName.mockResolvedValue(externalTool); + + return { + externalTool, + parameter, + }; + }; + + it('should throw exception', async () => { + const { externalTool, parameter } = setup(); + + const result: Promise = service.validateCommon(externalTool); + + await expect(result).rejects.toThrow( + new ValidationError( + `tool_param_auto_medium_id: The custom parameter "${parameter.name}" with type "${parameter.type}" must have the mediumId set.` + ) + ); + }); + }); + + describe('when medium id is set', () => { + const setup = () => { + const parameter = customParameterFactory.buildWithId({ + type: CustomParameterType.AUTO_MEDIUMID, + scope: CustomParameterScope.GLOBAL, + }); + const externalTool: ExternalTool = externalToolFactory.withMedium().buildWithId({ + parameters: [parameter], + }); + + externalToolService.findExternalToolByName.mockResolvedValue(externalTool); + + return { + externalTool, + parameter, + }; + }; + + it('should not throw exception', async () => { + const { externalTool } = setup(); + + const result: Promise = service.validateCommon(externalTool); + + await expect(result).resolves.not.toThrow(); + }); + }); + }); }); }); diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.ts index 123efd0b165..b7ed1709209 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-parameter-validation.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ValidationError } from '@shared/common'; import { CustomParameter } from '../../common/domain'; -import { autoParameters, CustomParameterScope } from '../../common/enum'; +import { autoParameters, CustomParameterScope, CustomParameterType } from '../../common/enum'; import { ToolParameterTypeValidationUtil } from '../../common/service'; import { ExternalTool } from '../domain'; import { ExternalToolService } from './external-tool.service'; @@ -10,7 +10,7 @@ import { ExternalToolService } from './external-tool.service'; export class ExternalToolParameterValidationService { constructor(private readonly externalToolService: ExternalToolService) {} - async validateCommon(externalTool: ExternalTool | Partial): Promise { + async validateCommon(externalTool: ExternalTool): Promise { if (!(await this.isNameUnique(externalTool))) { throw new ValidationError(`tool_name_duplicate: The tool name "${externalTool.name || ''}" is already used.`); } @@ -39,6 +39,12 @@ export class ExternalToolParameterValidationService { ); } + if (!this.isAutoParameterMediumIdValid(param, externalTool)) { + throw new ValidationError( + `tool_param_auto_medium_id: The custom parameter "${param.name}" with type "${param.type}" must have the mediumId set.` + ); + } + if (!this.isRegexCommentMandatoryAndFilled(param)) { throw new ValidationError( `tool_param_regexComment: The custom parameter "${param.name}" parameter is missing a regex comment.` @@ -70,7 +76,7 @@ export class ExternalToolParameterValidationService { return !param.name || !param.displayName; } - private async isNameUnique(externalTool: ExternalTool | Partial): Promise { + private async isNameUnique(externalTool: ExternalTool): Promise { if (!externalTool.name) { return true; } @@ -151,4 +157,12 @@ export class ExternalToolParameterValidationService { return isGlobal; } + + private isAutoParameterMediumIdValid(customParameter: CustomParameter, externalTool: ExternalTool) { + if (customParameter.type === CustomParameterType.AUTO_MEDIUMID && !externalTool.medium?.mediumId) { + return false; + } + + return true; + } } diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts index bf768a83a3e..53db997369f 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool-validation.service.ts @@ -23,7 +23,7 @@ export class ExternalToolValidationService { this.externalToolLogoService.validateLogoSize(externalTool); } - async validateUpdate(toolId: string, externalTool: Partial): Promise { + async validateUpdate(toolId: string, externalTool: ExternalTool): Promise { if (toolId !== externalTool.id) { throw new ValidationError(`tool_id_mismatch: The tool has no id or it does not match the path parameter.`); } diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts b/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts index 41593d34fe4..16568324e53 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool.service.spec.ts @@ -8,12 +8,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Page } from '@shared/domain/domainobject'; import { IFindOptions, SortOrder } from '@shared/domain/interface'; import { ContextExternalToolRepo, ExternalToolRepo, SchoolExternalToolRepo } from '@shared/repo'; -import { - externalToolFactory, - lti11ToolConfigFactory, - oauth2ToolConfigFactory, -} from '@shared/testing/factory/domainobject/tool/external-tool.factory'; import { LegacyLogger } from '@src/core/logger'; +import { externalToolFactory, lti11ToolConfigFactory, oauth2ToolConfigFactory } from '@shared/testing'; import { ExternalToolSearchQuery } from '../../common/interface'; import { SchoolExternalTool } from '../../school-external-tool/domain'; import { ExternalTool, Lti11ToolConfig, Oauth2ToolConfig } from '../domain'; diff --git a/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts b/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts index 67f7edd161b..2d65debb51f 100644 --- a/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts +++ b/apps/server/src/modules/tool/external-tool/service/external-tool.service.ts @@ -47,6 +47,7 @@ export class ExternalToolService { await this.updateOauth2ToolConfig(toUpdate); this.externalToolVersionService.increaseVersionOfNewToolIfNecessary(loadedTool, toUpdate); const externalTool: ExternalTool = await this.externalToolRepo.save(toUpdate); + return externalTool; } diff --git a/apps/server/src/modules/tool/external-tool/service/index.ts b/apps/server/src/modules/tool/external-tool/service/index.ts index e2a936d158b..a0bb13c7690 100644 --- a/apps/server/src/modules/tool/external-tool/service/index.ts +++ b/apps/server/src/modules/tool/external-tool/service/index.ts @@ -5,4 +5,4 @@ export * from './external-tool-validation.service'; export * from './external-tool-parameter-validation.service'; export * from './external-tool-configuration.service'; export * from './external-tool-logo.service'; -export * from './external-tool-metadata.service'; +export { DatasheetPdfService } from './datasheet-pdf.service'; diff --git a/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts b/apps/server/src/modules/tool/external-tool/testing/external-tool-entity.factory.ts similarity index 95% rename from apps/server/src/shared/testing/factory/external-tool-entity.factory.ts rename to apps/server/src/modules/tool/external-tool/testing/external-tool-entity.factory.ts index dff6c1d00e0..9aefd55bf49 100644 --- a/apps/server/src/shared/testing/factory/external-tool-entity.factory.ts +++ b/apps/server/src/modules/tool/external-tool/testing/external-tool-entity.factory.ts @@ -13,9 +13,10 @@ import { IExternalToolProperties, Lti11ToolConfigEntity, Oauth2ToolConfigEntity, + ExternalToolMediumEntity, } from '@modules/tool/external-tool/entity'; +import { BaseFactory } from '@shared/testing/factory/base.factory'; import { DeepPartial } from 'fishery'; -import { BaseFactory } from './base.factory'; export class ExternalToolEntityFactory extends BaseFactory { withName(name: string): this { @@ -54,7 +55,6 @@ export class ExternalToolEntityFactory extends BaseFactory = { + medium: new ExternalToolMediumEntity({ + ...medium, + mediumId: 'mediumId', + publisher: 'publisher', + }), + }; + + return this.params(params); + } } export const customParameterEntityFactory = BaseFactory.define( @@ -95,6 +107,7 @@ export const externalToolEntityFactory = ExternalToolEntityFactory.define( ({ sequence }): IExternalToolProperties => { return { name: `external-tool-${sequence}`, + description: 'This is a tool description', url: '', logoUrl: 'https://logourl.com', config: new BasicToolConfigEntity({ diff --git a/apps/server/src/modules/tool/external-tool/testing/index.ts b/apps/server/src/modules/tool/external-tool/testing/index.ts new file mode 100644 index 00000000000..6ebcda28f7a --- /dev/null +++ b/apps/server/src/modules/tool/external-tool/testing/index.ts @@ -0,0 +1 @@ +export { externalToolEntityFactory, customParameterEntityFactory } from './external-tool-entity.factory'; diff --git a/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts b/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts index a2daafe5f69..13b06edf603 100644 --- a/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts +++ b/apps/server/src/modules/tool/external-tool/uc/dto/external-tool.types.ts @@ -1,4 +1,4 @@ -import { BasicToolConfig, Lti11ToolConfig, Oauth2ToolConfig } from '../../domain'; +import { BasicToolConfig, ExternalToolMedium, Lti11ToolConfig, Oauth2ToolConfig } from '../../domain'; import { CustomParameter } from '../../../common/domain'; import { ToolContextType } from '../../../common/enum'; @@ -16,9 +16,13 @@ export type Oauth2ToolConfigUpdate = PartialBy export type CustomParameterDto = CustomParameter; +export type ExternalToolMediumDto = ExternalToolMedium; + export type ExternalToolDto = { name: string; + description?: string; + url?: string; logo?: string; @@ -38,6 +42,8 @@ export type ExternalToolDto = { version: number; restrictToContexts?: ToolContextType[]; + + medium?: ExternalToolMediumDto; }; export type ExternalToolCreate = ExternalToolDto; diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.spec.ts index 933ff82c9a2..a2de0d99460 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.spec.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.spec.ts @@ -1,8 +1,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { schoolFactory } from '@modules/school/testing'; import { ForbiddenException, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Page } from '@shared/domain/domainobject'; +import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { contextExternalToolFactory, @@ -12,8 +15,7 @@ import { setupEntities, userFactory, } from '@shared/testing'; -import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { User } from '@shared/domain/entity'; +import { School, SchoolService } from '@src/modules/school'; import { CustomParameterScope, ToolContextType } from '../../common/enum'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalTool } from '../../context-external-tool/domain'; @@ -35,6 +37,7 @@ describe('ExternalToolConfigurationUc', () => { let toolPermissionHelper: DeepMocked; let logoService: DeepMocked; let authorizationService: DeepMocked; + let schoolService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -70,6 +73,10 @@ describe('ExternalToolConfigurationUc', () => { provide: AuthorizationService, useValue: createMock(), }, + { + provide: SchoolService, + useValue: createMock(), + }, ], }).compile(); @@ -81,6 +88,7 @@ describe('ExternalToolConfigurationUc', () => { toolPermissionHelper = module.get(ToolPermissionHelper); logoService = module.get(ExternalToolLogoService); authorizationService = module.get(AuthorizationService); + schoolService = module.get(SchoolService); }); afterEach(() => { @@ -95,35 +103,37 @@ describe('ExternalToolConfigurationUc', () => { describe('when checking for the users permission', () => { const setup = () => { const tool: SchoolExternalTool = schoolExternalToolFactory.build(); + const user: User = userFactory.build(); externalToolService.findExternalTools.mockResolvedValue(new Page([], 0)); schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([tool]); return { tool, + user, }; }; - it('should call the toolPermissionHelper with SCHOOL_TOOL_ADMIN permission', async () => { - const { tool } = setup(); + it('should check for SCHOOL_TOOL_ADMIN permission', async () => { + const { tool, user } = setup(); await uc.getAvailableToolsForSchool('userId', 'schoolId'); - expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( - 'userId', + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, tool, AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); }); - describe('when toolPermissionHelper throws ForbiddenException', () => { + describe('when permission check throws ForbiddenException', () => { const setup = () => { const tool: SchoolExternalTool = schoolExternalToolFactory.build(); externalToolService.findExternalTools.mockResolvedValue(new Page([], 0)); schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([tool]); - toolPermissionHelper.ensureSchoolPermissions.mockImplementation(() => { + authorizationService.checkPermission.mockImplementation(() => { throw new ForbiddenException(); }); }; @@ -209,23 +219,25 @@ describe('ExternalToolConfigurationUc', () => { describe('getAvailableToolsForContext', () => { describe('when the user has insufficient permission', () => { const setup = () => { + const user: User = userFactory.build(); const tool: ContextExternalTool = contextExternalToolFactory.build(); + authorizationService.getUserWithPermissions.mockResolvedValue(user); toolPermissionHelper.ensureContextPermissions.mockRejectedValue(new ForbiddenException()); contextExternalToolService.findContextExternalTools.mockResolvedValue([tool]); - return { tool }; + return { user, tool }; }; it('should fail when authorizationService throws ForbiddenException', async () => { - const { tool } = setup(); + const { user, tool } = setup(); const func = async () => uc.getAvailableToolsForContext('userId', 'schoolId', 'contextId', ToolContextType.COURSE); await expect(func).rejects.toThrow(ForbiddenException); expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( - 'userId', + user, tool, AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]) ); @@ -234,6 +246,7 @@ describe('ExternalToolConfigurationUc', () => { describe('when getting the list of external tools that can be added to a school', () => { const setup = () => { + const user: User = userFactory.build(); const hiddenTool: ExternalTool = externalToolFactory.buildWithId({ isHidden: true }); const usedTool: ExternalTool = externalToolFactory.buildWithId({ isHidden: false }, 'usedToolId'); const unusedTool: ExternalTool = externalToolFactory.buildWithId({ isHidden: false }, 'unusedToolId'); @@ -270,6 +283,7 @@ describe('ExternalToolConfigurationUc', () => { unusedSchoolExternalTool, ]); contextExternalToolService.findContextExternalTools.mockResolvedValue([usedContextExternalTool]); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); externalToolConfigurationService.filterForAvailableSchoolExternalTools.mockReturnValue([ usedSchoolExternalTool, @@ -282,6 +296,7 @@ describe('ExternalToolConfigurationUc', () => { ]); return { + user, toolIds, externalTools, schoolExternalTools, @@ -296,12 +311,12 @@ describe('ExternalToolConfigurationUc', () => { }; it('should call the toolPermissionHelper with CONTEXT_TOOL_ADMIN permission', async () => { - const { usedContextExternalTool } = setup(); + const { user, usedContextExternalTool } = setup(); await uc.getAvailableToolsForContext('userId', 'schoolId', 'contextId', ToolContextType.COURSE); expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( - 'userId', + user, usedContextExternalTool, AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]) ); @@ -456,34 +471,39 @@ describe('ExternalToolConfigurationUc', () => { describe('when the user has permission to read an external tool', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const user: User = userFactory.build(); + const school: School = schoolFactory.build(); const schoolExternalToolId: string = new ObjectId().toHexString(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId( { toolId: externalTool.id, - schoolId: 'schoolId', + schoolId: school.id, }, schoolExternalToolId ); + schoolService.getSchoolById.mockResolvedValueOnce(school); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); externalToolService.findById.mockResolvedValueOnce(externalTool); return { + user, externalTool, schoolExternalToolId, - schoolExternalTool, + school, }; }; it('should successfully check the user permission with the authorization service', async () => { - const { schoolExternalToolId, schoolExternalTool } = setup(); + const { user, schoolExternalToolId, school } = setup(); await uc.getTemplateForSchoolExternalTool('userId', schoolExternalToolId); - expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( - 'userId', - schoolExternalTool, + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + school, AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); @@ -506,7 +526,7 @@ describe('ExternalToolConfigurationUc', () => { ); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); - toolPermissionHelper.ensureSchoolPermissions.mockImplementation(() => { + authorizationService.checkPermission.mockImplementation(() => { throw new UnauthorizedException(); }); @@ -560,6 +580,7 @@ describe('ExternalToolConfigurationUc', () => { describe('getTemplateForContextExternalTool', () => { describe('when the user has permission to read an external tool', () => { const setup = () => { + const user: User = userFactory.build(); const externalTool: ExternalTool = externalToolFactory.buildWithId(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ @@ -581,10 +602,12 @@ describe('ExternalToolConfigurationUc', () => { ); contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); externalToolService.findById.mockResolvedValueOnce(externalTool); return { + user, externalTool, contextExternalTool, contextExternalToolId, @@ -592,12 +615,12 @@ describe('ExternalToolConfigurationUc', () => { }; it('should successfully check the user permission with the toolPermissionHelper', async () => { - const { contextExternalToolId, contextExternalTool } = setup(); + const { user, contextExternalToolId, contextExternalTool } = setup(); - await uc.getTemplateForContextExternalTool('userId', contextExternalToolId); + await uc.getTemplateForContextExternalTool(user.id, contextExternalToolId); expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( - 'userId', + user, contextExternalTool, AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]) ); diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts index 90d9f8f718c..c1b01ac33e2 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool-configuration.uc.ts @@ -1,10 +1,11 @@ import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; -import { Inject, Injectable, forwardRef } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common/exceptions/not-found.exception'; import { Page } from '@shared/domain/domainobject/page'; +import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { User } from '@shared/domain/entity'; +import { School, SchoolService } from '@src/modules/school'; import { CustomParameterScope, ToolContextType } from '../../common/enum'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalTool } from '../../context-external-tool/domain'; @@ -25,7 +26,8 @@ export class ExternalToolConfigurationUc { private readonly toolPermissionHelper: ToolPermissionHelper, private readonly externalToolConfigurationService: ExternalToolConfigurationService, private readonly externalToolLogoService: ExternalToolLogoService, - private readonly authorizationService: AuthorizationService + private readonly authorizationService: AuthorizationService, + private readonly schoolService: SchoolService ) {} public async getToolContextTypes(userId: EntityId): Promise { @@ -46,9 +48,11 @@ export class ExternalToolConfigurationUc { } ); - const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const school: School = await this.schoolService.getSchoolById(schoolId); - await this.ensureSchoolPermissions(userId, schoolExternalToolsInUse, context); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + await this.ensureSchoolPermissions(user, schoolExternalToolsInUse, school, context); const toolIdsInUse: EntityId[] = schoolExternalToolsInUse.map( (schoolExternalTool: SchoolExternalTool): EntityId => schoolExternalTool.toolId @@ -92,9 +96,10 @@ export class ExternalToolConfigurationUc { context: { id: contextId, type: contextType }, }), ]); - const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]); + const user: User = await this.authorizationService.getUserWithPermissions(userId); - await this.ensureContextPermissions(userId, contextExternalToolsInUse, context); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]); + await this.ensureContextPermissions(user, contextExternalToolsInUse, context); const availableSchoolExternalTools: SchoolExternalTool[] = this.externalToolConfigurationService.filterForAvailableSchoolExternalTools( @@ -135,9 +140,11 @@ export class ExternalToolConfigurationUc { schoolExternalToolId: EntityId ): Promise { const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById(schoolExternalToolId); - const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const school: School = await this.schoolService.getSchoolById(schoolExternalTool.schoolId); - await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + this.authorizationService.checkPermission(user, school, context); const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); @@ -157,9 +164,10 @@ export class ExternalToolConfigurationUc { const contextExternalTool: ContextExternalTool = await this.contextExternalToolService.findByIdOrFail( contextExternalToolId ); + const user: User = await this.authorizationService.getUserWithPermissions(userId); const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_ADMIN]); - await this.toolPermissionHelper.ensureContextPermissions(userId, contextExternalTool, context); + await this.toolPermissionHelper.ensureContextPermissions(user, contextExternalTool, context); const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById( contextExternalTool.schoolToolRef.schoolToolId @@ -180,25 +188,22 @@ export class ExternalToolConfigurationUc { } private async ensureSchoolPermissions( - userId: EntityId, + user: User, tools: SchoolExternalTool[], + school: School, context: AuthorizationContext ): Promise { - await Promise.all( - tools.map(async (tool: SchoolExternalTool) => - this.toolPermissionHelper.ensureSchoolPermissions(userId, tool, context) - ) - ); + await Promise.all(tools.map(() => this.authorizationService.checkPermission(user, school, context))); } private async ensureContextPermissions( - userId: EntityId, + user: User, tools: ContextExternalTool[], context: AuthorizationContext ): Promise { await Promise.all( tools.map(async (tool: ContextExternalTool) => - this.toolPermissionHelper.ensureContextPermissions(userId, tool, context) + this.toolPermissionHelper.ensureContextPermissions(user, tool, context) ) ); } diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts index 47bebac803b..cf35acccf86 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.spec.ts @@ -1,23 +1,43 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ObjectId } from '@mikro-orm/mongodb'; import { ICurrentUser } from '@modules/authentication'; import { Action, AuthorizationService } from '@modules/authorization'; +import { School, SchoolService } from '@modules/school'; +import { schoolFactory } from '@modules/school/testing'; +import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; +import { SchoolExternalToolService } from '@modules/tool/school-external-tool/service'; import { UnauthorizedException, UnprocessableEntityException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Page } from '@shared/domain/domainobject/page'; import { Role, User } from '@shared/domain/entity'; import { IFindOptions, Permission, SortOrder } from '@shared/domain/interface'; -import { roleFactory, setupEntities, userFactory } from '@shared/testing'; -import { externalToolFactory, oauth2ToolConfigFactory } from '@shared/testing/factory'; +import { + customParameterFactory, + externalToolDatasheetTemplateDataFactory, + externalToolFactory, + oauth2ToolConfigFactory, + roleFactory, + schoolExternalToolFactory, + setupEntities, + userFactory, +} from '@shared/testing'; +import { CustomParameter } from '../../common/domain'; import { ExternalToolSearchQuery } from '../../common/interface'; -import { ExternalTool, ExternalToolMetadata, Oauth2ToolConfig } from '../domain'; +import { CommonToolMetadataService } from '../../common/service/common-tool-metadata.service'; +import { + ExternalTool, + ExternalToolDatasheetTemplateData, + ExternalToolMetadata, + ExternalToolParameterDatasheetTemplateProperty, + Oauth2ToolConfig, +} from '../domain'; import { + DatasheetPdfService, ExternalToolLogoService, - ExternalToolMetadataService, ExternalToolService, ExternalToolValidationService, } from '../service'; - import { ExternalToolUpdate } from './dto'; import { ExternalToolUc } from './external-tool.uc'; @@ -26,14 +46,19 @@ describe('ExternalToolUc', () => { let uc: ExternalToolUc; let externalToolService: DeepMocked; + let schoolExternalToolService: DeepMocked; + let schoolService: DeepMocked; let authorizationService: DeepMocked; let toolValidationService: DeepMocked; let logoService: DeepMocked; - let externalToolMetadataService: DeepMocked; + let commonToolMetadataService: DeepMocked; + let pdfService: DeepMocked; beforeAll(async () => { await setupEntities(); + Configuration.set('SC_THEME', 'default'); + module = await Test.createTestingModule({ providers: [ ExternalToolUc, @@ -41,6 +66,14 @@ describe('ExternalToolUc', () => { provide: ExternalToolService, useValue: createMock(), }, + { + provide: SchoolExternalToolService, + useValue: createMock(), + }, + { + provide: SchoolService, + useValue: createMock(), + }, { provide: AuthorizationService, useValue: createMock(), @@ -54,18 +87,25 @@ describe('ExternalToolUc', () => { useValue: createMock(), }, { - provide: ExternalToolMetadataService, - useValue: createMock(), + provide: CommonToolMetadataService, + useValue: createMock(), + }, + { + provide: DatasheetPdfService, + useValue: createMock(), }, ], }).compile(); uc = module.get(ExternalToolUc); externalToolService = module.get(ExternalToolService); + schoolExternalToolService = module.get(SchoolExternalToolService); + schoolService = module.get(SchoolService); authorizationService = module.get(AuthorizationService); toolValidationService = module.get(ExternalToolValidationService); logoService = module.get(ExternalToolLogoService); - externalToolMetadataService = module.get(ExternalToolMetadataService); + commonToolMetadataService = module.get(CommonToolMetadataService); + pdfService = module.get(DatasheetPdfService); }); afterAll(async () => { @@ -88,7 +128,7 @@ describe('ExternalToolUc', () => { }; }; - const setup = () => { + const setupDefault = () => { const toolId = 'toolId'; const externalTool: ExternalTool = externalToolFactory.withCustomParameters(1).buildWithId(); @@ -129,7 +169,7 @@ describe('ExternalToolUc', () => { describe('Authorization', () => { it('should call getUserWithPermissions', async () => { const { currentUser } = setupAuthorization(); - const { externalTool } = setup(); + const { externalTool } = setupDefault(); await uc.createExternalTool(currentUser.userId, externalTool); @@ -138,7 +178,7 @@ describe('ExternalToolUc', () => { it('should successfully check the user permission with the authorization service', async () => { const { currentUser, user } = setupAuthorization(); - const { externalTool } = setup(); + const { externalTool } = setupDefault(); await uc.createExternalTool(currentUser.userId, externalTool); @@ -147,7 +187,7 @@ describe('ExternalToolUc', () => { it('should throw if the user has insufficient permission to create an external tool', async () => { const { currentUser } = setupAuthorization(); - const { externalTool } = setup(); + const { externalTool } = setupDefault(); authorizationService.checkAllPermissions.mockImplementation(() => { throw new UnauthorizedException(); }); @@ -160,7 +200,7 @@ describe('ExternalToolUc', () => { it('should validate the tool', async () => { const { currentUser } = setupAuthorization(); - const { externalTool } = setup(); + const { externalTool } = setupDefault(); await uc.createExternalTool(currentUser.userId, externalTool); @@ -169,7 +209,7 @@ describe('ExternalToolUc', () => { it('should throw if validation of the tool fails', async () => { const { currentUser } = setupAuthorization(); - const { externalTool } = setup(); + const { externalTool } = setupDefault(); toolValidationService.validateCreate.mockImplementation(() => { throw new UnprocessableEntityException(); }); @@ -181,7 +221,7 @@ describe('ExternalToolUc', () => { it('should call the service to save a tool', async () => { const { currentUser } = setupAuthorization(); - const { externalTool } = setup(); + const { externalTool } = setupDefault(); await uc.createExternalTool(currentUser.userId, externalTool); @@ -190,7 +230,7 @@ describe('ExternalToolUc', () => { it('should return saved a tool', async () => { const { currentUser } = setupAuthorization(); - const { externalTool } = setup(); + const { externalTool } = setupDefault(); const result: ExternalTool = await uc.createExternalTool(currentUser.userId, externalTool); @@ -198,7 +238,7 @@ describe('ExternalToolUc', () => { }); describe('when fetching logo', () => { - const setupLogo = () => { + const setup = () => { const user: User = userFactory.buildWithId(); const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; @@ -213,7 +253,7 @@ describe('ExternalToolUc', () => { }; it('should call ExternalToolLogoService', async () => { - const { currentUser, externalTool } = setupLogo(); + const { currentUser, externalTool } = setup(); await uc.createExternalTool(currentUser.userId, externalTool); @@ -226,7 +266,7 @@ describe('ExternalToolUc', () => { describe('Authorization', () => { it('should call getUserWithPermissions', async () => { const { currentUser } = setupAuthorization(); - const { query, options } = setup(); + const { query, options } = setupDefault(); await uc.findExternalTool(currentUser.userId, query, options); @@ -235,7 +275,7 @@ describe('ExternalToolUc', () => { it('should successfully check the user permission with the authorization service', async () => { const { currentUser, user } = setupAuthorization(); - const { query, options } = setup(); + const { query, options } = setupDefault(); await uc.findExternalTool(currentUser.userId, query, options); @@ -244,7 +284,7 @@ describe('ExternalToolUc', () => { it('should throw if the user has insufficient permission to find an external tool', async () => { const { currentUser } = setupAuthorization(); - const { query, options } = setup(); + const { query, options } = setupDefault(); authorizationService.checkAllPermissions.mockImplementation(() => { throw new UnauthorizedException(); }); @@ -257,7 +297,7 @@ describe('ExternalToolUc', () => { it('should call the externalToolService', async () => { const { currentUser } = setupAuthorization(); - const { query, options } = setup(); + const { query, options } = setupDefault(); await uc.findExternalTool(currentUser.userId, query, options); @@ -266,7 +306,7 @@ describe('ExternalToolUc', () => { it('should return a page of externalTool', async () => { const { currentUser } = setupAuthorization(); - const { query, options, page } = setup(); + const { query, options, page } = setupDefault(); externalToolService.findExternalTools.mockResolvedValue(page); const resultPage: Page = await uc.findExternalTool(currentUser.userId, query, options); @@ -279,7 +319,7 @@ describe('ExternalToolUc', () => { describe('Authorization', () => { it('should call getUserWithPermissions', async () => { const { currentUser } = setupAuthorization(); - const { toolId } = setup(); + const { toolId } = setupDefault(); await uc.getExternalTool(currentUser.userId, toolId); @@ -288,7 +328,7 @@ describe('ExternalToolUc', () => { it('should successfully check the user permission with the authorization service', async () => { const { currentUser, user } = setupAuthorization(); - const { toolId } = setup(); + const { toolId } = setupDefault(); await uc.getExternalTool(currentUser.userId, toolId); @@ -297,7 +337,7 @@ describe('ExternalToolUc', () => { it('should throw if the user has insufficient permission to get an external tool', async () => { const { currentUser } = setupAuthorization(); - const { toolId } = setup(); + const { toolId } = setupDefault(); authorizationService.checkAllPermissions.mockImplementation(() => { throw new UnauthorizedException(); }); @@ -310,7 +350,7 @@ describe('ExternalToolUc', () => { it('should fetch a tool', async () => { const { currentUser } = setupAuthorization(); - const { externalTool, toolId } = setup(); + const { externalTool, toolId } = setupDefault(); externalToolService.findById.mockResolvedValue(externalTool); const result: ExternalTool = await uc.getExternalTool(currentUser.userId, toolId); @@ -320,8 +360,8 @@ describe('ExternalToolUc', () => { }); describe('updateExternalTool', () => { - const setupUpdate = () => { - const { externalTool, toolId } = setup(); + const setup = () => { + const { externalTool, toolId } = setupDefault(); const externalToolDOtoUpdate: ExternalToolUpdate = { id: toolId, @@ -350,7 +390,7 @@ describe('ExternalToolUc', () => { describe('Authorization', () => { it('should call getUserWithPermissions', async () => { const { currentUser } = setupAuthorization(); - const { toolId, externalToolDOtoUpdate } = setupUpdate(); + const { toolId, externalToolDOtoUpdate } = setup(); await uc.updateExternalTool(currentUser.userId, toolId, externalToolDOtoUpdate); @@ -359,7 +399,7 @@ describe('ExternalToolUc', () => { it('should successfully check the user permission with the authorization service', async () => { const { currentUser, user } = setupAuthorization(); - const { toolId, externalToolDOtoUpdate } = setupUpdate(); + const { toolId, externalToolDOtoUpdate } = setup(); await uc.updateExternalTool(currentUser.userId, toolId, externalToolDOtoUpdate); @@ -368,7 +408,7 @@ describe('ExternalToolUc', () => { it('should throw if the user has insufficient permission to get an external tool', async () => { const { currentUser } = setupAuthorization(); - const { toolId, externalToolDOtoUpdate } = setupUpdate(); + const { toolId, externalToolDOtoUpdate } = setup(); authorizationService.checkAllPermissions.mockImplementation(() => { throw new UnauthorizedException(); }); @@ -381,7 +421,7 @@ describe('ExternalToolUc', () => { it('should validate the tool', async () => { const { currentUser } = setupAuthorization(); - const { toolId, externalToolDOtoUpdate } = setupUpdate(); + const { toolId, externalToolDOtoUpdate } = setup(); await uc.updateExternalTool(currentUser.userId, toolId, externalToolDOtoUpdate); @@ -390,7 +430,7 @@ describe('ExternalToolUc', () => { it('should throw if validation of the tool fails', async () => { const { currentUser } = setupAuthorization(); - const { toolId, externalToolDOtoUpdate } = setupUpdate(); + const { toolId, externalToolDOtoUpdate } = setup(); toolValidationService.validateUpdate.mockImplementation(() => { throw new UnprocessableEntityException(); }); @@ -402,7 +442,7 @@ describe('ExternalToolUc', () => { it('should call the service to update the tool', async () => { const { currentUser } = setupAuthorization(); - const { toolId, updatedExternalToolDO, externalToolDOtoUpdate } = setupUpdate(); + const { toolId, updatedExternalToolDO, externalToolDOtoUpdate } = setup(); await uc.updateExternalTool(currentUser.userId, toolId, externalToolDOtoUpdate); @@ -414,7 +454,7 @@ describe('ExternalToolUc', () => { it('should return the updated tool', async () => { const { currentUser } = setupAuthorization(); - const { toolId, externalToolDOtoUpdate, updatedExternalToolDO } = setupUpdate(); + const { toolId, externalToolDOtoUpdate, updatedExternalToolDO } = setup(); const result: ExternalTool = await uc.updateExternalTool(currentUser.userId, toolId, externalToolDOtoUpdate); @@ -447,7 +487,7 @@ describe('ExternalToolUc', () => { }); describe('deleteExternalTool', () => { - const setupDelete = () => { + const setup = () => { const toolId = 'toolId'; const currentUser: ICurrentUser = { userId: 'userId' } as ICurrentUser; const user: User = userFactory.buildWithId(); @@ -462,7 +502,7 @@ describe('ExternalToolUc', () => { }; it('should check that the user has TOOL_ADMIN permission', async () => { - const { toolId, currentUser, user } = setupDelete(); + const { toolId, currentUser, user } = setup(); await uc.deleteExternalTool(currentUser.userId, toolId); @@ -471,7 +511,7 @@ describe('ExternalToolUc', () => { }); it('should call the externalToolService', async () => { - const { toolId, currentUser } = setupDelete(); + const { toolId, currentUser } = setup(); await uc.deleteExternalTool(currentUser.userId, toolId); @@ -481,7 +521,7 @@ describe('ExternalToolUc', () => { describe('getMetadataForExternalTool', () => { describe('when authorize user', () => { - const setupMetadata = () => { + const setup = () => { const toolId: string = new ObjectId().toHexString(); const tool: ExternalTool = externalToolFactory.buildWithId({ id: toolId }, toolId); @@ -503,7 +543,7 @@ describe('ExternalToolUc', () => { }; it('get user with permissions', async () => { - const { toolId, user } = setupMetadata(); + const { toolId, user } = setup(); await uc.getMetadataForExternalTool(user.id, toolId); @@ -511,7 +551,7 @@ describe('ExternalToolUc', () => { }); it('should check that the user has TOOL_ADMIN permission', async () => { - const { user, tool } = setupMetadata(); + const { user, tool } = setup(); await uc.getMetadataForExternalTool(user.id, tool.id!); @@ -520,7 +560,7 @@ describe('ExternalToolUc', () => { }); describe('when user has insufficient permission to get an metadata for external tool ', () => { - const setupMetadata = () => { + const setup = () => { const toolId: string = new ObjectId().toHexString(); const user: User = userFactory.buildWithId(); @@ -534,7 +574,7 @@ describe('ExternalToolUc', () => { }; it('should throw UnauthorizedException ', async () => { - const { toolId, user } = setupMetadata(); + const { toolId, user } = setup(); const result: Promise = uc.getMetadataForExternalTool(user.id, toolId); @@ -543,7 +583,7 @@ describe('ExternalToolUc', () => { }); describe('when externalToolId is given', () => { - const setupMetadata = () => { + const setup = () => { const toolId: string = new ObjectId().toHexString(); const externalToolMetadata: ExternalToolMetadata = new ExternalToolMetadata({ @@ -551,7 +591,7 @@ describe('ExternalToolUc', () => { contextExternalToolCountPerContext: { course: 3, boardElement: 3 }, }); - externalToolMetadataService.getMetadata.mockResolvedValue(externalToolMetadata); + commonToolMetadataService.getMetadataForExternalTool.mockResolvedValue(externalToolMetadata); const user: User = userFactory.buildWithId(); const currentUser: ICurrentUser = { userId: user.id } as ICurrentUser; @@ -567,15 +607,15 @@ describe('ExternalToolUc', () => { }; it('get metadata for external tool', async () => { - const { toolId, currentUser } = setupMetadata(); + const { toolId, currentUser } = setup(); await uc.getMetadataForExternalTool(currentUser.userId, toolId); - expect(externalToolMetadataService.getMetadata).toHaveBeenCalledWith(toolId); + expect(commonToolMetadataService.getMetadataForExternalTool).toHaveBeenCalledWith(toolId); }); it('return metadata of external tool', async () => { - const { toolId, currentUser, externalToolMetadata } = setupMetadata(); + const { toolId, currentUser, externalToolMetadata } = setup(); const result = await uc.getMetadataForExternalTool(currentUser.userId, toolId); @@ -583,4 +623,199 @@ describe('ExternalToolUc', () => { }); }); }); + + describe('getDatasheet', () => { + describe('when user has insufficient permission to create a datasheet for an external tool ', () => { + const setup = () => { + const toolId: string = new ObjectId().toHexString(); + + const user: User = userFactory.buildWithId(); + + authorizationService.getUserWithPermissions.mockRejectedValue(new UnauthorizedException()); + + return { + user, + toolId, + }; + }; + + it('should throw UnauthorizedException ', async () => { + const { toolId, user } = setup(); + + const result = uc.getDatasheet(user.id, toolId); + + await expect(result).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('when externalToolId is given', () => { + const setup = () => { + const toolId: string = new ObjectId().toHexString(); + const user: User = userFactory.buildWithId(); + const school: School = schoolFactory.build(); + + const param: CustomParameter = customParameterFactory.build(); + const externalTool: ExternalTool = externalToolFactory.build({ parameters: [param] }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.build(); + const datasheetData: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory + .withParameters(1, { name: param.name, properties: ExternalToolParameterDatasheetTemplateProperty.MANDATORY }) + .build({ + toolName: externalTool.name, + instance: 'dBildungscloud', + creatorName: `${user.firstName} ${user.lastName}`, + schoolName: school.getInfo().name, + }); + + authorizationService.getUserWithPermissions.mockResolvedValue(user); + authorizationService.hasAllPermissions.mockReturnValue(true); + externalToolService.findById.mockResolvedValue(externalTool); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([schoolExternalTool]); + schoolService.getSchoolById.mockResolvedValue(school); + pdfService.generatePdf.mockResolvedValue(Buffer.from('mockData')); + + return { + user, + toolId, + datasheetData, + schoolExternalTool, + }; + }; + + it('should get user with permission', async () => { + const { user, toolId } = setup(); + + await uc.getDatasheet(user.id, toolId); + + expect(authorizationService.getUserWithPermissions).toHaveBeenCalledWith(user.id); + }); + + it('should check that the user has TOOL_ADMIN ot SCHOOL_TOOL_ADMIN permission', async () => { + const { user, toolId } = setup(); + + await uc.getDatasheet(user.id, toolId); + + expect(authorizationService.checkOneOfPermissions).toHaveBeenCalledWith(user, [ + Permission.TOOL_ADMIN, + Permission.SCHOOL_TOOL_ADMIN, + ]); + }); + + it('should get external tool', async () => { + const { toolId, user } = setup(); + + await uc.getDatasheet(user.id, toolId); + + expect(externalToolService.findById).toHaveBeenCalledWith(toolId); + }); + + it('should get school external tool', async () => { + const { toolId, user } = setup(); + + await uc.getDatasheet(user.id, toolId); + + expect(schoolExternalToolService.findSchoolExternalTools).toHaveBeenCalledWith({ + schoolId: user.school.id, + toolId, + }); + }); + + it('should get school', async () => { + const { toolId, user, schoolExternalTool } = setup(); + + await uc.getDatasheet(user.id, toolId); + + expect(schoolService.getSchoolById).toHaveBeenCalledWith(schoolExternalTool.schoolId); + }); + + it('should create pdf buffer', async () => { + const { toolId, user, datasheetData } = setup(); + + await uc.getDatasheet(user.id, toolId); + + expect(pdfService.generatePdf).toHaveBeenCalledWith(datasheetData); + }); + + it('should return buffer', async () => { + const { toolId, user } = setup(); + + const result = await uc.getDatasheet(user.id, toolId); + + expect(result).toEqual(expect.any(Buffer)); + }); + }); + + describe('when there are no schoolExternalTools', () => { + const setup = () => { + const toolId: string = new ObjectId().toHexString(); + const user: User = userFactory.buildWithId(); + + const externalTool: ExternalTool = externalToolFactory.build(); + const datasheetData: ExternalToolDatasheetTemplateData = externalToolDatasheetTemplateDataFactory.build({ + toolName: externalTool.name, + instance: 'dBildungscloud', + creatorName: `${user.firstName} ${user.lastName}`, + }); + + authorizationService.getUserWithPermissions.mockResolvedValue(user); + externalToolService.findById.mockResolvedValue(externalTool); + schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([]); + pdfService.generatePdf.mockResolvedValueOnce(Buffer.from('mockData')); + + return { + user, + toolId, + datasheetData, + }; + }; + + it('should create pdf buffer', async () => { + const { toolId, user, datasheetData } = setup(); + + await uc.getDatasheet(user.id, toolId); + + expect(pdfService.generatePdf).toHaveBeenCalledWith(datasheetData); + }); + }); + }); + + describe('createDatasheetFilename', () => { + describe('when externalToolId is given', () => { + const setup = () => { + const toolId: string = new ObjectId().toHexString(); + const externalTool: ExternalTool = externalToolFactory.withCustomParameters(1).build(); + + const date = new Date(); + jest.setSystemTime(date); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const dateString = `${year}-${month}-${day}`; + + const filename = `CTL-Datenblatt-${externalTool.name}-${dateString}.pdf`; + + externalToolService.findById.mockResolvedValue(externalTool); + + return { + toolId, + filename, + }; + }; + + it('should get datasheetData', async () => { + const { toolId } = setup(); + + await uc.createDatasheetFilename(toolId); + + expect(externalToolService.findById).toHaveBeenCalledWith(toolId); + }); + + it('should create a filename', async () => { + const { toolId, filename } = setup(); + + const result = await uc.createDatasheetFilename(toolId); + + expect(result).toEqual(filename); + }); + }); + }); }); diff --git a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts index c48454adb07..1fd6ca576bf 100644 --- a/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts +++ b/apps/server/src/modules/tool/external-tool/uc/external-tool.uc.ts @@ -1,14 +1,19 @@ import { AuthorizationService } from '@modules/authorization'; +import { School, SchoolService } from '@modules/school'; +import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; +import { SchoolExternalToolService } from '@modules/tool/school-external-tool/service'; import { Injectable } from '@nestjs/common'; import { Page } from '@shared/domain/domainobject'; import { User } from '@shared/domain/entity'; import { IFindOptions, Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { ExternalToolSearchQuery } from '../../common/interface'; -import { ExternalTool, ExternalToolConfig, ExternalToolMetadata } from '../domain'; +import { CommonToolMetadataService } from '../../common/service/common-tool-metadata.service'; +import { ExternalTool, ExternalToolConfig, ExternalToolDatasheetTemplateData, ExternalToolMetadata } from '../domain'; +import { ExternalToolDatasheetMapper } from '../mapper/external-tool-datasheet.mapper'; import { + DatasheetPdfService, ExternalToolLogoService, - ExternalToolMetadataService, ExternalToolService, ExternalToolValidationService, } from '../service'; @@ -18,10 +23,13 @@ import { ExternalToolCreate, ExternalToolUpdate } from './dto'; export class ExternalToolUc { constructor( private readonly externalToolService: ExternalToolService, + private readonly schoolExternalToolService: SchoolExternalToolService, + private readonly schoolService: SchoolService, private readonly authorizationService: AuthorizationService, private readonly toolValidationService: ExternalToolValidationService, private readonly externalToolLogoService: ExternalToolLogoService, - private readonly externalToolMetadataService: ExternalToolMetadataService + private readonly commonToolMetadataService: CommonToolMetadataService, + private readonly datasheetPdfService: DatasheetPdfService ) {} async createExternalTool(userId: EntityId, externalToolCreate: ExternalToolCreate): Promise { @@ -42,9 +50,8 @@ export class ExternalToolUc { externalTool.logo = await this.externalToolLogoService.fetchLogo(externalTool); - await this.toolValidationService.validateUpdate(toolId, externalTool); - const loaded: ExternalTool = await this.externalToolService.findById(toolId); + const configToUpdate: ExternalToolConfig = { ...loaded.config, ...externalTool.config }; const toUpdate: ExternalTool = new ExternalTool({ ...loaded, @@ -53,6 +60,8 @@ export class ExternalToolUc { version: loaded.version, }); + await this.toolValidationService.validateUpdate(toolId, toUpdate); + const saved: ExternalTool = await this.externalToolService.updateExternalTool(toUpdate, loaded); return saved; @@ -87,13 +96,62 @@ export class ExternalToolUc { // TODO N21-1496: Change External Tools to use authorizationService.checkPermission await this.ensurePermission(userId, Permission.TOOL_ADMIN); - const metadata: ExternalToolMetadata = await this.externalToolMetadataService.getMetadata(toolId); + const metadata: ExternalToolMetadata = await this.commonToolMetadataService.getMetadataForExternalTool(toolId); return metadata; } - private async ensurePermission(userId: EntityId, permission: Permission) { + private async ensurePermission(userId: EntityId, permission: Permission): Promise { const user: User = await this.authorizationService.getUserWithPermissions(userId); this.authorizationService.checkAllPermissions(user, [permission]); } + + public async getDatasheet(userId: EntityId, externalToolId: EntityId): Promise { + const user: User = await this.authorizationService.getUserWithPermissions(userId); + this.authorizationService.checkOneOfPermissions(user, [Permission.TOOL_ADMIN, Permission.SCHOOL_TOOL_ADMIN]); + + const schoolExternalTools: SchoolExternalTool[] = await this.schoolExternalToolService.findSchoolExternalTools({ + schoolId: user.school.id, + toolId: externalToolId, + }); + + let schoolExternalTool: SchoolExternalTool | undefined; + let schoolName: string | undefined; + if (schoolExternalTools.length) { + schoolExternalTool = schoolExternalTools[0]; + + if (this.authorizationService.hasAllPermissions(user, [Permission.SCHOOL_TOOL_ADMIN])) { + const school: School = await this.schoolService.getSchoolById(schoolExternalTool.schoolId); + schoolName = school.getInfo().name; + } + } + + const externalTool: ExternalTool = await this.externalToolService.findById(externalToolId); + const dataSheetData: ExternalToolDatasheetTemplateData = + ExternalToolDatasheetMapper.mapToExternalToolDatasheetTemplateData( + externalTool, + user.firstName, + user.lastName, + schoolExternalTool, + schoolName + ); + + const buffer: Buffer = await this.datasheetPdfService.generatePdf(dataSheetData); + + return buffer; + } + + public async createDatasheetFilename(externalToolId: EntityId): Promise { + const externalTool: ExternalTool = await this.externalToolService.findById(externalToolId); + + const date = new Date(); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const dateString = `${year}-${month}-${day}`; + + const fileName = `CTL-Datenblatt-${externalTool.name}-${dateString}.pdf`; + + return fileName; + } } diff --git a/apps/server/src/modules/tool/index.ts b/apps/server/src/modules/tool/index.ts index a7a6c23ddab..808813349b9 100644 --- a/apps/server/src/modules/tool/index.ts +++ b/apps/server/src/modules/tool/index.ts @@ -1,7 +1,5 @@ -export * from './common/entity/custom-parameter-entry.entity'; export * from './common/interface'; -export * from './context-external-tool/entity'; export * from './context-external-tool/service/context-external-tool-authorizable.service'; export * from './external-tool'; -export * from './school-external-tool/entity/school-external-tool.entity'; export * from './tool.module'; +export { default as ToolConfiguration, IToolFeatures } from './tool-config'; diff --git a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts index 7a80650eb76..96b205e7c73 100644 --- a/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/controller/api-test/tool-school.api.spec.ts @@ -3,22 +3,23 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Account, SchoolEntity, User } from '@shared/domain/entity'; +import { ColumnBoardNode, ExternalToolElementNodeEntity, SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { TestApiClient, UserAndAccountTestFactory, accountFactory, - contextExternalToolEntityFactory, - customParameterEntityFactory, - externalToolEntityFactory, - schoolExternalToolEntityFactory, - schoolFactory, + columnBoardNodeFactory, + externalToolElementNodeFactory, + schoolEntityFactory, userFactory, } from '@shared/testing'; import { schoolToolConfigurationStatusFactory } from '@shared/testing/factory'; +import { AccountEntity } from '@modules/account/entity/account.entity'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; +import { contextExternalToolEntityFactory } from '../../../context-external-tool/testing'; import { CustomParameterScope, CustomParameterType, ExternalToolEntity } from '../../../external-tool/entity'; +import { customParameterEntityFactory, externalToolEntityFactory } from '../../../external-tool/testing'; import { SchoolExternalToolEntity } from '../../entity'; import { CustomParameterEntryParam, @@ -28,6 +29,7 @@ import { SchoolExternalToolSearchListResponse, SchoolExternalToolSearchParams, } from '../dto'; +import { schoolExternalToolEntityFactory } from '../../testing/school-external-tool-entity.factory'; describe('ToolSchoolController (API)', () => { let app: INestApplication; @@ -59,14 +61,14 @@ describe('ToolSchoolController (API)', () => { describe('[POST] tools/school-external-tools', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ Permission.SCHOOL_TOOL_ADMIN, ]); const userWithMissingPermission: User = userFactory.buildWithId({ school }); - const accountWithMissingPermission: Account = accountFactory.buildWithId({ + const accountWithMissingPermission: AccountEntity = accountFactory.buildWithId({ userId: userWithMissingPermission.id, }); @@ -162,14 +164,14 @@ describe('ToolSchoolController (API)', () => { describe('[DELETE] tools/school-external-tools/:schoolExternalToolId', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ Permission.SCHOOL_TOOL_ADMIN, ]); const userWithMissingPermission: User = userFactory.buildWithId({ school }); - const accountWithMissingPermission: Account = accountFactory.buildWithId({ + const accountWithMissingPermission: AccountEntity = accountFactory.buildWithId({ userId: userWithMissingPermission.id, }); @@ -227,14 +229,14 @@ describe('ToolSchoolController (API)', () => { describe('[GET] tools/school-external-tools/', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ Permission.SCHOOL_TOOL_ADMIN, ]); const userWithMissingPermission: User = userFactory.buildWithId({ school }); - const accountWithMissingPermission: Account = accountFactory.buildWithId({ + const accountWithMissingPermission: AccountEntity = accountFactory.buildWithId({ userId: userWithMissingPermission.id, }); @@ -320,14 +322,14 @@ describe('ToolSchoolController (API)', () => { describe('[GET] tools/school-external-tools/:schoolExternalToolId', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ Permission.SCHOOL_TOOL_ADMIN, ]); const userWithMissingPermission: User = userFactory.buildWithId({ school }); - const accountWithMissingPermission: Account = accountFactory.buildWithId({ + const accountWithMissingPermission: AccountEntity = accountFactory.buildWithId({ userId: userWithMissingPermission.id, }); @@ -403,13 +405,13 @@ describe('ToolSchoolController (API)', () => { describe('[PUT] tools/school-external-tools/:schoolExternalToolId', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ Permission.SCHOOL_TOOL_ADMIN, ]); const userWithMissingPermission: User = userFactory.buildWithId({ school }); - const accountWithMissingPermission: Account = accountFactory.buildWithId({ + const accountWithMissingPermission: AccountEntity = accountFactory.buildWithId({ userId: userWithMissingPermission.id, }); @@ -543,7 +545,7 @@ describe('ToolSchoolController (API)', () => { describe('when schoolExternalToolId is given ', () => { const setup = async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const schoolToolId: string = new ObjectId().toHexString(); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId( { school }, @@ -562,9 +564,14 @@ describe('ToolSchoolController (API)', () => { contextId: new ObjectId().toHexString(), }); - const schoolExternalToolMetadata: SchoolExternalToolMetadataResponse = new SchoolExternalToolMetadataResponse({ - contextExternalToolCountPerContext: { course: 3, boardElement: 2 }, - }); + const board: ColumnBoardNode = columnBoardNodeFactory.buildWithId(); + const externalToolElements: ExternalToolElementNodeEntity[] = externalToolElementNodeFactory.buildListWithId( + 2, + { + contextExternalTool: boardExternalToolEntitys[0], + parent: board, + } + ); const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ Permission.SCHOOL_TOOL_ADMIN, @@ -576,12 +583,14 @@ describe('ToolSchoolController (API)', () => { schoolExternalToolEntity, ...courseExternalToolEntitys, ...boardExternalToolEntitys, + board, + ...externalToolElements, ]); em.clear(); const loggedInClient: TestApiClient = await testApiClient.login(adminAccount); - return { loggedInClient, schoolExternalToolEntity, schoolExternalToolMetadata }; + return { loggedInClient, schoolExternalToolEntity }; }; it('should return the metadata of schoolExternalTool', async () => { @@ -592,8 +601,8 @@ describe('ToolSchoolController (API)', () => { expect(response.statusCode).toEqual(HttpStatus.OK); expect(response.body).toEqual({ contextExternalToolCountPerContext: { - course: 3, - boardElement: 2, + course: 1, + boardElement: 1, }, }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts index 932ec713f54..4e963743a72 100644 --- a/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/entity/school-external-tool.entity.spec.ts @@ -1,14 +1,9 @@ -import { - basicToolConfigFactory, - customParameterEntityFactory, - externalToolEntityFactory, - schoolFactory, - setupEntities, -} from '@shared/testing'; +import { basicToolConfigFactory, schoolEntityFactory, setupEntities } from '@shared/testing'; import { schoolExternalToolConfigurationStatusEntityFactory } from '@shared/testing/factory/school-external-tool-configuration-status-entity.factory'; -import { schoolExternalToolEntityFactory } from '@shared/testing/factory/school-external-tool-entity.factory'; -import { CustomParameterEntity, ExternalToolEntity, ExternalToolConfigEntity } from '../../external-tool/entity'; import { CustomParameterLocation, CustomParameterScope, CustomParameterType, ToolConfigType } from '../../common/enum'; +import { CustomParameterEntity, ExternalToolConfigEntity, ExternalToolEntity } from '../../external-tool/entity'; +import { customParameterEntityFactory, externalToolEntityFactory } from '../../external-tool/testing'; +import { schoolExternalToolEntityFactory } from '../testing'; import { SchoolExternalToolEntity } from './school-external-tool.entity'; describe('SchoolExternalToolEntity', () => { @@ -58,7 +53,7 @@ describe('SchoolExternalToolEntity', () => { }); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ tool: externalToolEntity, - school: schoolFactory.buildWithId(), + school: schoolEntityFactory.buildWithId(), schoolParameters: [], toolVersion: 1, status: schoolExternalToolConfigurationStatusEntityFactory.build(), @@ -97,7 +92,7 @@ describe('SchoolExternalToolEntity', () => { }); const schoolExternalToolEntity: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ tool: externalToolEntity, - school: schoolFactory.buildWithId(), + school: schoolEntityFactory.buildWithId(), schoolParameters: [], toolVersion: 1, status: schoolExternalToolConfigurationStatusEntityFactory.build(), diff --git a/apps/server/src/modules/tool/school-external-tool/school-external-tool.module.ts b/apps/server/src/modules/tool/school-external-tool/school-external-tool.module.ts index 93d4c4f6705..2ff671cb62b 100644 --- a/apps/server/src/modules/tool/school-external-tool/school-external-tool.module.ts +++ b/apps/server/src/modules/tool/school-external-tool/school-external-tool.module.ts @@ -1,16 +1,12 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { CommonToolModule } from '../common'; -import { - SchoolExternalToolService, - SchoolExternalToolValidationService, - SchoolExternalToolMetadataService, -} from './service'; import { ExternalToolModule } from '../external-tool'; import { ToolConfigModule } from '../tool-config.module'; +import { SchoolExternalToolService, SchoolExternalToolValidationService } from './service'; @Module({ - imports: [CommonToolModule, ExternalToolModule, ToolConfigModule], - providers: [SchoolExternalToolService, SchoolExternalToolValidationService, SchoolExternalToolMetadataService], - exports: [SchoolExternalToolService, SchoolExternalToolValidationService, SchoolExternalToolMetadataService], + imports: [forwardRef(() => CommonToolModule), forwardRef(() => ExternalToolModule), ToolConfigModule], + providers: [SchoolExternalToolService, SchoolExternalToolValidationService], + exports: [SchoolExternalToolService, SchoolExternalToolValidationService], }) export class SchoolExternalToolModule {} diff --git a/apps/server/src/modules/tool/school-external-tool/service/index.ts b/apps/server/src/modules/tool/school-external-tool/service/index.ts index ea949d8b70a..1ceab5f3da5 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/index.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/index.ts @@ -1,3 +1,2 @@ export * from './school-external-tool.service'; export * from './school-external-tool-validation.service'; -export * from './school-external-tool-metadata.service'; diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.spec.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.spec.ts deleted file mode 100644 index 8aa29737550..00000000000 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { ObjectId } from '@mikro-orm/mongodb'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ContextExternalToolRepo } from '@shared/repo'; -import { Logger } from '@src/core/logger'; -import { SchoolExternalToolMetadata } from '../domain'; -import { SchoolExternalToolMetadataService } from './school-external-tool-metadata.service'; - -describe('SchoolExternalToolMetadataService', () => { - let module: TestingModule; - let service: SchoolExternalToolMetadataService; - - let contextExternalToolRepo: DeepMocked; - - beforeAll(async () => { - module = await Test.createTestingModule({ - providers: [ - SchoolExternalToolMetadataService, - { - provide: ContextExternalToolRepo, - useValue: createMock(), - }, - { - provide: Logger, - useValue: createMock(), - }, - ], - }).compile(); - - service = module.get(SchoolExternalToolMetadataService); - contextExternalToolRepo = module.get(ContextExternalToolRepo); - }); - - afterAll(async () => { - await module.close(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('getMetadata', () => { - describe('when schoolExternalToolId is given', () => { - const setup = () => { - const schoolToolId: string = new ObjectId().toHexString(); - - const schoolExternalToolMetadata: SchoolExternalToolMetadata = new SchoolExternalToolMetadata({ - contextExternalToolCountPerContext: { course: 3, boardElement: 3 }, - }); - - contextExternalToolRepo.countBySchoolToolIdsAndContextType.mockResolvedValue(3); - - return { - schoolToolId, - schoolExternalToolMetadata, - }; - }; - - it('should return externalToolMetadata', async () => { - const { schoolToolId, schoolExternalToolMetadata } = setup(); - - const result: SchoolExternalToolMetadata = await service.getMetadata(schoolToolId); - - expect(result).toEqual(schoolExternalToolMetadata); - }); - }); - - describe('when no related context external tool was found', () => { - const setup = () => { - const schoolToolId: string = new ObjectId().toHexString(); - - const schoolExternalToolMetadata: SchoolExternalToolMetadata = new SchoolExternalToolMetadata({ - contextExternalToolCountPerContext: { course: 0, boardElement: 0 }, - }); - - contextExternalToolRepo.countBySchoolToolIdsAndContextType.mockResolvedValue(0); - - return { - schoolToolId, - schoolExternalToolMetadata, - }; - }; - - it('should return empty schoolExternalToolMetadata', async () => { - const { schoolToolId, schoolExternalToolMetadata } = setup(); - - const result: SchoolExternalToolMetadata = await service.getMetadata(schoolToolId); - - expect(result).toEqual(schoolExternalToolMetadata); - }); - }); - }); -}); diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts deleted file mode 100644 index 953e3decb26..00000000000 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool-metadata.service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { EntityId } from '@shared/domain/types'; -import { ContextExternalToolRepo } from '@shared/repo'; -import { ToolContextType } from '../../common/enum'; -import { ToolContextMapper } from '../../common/mapper/tool-context.mapper'; -import { ContextExternalToolType } from '../../context-external-tool/entity'; -import { SchoolExternalToolMetadata } from '../domain'; - -@Injectable() -export class SchoolExternalToolMetadataService { - constructor(private readonly contextToolRepo: ContextExternalToolRepo) {} - - async getMetadata(schoolExternalToolId: EntityId) { - const contextExternalToolCount: Record = { - [ContextExternalToolType.BOARD_ELEMENT]: 0, - [ContextExternalToolType.COURSE]: 0, - }; - - await Promise.all( - Object.values(ToolContextType).map(async (contextType: ToolContextType): Promise => { - const type: ContextExternalToolType = ToolContextMapper.contextMapping[contextType]; - - const countPerContext: number = await this.contextToolRepo.countBySchoolToolIdsAndContextType(type, [ - schoolExternalToolId, - ]); - - contextExternalToolCount[type] = countPerContext; - }) - ); - - const schoolExternalToolMetadata: SchoolExternalToolMetadata = new SchoolExternalToolMetadata({ - contextExternalToolCountPerContext: contextExternalToolCount, - }); - - return schoolExternalToolMetadata; - } -} diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts index ad0183f4536..95f9499a7c9 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.spec.ts @@ -3,15 +3,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ApiValidationError } from '@shared/common'; import { SchoolExternalToolRepo } from '@shared/repo'; import { + externalToolFactory, schoolExternalToolFactory, schoolToolConfigurationStatusFactory, - externalToolFactory, } from '@shared/testing/factory'; import { ExternalTool } from '../../external-tool/domain'; import { ExternalToolService } from '../../external-tool/service'; import { IToolFeatures, ToolFeatures } from '../../tool-config'; import { SchoolExternalToolConfigurationStatus } from '../controller/domain/school-external-tool-configuration-status'; import { SchoolExternalTool } from '../domain'; +import { SchoolExternalToolQuery } from '../uc/dto/school-external-tool.types'; import { SchoolExternalToolValidationService } from './school-external-tool-validation.service'; import { SchoolExternalToolService } from './school-external-tool.service'; @@ -78,7 +79,10 @@ describe('SchoolExternalToolService', () => { await service.findSchoolExternalTools(schoolExternalTool); - expect(schoolExternalToolRepo.find).toHaveBeenCalledWith({ schoolId: schoolExternalTool.schoolId }); + expect(schoolExternalToolRepo.find).toHaveBeenCalledWith<[Required]>({ + schoolId: schoolExternalTool.schoolId, + toolId: schoolExternalTool.toolId, + }); }); it('should return schoolExternalTool array', async () => { diff --git a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts index 0d58bcf00d0..b2b30e5b6fe 100644 --- a/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts +++ b/apps/server/src/modules/tool/school-external-tool/service/school-external-tool.service.ts @@ -26,6 +26,7 @@ export class SchoolExternalToolService { async findSchoolExternalTools(query: SchoolExternalToolQuery): Promise { let schoolExternalTools: SchoolExternalTool[] = await this.schoolExternalToolRepo.find({ schoolId: query.schoolId, + toolId: query.toolId, }); schoolExternalTools = await this.enrichWithDataFromExternalTools(schoolExternalTools); diff --git a/apps/server/src/modules/tool/school-external-tool/testing/index.ts b/apps/server/src/modules/tool/school-external-tool/testing/index.ts new file mode 100644 index 00000000000..cc2eb35d068 --- /dev/null +++ b/apps/server/src/modules/tool/school-external-tool/testing/index.ts @@ -0,0 +1 @@ +export { schoolExternalToolEntityFactory } from './school-external-tool-entity.factory'; diff --git a/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts b/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-entity.factory.ts similarity index 68% rename from apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts rename to apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-entity.factory.ts index e3f8b45cd59..081199aca04 100644 --- a/apps/server/src/shared/testing/factory/school-external-tool-entity.factory.ts +++ b/apps/server/src/modules/tool/school-external-tool/testing/school-external-tool-entity.factory.ts @@ -1,8 +1,8 @@ +import { externalToolEntityFactory } from '@modules/tool/external-tool/testing'; import { SchoolExternalToolEntity, SchoolExternalToolProperties } from '@modules/tool/school-external-tool/entity'; import { BaseFactory } from '@shared/testing/factory/base.factory'; -import { externalToolEntityFactory } from './external-tool-entity.factory'; -import { schoolExternalToolConfigurationStatusEntityFactory } from './school-external-tool-configuration-status-entity.factory'; -import { schoolFactory } from './school.factory'; +import { schoolEntityFactory } from '@shared/testing/factory/school-entity.factory'; +import { schoolExternalToolConfigurationStatusEntityFactory } from '@shared/testing/factory/school-external-tool-configuration-status-entity.factory'; export const schoolExternalToolEntityFactory = BaseFactory.define< SchoolExternalToolEntity, @@ -10,7 +10,7 @@ export const schoolExternalToolEntityFactory = BaseFactory.define< >(SchoolExternalToolEntity, () => { return { tool: externalToolEntityFactory.buildWithId(), - school: schoolFactory.buildWithId(), + school: schoolEntityFactory.buildWithId(), schoolParameters: [{ name: 'schoolMockParameter', value: 'mockValue' }], toolVersion: 0, status: schoolExternalToolConfigurationStatusEntityFactory.build(), diff --git a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts index d64ac866725..caca40953e5 100644 --- a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts +++ b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.spec.ts @@ -1,19 +1,17 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AuthorizationContextBuilder } from '@modules/authorization'; +import { AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { Test, TestingModule } from '@nestjs/testing'; import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { schoolExternalToolFactory, setupEntities, userFactory } from '@shared/testing'; -import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { School, SchoolService } from '@src/modules/school'; +import { schoolFactory } from '@modules/school/testing'; +import { CommonToolMetadataService } from '../../common/service/common-tool-metadata.service'; import { ContextExternalToolService } from '../../context-external-tool/service'; import { SchoolExternalTool } from '../domain'; -import { - SchoolExternalToolMetadataService, - SchoolExternalToolService, - SchoolExternalToolValidationService, -} from '../service'; +import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; import { SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; import { SchoolExternalToolUc } from './school-external-tool.uc'; @@ -24,8 +22,9 @@ describe('SchoolExternalToolUc', () => { let schoolExternalToolService: DeepMocked; let contextExternalToolService: DeepMocked; let schoolExternalToolValidationService: DeepMocked; - let toolPermissionHelper: DeepMocked; - let schoolExternalToolMetadataService: DeepMocked; + let commonToolMetadataService: DeepMocked; + let authorizationService: DeepMocked; + let schoolService: DeepMocked; beforeAll(async () => { await setupEntities(); @@ -45,12 +44,16 @@ describe('SchoolExternalToolUc', () => { useValue: createMock(), }, { - provide: ToolPermissionHelper, - useValue: createMock(), + provide: CommonToolMetadataService, + useValue: createMock(), }, { - provide: SchoolExternalToolMetadataService, - useValue: createMock(), + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: SchoolService, + useValue: createMock(), }, ], }).compile(); @@ -59,8 +62,9 @@ describe('SchoolExternalToolUc', () => { schoolExternalToolService = module.get(SchoolExternalToolService); contextExternalToolService = module.get(ContextExternalToolService); schoolExternalToolValidationService = module.get(SchoolExternalToolValidationService); - toolPermissionHelper = module.get(ToolPermissionHelper); - schoolExternalToolMetadataService = module.get(SchoolExternalToolMetadataService); + commonToolMetadataService = module.get(CommonToolMetadataService); + authorizationService = module.get(AuthorizationService); + schoolService = module.get(SchoolService); }); afterAll(async () => { @@ -76,23 +80,27 @@ describe('SchoolExternalToolUc', () => { const setup = () => { const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); const user: User = userFactory.buildWithId(); + const school: School = schoolFactory.build({ id: tool.schoolId }); schoolExternalToolService.findSchoolExternalTools.mockResolvedValue([tool]); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); return { user, tool, + school, }; }; it('should check the permissions of the user', async () => { - const { user, tool } = setup(); + const { user, tool, school } = setup(); await uc.findSchoolExternalTools(user.id, tool); - expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( - user.id, - tool, + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + school, AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); @@ -160,24 +168,30 @@ describe('SchoolExternalToolUc', () => { describe('deleteSchoolExternalTool', () => { describe('when checks permission', () => { const setup = () => { - const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + schoolId: new ObjectId().toHexString(), + }); const user: User = userFactory.buildWithId(); + const school: School = schoolFactory.build({ id: tool.schoolId }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); return { user, - tool, schoolExternalToolId: tool.id as EntityId, + school, }; }; it('should check the permissions of the user', async () => { - const { user, tool, schoolExternalToolId } = setup(); + const { user, schoolExternalToolId, school } = setup(); await uc.deleteSchoolExternalTool(user.id, schoolExternalToolId); - expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( - user.id, - tool, + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + school, AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); @@ -185,8 +199,15 @@ describe('SchoolExternalToolUc', () => { describe('when calls services', () => { const setup = () => { - const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + schoolId: new ObjectId().toHexString(), + }); const user: User = userFactory.buildWithId(); + const school: School = schoolFactory.build({ id: tool.schoolId }); + + schoolExternalToolService.findById.mockResolvedValueOnce(tool); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); return { userId: user.id, @@ -215,23 +236,30 @@ describe('SchoolExternalToolUc', () => { describe('createSchoolExternalTool', () => { describe('when checks permission', () => { const setup = () => { - const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + schoolId: new ObjectId().toHexString(), + }); const user: User = userFactory.buildWithId(); + const school: School = schoolFactory.build({ id: tool.schoolId }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); return { user, tool, + school, }; }; it('should check the permissions of the user', async () => { - const { user, tool } = setup(); + const { user, tool, school } = setup(); await uc.createSchoolExternalTool(user.id, tool); - expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( - user.id, - tool, + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + school, AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); @@ -269,26 +297,31 @@ describe('SchoolExternalToolUc', () => { describe('getSchoolExternalTool', () => { describe('when checks permission', () => { const setup = () => { - const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + schoolId: new ObjectId().toHexString(), + }); const user: User = userFactory.buildWithId(); + const school: School = schoolFactory.build({ id: tool.schoolId }); schoolExternalToolService.findById.mockResolvedValue(tool); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); return { user, - tool, schoolExternalToolId: tool.id as EntityId, + school, }; }; it('should check the permissions of the user', async () => { - const { user, schoolExternalToolId, tool } = setup(); + const { user, schoolExternalToolId, school } = setup(); await uc.getSchoolExternalTool(user.id, schoolExternalToolId); - expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( - user.id, - tool, + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + school, AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); @@ -319,30 +352,35 @@ describe('SchoolExternalToolUc', () => { describe('updateSchoolExternalTool', () => { const setup = () => { - const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + schoolId: new ObjectId().toHexString(), + }); const updatedTool: SchoolExternalTool = schoolExternalToolFactory.build({ ...tool }); updatedTool.parameters[0].value = 'updatedValue'; const user: User = userFactory.buildWithId(); + const school: School = schoolFactory.build({ id: tool.schoolId }); schoolExternalToolService.saveSchoolExternalTool.mockResolvedValue(updatedTool); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); return { user, userId: user.id, updatedTool, - schoolId: tool.schoolId, + school, schoolExternalToolId: updatedTool.id as EntityId, }; }; it('should check the permissions of the user', async () => { - const { updatedTool, schoolExternalToolId, user } = setup(); + const { updatedTool, schoolExternalToolId, user, school } = setup(); await uc.updateSchoolExternalTool(user.id, schoolExternalToolId, updatedTool); - expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( - user.id, - updatedTool, + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + school, AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); @@ -379,23 +417,27 @@ describe('SchoolExternalToolUc', () => { const tool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ id: toolId }, toolId); const userId: string = new ObjectId().toHexString(); const user: User = userFactory.buildWithId({}, userId); + const school: School = schoolFactory.build({ id: tool.schoolId }); schoolExternalToolService.findById.mockResolvedValue(tool); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); return { user, - tool, + toolId: tool.id as string, + school, }; }; it('should check the permissions of the user', async () => { - const { user, tool } = setupMetadata(); + const { user, toolId, school } = setupMetadata(); - await uc.getMetadataForSchoolExternalTool(user.id, tool.id!); + await uc.getMetadataForSchoolExternalTool(user.id, toolId); - expect(toolPermissionHelper.ensureSchoolPermissions).toHaveBeenCalledWith( - user.id, - tool, + expect(authorizationService.checkPermission).toHaveBeenCalledWith( + user, + school, AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]) ); }); @@ -404,8 +446,13 @@ describe('SchoolExternalToolUc', () => { describe('when externalToolId is given', () => { const setupMetadata = () => { const user: User = userFactory.buildWithId(); - const toolId: string = new ObjectId().toHexString(); + const school: School = schoolFactory.build({ id: new ObjectId().toHexString() }); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ id: toolId }); + + schoolExternalToolService.findById.mockResolvedValueOnce(schoolExternalTool); + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); return { toolId, @@ -418,7 +465,7 @@ describe('SchoolExternalToolUc', () => { await uc.getMetadataForSchoolExternalTool(user.id, toolId); - expect(schoolExternalToolMetadataService.getMetadata).toHaveBeenCalledWith(toolId); + expect(commonToolMetadataService.getMetadataForSchoolExternalTool).toHaveBeenCalledWith(toolId); }); }); }); diff --git a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts index 54cba380286..d8d4c95405c 100644 --- a/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts +++ b/apps/server/src/modules/tool/school-external-tool/uc/school-external-tool.uc.ts @@ -1,15 +1,13 @@ -import { AuthorizationContext, AuthorizationContextBuilder } from '@modules/authorization'; -import { Injectable } from '@nestjs/common'; +import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; +import { School, SchoolService } from '@src/modules/school'; +import { CommonToolMetadataService } from '../../common/service/common-tool-metadata.service'; import { ContextExternalToolService } from '../../context-external-tool/service'; import { SchoolExternalTool, SchoolExternalToolMetadata } from '../domain'; -import { - SchoolExternalToolMetadataService, - SchoolExternalToolService, - SchoolExternalToolValidationService, -} from '../service'; +import { SchoolExternalToolService, SchoolExternalToolValidationService } from '../service'; import { SchoolExternalToolDto, SchoolExternalToolQueryInput } from './dto/school-external-tool.types'; @Injectable() @@ -18,17 +16,22 @@ export class SchoolExternalToolUc { private readonly schoolExternalToolService: SchoolExternalToolService, private readonly contextExternalToolService: ContextExternalToolService, private readonly schoolExternalToolValidationService: SchoolExternalToolValidationService, - private readonly schoolExternalToolMetadataService: SchoolExternalToolMetadataService, - private readonly toolPermissionHelper: ToolPermissionHelper + private readonly commonToolMetadataService: CommonToolMetadataService, + @Inject(forwardRef(() => AuthorizationService)) private readonly authorizationService: AuthorizationService, + private readonly schoolService: SchoolService ) {} async findSchoolExternalTools(userId: EntityId, query: SchoolExternalToolQueryInput): Promise { let tools: SchoolExternalTool[] = []; if (query.schoolId) { tools = await this.schoolExternalToolService.findSchoolExternalTools({ schoolId: query.schoolId }); + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const school: School = await this.schoolService.getSchoolById(query.schoolId); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); - await this.ensureSchoolPermissions(userId, tools, context); + await this.ensureSchoolPermissions(user, tools, school, context); } return tools; } @@ -38,9 +41,13 @@ export class SchoolExternalToolUc { schoolExternalToolDto: SchoolExternalToolDto ): Promise { const schoolExternalTool = new SchoolExternalTool({ ...schoolExternalToolDto }); + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const school: School = await this.schoolService.getSchoolById(schoolExternalTool.schoolId); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + this.authorizationService.checkPermission(user, school, context); - await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); await this.schoolExternalToolValidationService.validate(schoolExternalTool); const createdSchoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.saveSchoolExternalTool( @@ -51,22 +58,22 @@ export class SchoolExternalToolUc { } private async ensureSchoolPermissions( - userId: EntityId, + user: User, tools: SchoolExternalTool[], + school: School, context: AuthorizationContext ): Promise { - await Promise.all( - tools.map(async (tool: SchoolExternalTool) => - this.toolPermissionHelper.ensureSchoolPermissions(userId, tool, context) - ) - ); + await Promise.all(tools.map(() => this.authorizationService.checkPermission(user, school, context))); } async deleteSchoolExternalTool(userId: EntityId, schoolExternalToolId: EntityId): Promise { const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById(schoolExternalToolId); - const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); - await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const school: School = await this.schoolService.getSchoolById(schoolExternalTool.schoolId); + + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + this.authorizationService.checkPermission(user, school, context); await Promise.all([ this.contextExternalToolService.deleteBySchoolExternalToolId(schoolExternalToolId), @@ -76,9 +83,13 @@ export class SchoolExternalToolUc { async getSchoolExternalTool(userId: EntityId, schoolExternalToolId: EntityId): Promise { const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById(schoolExternalToolId); + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const school: School = await this.schoolService.getSchoolById(schoolExternalTool.schoolId); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + this.authorizationService.checkPermission(user, school, context); - await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); return schoolExternalTool; } @@ -88,9 +99,13 @@ export class SchoolExternalToolUc { schoolExternalToolDto: SchoolExternalToolDto ): Promise { const schoolExternalTool = new SchoolExternalTool({ ...schoolExternalToolDto }); + + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const school: School = await this.schoolService.getSchoolById(schoolExternalTool.schoolId); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); + this.authorizationService.checkPermission(user, school, context); - await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); await this.schoolExternalToolValidationService.validate(schoolExternalTool); const updated: SchoolExternalTool = new SchoolExternalTool({ @@ -98,7 +113,7 @@ export class SchoolExternalToolUc { id: schoolExternalToolId, }); - const saved = await this.schoolExternalToolService.saveSchoolExternalTool(updated); + const saved: SchoolExternalTool = await this.schoolExternalToolService.saveSchoolExternalTool(updated); return saved; } @@ -108,10 +123,13 @@ export class SchoolExternalToolUc { ): Promise { const schoolExternalTool: SchoolExternalTool = await this.schoolExternalToolService.findById(schoolExternalToolId); + const user: User = await this.authorizationService.getUserWithPermissions(userId); + const school: School = await this.schoolService.getSchoolById(schoolExternalTool.schoolId); + const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.SCHOOL_TOOL_ADMIN]); - await this.toolPermissionHelper.ensureSchoolPermissions(userId, schoolExternalTool, context); + this.authorizationService.checkPermission(user, school, context); - const metadata: SchoolExternalToolMetadata = await this.schoolExternalToolMetadataService.getMetadata( + const metadata: SchoolExternalToolMetadata = await this.commonToolMetadataService.getMetadataForSchoolExternalTool( schoolExternalToolId ); diff --git a/apps/server/src/modules/tool/tool-api.module.ts b/apps/server/src/modules/tool/tool-api.module.ts index b8d12a16006..97c3f93bdfb 100644 --- a/apps/server/src/modules/tool/tool-api.module.ts +++ b/apps/server/src/modules/tool/tool-api.module.ts @@ -1,10 +1,11 @@ import { AuthorizationModule } from '@modules/authorization'; +import { BoardModule } from '@modules/board'; import { LegacySchoolModule } from '@modules/legacy-school'; +import { SchoolModule } from '@modules/school'; import { UserModule } from '@modules/user'; import { Module } from '@nestjs/common'; import { LtiToolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { BoardModule } from '../board'; import { LearnroomModule } from '../learnroom'; import { CommonToolModule } from './common'; import { ToolPermissionHelper } from './common/uc/tool-permission-helper'; @@ -34,6 +35,7 @@ import { ToolModule } from './tool.module'; ToolConfigModule, LearnroomModule, BoardModule, + SchoolModule, ], controllers: [ ToolLaunchController, diff --git a/apps/server/src/modules/tool/tool-config.ts b/apps/server/src/modules/tool/tool-config.ts index a7ee1f65d8a..d84e1900361 100644 --- a/apps/server/src/modules/tool/tool-config.ts +++ b/apps/server/src/modules/tool/tool-config.ts @@ -11,6 +11,7 @@ export interface IToolFeatures { maxExternalToolLogoSizeInBytes: number; backEndUrl: string; ctlToolsCopyEnabled: boolean; + ctlToolsReloadTimeMs: number; } export default class ToolConfiguration { @@ -23,5 +24,6 @@ export default class ToolConfiguration { maxExternalToolLogoSizeInBytes: Configuration.get('CTL_TOOLS__EXTERNAL_TOOL_MAX_LOGO_SIZE_IN_BYTES') as number, backEndUrl: Configuration.get('PUBLIC_BACKEND_URL') as string, ctlToolsCopyEnabled: Configuration.get('FEATURE_CTL_TOOLS_COPY_ENABLED') as boolean, + ctlToolsReloadTimeMs: Configuration.get('CTL_TOOLS_RELOAD_TIME_MS') as number, }; } diff --git a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts index d313cae5d50..b04a56d2abf 100644 --- a/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/controller/api-test/tool-launch.controller.api.spec.ts @@ -5,23 +5,23 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Course, SchoolEntity } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { - TestApiClient, - UserAndAccountTestFactory, basicToolConfigFactory, - contextExternalToolEntityFactory, contextExternalToolFactory, courseFactory, - externalToolEntityFactory, - schoolExternalToolEntityFactory, - schoolFactory, customParameterFactory, + schoolEntityFactory, + TestApiClient, + UserAndAccountTestFactory, } from '@shared/testing'; import { schoolExternalToolConfigurationStatusEntityFactory } from '@shared/testing/factory/school-external-tool-configuration-status-entity.factory'; import { Response } from 'supertest'; import { CustomParameterLocation, CustomParameterScope, ToolConfigType } from '../../../common/enum'; import { ContextExternalToolEntity, ContextExternalToolType } from '../../../context-external-tool/entity'; +import { contextExternalToolEntityFactory } from '../../../context-external-tool/testing'; import { ExternalToolEntity } from '../../../external-tool/entity'; +import { externalToolEntityFactory } from '../../../external-tool/testing'; import { SchoolExternalToolEntity } from '../../../school-external-tool/entity'; +import { schoolExternalToolEntityFactory } from '../../../school-external-tool/testing'; import { LaunchRequestMethod } from '../../types'; import { ToolLaunchParams, ToolLaunchRequestResponse } from '../dto'; @@ -58,7 +58,7 @@ describe('ToolLaunchController (API)', () => { describe('[GET] tools/context/{contextExternalToolId}/launch', () => { describe('when valid data is given', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_USER, ]); @@ -126,7 +126,7 @@ describe('ToolLaunchController (API)', () => { describe('when user wants to launch an outdated tool', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_USER, ]); @@ -178,7 +178,7 @@ describe('ToolLaunchController (API)', () => { describe('when user wants to launch a deactivated tool', () => { describe('when external tool is deactivated', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_USER, ]); @@ -230,7 +230,7 @@ describe('ToolLaunchController (API)', () => { describe('when school external tool is deactivated', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.CONTEXT_TOOL_USER, ]); @@ -285,8 +285,8 @@ describe('ToolLaunchController (API)', () => { describe('when user wants to launch tool from another school', () => { const setup = async () => { - const toolSchool: SchoolEntity = schoolFactory.buildWithId(); - const usersSchool: SchoolEntity = schoolFactory.buildWithId(); + const toolSchool: SchoolEntity = schoolEntityFactory.buildWithId(); + const usersSchool: SchoolEntity = schoolEntityFactory.buildWithId(); const { teacherUser, teacherAccount } = UserAndAccountTestFactory.buildTeacher({ school: usersSchool }, [ Permission.CONTEXT_TOOL_USER, diff --git a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-medium-id.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-medium-id.strategy.spec.ts new file mode 100644 index 00000000000..79fdfaa0a20 --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-medium-id.strategy.spec.ts @@ -0,0 +1,91 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { contextExternalToolFactory, externalToolFactory, schoolExternalToolFactory } from '@shared/testing'; +import { ContextExternalTool } from '../../../context-external-tool/domain'; +import { ExternalTool } from '../../../external-tool/domain'; +import { ExternalToolService } from '../../../external-tool/service'; +import { SchoolExternalTool } from '../../../school-external-tool/domain'; +import { AutoMediumIdStrategy } from './auto-medium-id.strategy'; + +describe(AutoMediumIdStrategy.name, () => { + let module: TestingModule; + let strategy: AutoMediumIdStrategy; + + let externalToolService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + AutoMediumIdStrategy, + { + provide: ExternalToolService, + useValue: createMock(), + }, + ], + }).compile(); + + strategy = module.get(AutoMediumIdStrategy); + externalToolService = module.get(ExternalToolService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getValue', () => { + describe('when the external tool has a medium id', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.withMedium().buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id, + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({}); + + externalToolService.findById.mockResolvedValueOnce(externalTool); + + return { + externalTool, + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return the medium id', async () => { + const { externalTool, schoolExternalTool, contextExternalTool } = setup(); + + const result: string | undefined = await strategy.getValue(schoolExternalTool, contextExternalTool); + + expect(result).toEqual(externalTool.medium?.mediumId); + }); + }); + + describe('when the external tool does not have a medium id', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory.buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId({ + toolId: externalTool.id, + }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({}); + + externalToolService.findById.mockResolvedValueOnce(externalTool); + + return { + schoolExternalTool, + contextExternalTool, + }; + }; + + it('should return undefined', async () => { + const { schoolExternalTool, contextExternalTool } = setup(); + + const result: string | undefined = await strategy.getValue(schoolExternalTool, contextExternalTool); + + expect(result).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-medium-id.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-medium-id.strategy.ts new file mode 100644 index 00000000000..ae652ea31fb --- /dev/null +++ b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/auto-medium-id.strategy.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { ContextExternalTool } from '@src/modules/tool/context-external-tool/domain'; +import { SchoolExternalTool } from '@src/modules/tool/school-external-tool/domain'; +import { ExternalTool } from '../../../external-tool/domain'; +import { ExternalToolService } from '../../../external-tool/service'; +import { AutoParameterStrategy } from './auto-parameter.strategy'; + +@Injectable() +export class AutoMediumIdStrategy implements AutoParameterStrategy { + constructor(private readonly externalToolService: ExternalToolService) {} + + async getValue( + schoolExternalTool: SchoolExternalTool, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + contextExternalTool: ContextExternalTool + ): Promise { + const externalTool: ExternalTool = await this.externalToolService.findById(schoolExternalTool.toolId); + return externalTool.medium?.mediumId; + } +} diff --git a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/index.ts b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/index.ts index 619a0a6296c..30f058547a1 100644 --- a/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/index.ts +++ b/apps/server/src/modules/tool/tool-launch/service/auto-parameter-strategy/index.ts @@ -3,3 +3,4 @@ export * from './auto-school-id.strategy'; export * from './auto-context-id.strategy'; export * from './auto-context-name.strategy'; export * from './auto-school-number.strategy'; +export { AutoMediumIdStrategy } from './auto-medium-id.strategy'; diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts index 3993ad56e61..27d81daaf90 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.spec.ts @@ -28,6 +28,7 @@ import { AutoContextNameStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoMediumIdStrategy, } from '../auto-parameter-strategy'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -73,6 +74,7 @@ describe(AbstractLaunchStrategy.name, () => { let autoSchoolNumberStrategy: DeepMocked; let autoContextIdStrategy: DeepMocked; let autoContextNameStrategy: DeepMocked; + let autoMediumIdStrategy: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -94,6 +96,10 @@ describe(AbstractLaunchStrategy.name, () => { provide: AutoContextNameStrategy, useValue: createMock(), }, + { + provide: AutoMediumIdStrategy, + useValue: createMock(), + }, ], }).compile(); @@ -103,6 +109,7 @@ describe(AbstractLaunchStrategy.name, () => { autoSchoolNumberStrategy = module.get(AutoSchoolNumberStrategy); autoContextIdStrategy = module.get(AutoContextIdStrategy); autoContextNameStrategy = module.get(AutoContextNameStrategy); + autoMediumIdStrategy = module.get(AutoMediumIdStrategy); }); afterAll(async () => { @@ -159,6 +166,12 @@ describe(AbstractLaunchStrategy.name, () => { name: 'autoSchoolNumberParam', type: CustomParameterType.AUTO_CONTEXTNAME, }); + const autoMediumIdCustomParameter = customParameterFactory.build({ + scope: CustomParameterScope.GLOBAL, + location: CustomParameterLocation.QUERY, + name: 'autoMediumIdParam', + type: CustomParameterType.AUTO_MEDIUMID, + }); const externalTool: ExternalTool = externalToolFactory.build({ parameters: [ @@ -169,6 +182,7 @@ describe(AbstractLaunchStrategy.name, () => { autoSchoolNumberCustomParameter, autoContextIdCustomParameter, autoContextNameCustomParameter, + autoMediumIdCustomParameter, ], }); @@ -205,6 +219,7 @@ describe(AbstractLaunchStrategy.name, () => { autoSchoolNumberStrategy.getValue.mockResolvedValueOnce(mockedAutoValue); autoContextIdStrategy.getValue.mockReturnValueOnce(mockedAutoValue); autoContextNameStrategy.getValue.mockResolvedValueOnce(mockedAutoValue); + autoMediumIdStrategy.getValue.mockResolvedValueOnce(mockedAutoValue); return { globalCustomParameter, @@ -213,6 +228,7 @@ describe(AbstractLaunchStrategy.name, () => { autoSchoolNumberCustomParameter, autoContextIdCustomParameter, autoContextNameCustomParameter, + autoMediumIdCustomParameter, schoolParameterEntry, contextParameterEntry, externalTool, @@ -232,6 +248,7 @@ describe(AbstractLaunchStrategy.name, () => { autoSchoolNumberCustomParameter, autoContextIdCustomParameter, autoContextNameCustomParameter, + autoMediumIdCustomParameter, schoolParameterEntry, externalTool, schoolExternalTool, @@ -287,6 +304,11 @@ describe(AbstractLaunchStrategy.name, () => { value: mockedAutoValue, location: PropertyLocation.BODY, }, + { + name: autoMediumIdCustomParameter.name, + value: mockedAutoValue, + location: PropertyLocation.QUERY, + }, { name: concreteConfigParameter.name, value: concreteConfigParameter.value, diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts index 7ca974963df..36057141c08 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/abstract-launch.strategy.ts @@ -12,6 +12,7 @@ import { LaunchRequestMethod, PropertyData, PropertyLocation, ToolLaunchData, To import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoMediumIdStrategy, AutoParameterStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, @@ -27,13 +28,15 @@ export abstract class AbstractLaunchStrategy implements ToolLaunchStrategy { autoSchoolIdStrategy: AutoSchoolIdStrategy, autoSchoolNumberStrategy: AutoSchoolNumberStrategy, autoContextIdStrategy: AutoContextIdStrategy, - autoContextNameStrategy: AutoContextNameStrategy + autoContextNameStrategy: AutoContextNameStrategy, + autoMediumIdStrategy: AutoMediumIdStrategy ) { this.autoParameterStrategyMap = new Map([ [CustomParameterType.AUTO_SCHOOLID, autoSchoolIdStrategy], [CustomParameterType.AUTO_SCHOOLNUMBER, autoSchoolNumberStrategy], [CustomParameterType.AUTO_CONTEXTID, autoContextIdStrategy], [CustomParameterType.AUTO_CONTEXTNAME, autoContextNameStrategy], + [CustomParameterType.AUTO_MEDIUMID, autoMediumIdStrategy], ]); } diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts index 32ca68a74f0..7335f0d149a 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/basic-tool-launch.strategy.spec.ts @@ -8,6 +8,7 @@ import { LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types import { AutoContextIdStrategy, AutoContextNameStrategy, + AutoMediumIdStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, } from '../auto-parameter-strategy'; @@ -38,6 +39,10 @@ describe('BasicToolLaunchStrategy', () => { provide: AutoContextNameStrategy, useValue: createMock(), }, + { + provide: AutoMediumIdStrategy, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts index ea670b8e33d..610ecefb48f 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.spec.ts @@ -12,9 +12,9 @@ import { userDoFactory, } from '@shared/testing'; import { pseudonymFactory } from '@shared/testing/factory/domainobject/pseudonym.factory'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Authorization } from 'oauth-1.0a'; -import { LtiMessageType, LtiPrivacyPermission, LtiRole, ToolContextType } from '../../../common/enum'; +import { LtiMessageType, LtiPrivacyPermission, LtiRole } from '../../../common/enum'; import { ContextExternalTool } from '../../../context-external-tool/domain'; import { ExternalTool } from '../../../external-tool/domain'; import { SchoolExternalTool } from '../../../school-external-tool/domain'; @@ -24,6 +24,7 @@ import { AutoContextNameStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoMediumIdStrategy, } from '../auto-parameter-strategy'; import { Lti11EncryptionService } from '../lti11-encryption.service'; import { Lti11ToolLaunchStrategy } from './lti11-tool-launch.strategy'; @@ -69,6 +70,10 @@ describe('Lti11ToolLaunchStrategy', () => { provide: AutoContextNameStrategy, useValue: createMock(), }, + { + provide: AutoMediumIdStrategy, + useValue: createMock(), + }, ], }).compile(); @@ -93,7 +98,6 @@ describe('Lti11ToolLaunchStrategy', () => { const mockKey = 'mockKey'; const mockSecret = 'mockSecret'; const ltiMessageType = LtiMessageType.BASIC_LTI_LAUNCH_REQUEST; - const resourceLinkId = 'resourceLinkId'; const launchPresentationLocale = 'de-DE'; const externalTool: ExternalTool = externalToolFactory @@ -102,7 +106,6 @@ describe('Lti11ToolLaunchStrategy', () => { secret: mockSecret, lti_message_type: ltiMessageType, privacy_permission: LtiPrivacyPermission.PUBLIC, - resource_link_id: resourceLinkId, launch_presentation_locale: launchPresentationLocale, }) .buildWithId(); @@ -136,7 +139,7 @@ describe('Lti11ToolLaunchStrategy', () => { mockKey, mockSecret, ltiMessageType, - resourceLinkId, + contextExternalTool, launchPresentationLocale, }; }; @@ -155,7 +158,7 @@ describe('Lti11ToolLaunchStrategy', () => { }); it('should contain mandatory lti attributes', async () => { - const { data, ltiMessageType, resourceLinkId, launchPresentationLocale } = setup(); + const { data, ltiMessageType, contextExternalTool, launchPresentationLocale } = setup(); const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); @@ -165,7 +168,7 @@ describe('Lti11ToolLaunchStrategy', () => { new PropertyData({ name: 'lti_version', value: 'LTI-1p0', location: PropertyLocation.BODY }), new PropertyData({ name: 'resource_link_id', - value: resourceLinkId, + value: contextExternalTool.id as string, location: PropertyLocation.BODY, }), new PropertyData({ @@ -178,17 +181,12 @@ describe('Lti11ToolLaunchStrategy', () => { value: launchPresentationLocale, location: PropertyLocation.BODY, }), - new PropertyData({ - name: 'roles', - value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, - location: PropertyLocation.BODY, - }), ]) ); }); }); - describe('when no resource link id is available', () => { + describe('when lti privacyPermission is public', () => { const setup = () => { const externalTool: ExternalTool = externalToolFactory .withLti11Config({ @@ -196,15 +194,10 @@ describe('Lti11ToolLaunchStrategy', () => { secret: 'mockSecret', lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.PUBLIC, - resource_link_id: undefined, }) .buildWithId(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); - - const contextId: string = new ObjectId().toHexString(); - const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId({ - contextRef: { id: contextId, type: ToolContextType.COURSE }, - }); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); const data: ToolLaunchParams = { contextExternalTool, @@ -212,28 +205,57 @@ describe('Lti11ToolLaunchStrategy', () => { externalTool, }; - const user: UserDO = userDoFactory.buildWithId(); + const userId: string = new ObjectId().toHexString(); + const userEmail = 'user@email.com'; + const user: UserDO = userDoFactory.buildWithId( + { + email: userEmail, + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], + }, + userId + ); + + const userDisplayName = 'Hans Peter Test'; userService.findById.mockResolvedValue(user); + userService.getDisplayName.mockResolvedValue(userDisplayName); return { data, - contextId, + userId, + userDisplayName, + userEmail, }; }; - it('should use the context id as resource link id', async () => { - const { data, contextId } = setup(); + it('should contain the all user related attributes', async () => { + const { data, userId, userDisplayName, userEmail } = setup(); - const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig(userId, data); expect(result).toEqual( expect.arrayContaining([ new PropertyData({ - name: 'resource_link_id', - value: contextId, + name: 'roles', + value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, location: PropertyLocation.BODY, }), + new PropertyData({ name: 'lis_person_name_full', value: userDisplayName, location: PropertyLocation.BODY }), + new PropertyData({ + name: 'lis_person_contact_email_primary', + value: userEmail, + location: PropertyLocation.BODY, + }), + new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), ]) ); }); @@ -247,7 +269,6 @@ describe('Lti11ToolLaunchStrategy', () => { secret: 'mockSecret', lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.NAME, - resource_link_id: 'resourceLinkId', }) .buildWithId(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); @@ -260,7 +281,21 @@ describe('Lti11ToolLaunchStrategy', () => { }; const userId: string = new ObjectId().toHexString(); - const user: UserDO = userDoFactory.buildWithId(undefined, userId); + const user: UserDO = userDoFactory.buildWithId( + { + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], + }, + userId + ); const userDisplayName = 'Hans Peter Test'; @@ -281,10 +316,18 @@ describe('Lti11ToolLaunchStrategy', () => { expect(result).toEqual( expect.arrayContaining([ + new PropertyData({ + name: 'roles', + value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, + location: PropertyLocation.BODY, + }), new PropertyData({ name: 'lis_person_name_full', value: userDisplayName, location: PropertyLocation.BODY }), new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), ]) ); + expect(result).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'lis_person_contact_email_primary' })]) + ); }); }); @@ -296,7 +339,6 @@ describe('Lti11ToolLaunchStrategy', () => { secret: 'mockSecret', lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.EMAIL, - resource_link_id: 'resourceLinkId', }) .buildWithId(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); @@ -310,7 +352,22 @@ describe('Lti11ToolLaunchStrategy', () => { const userId: string = new ObjectId().toHexString(); const userEmail = 'user@email.com'; - const user: UserDO = userDoFactory.buildWithId({ email: userEmail }, userId); + const user: UserDO = userDoFactory.buildWithId( + { + email: userEmail, + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], + }, + userId + ); userService.findById.mockResolvedValue(user); @@ -328,6 +385,11 @@ describe('Lti11ToolLaunchStrategy', () => { expect(result).toEqual( expect.arrayContaining([ + new PropertyData({ + name: 'roles', + value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, + location: PropertyLocation.BODY, + }), new PropertyData({ name: 'lis_person_contact_email_primary', value: userEmail, @@ -336,6 +398,7 @@ describe('Lti11ToolLaunchStrategy', () => { new PropertyData({ name: 'user_id', value: userId, location: PropertyLocation.BODY }), ]) ); + expect(result).not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'lis_person_name_full' })])); }); }); @@ -347,7 +410,6 @@ describe('Lti11ToolLaunchStrategy', () => { secret: 'mockSecret', lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, - resource_link_id: 'resourceLinkId', }) .buildWithId(); const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); @@ -359,7 +421,18 @@ describe('Lti11ToolLaunchStrategy', () => { externalTool, }; - const user: UserDO = userDoFactory.buildWithId(); + const user: UserDO = userDoFactory.buildWithId({ + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], + }); const pseudonym: Pseudonym = pseudonymFactory.build(); @@ -379,9 +452,75 @@ describe('Lti11ToolLaunchStrategy', () => { expect(result).toEqual( expect.arrayContaining([ + new PropertyData({ + name: 'roles', + value: `${LtiRole.INSTRUCTOR},${LtiRole.LEARNER}`, + location: PropertyLocation.BODY, + }), new PropertyData({ name: 'user_id', value: pseudonym.pseudonym, location: PropertyLocation.BODY }), ]) ); + expect(result).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'lis_person_name_full' }), + expect.objectContaining({ name: 'lis_person_contact_email_primary' }), + ]) + ); + }); + }); + + describe('when lti privacyPermission is anonymous', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.buildWithId(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + const user: UserDO = userDoFactory.buildWithId({ + roles: [ + { + id: 'roleId1', + name: RoleName.TEACHER, + }, + { + id: 'roleId2', + name: RoleName.USER, + }, + ], + }); + + userService.findById.mockResolvedValue(user); + + return { + data, + }; + }; + + it('should not contain user related information', async () => { + const { data } = setup(); + + const result: PropertyData[] = await strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + + expect(result).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'lis_person_name_full' }), + expect.objectContaining({ name: 'lis_person_contact_email_primary' }), + expect.objectContaining({ name: 'user_id' }), + expect.objectContaining({ name: 'roles' }), + ]) + ); }); }); @@ -414,6 +553,39 @@ describe('Lti11ToolLaunchStrategy', () => { ); }); }); + + describe('when context external tool id is undefined', () => { + const setup = () => { + const externalTool: ExternalTool = externalToolFactory + .withLti11Config({ + key: 'mockKey', + secret: 'mockSecret', + lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy_permission: LtiPrivacyPermission.ANONYMOUS, + }) + .buildWithId(); + const schoolExternalTool: SchoolExternalTool = schoolExternalToolFactory.buildWithId(); + const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build(); + + const data: ToolLaunchParams = { + contextExternalTool, + schoolExternalTool, + externalTool, + }; + + return { + data, + }; + }; + + it('should throw an InternalServerErrorException', async () => { + const { data } = setup(); + + const func = async () => strategy.buildToolLaunchDataFromConcreteConfig('userId', data); + + await expect(func).rejects.toThrow(new InternalServerErrorException()); + }); + }); }); describe('buildToolLaunchRequestPayload', () => { diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts index 0180cb885f5..04852bb6537 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/lti11-tool-launch.strategy.ts @@ -2,11 +2,10 @@ import { PseudonymService } from '@modules/pseudonym/service'; import { UserService } from '@modules/user'; import { Injectable, InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common'; import { Pseudonym, RoleReference, UserDO } from '@shared/domain/domainobject'; -import { LtiPrivacyPermission } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { Authorization } from 'oauth-1.0a'; -import { LtiRole } from '../../../common/enum'; +import { LtiPrivacyPermission, LtiRole } from '../../../common/enum'; import { ExternalTool } from '../../../external-tool/domain'; import { LtiRoleMapper } from '../../mapper'; import { AuthenticationValues, LaunchRequestMethod, PropertyData, PropertyLocation } from '../../types'; @@ -15,6 +14,7 @@ import { AutoContextNameStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoMediumIdStrategy, } from '../auto-parameter-strategy'; import { Lti11EncryptionService } from '../lti11-encryption.service'; import { AbstractLaunchStrategy } from './abstract-launch.strategy'; @@ -29,9 +29,16 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { autoSchoolIdStrategy: AutoSchoolIdStrategy, autoSchoolNumberStrategy: AutoSchoolNumberStrategy, autoContextIdStrategy: AutoContextIdStrategy, - autoContextNameStrategy: AutoContextNameStrategy + autoContextNameStrategy: AutoContextNameStrategy, + autoMediumIdStrategy: AutoMediumIdStrategy ) { - super(autoSchoolIdStrategy, autoSchoolNumberStrategy, autoContextIdStrategy, autoContextNameStrategy); + super( + autoSchoolIdStrategy, + autoSchoolNumberStrategy, + autoContextIdStrategy, + autoContextNameStrategy, + autoMediumIdStrategy + ); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -40,7 +47,6 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { data: ToolLaunchParams ): Promise { const { config } = data.externalTool; - const contextId: EntityId = data.contextExternalTool.contextRef.id; if (!ExternalTool.isLti11Config(config)) { throw new UnprocessableEntityException( @@ -48,6 +54,10 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { ); } + if (!data.contextExternalTool.id) { + throw new InternalServerErrorException(); + } + const user: UserDO = await this.userService.findById(userId); const roleNames: RoleName[] = user.roles.map((roleRef: RoleReference): RoleName => roleRef.name); @@ -61,7 +71,7 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { new PropertyData({ name: 'lti_version', value: 'LTI-1p0', location: PropertyLocation.BODY }), new PropertyData({ name: 'resource_link_id', - value: config.resource_link_id || contextId, + value: data.contextExternalTool.id, location: PropertyLocation.BODY, }), new PropertyData({ @@ -74,14 +84,22 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { value: config.launch_presentation_locale, location: PropertyLocation.BODY, }), - new PropertyData({ - name: 'roles', - value: ltiRoles.join(','), - location: PropertyLocation.BODY, - }), ]; - if (config.privacy_permission === LtiPrivacyPermission.NAME) { + if (config.privacy_permission !== LtiPrivacyPermission.ANONYMOUS) { + additionalProperties.push( + new PropertyData({ + name: 'roles', + value: ltiRoles.join(','), + location: PropertyLocation.BODY, + }) + ); + } + + if ( + config.privacy_permission === LtiPrivacyPermission.NAME || + config.privacy_permission === LtiPrivacyPermission.PUBLIC + ) { const displayName: string = await this.userService.getDisplayName(user); additionalProperties.push( @@ -93,7 +111,10 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { ); } - if (config.privacy_permission === LtiPrivacyPermission.EMAIL) { + if ( + config.privacy_permission === LtiPrivacyPermission.EMAIL || + config.privacy_permission === LtiPrivacyPermission.PUBLIC + ) { additionalProperties.push( new PropertyData({ name: 'lis_person_contact_email_primary', @@ -113,7 +134,7 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { location: PropertyLocation.BODY, }) ); - } else { + } else if (config.privacy_permission !== LtiPrivacyPermission.ANONYMOUS) { additionalProperties.push( new PropertyData({ name: 'user_id', @@ -121,6 +142,8 @@ export class Lti11ToolLaunchStrategy extends AbstractLaunchStrategy { location: PropertyLocation.BODY, }) ); + } else { + // Don't add a user_id, when the privacy is anonymous } return additionalProperties; diff --git a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts index 4e80d439a97..d1e1e1662fb 100644 --- a/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/service/launch-strategy/oauth2-tool-launch.strategy.spec.ts @@ -10,6 +10,7 @@ import { AutoContextNameStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoMediumIdStrategy, } from '../auto-parameter-strategy'; import { OAuth2ToolLaunchStrategy } from './oauth2-tool-launch.strategy'; import { ToolLaunchParams } from './tool-launch-params.interface'; @@ -38,6 +39,10 @@ describe('OAuth2ToolLaunchStrategy', () => { provide: AutoContextNameStrategy, useValue: createMock(), }, + { + provide: AutoMediumIdStrategy, + useValue: createMock(), + }, ], }).compile(); diff --git a/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts b/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts index b8ff7623586..3abbaa1786c 100644 --- a/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts +++ b/apps/server/src/modules/tool/tool-launch/tool-launch.module.ts @@ -14,6 +14,7 @@ import { AutoContextNameStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoMediumIdStrategy, } from './service/auto-parameter-strategy'; import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrategy } from './service/launch-strategy'; @@ -39,6 +40,7 @@ import { BasicToolLaunchStrategy, Lti11ToolLaunchStrategy, OAuth2ToolLaunchStrat AutoContextNameStrategy, AutoSchoolIdStrategy, AutoSchoolNumberStrategy, + AutoMediumIdStrategy, ], exports: [ToolLaunchService], }) diff --git a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts index 20ae93b7dd0..e3eb1996b95 100644 --- a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts +++ b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.spec.ts @@ -1,7 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { contextExternalToolFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { User } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; +import { contextExternalToolFactory, setupEntities, userFactory } from '@shared/testing'; +import { AuthorizationContextBuilder, AuthorizationService } from '@src/modules/authorization'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; import { ContextExternalTool } from '../../context-external-tool/domain'; import { ContextExternalToolService } from '../../context-external-tool/service'; @@ -16,6 +18,7 @@ describe('ToolLaunchUc', () => { let toolLaunchService: DeepMocked; let contextExternalToolService: DeepMocked; let toolPermissionHelper: DeepMocked; + let authorizationService: DeepMocked; beforeEach(async () => { module = await Test.createTestingModule({ @@ -33,6 +36,10 @@ describe('ToolLaunchUc', () => { provide: ToolPermissionHelper, useValue: createMock(), }, + { + provide: AuthorizationService, + useValue: createMock(), + }, ], }).compile(); @@ -40,6 +47,11 @@ describe('ToolLaunchUc', () => { toolLaunchService = module.get(ToolLaunchService); contextExternalToolService = module.get(ContextExternalToolService); toolPermissionHelper = module.get(ToolPermissionHelper); + authorizationService = module.get(AuthorizationService); + }); + + beforeAll(async () => { + await setupEntities(); }); afterAll(async () => { @@ -52,6 +64,7 @@ describe('ToolLaunchUc', () => { describe('getToolLaunchRequest', () => { const setup = () => { + const user: User = userFactory.build(); const contextExternalToolId = 'contextExternalToolId'; const contextExternalTool: ContextExternalTool = contextExternalToolFactory.build({ id: contextExternalToolId, @@ -63,50 +76,61 @@ describe('ToolLaunchUc', () => { properties: [], }); - const userId: string = new ObjectId().toHexString(); - + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); toolPermissionHelper.ensureContextPermissions.mockResolvedValueOnce(); contextExternalToolService.findByIdOrFail.mockResolvedValueOnce(contextExternalTool); toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); return { - userId, + user, contextExternalToolId, contextExternalTool, toolLaunchData, }; }; + it('should check user permissions to launch the tool', async () => { + const { user, contextExternalToolId, contextExternalTool } = setup(); + + await uc.getToolLaunchRequest(user.id, contextExternalToolId); + + expect(toolPermissionHelper.ensureContextPermissions).toHaveBeenCalledWith( + user, + contextExternalTool, + AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]) + ); + }); + it('should call service to get context external tool', async () => { - const { userId, contextExternalToolId } = setup(); + const { user, contextExternalToolId } = setup(); - await uc.getToolLaunchRequest(userId, contextExternalToolId); + await uc.getToolLaunchRequest(user.id, contextExternalToolId); expect(contextExternalToolService.findByIdOrFail).toHaveBeenCalledWith(contextExternalToolId); }); it('should call service to get data', async () => { - const { userId, contextExternalToolId, contextExternalTool } = setup(); + const { user, contextExternalToolId, contextExternalTool } = setup(); - await uc.getToolLaunchRequest(userId, contextExternalToolId); + await uc.getToolLaunchRequest(user.id, contextExternalToolId); - expect(toolLaunchService.getLaunchData).toHaveBeenCalledWith(userId, contextExternalTool); + expect(toolLaunchService.getLaunchData).toHaveBeenCalledWith(user.id, contextExternalTool); }); it('should call service to generate launch request', async () => { - const { userId, contextExternalToolId, toolLaunchData } = setup(); + const { user, contextExternalToolId, toolLaunchData } = setup(); toolLaunchService.getLaunchData.mockResolvedValueOnce(toolLaunchData); - await uc.getToolLaunchRequest(userId, contextExternalToolId); + await uc.getToolLaunchRequest(user.id, contextExternalToolId); expect(toolLaunchService.generateLaunchRequest).toHaveBeenCalledWith(toolLaunchData); }); it('should return launch request', async () => { - const { userId, contextExternalToolId } = setup(); + const { user, contextExternalToolId } = setup(); - const toolLaunchRequest: ToolLaunchRequest = await uc.getToolLaunchRequest(userId, contextExternalToolId); + const toolLaunchRequest: ToolLaunchRequest = await uc.getToolLaunchRequest(user.id, contextExternalToolId); expect(toolLaunchRequest).toBeDefined(); }); diff --git a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts index 516e483ce09..13fe611002c 100644 --- a/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts +++ b/apps/server/src/modules/tool/tool-launch/uc/tool-launch.uc.ts @@ -1,5 +1,6 @@ -import { AuthorizationContext, AuthorizationContextBuilder } from '@modules/authorization'; +import { AuthorizationContext, AuthorizationContextBuilder, AuthorizationService } from '@modules/authorization'; import { Injectable } from '@nestjs/common'; +import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { ToolPermissionHelper } from '../../common/uc/tool-permission-helper'; @@ -13,7 +14,8 @@ export class ToolLaunchUc { constructor( private readonly toolLaunchService: ToolLaunchService, private readonly contextExternalToolService: ContextExternalToolService, - private readonly toolPermissionHelper: ToolPermissionHelper + private readonly toolPermissionHelper: ToolPermissionHelper, + private readonly authorizationService: AuthorizationService ) {} async getToolLaunchRequest(userId: EntityId, contextExternalToolId: EntityId): Promise { @@ -21,8 +23,9 @@ export class ToolLaunchUc { contextExternalToolId ); const context: AuthorizationContext = AuthorizationContextBuilder.read([Permission.CONTEXT_TOOL_USER]); + const user: User = await this.authorizationService.getUserWithPermissions(userId); - await this.toolPermissionHelper.ensureContextPermissions(userId, contextExternalTool, context); + await this.toolPermissionHelper.ensureContextPermissions(user, contextExternalTool, context); const toolLaunchData: ToolLaunchData = await this.toolLaunchService.getLaunchData(userId, contextExternalTool); const launchRequest: ToolLaunchRequest = this.toolLaunchService.generateLaunchRequest(toolLaunchData); diff --git a/apps/server/src/modules/user-import/config/index.ts b/apps/server/src/modules/user-import/config/index.ts new file mode 100644 index 00000000000..3267752a160 --- /dev/null +++ b/apps/server/src/modules/user-import/config/index.ts @@ -0,0 +1 @@ +export { UserImportFeatures, UserImportConfiguration, IUserImportFeatures } from './user-import-config'; diff --git a/apps/server/src/modules/user-import/config/user-import-config.ts b/apps/server/src/modules/user-import/config/user-import-config.ts new file mode 100644 index 00000000000..52b46a06560 --- /dev/null +++ b/apps/server/src/modules/user-import/config/user-import-config.ts @@ -0,0 +1,17 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; + +export const UserImportFeatures = Symbol('UserImportFeatures'); + +export interface IUserImportFeatures { + userMigrationEnabled: boolean; + userMigrationSystemId: string; + instance: string; +} + +export class UserImportConfiguration { + static userImportFeatures: IUserImportFeatures = { + userMigrationEnabled: Configuration.get('FEATURE_USER_MIGRATION_ENABLED') as boolean, + userMigrationSystemId: Configuration.get('FEATURE_USER_MIGRATION_SYSTEM_ID') as string, + instance: Configuration.get('SC_THEME') as string, + }; +} diff --git a/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts b/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts index 74fa884fd21..183a4b282d3 100644 --- a/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts +++ b/apps/server/src/modules/user-import/controller/api-test/import-user.api.spec.ts @@ -1,7 +1,6 @@ -import { Configuration } from '@hpi-schul-cloud/commons'; +import { SanisResponse, schulconnexResponseFactory } from '@infra/schulconnex-client'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; -import { ICurrentUser } from '@modules/authentication'; -import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; +import { OauthTokenResponse } from '@modules/oauth/service/dto'; import { ServerTestModule } from '@modules/server/server.module'; import { FilterImportUserParams, @@ -19,67 +18,76 @@ import { UserMatchResponse, UserRole, } from '@modules/user-import/controller/dto'; -import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { PaginationParams } from '@shared/controller'; import { ImportUser, MatchCreator, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; import { Permission, RoleName, SortOrder } from '@shared/domain/interface'; import { SchoolFeature } from '@shared/domain/types'; import { + TestApiClient, + UserAndAccountTestFactory, + accountFactory, cleanupCollections, importUserFactory, - mapUserToCurrentUser, roleFactory, - schoolFactory, + schoolEntityFactory, systemEntityFactory, userFactory, } from '@shared/testing'; -import { Request } from 'express'; -import request from 'supertest'; +import { AccountEntity } from '@modules/account/entity/account.entity'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { IUserImportFeatures, UserImportFeatures } from '../../config'; describe('ImportUser Controller (API)', () => { let app: INestApplication; let em: EntityManager; - let currentUser: ICurrentUser; - const authenticatedUser = async (permissions: Permission[] = [], features: SchoolFeature[] = []) => { - const system = systemEntityFactory.buildWithId(); // TODO no id? - const school = schoolFactory.build({ officialSchoolNumber: 'foo', features }); - const roles = [roleFactory.build({ name: RoleName.ADMINISTRATOR, permissions })]; - await em.persistAndFlush([school, system, ...roles]); - const user = userFactory.build({ - school, - roles, + let testApiClient: TestApiClient; + let userImportFeatures: IUserImportFeatures; + let axiosMock: MockAdapter; + + const authenticatedUser = async ( + permissions: Permission[] = [], + features: SchoolFeature[] = [], + schoolHasExternalId = true + ) => { + const system = systemEntityFactory.buildWithId(); + const school = schoolEntityFactory.build({ + officialSchoolNumber: 'foo', + features, + systems: [system], + externalId: schoolHasExternalId ? system.id : undefined, }); - await em.persistAndFlush([user]); + const roles = [roleFactory.build({ name: RoleName.ADMINISTRATOR, permissions })]; + await em.persistAndFlush([system, school, ...roles]); + const user = userFactory.buildWithId({ roles, school }); + const account = accountFactory.withUser(user).buildWithId(); + await em.persistAndFlush([user, account]); em.clear(); - return { user, roles, school, system }; + return { user, account, roles, school, system }; }; const setConfig = (systemId?: string) => { - Configuration.set('FEATURE_USER_MIGRATION_ENABLED', true); - Configuration.set('FEATURE_USER_MIGRATION_SYSTEM_ID', systemId || new ObjectId().toString()); + userImportFeatures.userMigrationEnabled = true; + userImportFeatures.userMigrationSystemId = systemId || new ObjectId().toString(); + userImportFeatures.instance = 'dbc'; }; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [ServerTestModule], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ - canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - req.user = currentUser; - return true; - }, - }) - - .compile(); + }).compile(); app = moduleFixture.createNestApplication(); + await app.init(); em = app.get(EntityManager); + testApiClient = new TestApiClient(app, 'user/import'); + userImportFeatures = app.get(UserImportFeatures); + axiosMock = new MockAdapter(axios); }); afterAll(async () => { @@ -104,273 +112,326 @@ describe('ImportUser Controller (API)', () => { describe('Generic Errors', () => { describe('When feature is not enabled', () => { - let user: User; + let account: AccountEntity; beforeEach(async () => { - ({ user } = await authenticatedUser([ + ({ account } = await authenticatedUser([ Permission.SCHOOL_IMPORT_USERS_MIGRATE, Permission.SCHOOL_IMPORT_USERS_UPDATE, Permission.SCHOOL_IMPORT_USERS_VIEW, ])); - currentUser = mapUserToCurrentUser(user); - Configuration.set('FEATURE_USER_MIGRATION_ENABLED', false); + testApiClient = await testApiClient.login(account); + userImportFeatures.userMigrationEnabled = false; + userImportFeatures.userMigrationSystemId = ''; }); + afterEach(() => { setConfig(); }); + it('System is not set', async () => { - Configuration.set('FEATURE_USER_MIGRATION_SYSTEM_ID', ''); - await request(app.getHttpServer()).get('/user/import').expect(500); + await testApiClient.get().expect(HttpStatus.INTERNAL_SERVER_ERROR); }); + it('GET /user/import is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).get('/user/import').expect(500); + await testApiClient.get().expect(HttpStatus.INTERNAL_SERVER_ERROR); }); + it('GET /user/import/unassigned is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).get('/user/import/unassigned').expect(500); + await testApiClient.get('unassigned').expect(HttpStatus.INTERNAL_SERVER_ERROR); }); + it('PATCH /user/import/:id/match is UNAUTHORIZED', async () => { const id = new ObjectId().toString(); const params: UpdateMatchParams = { userId: new ObjectId().toString() }; - await request(app.getHttpServer()).patch(`/user/import/${id}/match`).send(params).expect(500); + await testApiClient.patch(`${id}/match`).send(params).expect(HttpStatus.INTERNAL_SERVER_ERROR); }); + it('DELETE /user/import/:id/match is UNAUTHORIZED', async () => { const id = new ObjectId().toString(); - await request(app.getHttpServer()).delete(`/user/import/${id}/match`).send().expect(500); + await testApiClient.delete(`${id}/match`).send().expect(HttpStatus.INTERNAL_SERVER_ERROR); }); + it('PATCH /user/import/:id/flag is UNAUTHORIZED', async () => { const id = new ObjectId().toString(); const params: UpdateFlagParams = { flagged: true }; - await request(app.getHttpServer()).patch(`/user/import/${id}/flag`).send(params).expect(500); + await testApiClient.patch(`${id}/flag`).send(params).expect(HttpStatus.INTERNAL_SERVER_ERROR); }); + it('POST /user/import/migrate is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/migrate`).send().expect(500); + await testApiClient.post('migrate').send().expect(HttpStatus.INTERNAL_SERVER_ERROR); }); + it('POST /user/import/startSync is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/startSync`).send().expect(500); + await testApiClient.post('startSync').send().expect(HttpStatus.INTERNAL_SERVER_ERROR); }); + it('POST /user/import/startUserMigration is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/startUserMigration`).send().expect(500); + await testApiClient.post('startUserMigration').send().expect(HttpStatus.INTERNAL_SERVER_ERROR); }); }); + describe('When authorization is missing', () => { - let user: User; + let account: AccountEntity; let system: SystemEntity; + beforeEach(async () => { - ({ user, system } = await authenticatedUser()); - currentUser = mapUserToCurrentUser(user); - Configuration.set('FEATURE_USER_MIGRATION_SYSTEM_ID', system._id.toString()); + ({ account, system } = await authenticatedUser()); + testApiClient = await testApiClient.login(account); + userImportFeatures.userMigrationSystemId = system._id.toString(); }); + it('GET /user/import is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).get('/user/import').expect(401); + await testApiClient.get().expect(HttpStatus.UNAUTHORIZED); }); + it('GET /user/import/unassigned is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).get('/user/import/unassigned').expect(401); + await testApiClient.get('unassigned').expect(HttpStatus.UNAUTHORIZED); }); + it('PATCH /user/import/:id/match is UNAUTHORIZED', async () => { const id = new ObjectId().toString(); const params: UpdateMatchParams = { userId: new ObjectId().toString() }; - await request(app.getHttpServer()).patch(`/user/import/${id}/match`).send(params).expect(401); + await testApiClient.patch(`${id}/match`).send(params).expect(HttpStatus.UNAUTHORIZED); }); + it('DELETE /user/import/:id/match is UNAUTHORIZED', async () => { const id = new ObjectId().toString(); - await request(app.getHttpServer()).delete(`/user/import/${id}/match`).send().expect(401); + await testApiClient.delete(`${id}/match`).send().expect(HttpStatus.UNAUTHORIZED); }); + it('PATCH /user/import/:id/flag is UNAUTHORIZED', async () => { const id = new ObjectId().toString(); const params: UpdateFlagParams = { flagged: true }; - await request(app.getHttpServer()).patch(`/user/import/${id}/flag`).send(params).expect(401); + await testApiClient.patch(`${id}/flag`).send(params).expect(HttpStatus.UNAUTHORIZED); }); + it('POST /user/import/migrate is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/migrate`).send().expect(401); + await testApiClient.post('migrate').send().expect(HttpStatus.UNAUTHORIZED); }); + it('POST /user/import/startSync is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/startSync`).send().expect(401); + await testApiClient.post('startSync').send().expect(HttpStatus.UNAUTHORIZED); }); + it('POST /user/import/startUserMigration is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/startUserMigration`).send().expect(401); + await testApiClient.post('startUserMigration').send().expect(HttpStatus.UNAUTHORIZED); }); }); describe('When school is LDAP Migration Pilot School', () => { - let user: User; + let account: AccountEntity; let school: SchoolEntity; let system: SystemEntity; + beforeEach(async () => { - ({ school, system, user } = await authenticatedUser( + ({ school, system, account } = await authenticatedUser( [Permission.SCHOOL_IMPORT_USERS_VIEW], [SchoolFeature.LDAP_UNIVENTION_MIGRATION] )); - currentUser = mapUserToCurrentUser(user); - Configuration.set('FEATURE_USER_MIGRATION_SYSTEM_ID', system._id.toString()); - Configuration.set('FEATURE_USER_MIGRATION_ENABLED', false); + testApiClient = await testApiClient.login(account); + userImportFeatures.userMigrationSystemId = system._id.toString(); + userImportFeatures.userMigrationEnabled = false; }); + it('GET user/import is authorized, despite feature not enabled', async () => { const usermatch = userFactory.build({ school }); const importuser = importUserFactory.build({ school }); await em.persistAndFlush([usermatch, importuser]); - await request(app.getHttpServer()).get('/user/import').expect(200); + await testApiClient.get().expect(HttpStatus.OK); }); }); describe('When current user has permission Permission.SCHOOL_IMPORT_USERS_VIEW', () => { - let user: User; + let account: AccountEntity; let school: SchoolEntity; let system: SystemEntity; beforeEach(async () => { - ({ school, system, user } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_VIEW])); - currentUser = mapUserToCurrentUser(user); - Configuration.set('FEATURE_USER_MIGRATION_SYSTEM_ID', system._id.toString()); + ({ school, system, account } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_VIEW])); + testApiClient = await testApiClient.login(account); + userImportFeatures.userMigrationSystemId = system._id.toString(); }); it('GET /user/import responds with importusers', async () => { const usermatch = userFactory.build({ school }); const importuser = importUserFactory.build({ school }); await em.persistAndFlush([usermatch, importuser]); - await request(app.getHttpServer()).get('/user/import').expect(200); + await testApiClient.get().expect(HttpStatus.OK); }); + it('GET /user/import/unassigned is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).get('/user/import/unassigned').expect(200); + await testApiClient.get('unassigned').expect(HttpStatus.OK); }); + it('PATCH /user/import/:id/match is UNAUTHORIZED', async () => { const id = new ObjectId().toString(); const params: UpdateMatchParams = { userId: new ObjectId().toString() }; - await request(app.getHttpServer()).patch(`/user/import/${id}/match`).send(params).expect(401); + await testApiClient.patch(`${id}/match`).send(params).expect(HttpStatus.UNAUTHORIZED); }); + it('DELETE /user/import/:id/match is UNAUTHORIZED', async () => { const id = new ObjectId().toString(); - await request(app.getHttpServer()).delete(`/user/import/${id}/match`).send().expect(401); + await testApiClient.delete(`${id}/match`).send().expect(HttpStatus.UNAUTHORIZED); }); + it('PATCH /user/import/:id/flag is UNAUTHORIZED', async () => { const id = new ObjectId().toString(); const params: UpdateFlagParams = { flagged: true }; - await request(app.getHttpServer()).patch(`/user/import/${id}/flag`).send(params).expect(401); + await testApiClient.patch(`${id}/flag`).send(params).expect(HttpStatus.UNAUTHORIZED); }); + it('POST /user/import/migrate is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/migrate`).send().expect(401); + await testApiClient.post('migrate').send().expect(HttpStatus.UNAUTHORIZED); }); + it('POST /user/import/startSync is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/startSync`).send().expect(401); + await testApiClient.post('startSync').send().expect(HttpStatus.UNAUTHORIZED); }); + it('POST /user/import/startUserMigration is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/startUserMigration`).send().expect(401); + await testApiClient.post('startUserMigration').send().expect(HttpStatus.UNAUTHORIZED); }); }); + describe('When current user has permission Permission.SCHOOL_IMPORT_USERS_UPDATE', () => { + let account: AccountEntity; let user: User; let school: SchoolEntity; let system: SystemEntity; + beforeEach(async () => { - ({ user, school, system } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_UPDATE])); - currentUser = mapUserToCurrentUser(user); + ({ account, school, system, user } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_UPDATE])); + testApiClient = await testApiClient.login(account); setConfig(system._id.toString()); }); + it('GET /user/import is UNAUTHORIZED', async () => { const usermatch = userFactory.build({ school }); const importuser = importUserFactory.build({ school }); await em.persistAndFlush([usermatch, importuser]); em.clear(); - await request(app.getHttpServer()).get('/user/import').expect(401); + await testApiClient.get().expect(HttpStatus.UNAUTHORIZED); }); + it('GET /user/import/unassigned is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).get('/user/import/unassigned').expect(401); + await testApiClient.get('unassigned').expect(HttpStatus.UNAUTHORIZED); }); + it('PATCH /user/import/:id/match is allowed', async () => { const userMatch = userFactory.build({ school }); const importUser = importUserFactory.build({ school }); await em.persistAndFlush([userMatch, importUser]); em.clear(); const params: UpdateMatchParams = { userId: user.id }; - await request(app.getHttpServer()).patch(`/user/import/${importUser.id}/match`).send(params).expect(200); + await testApiClient.patch(`${importUser.id}/match`).send(params).expect(HttpStatus.OK); }); + it('DELETE /user/import/:id/match is allowed', async () => { const userMatch = userFactory.build({ school }); const importUser = importUserFactory.matched(MatchCreator.AUTO, userMatch).build({ school }); await em.persistAndFlush([userMatch, importUser]); em.clear(); - await request(app.getHttpServer()).delete(`/user/import/${importUser.id}/match`).send().expect(200); + await testApiClient.delete(`${importUser.id}/match`).send().expect(HttpStatus.OK); }); + it('PATCH /user/import/:id/flag is allowed', async () => { const importUser = importUserFactory.build({ school }); await em.persistAndFlush(importUser); em.clear(); const params: UpdateFlagParams = { flagged: true }; - await request(app.getHttpServer()).patch(`/user/import/${importUser.id}/flag`).send(params).expect(200); + await testApiClient.patch(`${importUser.id}/flag`).send(params).expect(HttpStatus.OK); }); + it('POST /user/import/migrate is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/migrate`).send().expect(401); + await testApiClient.post('migrate').send().expect(HttpStatus.UNAUTHORIZED); }); + it('POST /user/import/startSync is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/startSync`).send().expect(401); + await testApiClient.post('startSync').send().expect(HttpStatus.UNAUTHORIZED); }); + it('POST /user/import/startUserMigration is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/startUserMigration`).send().expect(401); + await testApiClient.post('startUserMigration').send().expect(HttpStatus.UNAUTHORIZED); }); }); + describe('When current user has permissions Permission.SCHOOL_IMPORT_USERS_MIGRATE', () => { - let user: User; + let account: AccountEntity; let system: SystemEntity; + beforeEach(async () => { - ({ user, system } = await authenticatedUser()); - currentUser = mapUserToCurrentUser(user); + ({ account, system } = await authenticatedUser()); + testApiClient = await testApiClient.login(account); setConfig(system._id.toString()); }); + it('GET /user/import is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).get('/user/import').expect(401); + await testApiClient.get().expect(HttpStatus.UNAUTHORIZED); }); + it('GET /user/import/unassigned is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).get('/user/import/unassigned').expect(401); + await testApiClient.get('unassigned').expect(HttpStatus.UNAUTHORIZED); }); + it('PATCH /user/import/:id/match is UNAUTHORIZED', async () => { const id = new ObjectId().toString(); const params: UpdateMatchParams = { userId: new ObjectId().toString() }; - await request(app.getHttpServer()).patch(`/user/import/${id}/match`).send(params).expect(401); + await testApiClient.patch(`${id}/match`).send(params).expect(HttpStatus.UNAUTHORIZED); }); + it('DELETE /user/import/:id/match is UNAUTHORIZED', async () => { const id = new ObjectId().toString(); - await request(app.getHttpServer()).delete(`/user/import/${id}/match`).send().expect(401); + await testApiClient.delete(`${id}/match`).send().expect(HttpStatus.UNAUTHORIZED); }); + it('PATCH /user/import/:id/flag is UNAUTHORIZED', async () => { const id = new ObjectId().toString(); const params: UpdateFlagParams = { flagged: true }; - await request(app.getHttpServer()).patch(`/user/import/${id}/flag`).send(params).expect(401); + await testApiClient.patch(`${id}/flag`).send(params).expect(HttpStatus.UNAUTHORIZED); }); + it('POST /user/import/migrate is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/migrate`).send().expect(401); + await testApiClient.post('migrate').send().expect(HttpStatus.UNAUTHORIZED); }); + it('POST /user/import/startSync is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/startSync`).send().expect(401); + await testApiClient.post('startSync').send().expect(HttpStatus.UNAUTHORIZED); }); + it('POST /user/import/startUserMigration is UNAUTHORIZED', async () => { - await request(app.getHttpServer()).post(`/user/import/startUserMigration`).send().expect(401); + await testApiClient.post('startUserMigration').send().expect(HttpStatus.UNAUTHORIZED); }); }); }); describe('Business Errors', () => { - let user: User; + let account: AccountEntity; let school: SchoolEntity; + beforeEach(async () => { - ({ user, school } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_UPDATE])); - currentUser = mapUserToCurrentUser(user); + ({ account, school } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_UPDATE])); + testApiClient = await testApiClient.login(account); }); + describe('[setMatch]', () => { describe('When set a match on import user', () => { it('should fail for different school of match- and import-user', async () => { const importUser = importUserFactory.build({ school }); - const otherSchool = schoolFactory.build(); + const otherSchool = schoolEntityFactory.build(); const userMatch = userFactory.build({ school: otherSchool }); await em.persistAndFlush([userMatch, importUser]); em.clear(); const params: UpdateMatchParams = { userId: userMatch.id }; - await request(app.getHttpServer()).patch(`/user/import/${importUser.id}/match`).send(params).expect(403); + await testApiClient.patch(`${importUser.id}/match`).send(params).expect(HttpStatus.FORBIDDEN); }); + it('should fail for different school of current-/authenticated- and import-user', async () => { - const otherSchool = schoolFactory.build(); + const otherSchool = schoolEntityFactory.build(); const importUser = importUserFactory.build({ school: otherSchool }); const userMatch = userFactory.build({ school: otherSchool }); await em.persistAndFlush([userMatch, importUser]); em.clear(); const params: UpdateMatchParams = { userId: userMatch.id }; - await request(app.getHttpServer()).patch(`/user/import/${importUser.id}/match`).send(params).expect(403); + await testApiClient.patch(`${importUser.id}/match`).send(params).expect(HttpStatus.FORBIDDEN); }); }); @@ -382,33 +443,31 @@ describe('ImportUser Controller (API)', () => { await em.persistAndFlush([userMatch, importUser]); em.clear(); const params: UpdateMatchParams = { userId: userMatch.id }; - await request(app.getHttpServer()) - .patch(`/user/import/${unmatchedImportUser.id}/match`) - .send(params) - .expect(400); + await testApiClient.patch(`${unmatchedImportUser.id}/match`).send(params).expect(HttpStatus.BAD_REQUEST); }); }); }); + describe('[removeMatch]', () => { describe('When remove a match on import user', () => { it('should fail for different school of current- and import-user', async () => { - const otherSchool = schoolFactory.build(); + const otherSchool = schoolEntityFactory.build(); const importUser = importUserFactory.build({ school: otherSchool }); await em.persistAndFlush(importUser); em.clear(); - await request(app.getHttpServer()).delete(`/user/import/${importUser.id}/match`).send().expect(403); + await testApiClient.delete(`${importUser.id}/match`).send().expect(HttpStatus.FORBIDDEN); }); }); }); describe('[updateFlag]', () => { describe('When change flag on import user', () => { it('should fail for different school of current- and import-user', async () => { - const otherSchool = schoolFactory.build(); + const otherSchool = schoolEntityFactory.build(); const importUser = importUserFactory.build({ school: otherSchool }); await em.persistAndFlush(importUser); em.clear(); const params: UpdateFlagParams = { flagged: true }; - await request(app.getHttpServer()).patch(`/user/import/${importUser.id}/flag`).send(params).expect(403); + await testApiClient.patch(`${importUser.id}/flag`).send(params).expect(HttpStatus.FORBIDDEN); }); }); }); @@ -427,6 +486,7 @@ describe('ImportUser Controller (API)', () => { expect(['admin', 'auto']).toContain(match?.matchedBy); } }; + const expectAllImportUserResponsePropertiesExist = (data: ImportUserResponse, matchExists: boolean) => { expect(data).toEqual( expect.objectContaining({ @@ -447,15 +507,17 @@ describe('ImportUser Controller (API)', () => { expect(data.match).toBeUndefined(); } }; + describe('find', () => { - let user: User; + let account: AccountEntity; let school: SchoolEntity; + beforeEach(async () => { await cleanupCollections(em); - ({ user, school } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_VIEW])); + ({ account, school } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_VIEW])); - currentUser = mapUserToCurrentUser(user); + testApiClient = await testApiClient.login(account); }); describe('[findAllUnmatchedUsers]', () => { @@ -465,27 +527,35 @@ describe('ImportUser Controller (API)', () => { const currentSchoolsUser = userFactory.build({ school }); await em.persistAndFlush([otherSchoolsUser, currentSchoolsUser]); em.clear(); - const response = await request(app.getHttpServer()).get('/user/import/unassigned').expect(200); + + const response = await testApiClient.get('unassigned').expect(HttpStatus.OK); + const listResponse = response.body as UserMatchListResponse; expect(listResponse.data.some((elem) => elem.userId === currentSchoolsUser.id)).toEqual(true); }); + it('should not respond with assigned users', async () => { const otherSchoolsUser = userFactory.build(); const currentSchoolsUser = userFactory.build({ school }); const importUser = importUserFactory.matched(MatchCreator.AUTO, currentSchoolsUser).build({ school }); await em.persistAndFlush([otherSchoolsUser, currentSchoolsUser, importUser]); em.clear(); - const response = await request(app.getHttpServer()).get('/user/import/unassigned').expect(200); + + const response = await testApiClient.get('unassigned').expect(HttpStatus.OK); + const listResponse = response.body as UserMatchListResponse; expect(listResponse.data.some((elem) => elem.userId === currentSchoolsUser.id)).toEqual(false); }); + it('should respond userMatch with all properties', async () => { const currentSchoolsUser = userFactory.withRoleByName(RoleName.TEACHER).build({ school, }); await em.persistAndFlush([currentSchoolsUser]); em.clear(); - const response = await request(app.getHttpServer()).get('/user/import/unassigned').expect(200); + + const response = await testApiClient.get('unassigned').expect(HttpStatus.OK); + const listResponse = response.body as UserMatchListResponse; expectAllUserMatchResponsePropertiesExist(listResponse.data[0], false); }); @@ -495,22 +565,21 @@ describe('ImportUser Controller (API)', () => { const unassignedUsers = userFactory.buildList(10, { school }); await em.persistAndFlush(unassignedUsers); const query: PaginationParams = { skip: 3 }; - const response = await request(app.getHttpServer()) - .get('/user/import/unassigned') - .query(query) - .expect(200); + + const response = await testApiClient.get('unassigned').query(query).expect(HttpStatus.OK); + const result = response.body as UserMatchListResponse; expect(result.total).toBeGreaterThanOrEqual(10); expect(result.data.length).toBeGreaterThanOrEqual(7); }); + it('should limit users', async () => { const unassignedUsers = userFactory.buildList(10, { school }); await em.persistAndFlush(unassignedUsers); const query: PaginationParams = { limit: 3 }; - const response = await request(app.getHttpServer()) - .get('/user/import/unassigned') - .query(query) - .expect(200); + + const response = await testApiClient.get('unassigned').query(query).expect(HttpStatus.OK); + const result = response.body as UserMatchListResponse; expect(result.total).toBeGreaterThanOrEqual(10); expect(result.data).toHaveLength(3); @@ -524,24 +593,23 @@ describe('ImportUser Controller (API)', () => { users.push(searchUser); await em.persistAndFlush(users); const query: FilterUserParams = { name: 'ETE' }; - const response = await request(app.getHttpServer()) - .get('/user/import/unassigned') - .query(query) - .expect(200); + + const response = await testApiClient.get('unassigned').query(query).expect(HttpStatus.OK); + const result = response.body as UserMatchListResponse; expect(result.total).toEqual(1); expect(result.data.some((u) => u.userId === searchUser.id)).toEqual(true); }); + it('should match name in lastname', async () => { const users = userFactory.buildList(10, { school }); const searchUser = userFactory.build({ school, firstName: 'Peter', lastName: 'fox' }); users.push(searchUser); await em.persistAndFlush(users); const query: FilterUserParams = { name: 'X' }; - const response = await request(app.getHttpServer()) - .get('/user/import/unassigned') - .query(query) - .expect(200); + + const response = await testApiClient.get('unassigned').query(query).expect(HttpStatus.OK); + const result = response.body as UserMatchListResponse; expect(result.total).toEqual(1); expect(result.data.some((u) => u.userId === searchUser.id)).toEqual(true); @@ -549,6 +617,7 @@ describe('ImportUser Controller (API)', () => { }); }); }); + describe('[findAllImportUsers]', () => { it('should return importUsers of current school', async () => { const otherSchoolsImportUser = importUserFactory.build(); @@ -557,19 +626,24 @@ describe('ImportUser Controller (API)', () => { }); await em.persistAndFlush([otherSchoolsImportUser, currentSchoolsImportUser]); em.clear(); - const response = await request(app.getHttpServer()).get('/user/import').expect(200); + + const response = await testApiClient.get().expect(HttpStatus.OK); + const listResponse = response.body as ImportUserListResponse; expect(listResponse.data.some((elem) => elem.importUserId === currentSchoolsImportUser.id)).toEqual(true); expect(listResponse.data.some((elem) => elem.importUserId === otherSchoolsImportUser.id)).toEqual(false); expectAllImportUserResponsePropertiesExist(listResponse.data[0], false); }); + it('should return importUsers with all properties including match and roles', async () => { const otherSchoolsImportUser = importUserFactory.build(); const userMatch = userFactory.withRoleByName(RoleName.TEACHER).build({ school }); const currentSchoolsImportUser = importUserFactory.matched(MatchCreator.AUTO, userMatch).build({ school }); await em.persistAndFlush([otherSchoolsImportUser, currentSchoolsImportUser]); em.clear(); - const response = await request(app.getHttpServer()).get('/user/import').expect(200); + + const response = await testApiClient.get().expect(HttpStatus.OK); + const listResponse = response.body as ImportUserListResponse; expect(listResponse.data.some((elem) => elem.importUserId === currentSchoolsImportUser.id)).toEqual(true); expect(listResponse.data.some((elem) => elem.importUserId === otherSchoolsImportUser.id)).toEqual(false); @@ -589,12 +663,15 @@ describe('ImportUser Controller (API)', () => { sortBy: ImportUserSortOrder.FIRSTNAME, sortOrder: SortOrder.asc, }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const listResponse = response.body as ImportUserListResponse; const smallIndex = listResponse.data.findIndex((elem) => elem.firstName === 'Anne'); const higherIndex = listResponse.data.findIndex((elem) => elem.firstName === 'Zoe'); expect(smallIndex).toBeLessThan(higherIndex); }); + it('should sort by firstname desc', async () => { const currentSchoolsImportUsers = importUserFactory.buildList(10, { school, @@ -607,12 +684,15 @@ describe('ImportUser Controller (API)', () => { sortBy: ImportUserSortOrder.FIRSTNAME, sortOrder: SortOrder.desc, }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const listResponse = response.body as ImportUserListResponse; const smallIndex = listResponse.data.findIndex((elem) => elem.firstName === 'Zoe'); const higherIndex = listResponse.data.findIndex((elem) => elem.firstName === 'Anne'); expect(smallIndex).toBeLessThan(higherIndex); }); + it('should sort by lastname asc', async () => { const currentSchoolsImportUsers = importUserFactory.buildList(10, { school, @@ -625,12 +705,15 @@ describe('ImportUser Controller (API)', () => { sortBy: ImportUserSortOrder.LASTNAME, sortOrder: SortOrder.asc, }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const listResponse = response.body as ImportUserListResponse; const smallIndex = listResponse.data.findIndex((elem) => elem.lastName === 'MÃŧller'); const higherIndex = listResponse.data.findIndex((elem) => elem.lastName === 'Schmidt'); expect(smallIndex).toBeLessThan(higherIndex); }); + it('should sort by lastname desc', async () => { const currentSchoolsImportUsers = importUserFactory.buildList(10, { school, @@ -643,28 +726,36 @@ describe('ImportUser Controller (API)', () => { sortBy: ImportUserSortOrder.LASTNAME, sortOrder: SortOrder.desc, }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const listResponse = response.body as ImportUserListResponse; const smallIndex = listResponse.data.findIndex((elem) => elem.lastName === 'Schmidt'); const higherIndex = listResponse.data.findIndex((elem) => elem.lastName === 'MÃŧller'); expect(smallIndex).toBeLessThan(higherIndex); }); }); + describe('when use pagination', () => { it('should skip importusers', async () => { const importUsers = importUserFactory.buildList(10, { school }); await em.persistAndFlush(importUsers); const query: PaginationParams = { skip: 3 }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.total).toBeGreaterThanOrEqual(10); expect(result.data.length).toBeGreaterThanOrEqual(7); }); + it('should limit importusers', async () => { const importUsers = importUserFactory.buildList(10, { school }); await em.persistAndFlush(importUsers); const query: PaginationParams = { limit: 3 }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.total).toBeGreaterThanOrEqual(10); expect(result.data).toHaveLength(3); @@ -677,62 +768,80 @@ describe('ImportUser Controller (API)', () => { importUsers[0].firstName = 'Klaus-Peter'; await em.persistAndFlush(importUsers); const query: FilterImportUserParams = { firstName: 's-p' }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.data.length).toEqual(1); expect(result.data[0].firstName).toEqual('Klaus-Peter'); }); + it('should filter by lastname', async () => { const importUsers = importUserFactory.buildList(10, { school }); importUsers[0].lastName = 'Weimann'; await em.persistAndFlush(importUsers); const query: FilterImportUserParams = { lastName: 'Mann' }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.data.length).toEqual(1); expect(result.data[0].lastName).toEqual('Weimann'); }); + it('should filter by username', async () => { const importUsers = importUserFactory.buildList(10, { school }); importUsers[0].ldapDn = 'uid=EinarWeimann12,...'; await em.persistAndFlush(importUsers); const query: FilterImportUserParams = { loginName: 'Mann1' }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.data.length).toEqual(1); expect(result.data[0].loginName).toEqual('EinarWeimann12'); }); + it('should filter by one role of student, teacher, or admin', async () => { const importUsers = importUserFactory.buildList(10, { school }); importUsers[0].roleNames = [RoleName.TEACHER]; await em.persistAndFlush(importUsers); const query: FilterImportUserParams = { role: FilterRoleType.TEACHER }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.data.length).toEqual(1); expect(result.data[0].roleNames).toContain(UserRole.TEACHER); }); + it('should filter by class', async () => { const importUsers = importUserFactory.buildList(10, { school }); importUsers[0].classNames = ['class1', 'second']; await em.persistAndFlush(importUsers); const query: FilterImportUserParams = { classes: 'ss1' }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.data.length).toEqual(1); expect(result.data[0].classNames).toContain('class1'); }); + it('should filter by match type none', async () => { const importUsers = importUserFactory.buildList(10, { school }); importUsers[0].setMatch(userFactory.build({ school }), MatchCreator.AUTO); await em.persistAndFlush(importUsers); const query: FilterImportUserParams = { match: [FilterMatchType.NONE] }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.data.some((iu) => iu.match?.matchedBy !== MatchType.AUTO)).toEqual(true); expect(result.data.some((iu) => iu.match?.matchedBy !== MatchType.MANUAL)).toEqual(true); expect(result.data.length).toEqual(9); }); + it('should filter by match type none also deleted matches', async () => { const importUsers = importUserFactory.buildList(10, { school }); importUsers[0].setMatch(userFactory.build({ school }), MatchCreator.AUTO); @@ -740,52 +849,68 @@ describe('ImportUser Controller (API)', () => { importUsers[0].revokeMatch(); await em.persistAndFlush(importUsers); const query: FilterImportUserParams = { match: [FilterMatchType.NONE] }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.data.some((iu) => iu.match?.matchedBy !== MatchType.AUTO)).toEqual(true); expect(result.data.some((iu) => iu.match?.matchedBy !== MatchType.MANUAL)).toEqual(true); expect(result.data.length).toEqual(10); }); + it('should filter by match type admin (manual)', async () => { const importUsers = importUserFactory.buildList(10, { school }); importUsers[0].setMatch(userFactory.build({ school }), MatchCreator.MANUAL); await em.persistAndFlush(importUsers); const query: FilterImportUserParams = { match: [FilterMatchType.MANUAL] }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.data.some((iu) => iu.match?.matchedBy === MatchType.MANUAL)).toEqual(true); expect(result.data.some((iu) => iu.match?.matchedBy !== MatchType.MANUAL)).toEqual(false); expect(result.data.length).toEqual(1); }); + it('should filter by match type auto', async () => { const importUsers = importUserFactory.buildList(10, { school }); importUsers[0].setMatch(userFactory.build({ school }), MatchCreator.AUTO); await em.persistAndFlush(importUsers); const query: FilterImportUserParams = { match: [FilterMatchType.AUTO] }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.data.some((iu) => iu.match?.matchedBy === MatchType.AUTO)).toEqual(true); expect(result.data.some((iu) => iu.match?.matchedBy !== MatchType.AUTO)).toEqual(false); expect(result.data.length).toEqual(1); }); + it('should filter by multiple match types', async () => { const importUsers = importUserFactory.buildList(10, { school }); importUsers[0].setMatch(userFactory.build({ school }), MatchCreator.MANUAL); importUsers[1].setMatch(userFactory.build({ school }), MatchCreator.AUTO); await em.persistAndFlush(importUsers); - const query: FilterImportUserParams = { match: [FilterMatchType.AUTO, FilterMatchType.MANUAL] }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + const query: FilterImportUserParams = { + match: [FilterMatchType.AUTO, FilterMatchType.MANUAL], + }; + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.data.some((iu) => iu.match?.matchedBy === MatchType.MANUAL)).toEqual(true); expect(result.data.some((iu) => iu.match?.matchedBy === MatchType.AUTO)).toEqual(true); expect(result.data.length).toEqual(2); }); + it('should filter by flag enabled', async () => { const importUsers = importUserFactory.buildList(10, { school }); importUsers[0].flagged = true; await em.persistAndFlush(importUsers); const query: FilterImportUserParams = { flagged: true }; - const response = await request(app.getHttpServer()).get('/user/import').query(query).expect(200); + + const response = await testApiClient.get().query(query).expect(HttpStatus.OK); + const result = response.body as ImportUserListResponse; expect(result.data.some((iu) => iu.flagged === false)).toEqual(false); expect(result.data.some((iu) => iu.flagged === true)).toEqual(true); @@ -796,11 +921,12 @@ describe('ImportUser Controller (API)', () => { }); describe('updates', () => { - let user: User; + let account: AccountEntity; let school: SchoolEntity; + beforeEach(async () => { - ({ user, school } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_UPDATE])); - currentUser = mapUserToCurrentUser(user); + ({ account, school } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_UPDATE])); + testApiClient = await testApiClient.login(account); }); describe('[setMatch]', () => { @@ -815,15 +941,18 @@ describe('ImportUser Controller (API)', () => { await em.persistAndFlush([userToBeMatched, unmatchedImportUser]); em.clear(); const params: UpdateMatchParams = { userId: userToBeMatched.id }; - const result = await request(app.getHttpServer()) - .patch(`/user/import/${unmatchedImportUser.id}/match`) + + const result = await testApiClient + .patch(`${unmatchedImportUser.id}/match`) .send(params) - .expect(200); + .expect(HttpStatus.OK); + const importUserResponse = result.body as ImportUserResponse; expectAllImportUserResponsePropertiesExist(importUserResponse, true); expect(importUserResponse.match?.matchedBy).toEqual(MatchType.MANUAL); expect(importUserResponse.match?.userId).toEqual(userToBeMatched.id); }); + it('should update an existing auto match to manual', async () => { const userMatch = userFactory.withRoleByName(RoleName.STUDENT).build({ school, @@ -837,10 +966,12 @@ describe('ImportUser Controller (API)', () => { await em.persistAndFlush([userMatch, alreadyMatchedImportUser, manualUserMatch]); em.clear(); const params: UpdateMatchParams = { userId: manualUserMatch.id }; - const result = await request(app.getHttpServer()) - .patch(`/user/import/${alreadyMatchedImportUser.id}/match`) + + const result = await testApiClient + .patch(`${alreadyMatchedImportUser.id}/match`) .send(params) - .expect(200); + .expect(HttpStatus.OK); + const elem = result.body as ImportUserResponse; expectAllImportUserResponsePropertiesExist(elem, true); expect(elem.match?.matchedBy).toEqual(MatchType.MANUAL); @@ -860,20 +991,24 @@ describe('ImportUser Controller (API)', () => { }); await em.persistAndFlush([importUserWithMatch]); em.clear(); - const result = await request(app.getHttpServer()) - .delete(`/user/import/${importUserWithMatch.id}/match`) - .expect(200); + + const result = await testApiClient.delete(`${importUserWithMatch.id}/match`).send().expect(HttpStatus.OK); + expectAllImportUserResponsePropertiesExist(result.body as ImportUserResponse, false); }); + it('should not fail when importuser is not having a match', async () => { const importUserWithoutMatch = importUserFactory.build({ school, }); await em.persistAndFlush([importUserWithoutMatch]); em.clear(); - const result = await request(app.getHttpServer()) - .delete(`/user/import/${importUserWithoutMatch.id}/match`) - .expect(200); + + const result = await testApiClient + .delete(`${importUserWithoutMatch.id}/match`) + .send() + .expect(HttpStatus.OK); + expectAllImportUserResponsePropertiesExist(result.body as ImportUserResponse, false); }); }); @@ -888,14 +1023,14 @@ describe('ImportUser Controller (API)', () => { await em.persistAndFlush([importUser]); em.clear(); const params: UpdateFlagParams = { flagged: true }; - const result = await request(app.getHttpServer()) - .patch(`/user/import/${importUser.id}/flag`) - .send(params) - .expect(200); + + const result = await testApiClient.patch(`${importUser.id}/flag`).send(params).expect(HttpStatus.OK); + const response = result.body as ImportUserResponse; expectAllImportUserResponsePropertiesExist(response, false); expect(response.flagged).toEqual(true); }); + it('should remove a flag', async () => { const importUser = importUserFactory.build({ school, @@ -904,10 +1039,9 @@ describe('ImportUser Controller (API)', () => { await em.persistAndFlush([importUser]); em.clear(); const params: UpdateFlagParams = { flagged: false }; - const result = await request(app.getHttpServer()) - .patch(`/user/import/${importUser.id}/flag`) - .send(params) - .expect(200); + + const result = await testApiClient.patch(`${importUser.id}/flag`).send(params).expect(HttpStatus.OK); + const response = result.body as ImportUserResponse; expectAllImportUserResponsePropertiesExist(response, false); expect(response.flagged).toEqual(false); @@ -917,16 +1051,18 @@ describe('ImportUser Controller (API)', () => { }); describe('[migrate]', () => { - let user: User; + let account: AccountEntity; let school: SchoolEntity; + beforeEach(async () => { - ({ user, school } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_MIGRATE])); + ({ account, school } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_MIGRATE])); school.officialSchoolNumber = 'foo'; school.inMaintenanceSince = new Date(); school.externalId = 'foo'; school.inUserMigration = true; - currentUser = mapUserToCurrentUser(user); + testApiClient = await testApiClient.login(account); }); + describe('POST user/import/migrate', () => { it('should migrate', async () => { school.officialSchoolNumber = 'foo'; @@ -937,21 +1073,22 @@ describe('ImportUser Controller (API)', () => { await em.persistAndFlush([importUser]); em.clear(); - await request(app.getHttpServer()).post(`/user/import/migrate`).expect(201); + await testApiClient.post('migrate').expect(HttpStatus.CREATED); }); }); }); describe('[startUserMigration]', () => { - let user: User; + let account: AccountEntity; let system: SystemEntity; + describe('POST user/import/startUserMigration', () => { it('should set in user migration mode', async () => { - ({ user, system } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_MIGRATE])); - currentUser = mapUserToCurrentUser(user); - Configuration.set('FEATURE_USER_MIGRATION_SYSTEM_ID', system._id.toString()); + ({ account, system } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_MIGRATE])); + testApiClient = await testApiClient.login(account); + userImportFeatures.userMigrationSystemId = system._id.toString(); - await request(app.getHttpServer()).post(`/user/import/startUserMigration`).expect(201); + await testApiClient.post('startUserMigration').expect(HttpStatus.CREATED); }); }); }); @@ -959,7 +1096,7 @@ describe('ImportUser Controller (API)', () => { describe('[endSchoolMaintenance]', () => { describe('POST user/import/startSync', () => { it('should remove inMaintenanceSince from school', async () => { - const school = schoolFactory.buildWithId({ + const school = schoolEntityFactory.buildWithId({ externalId: 'foo', inMaintenanceSince: new Date(), inUserMigration: false, @@ -971,19 +1108,116 @@ describe('ImportUser Controller (API)', () => { }), ]; await em.persistAndFlush([school, ...roles]); - const user = userFactory.build({ - school, - roles, - }); - await em.persistAndFlush([user]); + + const { adminUser, adminAccount } = UserAndAccountTestFactory.buildAdmin({ school }, [ + Permission.SCHOOL_IMPORT_USERS_MIGRATE, + ]); + + await em.persistAndFlush([adminUser, adminAccount]); em.clear(); - currentUser = mapUserToCurrentUser(user); + testApiClient = await testApiClient.login(adminAccount); + + await testApiClient.post('startSync').expect(HttpStatus.CREATED); + }); + }); + }); + }); + + describe('[POST] populateImportUsers', () => { + describe('when user is not authenticated', () => { + const setup = () => { + const notLoggedInClient = new TestApiClient(app, 'user/import'); + + return { notLoggedInClient }; + }; + + it('should return unauthorized', async () => { + const { notLoggedInClient } = setup(); + + await notLoggedInClient.post('populate-import-users').send().expect(HttpStatus.UNAUTHORIZED); + }); + }); + + describe('when migration is not activated', () => { + const setup = async () => { + const { account } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_MIGRATE]); + const loggedInClient = await testApiClient.login(account); + + userImportFeatures.userMigrationEnabled = false; + + return { loggedInClient }; + }; - await request(app.getHttpServer()).post(`/user/import/startSync`).expect(201); + it('should return with status forbidden', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.post('populate-import-users').send(); + + expect(response.body).toEqual({ + type: 'USER_MIGRATION_IS_NOT_ENABLED', + title: 'User Migration Is Not Enabled', + message: 'Feature flag of user migration may be disable or the school is not an LDAP pilot', + code: HttpStatus.FORBIDDEN, }); }); }); + + describe('when users school has no external id', () => { + const setup = async () => { + const { account, school, system } = await authenticatedUser( + [Permission.SCHOOL_IMPORT_USERS_MIGRATE], + [], + false + ); + const loggedInClient = await testApiClient.login(account); + userImportFeatures.userMigrationSystemId = system.id; + + school.externalId = undefined; + + return { loggedInClient }; + }; + + it('should return with status bad request', async () => { + const { loggedInClient } = await setup(); + + const response = await loggedInClient.post('populate-import-users').send(); + + expect(response.body).toEqual({ + type: 'USER_IMPORT_SCHOOL_EXTERNAL_ID_MISSING', + title: 'User Import School External Id Missing', + message: 'Bad Request', + code: HttpStatus.BAD_REQUEST, + }); + }); + }); + + describe('when users were populated successful', () => { + const setup = async () => { + const { account, school, system } = await authenticatedUser([Permission.SCHOOL_IMPORT_USERS_MIGRATE]); + const loggedInClient = await testApiClient.login(account); + + userImportFeatures.userMigrationEnabled = true; + userImportFeatures.userMigrationSystemId = system.id; + + axiosMock.onPost(/(.*)\/token/).reply(HttpStatus.OK, { + id_token: 'idToken', + refresh_token: 'refreshToken', + access_token: 'accessToken', + }); + + const schulconnexResponse: SanisResponse = schulconnexResponseFactory.build(); + axiosMock.onGet(/(.*)\/personen-info/).reply(HttpStatus.OK, [schulconnexResponse]); + + return { loggedInClient, account, school }; + }; + + it('should return with status created', async () => { + const { loggedInClient } = await setup(); + + await loggedInClient.post('populate-import-users').send().expect(HttpStatus.CREATED); + }); + }); }); }); }); diff --git a/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts b/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts deleted file mode 100644 index ab94fc43143..00000000000 --- a/apps/server/src/modules/user-import/controller/import-user.controller.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { AccountService } from '@modules/account/services/account.service'; -import { AuthorizationService } from '@modules/authorization'; -import { LegacySchoolService } from '@modules/legacy-school'; -import { ConfigModule } from '@nestjs/config'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ImportUserRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; -import { LoggerModule } from '@src/core/logger'; -import { UserImportUc } from '../uc/user-import.uc'; -import { ImportUserController } from './import-user.controller'; - -describe('ImportUserController', () => { - let module: TestingModule; - let controller: ImportUserController; - - afterAll(async () => { - await module.close(); - }); - - beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [LoggerModule, ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true, ignoreEnvVars: true })], - providers: [ - UserImportUc, - { - provide: AccountService, - useValue: {}, - }, - { - provide: AuthorizationService, - useValue: {}, - }, - { - provide: ImportUserRepo, - useValue: {}, - }, - { - provide: LegacySchoolService, - useValue: {}, - }, - { - provide: LegacySystemRepo, - useValue: {}, - }, - { - provide: UserRepo, - useValue: {}, - }, - ], - controllers: [ImportUserController], - }).compile(); - - controller = module.get(ImportUserController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/apps/server/src/modules/user-import/controller/import-user.controller.ts b/apps/server/src/modules/user-import/controller/import-user.controller.ts index 228590f8f6d..4ba271e0e9e 100644 --- a/apps/server/src/modules/user-import/controller/import-user.controller.ts +++ b/apps/server/src/modules/user-import/controller/import-user.controller.ts @@ -1,13 +1,20 @@ import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { + ApiBadRequestResponse, + ApiCreatedResponse, + ApiForbiddenResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { RequestTimeout } from '@shared/common'; import { PaginationParams } from '@shared/controller'; import { ImportUser, User } from '@shared/domain/entity'; import { IFindOptions } from '@shared/domain/interface'; -import { ImportUserMapper } from '../mapper/import-user.mapper'; -import { UserMatchMapper } from '../mapper/user-match.mapper'; -import { UserImportUc } from '../uc/user-import.uc'; - +import { ImportUserMapper, UserMatchMapper } from '../mapper'; +import { UserImportFetchUc, UserImportUc } from '../uc'; import { FilterImportUserParams, FilterUserParams, @@ -24,7 +31,7 @@ import { @Authenticate('jwt') @Controller('user/import') export class ImportUserController { - constructor(private readonly userImportUc: UserImportUc, private readonly userUc: UserImportUc) {} + constructor(private readonly userImportUc: UserImportUc, private readonly userImportFetchUc: UserImportFetchUc) {} @Get() async findAllImportUsers( @@ -88,7 +95,7 @@ export class ImportUserController { const options: IFindOptions = { pagination }; const query = UserMatchMapper.mapToDomain(scope); - const [userList, total] = await this.userUc.findAllUnmatchedUsers(currentUser.userId, query, options); + const [userList, total] = await this.userImportUc.findAllUnmatchedUsers(currentUser.userId, query, options); const { skip, limit } = pagination; const dtoList = userList.map((user) => UserMatchMapper.mapToResponse(user)); const response = new UserMatchListResponse(dtoList, total, skip, limit); @@ -113,4 +120,19 @@ export class ImportUserController { async endSchoolInMaintenance(@CurrentUser() currentUser: ICurrentUser): Promise { await this.userImportUc.endSchoolInMaintenance(currentUser.userId); } + + @RequestTimeout('SCHULCONNEX_CLIENT__PERSONEN_INFO_TIMEOUT_IN_MS') + @Post('populate-import-users') + @ApiOperation({ + summary: 'Populates import users', + description: 'Populates import users from specific user migration populate endpoint.', + }) + @ApiCreatedResponse() + @ApiUnauthorizedResponse() + @ApiServiceUnavailableResponse() + @ApiBadRequestResponse() + @ApiForbiddenResponse() + async populateImportUsers(@CurrentUser() currentUser: ICurrentUser): Promise { + await this.userImportFetchUc.populateImportUsers(currentUser.userId); + } } diff --git a/apps/server/src/modules/user-import/index.ts b/apps/server/src/modules/user-import/index.ts index 48e2da47e2a..b6cd58577bf 100644 --- a/apps/server/src/modules/user-import/index.ts +++ b/apps/server/src/modules/user-import/index.ts @@ -1 +1,3 @@ -export * from './user-import.module'; +export { ImportUserModule } from './user-import.module'; +export { UserImportConfigModule } from './user-import-config.module'; +export { IUserImportFeatures, UserImportConfiguration } from './config'; diff --git a/apps/server/src/modules/user-import/loggable/index.ts b/apps/server/src/modules/user-import/loggable/index.ts index d1ee0b00597..7daf1e12b1d 100644 --- a/apps/server/src/modules/user-import/loggable/index.ts +++ b/apps/server/src/modules/user-import/loggable/index.ts @@ -1,6 +1,11 @@ -export * from './user-migration-not-enable.loggable'; export * from './school-in-user-migration-start.loggable'; export * from './school-in-user-migration-end.loggable'; export * from './school-id-does-not-match-with-user-school-id.loggable'; export * from './migration-is-not-completed.loggable'; export * from './migration-may-be-completed.loggable'; +export { UserImportConfigurationFailureLoggableException } from './user-import-configuration-failure-loggable-exception'; +export { UserImportPopulateFailureLoggableException } from './user-import-populate-failure-loggable-exception'; +export { UserImportSchoolExternalIdMissingLoggableException } from './user-import-school-external-id-missing-loggable-exception'; +export { SchoolNotMigratedLoggableException } from './school-not-migrated.loggable-exception'; +export { UserMigrationIsNotEnabled } from './user-migration-not-enable.loggable'; +export { UserMigrationIsNotEnabledLoggableException } from './user-migration-not-enable-loggable-exception'; diff --git a/apps/server/src/modules/user-import/loggable/school-not-migrated.loggable-exception.spec.ts b/apps/server/src/modules/user-import/loggable/school-not-migrated.loggable-exception.spec.ts new file mode 100644 index 00000000000..b867f0328d7 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/school-not-migrated.loggable-exception.spec.ts @@ -0,0 +1,28 @@ +import { SchoolNotMigratedLoggableException } from './school-not-migrated.loggable-exception'; + +describe(SchoolNotMigratedLoggableException, () => { + describe('getLogMessage', () => { + const setup = () => { + const schoolId = 'schoolId'; + const exception = new SchoolNotMigratedLoggableException(schoolId); + + return { + schoolId, + exception, + }; + }; + + it('should return the correct log message', () => { + const { schoolId, exception } = setup(); + + expect(exception.getLogMessage()).toEqual({ + type: 'SCHOOL_NOT_MIGRATED', + message: 'The school administrator started the migration for his school.', + stack: exception.stack, + data: { + schoolId, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-import/loggable/school-not-migrated.loggable-exception.ts b/apps/server/src/modules/user-import/loggable/school-not-migrated.loggable-exception.ts new file mode 100644 index 00000000000..2d0ee1fd7d9 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/school-not-migrated.loggable-exception.ts @@ -0,0 +1,20 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class SchoolNotMigratedLoggableException extends UnprocessableEntityException implements Loggable { + constructor(private readonly schoolId: EntityId) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'SCHOOL_NOT_MIGRATED', + message: 'The school administrator started the migration for his school.', + stack: this.stack, + data: { + schoolId: this.schoolId, + }, + }; + } +} diff --git a/apps/server/src/modules/user-import/loggable/user-import-configuration-failure-loggable-exception.spec.ts b/apps/server/src/modules/user-import/loggable/user-import-configuration-failure-loggable-exception.spec.ts new file mode 100644 index 00000000000..6c13177da53 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-import-configuration-failure-loggable-exception.spec.ts @@ -0,0 +1,23 @@ +import { UserImportConfigurationFailureLoggableException } from './user-import-configuration-failure-loggable-exception'; + +describe(UserImportConfigurationFailureLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const loggable = new UserImportConfigurationFailureLoggableException(); + + return { loggable }; + }; + + it('should return a loggable message', () => { + const { loggable } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'USER_IMPORT_CONFIGURATION_FAILURE', + message: 'Please check the user import configuration.', + stack: loggable.stack, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-import/loggable/user-import-configuration-failure-loggable-exception.ts b/apps/server/src/modules/user-import/loggable/user-import-configuration-failure-loggable-exception.ts new file mode 100644 index 00000000000..91337ccb4c9 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-import-configuration-failure-loggable-exception.ts @@ -0,0 +1,24 @@ +import { HttpStatus } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; +import { BusinessError } from '@shared/common'; + +export class UserImportConfigurationFailureLoggableException extends BusinessError implements Loggable { + constructor() { + super( + { + type: 'USER_IMPORT_CONFIGURATION_FAILURE', + title: 'The user import configuration has a failure.', + defaultMessage: 'Please check the user import configuration.', + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'USER_IMPORT_CONFIGURATION_FAILURE', + message: 'Please check the user import configuration.', + stack: this.stack, + }; + } +} diff --git a/apps/server/src/modules/user-import/loggable/user-import-populate-failure-loggable-exception.spec.ts b/apps/server/src/modules/user-import/loggable/user-import-populate-failure-loggable-exception.spec.ts new file mode 100644 index 00000000000..6d38adfaf39 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-import-populate-failure-loggable-exception.spec.ts @@ -0,0 +1,27 @@ +import { UserImportPopulateFailureLoggableException } from './user-import-populate-failure-loggable-exception'; + +describe(UserImportPopulateFailureLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const url = 'mockUrl'; + const loggable = new UserImportPopulateFailureLoggableException(url); + + return { loggable, url }; + }; + + it('should return a loggable message', () => { + const { loggable, url } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + type: 'USER_IMPORT_POPULATE_FAILURE', + message: 'While populate import users an error occurred.', + stack: loggable.stack, + data: { + url, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-import/loggable/user-import-populate-failure-loggable-exception.ts b/apps/server/src/modules/user-import/loggable/user-import-populate-failure-loggable-exception.ts new file mode 100644 index 00000000000..0348abfa45c --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-import-populate-failure-loggable-exception.ts @@ -0,0 +1,27 @@ +import { HttpStatus } from '@nestjs/common'; +import { BusinessError } from '@shared/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class UserImportPopulateFailureLoggableException extends BusinessError implements Loggable { + constructor(private readonly url: string) { + super( + { + type: 'USER_IMPORT_POPULATE_FAILURE', + title: 'Fetching import user failed.', + defaultMessage: 'While fetching import users an error occurred.', + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'USER_IMPORT_POPULATE_FAILURE', + message: 'While populate import users an error occurred.', + stack: this.stack, + data: { + url: this.url, + }, + }; + } +} diff --git a/apps/server/src/modules/user-import/loggable/user-import-school-external-id-missing-loggable-exception.ts b/apps/server/src/modules/user-import/loggable/user-import-school-external-id-missing-loggable-exception.ts new file mode 100644 index 00000000000..024f305b499 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-import-school-external-id-missing-loggable-exception.ts @@ -0,0 +1,19 @@ +import { BadRequestException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class UserImportSchoolExternalIdMissingLoggableException extends BadRequestException implements Loggable { + constructor(private readonly schoolId: string) { + super(); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'USER_IMPORT_SCHOOL_EXTERNAL_ID_MISSING', + message: 'The users school does not have an external id', + stack: this.stack, + data: { + schoolId: this.schoolId, + }, + }; + } +} diff --git a/apps/server/src/modules/user-import/loggable/user-migration-not-enable-loggable-exception.ts b/apps/server/src/modules/user-import/loggable/user-migration-not-enable-loggable-exception.ts new file mode 100644 index 00000000000..475323fb7f9 --- /dev/null +++ b/apps/server/src/modules/user-import/loggable/user-migration-not-enable-loggable-exception.ts @@ -0,0 +1,22 @@ +import { ForbiddenException } from '@nestjs/common'; +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class UserMigrationIsNotEnabledLoggableException extends ForbiddenException implements Loggable { + constructor(private readonly userId?: string, private readonly schoolId?: string) { + super({ + message: 'Feature flag of user migration may be disable or the school is not an LDAP pilot', + }); + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + type: 'USER_MIGRATION_IS_NOT_ENABLED', + message: 'Feature flag of user migration may be disable or the school is not an LDAP pilot', + stack: this.stack, + data: { + userId: this.userId, + schoolId: this.schoolId, + }, + }; + } +} diff --git a/apps/server/src/modules/user-import/mapper/import-user.mapper.spec.ts b/apps/server/src/modules/user-import/mapper/import-user.mapper.spec.ts index ad7d2a2c3e4..881b8fc6c4b 100644 --- a/apps/server/src/modules/user-import/mapper/import-user.mapper.spec.ts +++ b/apps/server/src/modules/user-import/mapper/import-user.mapper.spec.ts @@ -2,7 +2,7 @@ import { BadRequestException } from '@nestjs/common'; import { MatchCreator } from '@shared/domain/entity'; import { RoleName, SortOrder } from '@shared/domain/interface'; import { MatchCreatorScope } from '@shared/domain/types'; -import { importUserFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { importUserFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { FilterImportUserParams, FilterMatchType, @@ -95,7 +95,7 @@ describe('[ImportUserMapper]', () => { }); describe('when user and matchedBy is defined', () => { it('should map match', () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.build({ school }); const importUser = importUserFactory.matched(MatchCreator.AUTO, user).build({ school }); const mockResponse = Object.create(UserMatchResponse, {}) as UserMatchResponse; diff --git a/apps/server/src/modules/user-import/mapper/import-user.mapper.ts b/apps/server/src/modules/user-import/mapper/import-user.mapper.ts index 4c8bd53955c..767f09f978f 100644 --- a/apps/server/src/modules/user-import/mapper/import-user.mapper.ts +++ b/apps/server/src/modules/user-import/mapper/import-user.mapper.ts @@ -37,7 +37,7 @@ export class ImportUserMapper { loginName: importUser.loginName || '', firstName: importUser.firstName, lastName: importUser.lastName, - roleNames: importUser.roleNames.map((role) => RoleNameMapper.mapToResponse(role)), + roleNames: importUser.roleNames?.map((role) => RoleNameMapper.mapToResponse(role)), classNames: importUser.classNames, flagged: importUser.flagged, }); diff --git a/apps/server/src/modules/user-import/mapper/index.ts b/apps/server/src/modules/user-import/mapper/index.ts new file mode 100644 index 00000000000..776e5735224 --- /dev/null +++ b/apps/server/src/modules/user-import/mapper/index.ts @@ -0,0 +1,5 @@ +export { ImportUserMatchMapper } from './match.mapper'; +export { RoleNameMapper } from './role-name.mapper'; +export { UserMatchMapper } from './user-match.mapper'; +export { ImportUserMapper } from './import-user.mapper'; +export { SchulconnexImportUserMapper } from './schulconnex-import-user.mapper'; diff --git a/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts b/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts new file mode 100644 index 00000000000..d3a95151e03 --- /dev/null +++ b/apps/server/src/modules/user-import/mapper/schulconnex-import-user.mapper.ts @@ -0,0 +1,31 @@ +import { SanisResponse } from '@infra/schulconnex-client'; +import { SanisResponseMapper } from '@modules/provisioning'; +import { ImportUser, SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { RoleName } from '@shared/domain/interface'; + +export class SchulconnexImportUserMapper { + public static mapDataToUserImportEntities( + response: SanisResponse[], + system: SystemEntity, + school: SchoolEntity + ): ImportUser[] { + const importUsers: ImportUser[] = response.map((externalUser: SanisResponse): ImportUser => { + const role: RoleName = SanisResponseMapper.mapSanisRoleToRoleName(externalUser); + + const importUser: ImportUser = new ImportUser({ + system, + school, + ldapDn: `uid=${externalUser.person.name.vorname}.${externalUser.person.name.familienname}.${externalUser.pid},`, + externalId: externalUser.pid, + firstName: externalUser.person.name.vorname, + lastName: externalUser.person.name.familienname, + roleNames: ImportUser.isImportUserRole(role) ? [role] : [], + email: `${externalUser.person.name.vorname}.${externalUser.person.name.familienname}.${externalUser.pid}@schul-cloud.org`, + }); + + return importUser; + }); + + return importUsers; + } +} diff --git a/apps/server/src/modules/user-import/service/index.ts b/apps/server/src/modules/user-import/service/index.ts new file mode 100644 index 00000000000..3e9ef6f5466 --- /dev/null +++ b/apps/server/src/modules/user-import/service/index.ts @@ -0,0 +1,2 @@ +export { SchulconnexFetchImportUsersService } from './strategy/schulconnex-fetch-import-users.service'; +export { UserImportService } from './user-import.service'; diff --git a/apps/server/src/modules/user-import/service/strategy/schulconnex-fetch-import-users.service.spec.ts b/apps/server/src/modules/user-import/service/strategy/schulconnex-fetch-import-users.service.spec.ts new file mode 100644 index 00000000000..55f586a761d --- /dev/null +++ b/apps/server/src/modules/user-import/service/strategy/schulconnex-fetch-import-users.service.spec.ts @@ -0,0 +1,188 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { SanisResponse, schulconnexResponseFactory, SchulconnexRestClient } from '@infra/schulconnex-client'; +import { UserService } from '@modules/user'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UserDO } from '@shared/domain/domainobject'; +import { ImportUser, SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { RoleName } from '@shared/domain/interface'; +import { + importUserFactory, + schoolEntityFactory, + setupEntities, + systemEntityFactory, + userDoFactory, +} from '@shared/testing'; +import { UserImportSchoolExternalIdMissingLoggableException } from '../../loggable'; +import { SchulconnexFetchImportUsersService } from './schulconnex-fetch-import-users.service'; + +describe(SchulconnexFetchImportUsersService.name, () => { + let module: TestingModule; + let service: SchulconnexFetchImportUsersService; + + let schulconnexRestClient: DeepMocked; + let userService: DeepMocked; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + SchulconnexFetchImportUsersService, + { + provide: SchulconnexRestClient, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(SchulconnexFetchImportUsersService); + schulconnexRestClient = module.get(SchulconnexRestClient); + userService = module.get(UserService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const createImportUser = (externalUserData: SanisResponse, school: SchoolEntity, system: SystemEntity): ImportUser => + importUserFactory.build({ + system, + school, + ldapDn: `uid=${externalUserData.person.name.vorname}.${externalUserData.person.name.familienname}.${externalUserData.pid},`, + externalId: externalUserData.pid, + firstName: externalUserData.person.name.vorname, + lastName: externalUserData.person.name.familienname, + email: `${externalUserData.person.name.vorname}.${externalUserData.person.name.familienname}.${externalUserData.pid}@schul-cloud.org`, + roleNames: [RoleName.ADMINISTRATOR], + classNames: undefined, + }); + + describe('getData', () => { + describe('when fetching the data', () => { + const setup = () => { + const externalUserData: SanisResponse = schulconnexResponseFactory.build(); + const system: SystemEntity = systemEntityFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [system], + externalId: 'externalSchoolId', + }); + const importUser: ImportUser = createImportUser(externalUserData, school, system); + + schulconnexRestClient.getPersonenInfo.mockResolvedValueOnce([externalUserData]); + + return { + school, + system, + importUser, + }; + }; + + it('should call the schulconnex rest client', async () => { + const { school, system } = setup(); + + await service.getData(school, system); + + expect(schulconnexRestClient.getPersonenInfo).toHaveBeenCalledWith({ + vollstaendig: ['personen', 'personenkontexte', 'organisationen'], + 'organisation.id': school.externalId, + }); + }); + + it('should return import users', async () => { + const { school, system } = setup(); + + const result: ImportUser[] = await service.getData(school, system); + + expect(result).toHaveLength(1); + }); + }); + + describe('when the school has no external id', () => { + const setup = () => { + const system: SystemEntity = systemEntityFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [system], + externalId: undefined, + }); + + return { + school, + system, + }; + }; + + it('should throw an error', async () => { + const { school, system } = setup(); + + await expect(service.getData(school, system)).rejects.toThrow( + UserImportSchoolExternalIdMissingLoggableException + ); + }); + }); + }); + + describe('filterAlreadyMigratedUser', () => { + describe('when the user was not migrated yet', () => { + const setup = () => { + const externalUserData: SanisResponse = schulconnexResponseFactory.build(); + const system: SystemEntity = systemEntityFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [system], + externalId: 'externalSchoolId', + }); + const importUser: ImportUser = createImportUser(externalUserData, school, system); + const migratedUser: UserDO = userDoFactory.build({ externalId: externalUserData.pid }); + userService.findByExternalId.mockResolvedValueOnce(null); + + return { + systemId: system.id, + importUsers: [importUser], + migratedUser, + }; + }; + + it('should return the import users', async () => { + const { systemId, importUsers } = setup(); + + const result: ImportUser[] = await service.filterAlreadyMigratedUser(importUsers, systemId); + + expect(result).toHaveLength(1); + }); + }); + + describe('when the user already was migrated', () => { + const setup = () => { + const externalUserData: SanisResponse = schulconnexResponseFactory.build(); + const system: SystemEntity = systemEntityFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ + systems: [system], + externalId: 'externalSchoolId', + }); + const importUser: ImportUser = createImportUser(externalUserData, school, system); + const migratedUser: UserDO = userDoFactory.build({ externalId: externalUserData.pid }); + userService.findByExternalId.mockResolvedValueOnce(migratedUser); + + return { + systemId: system.id, + importUsers: [importUser], + }; + }; + + it('should return an empty array', async () => { + const { systemId, importUsers } = setup(); + + const result: ImportUser[] = await service.filterAlreadyMigratedUser(importUsers, systemId); + + expect(result).toHaveLength(0); + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-import/service/strategy/schulconnex-fetch-import-users.service.ts b/apps/server/src/modules/user-import/service/strategy/schulconnex-fetch-import-users.service.ts new file mode 100644 index 00000000000..e1723bfc488 --- /dev/null +++ b/apps/server/src/modules/user-import/service/strategy/schulconnex-fetch-import-users.service.ts @@ -0,0 +1,49 @@ +import { SanisResponse, SchulconnexRestClient } from '@infra/schulconnex-client'; +import { UserService } from '@modules/user'; +import { Injectable } from '@nestjs/common'; +import { UserDO } from '@shared/domain/domainobject'; +import { ImportUser, SchoolEntity, SystemEntity } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; +import { UserImportSchoolExternalIdMissingLoggableException } from '../../loggable'; +import { SchulconnexImportUserMapper } from '../../mapper'; + +@Injectable() +export class SchulconnexFetchImportUsersService { + constructor( + private readonly schulconnexRestClient: SchulconnexRestClient, + private readonly userService: UserService + ) {} + + public async getData(school: SchoolEntity, system: SystemEntity): Promise { + const externalSchoolId: string | undefined = school.externalId; + if (!externalSchoolId) { + throw new UserImportSchoolExternalIdMissingLoggableException(school.id); + } + + const response: SanisResponse[] = await this.schulconnexRestClient.getPersonenInfo({ + vollstaendig: ['personen', 'personenkontexte', 'organisationen'], + 'organisation.id': externalSchoolId, + }); + + const mappedImportUsers: ImportUser[] = SchulconnexImportUserMapper.mapDataToUserImportEntities( + response, + system, + school + ); + + return mappedImportUsers; + } + + public async filterAlreadyMigratedUser(importUsers: ImportUser[], systemId: EntityId): Promise { + const filteredUsers: ImportUser[] = ( + await Promise.all( + importUsers.map(async (importUser: ImportUser): Promise => { + const foundUser: UserDO | null = await this.userService.findByExternalId(importUser.externalId, systemId); + return foundUser ? null : importUser; + }) + ) + ).filter((user: ImportUser | null): user is ImportUser => user !== null); + + return filteredUsers; + } +} diff --git a/apps/server/src/modules/user-import/service/user-import.service.spec.ts b/apps/server/src/modules/user-import/service/user-import.service.spec.ts new file mode 100644 index 00000000000..d5f87f703f9 --- /dev/null +++ b/apps/server/src/modules/user-import/service/user-import.service.spec.ts @@ -0,0 +1,359 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { UserService } from '@modules/user'; +import { InternalServerErrorException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LegacySchoolDo } from '@shared/domain/domainobject'; +import { ImportUser, MatchCreator, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { SchoolFeature } from '@shared/domain/types'; +import { ImportUserRepo, LegacySystemRepo } from '@shared/repo'; +import { + cleanupCollections, + importUserFactory, + legacySchoolDoFactory, + schoolEntityFactory, + setupEntities, + systemEntityFactory, + userFactory, +} from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { IUserImportFeatures, UserImportFeatures } from '../config'; +import { UserMigrationIsNotEnabled } from '../loggable'; +import { UserImportService } from './user-import.service'; + +describe(UserImportService.name, () => { + let module: TestingModule; + let service: UserImportService; + let em: EntityManager; + + let importUserRepo: DeepMocked; + let legacySystemRepo: DeepMocked; + let userService: DeepMocked; + let logger: DeepMocked; + + const features: IUserImportFeatures = { + userMigrationSystemId: new ObjectId().toHexString(), + userMigrationEnabled: true, + instance: 'n21', + }; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [ + UserImportService, + { + provide: ImportUserRepo, + useValue: createMock(), + }, + { + provide: LegacySystemRepo, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: UserImportFeatures, + useValue: features, + }, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(UserImportService); + em = module.get(EntityManager); + importUserRepo = module.get(ImportUserRepo); + legacySystemRepo = module.get(LegacySystemRepo); + userService = module.get(UserService); + logger = module.get(Logger); + }); + + afterAll(async () => { + await module.close(); + }); + + beforeEach(async () => { + await cleanupCollections(em); + }); + + describe('saveImportUsers', () => { + describe('when saving import users', () => { + const setup = () => { + const importUser: ImportUser = importUserFactory.build(); + const otherImportUser: ImportUser = importUserFactory.build(); + + return { + importUsers: [importUser, otherImportUser], + }; + }; + + it('should call saveImportUsers', async () => { + const { importUsers } = setup(); + + await service.saveImportUsers(importUsers); + + expect(importUserRepo.saveImportUsers).toHaveBeenCalledWith(importUsers); + }); + }); + }); + + describe('getMigrationSystem', () => { + describe('when fetching the migration system', () => { + const setup = () => { + const system: SystemEntity = systemEntityFactory.buildWithId(undefined, features.userMigrationSystemId); + + legacySystemRepo.findById.mockResolvedValueOnce(system); + + return { + system, + }; + }; + + it('should return the system', async () => { + const { system } = setup(); + + const result: SystemEntity = await service.getMigrationSystem(); + + expect(result).toEqual(system); + }); + }); + }); + + describe('checkFeatureEnabled', () => { + describe('when the global feature is enabled', () => { + const setup = () => { + features.userMigrationEnabled = true; + + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ features: undefined }); + + return { + school, + }; + }; + + it('should do nothing', () => { + const { school } = setup(); + + service.checkFeatureEnabled(school); + }); + }); + + describe('when the school feature is enabled', () => { + const setup = () => { + features.userMigrationEnabled = false; + + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ + features: [SchoolFeature.LDAP_UNIVENTION_MIGRATION], + }); + + return { + school, + }; + }; + + it('should do nothing', () => { + const { school } = setup(); + + service.checkFeatureEnabled(school); + }); + }); + + describe('when the features are disabled', () => { + const setup = () => { + features.userMigrationEnabled = false; + + const school: LegacySchoolDo = legacySchoolDoFactory.buildWithId({ + features: [], + }); + + return { + school, + }; + }; + + it('should do nothing', () => { + const { school } = setup(); + + expect(() => service.checkFeatureEnabled(school)).toThrow( + new InternalServerErrorException('User Migration not enabled') + ); + }); + + it('should log a warning', () => { + const { school } = setup(); + + expect(() => service.checkFeatureEnabled(school)).toThrow( + new InternalServerErrorException('User Migration not enabled') + ); + expect(logger.warning).toHaveBeenCalledWith(new UserMigrationIsNotEnabled()); + }); + }); + + describe('matchUsers', () => { + describe('when all users have unique names', () => { + const setup = () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const user1: User = userFactory.buildWithId({ firstName: 'First1', lastName: 'Last1' }); + const user2: User = userFactory.buildWithId({ firstName: 'First2', lastName: 'Last2' }); + const importUser1: ImportUser = importUserFactory.buildWithId({ + school, + firstName: user1.firstName, + lastName: user1.lastName, + }); + const importUser2: ImportUser = importUserFactory.buildWithId({ + school, + firstName: user2.firstName, + lastName: user2.lastName, + }); + + userService.findUserBySchoolAndName.mockResolvedValueOnce([user1]); + userService.findUserBySchoolAndName.mockResolvedValueOnce([user2]); + + return { + user1, + user2, + importUser1, + importUser2, + }; + }; + + it('should return all users as auto matched', async () => { + const { user1, user2, importUser1, importUser2 } = setup(); + + const result: ImportUser[] = await service.matchUsers([importUser1, importUser2]); + + expect(result).toEqual([ + { ...importUser1, user: user1, matchedBy: MatchCreator.AUTO }, + { ...importUser2, user: user2, matchedBy: MatchCreator.AUTO }, + ]); + }); + }); + + describe('when the imported users have the same names', () => { + const setup = () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const user1: User = userFactory.buildWithId({ firstName: 'First', lastName: 'Last' }); + const importUser1: ImportUser = importUserFactory.buildWithId({ + school, + firstName: user1.firstName, + lastName: user1.lastName, + }); + const importUser2: ImportUser = importUserFactory.buildWithId({ + school, + firstName: user1.firstName, + lastName: user1.lastName, + }); + + userService.findUserBySchoolAndName.mockResolvedValueOnce([user1]); + userService.findUserBySchoolAndName.mockResolvedValueOnce([user1]); + + return { + user1, + importUser1, + importUser2, + }; + }; + + it('should return the users without a match', async () => { + const { importUser1, importUser2 } = setup(); + + const result: ImportUser[] = await service.matchUsers([importUser1, importUser2]); + + expect(result).toEqual([importUser1, importUser2]); + }); + }); + + describe('when existing users in svs have the same names', () => { + const setup = () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const user1: User = userFactory.buildWithId({ firstName: 'First', lastName: 'Last' }); + const user2: User = userFactory.buildWithId({ firstName: 'First', lastName: 'Last' }); + const importUser1: ImportUser = importUserFactory.buildWithId({ + school, + firstName: user1.firstName, + lastName: user1.lastName, + }); + + userService.findUserBySchoolAndName.mockResolvedValueOnce([user1, user2]); + userService.findUserBySchoolAndName.mockResolvedValueOnce([user1, user2]); + + return { + user1, + user2, + importUser1, + }; + }; + + it('should return the users without a match', async () => { + const { importUser1 } = setup(); + + const result: ImportUser[] = await service.matchUsers([importUser1]); + + expect(result).toEqual([importUser1]); + }); + }); + + describe('when import users have the same name ', () => { + const setup = () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + const user1: User = userFactory.buildWithId({ firstName: 'First', lastName: 'Last' }); + const importUser1: ImportUser = importUserFactory.buildWithId({ + school, + firstName: user1.firstName, + lastName: user1.lastName, + }); + const importUser2: ImportUser = importUserFactory.buildWithId({ + school, + firstName: user1.firstName, + lastName: user1.lastName, + }); + + userService.findUserBySchoolAndName.mockResolvedValueOnce([user1]); + userService.findUserBySchoolAndName.mockResolvedValueOnce([user1]); + + return { + user1, + importUser1, + importUser2, + }; + }; + + it('should return the users without a match', async () => { + const { importUser1, importUser2 } = setup(); + + const result: ImportUser[] = await service.matchUsers([importUser1, importUser2]); + + result.forEach((importUser) => expect(importUser.matchedBy).toBeUndefined()); + }); + }); + }); + + describe('deleteImportUsersBySchool', () => { + describe('when deleting all import users of school', () => { + const setup = () => { + const school: SchoolEntity = schoolEntityFactory.buildWithId(); + + return { + school, + }; + }; + + it('should call deleteImportUsersBySchool', async () => { + const { school } = setup(); + + await service.deleteImportUsersBySchool(school); + + expect(importUserRepo.deleteImportUsersBySchool).toHaveBeenCalledWith(school); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/user-import/service/user-import.service.ts b/apps/server/src/modules/user-import/service/user-import.service.ts new file mode 100644 index 00000000000..0e165d4828c --- /dev/null +++ b/apps/server/src/modules/user-import/service/user-import.service.ts @@ -0,0 +1,77 @@ +import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { LegacySchoolDo } from '@shared/domain/domainobject'; +import { ImportUser, MatchCreator, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { SchoolFeature } from '@shared/domain/types'; +import { ImportUserRepo, LegacySystemRepo } from '@shared/repo'; +import { UserService } from '@modules/user'; +import { Logger } from '@src/core/logger'; +import { IUserImportFeatures, UserImportFeatures } from '../config'; +import { UserMigrationIsNotEnabled } from '../loggable'; + +@Injectable() +export class UserImportService { + constructor( + private readonly userImportRepo: ImportUserRepo, + private readonly systemRepo: LegacySystemRepo, + private readonly userService: UserService, + @Inject(UserImportFeatures) private readonly userImportFeatures: IUserImportFeatures, + private readonly logger: Logger + ) {} + + public async saveImportUsers(importUsers: ImportUser[]): Promise { + await this.userImportRepo.saveImportUsers(importUsers); + } + + public async getMigrationSystem(): Promise { + const systemId: string = this.userImportFeatures.userMigrationSystemId; + + const system: SystemEntity = await this.systemRepo.findById(systemId); + + return system; + } + + public checkFeatureEnabled(school: LegacySchoolDo): void { + const enabled = this.userImportFeatures.userMigrationEnabled; + const isLdapPilotSchool = school.features && school.features.includes(SchoolFeature.LDAP_UNIVENTION_MIGRATION); + + if (!enabled && !isLdapPilotSchool) { + this.logger.warning(new UserMigrationIsNotEnabled()); + throw new InternalServerErrorException('User Migration not enabled'); + } + } + + public async matchUsers(importUsers: ImportUser[]): Promise { + const importUserMap: Map = new Map(); + + importUsers.forEach((importUser) => { + const key = `${importUser.school.id}_${importUser.firstName}_${importUser.lastName}`; + const count = importUserMap.get(key) || 0; + importUserMap.set(key, count + 1); + }); + + const matchedImportUsers: ImportUser[] = await Promise.all( + importUsers.map(async (importUser: ImportUser): Promise => { + const user: User[] = await this.userService.findUserBySchoolAndName( + importUser.school.id, + importUser.firstName, + importUser.lastName + ); + + const key = `${importUser.school.id}_${importUser.firstName}_${importUser.lastName}`; + + if (user.length === 1 && importUserMap.get(key) === 1) { + importUser.user = user[0]; + importUser.matchedBy = MatchCreator.AUTO; + } + + return importUser; + }) + ); + + return matchedImportUsers; + } + + public async deleteImportUsersBySchool(school: SchoolEntity): Promise { + await this.userImportRepo.deleteImportUsersBySchool(school); + } +} diff --git a/apps/server/src/modules/user-import/uc/index.ts b/apps/server/src/modules/user-import/uc/index.ts new file mode 100644 index 00000000000..a92cf5651ac --- /dev/null +++ b/apps/server/src/modules/user-import/uc/index.ts @@ -0,0 +1,2 @@ +export { UserImportUc } from './user-import.uc'; +export { UserImportFetchUc } from './user-import-fetch.uc'; diff --git a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts new file mode 100644 index 00000000000..057b35d3d77 --- /dev/null +++ b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.spec.ts @@ -0,0 +1,177 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AuthorizationService } from '@modules/authorization'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ImportUser, SystemEntity, User } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; +import { importUserFactory, setupEntities, systemEntityFactory, userFactory } from '@shared/testing'; +import { IUserImportFeatures, UserImportFeatures } from '../config'; +import { UserMigrationIsNotEnabledLoggableException } from '../loggable'; +import { SchulconnexFetchImportUsersService, UserImportService } from '../service'; +import { UserImportFetchUc } from './user-import-fetch.uc'; + +describe(UserImportFetchUc.name, () => { + let module: TestingModule; + let uc: UserImportFetchUc; + + let schulconnexFetchImportUsersService: DeepMocked; + let authorizationService: DeepMocked; + let userImportService: DeepMocked; + let userImportFeatures: IUserImportFeatures; + + beforeAll(async () => { + await setupEntities(); + + module = await Test.createTestingModule({ + providers: [ + UserImportFetchUc, + { + provide: UserImportFeatures, + useValue: {}, + }, + { + provide: SchulconnexFetchImportUsersService, + useValue: createMock(), + }, + { + provide: AuthorizationService, + useValue: createMock(), + }, + { + provide: UserImportService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(UserImportFetchUc); + schulconnexFetchImportUsersService = module.get(SchulconnexFetchImportUsersService); + authorizationService = module.get(AuthorizationService); + userImportService = module.get(UserImportService); + userImportFeatures = module.get(UserImportFeatures); + }); + + beforeEach(() => { + Object.assign(userImportFeatures, { + userMigrationEnabled: true, + userMigrationSystemId: new ObjectId().toHexString(), + instance: 'n21', + }); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchImportUsers', () => { + describe('when fetching and matching users', () => { + const setup = () => { + const system: SystemEntity = systemEntityFactory.buildWithId( + undefined, + userImportFeatures.userMigrationSystemId + ); + const user: User = userFactory.buildWithId(); + const importUser: ImportUser = importUserFactory.build({ + system, + }); + + authorizationService.getUserWithPermissions.mockResolvedValueOnce(user); + userImportService.getMigrationSystem.mockResolvedValueOnce(system); + schulconnexFetchImportUsersService.getData.mockResolvedValueOnce([importUser]); + schulconnexFetchImportUsersService.filterAlreadyMigratedUser.mockResolvedValueOnce([importUser]); + userImportService.matchUsers.mockResolvedValueOnce([importUser]); + + return { + user, + system, + importUser, + }; + }; + + it('should check the users permission', async () => { + const { user } = setup(); + + await uc.populateImportUsers(user.id); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [ + Permission.SCHOOL_IMPORT_USERS_MIGRATE, + ]); + }); + + it('should filter migrated users', async () => { + const { user, importUser, system } = setup(); + + await uc.populateImportUsers(user.id); + + expect(schulconnexFetchImportUsersService.filterAlreadyMigratedUser).toHaveBeenCalledWith( + [importUser], + system.id + ); + }); + + it('should match the users', async () => { + const { user, importUser } = setup(); + + await uc.populateImportUsers(user.id); + + expect(userImportService.matchUsers).toHaveBeenCalledWith([importUser]); + }); + + it('should delete all existing imported users of the school', async () => { + const { user } = setup(); + + await uc.populateImportUsers(user.id); + + expect(userImportService.deleteImportUsersBySchool).toHaveBeenCalledWith(user.school); + }); + + it('should save the import users', async () => { + const { user, importUser } = setup(); + + await uc.populateImportUsers(user.id); + + expect(userImportService.saveImportUsers).toHaveBeenCalledWith([importUser]); + }); + }); + }); + + describe('when the migration feature is not enabled', () => { + const setup = () => { + userImportFeatures.userMigrationEnabled = false; + + const user: User = userFactory.buildWithId(); + + return { + user, + }; + }; + + it('should throw an error', async () => { + const { user } = setup(); + + await expect(uc.populateImportUsers(user.id)).rejects.toThrow(UserMigrationIsNotEnabledLoggableException); + }); + }); + + describe('when the target system id is not defined', () => { + const setup = () => { + userImportFeatures.userMigrationSystemId = ''; + + const user: User = userFactory.buildWithId(); + + return { + user, + }; + }; + + it('should throw an error', async () => { + const { user } = setup(); + + await expect(uc.populateImportUsers(user.id)).rejects.toThrow(UserMigrationIsNotEnabledLoggableException); + }); + }); +}); diff --git a/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts new file mode 100644 index 00000000000..6a635b93a54 --- /dev/null +++ b/apps/server/src/modules/user-import/uc/user-import-fetch.uc.ts @@ -0,0 +1,45 @@ +import { AuthorizationService } from '@modules/authorization'; +import { Inject, Injectable } from '@nestjs/common'; +import { ImportUser, SystemEntity, User } from '@shared/domain/entity'; +import { Permission } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { IUserImportFeatures, UserImportFeatures } from '../config'; +import { UserMigrationIsNotEnabledLoggableException } from '../loggable/user-migration-not-enable-loggable-exception'; +import { SchulconnexFetchImportUsersService, UserImportService } from '../service'; + +@Injectable() +export class UserImportFetchUc { + constructor( + @Inject(UserImportFeatures) private readonly userImportFeatures: IUserImportFeatures, + private readonly schulconnexFetchImportUsersService: SchulconnexFetchImportUsersService, + private readonly authorizationService: AuthorizationService, + private readonly userImportService: UserImportService + ) {} + + public async populateImportUsers(currentUserId: EntityId): Promise { + this.checkMigrationEnabled(currentUserId); + + const user: User = await this.authorizationService.getUserWithPermissions(currentUserId); + this.authorizationService.checkAllPermissions(user, [Permission.SCHOOL_IMPORT_USERS_MIGRATE]); + + const system: SystemEntity = await this.userImportService.getMigrationSystem(); + const fetchedData: ImportUser[] = await this.schulconnexFetchImportUsersService.getData(user.school, system); + + const filteredFetchedData: ImportUser[] = await this.schulconnexFetchImportUsersService.filterAlreadyMigratedUser( + fetchedData, + this.userImportFeatures.userMigrationSystemId + ); + + const matchedImportUsers: ImportUser[] = await this.userImportService.matchUsers(filteredFetchedData); + + await this.userImportService.deleteImportUsersBySchool(user.school); + + await this.userImportService.saveImportUsers(matchedImportUsers); + } + + private checkMigrationEnabled(userId: EntityId): void { + if (!this.userImportFeatures.userMigrationEnabled || !this.userImportFeatures.userMigrationSystemId) { + throw new UserMigrationIsNotEnabledLoggableException(userId); + } + } +} diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts index b694691f1f5..98bfb923a30 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.spec.ts @@ -1,22 +1,32 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { Configuration } from '@hpi-schul-cloud/commons'; -import { MongoMemoryDatabaseModule } from '@infra/database'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AccountService } from '@modules/account/services/account.service'; +import { AccountService, Account } from '@modules/account'; import { AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; +import { UserLoginMigrationService, UserMigrationService } from '@modules/user-login-migration'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { UserAlreadyAssignedToImportUserError } from '@shared/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; import { LegacySchoolDo } from '@shared/domain/domainobject'; import { ImportUser, MatchCreator, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { MatchCreatorScope, SchoolFeature } from '@shared/domain/types'; import { ImportUserRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; -import { federalStateFactory, importUserFactory, schoolFactory, userFactory } from '@shared/testing'; +import { + federalStateFactory, + importUserFactory, + legacySchoolDoFactory, + schoolEntityFactory, + setupEntities, + userFactory, + userLoginMigrationDOFactory, +} from '@shared/testing'; import { systemEntityFactory } from '@shared/testing/factory/systemEntityFactory'; -import { LoggerModule } from '@src/core/logger'; +import { Logger } from '@src/core/logger'; +import { IUserImportFeatures, UserImportFeatures } from '../config'; +import { SchoolNotMigratedLoggableException } from '../loggable'; +import { UserImportService } from '../service'; import { LdapAlreadyPersistedException, MigrationAlreadyActivatedException, @@ -25,7 +35,7 @@ import { import { UserImportUc } from './user-import.uc'; describe('[ImportUserModule]', () => { - describe('UserUc', () => { + describe(UserImportUc.name, () => { let module: TestingModule; let uc: UserImportUc; let accountService: DeepMocked; @@ -34,21 +44,22 @@ describe('[ImportUserModule]', () => { let systemRepo: DeepMocked; let userRepo: DeepMocked; let authorizationService: DeepMocked; - let configurationSpy: jest.SpyInstance; + let userImportService: DeepMocked; + let userLoginMigrationService: DeepMocked; + let userMigrationService: DeepMocked; + + let userImportFeatures: IUserImportFeatures; beforeAll(async () => { + await setupEntities(); + module = await Test.createTestingModule({ - imports: [ - MongoMemoryDatabaseModule.forRoot(), - LoggerModule, - ConfigModule.forRoot({ isGlobal: true, ignoreEnvFile: true, ignoreEnvVars: true }), - ], providers: [ + UserImportUc, { provide: AccountService, useValue: createMock(), }, - UserImportUc, { provide: ImportUserRepo, useValue: createMock(), @@ -69,8 +80,29 @@ describe('[ImportUserModule]', () => { provide: AuthorizationService, useValue: createMock(), }, + { + provide: UserImportService, + useValue: createMock(), + }, + { + provide: UserLoginMigrationService, + useValue: createMock(), + }, + { + provide: UserImportFeatures, + useValue: {}, + }, + { + provide: UserMigrationService, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, ], }).compile(); + uc = module.get(UserImportUc); // TODO UserRepo not available in UserUc?! accountService = module.get(AccountService); importUserRepo = module.get(ImportUserRepo); @@ -78,35 +110,24 @@ describe('[ImportUserModule]', () => { systemRepo = module.get(LegacySystemRepo); userRepo = module.get(UserRepo); authorizationService = module.get(AuthorizationService); + userImportService = module.get(UserImportService); + userImportFeatures = module.get(UserImportFeatures); + userLoginMigrationService = module.get(UserLoginMigrationService); + userMigrationService = module.get(UserMigrationService); }); - afterAll(async () => { - await module.close(); + beforeEach(() => { + Object.assign(userImportFeatures, { + userMigrationEnabled: true, + userMigrationSystemId: new ObjectId().toHexString(), + instance: 'dbc', + }); }); - it('should be defined', () => { - expect(uc).toBeDefined(); - expect(accountService).toBeDefined(); - expect(importUserRepo).toBeDefined(); - expect(schoolService).toBeDefined(); - expect(systemRepo).toBeDefined(); - expect(userRepo).toBeDefined(); - expect(authorizationService).toBeDefined(); + afterAll(async () => { + await module.close(); }); - const setConfig = (systemId?: string) => { - const mockSystemId = systemId || new ObjectId().toString(); - configurationSpy = jest.spyOn(Configuration, 'get').mockImplementation((config: string) => { - if (config === 'FEATURE_USER_MIGRATION_SYSTEM_ID') { - return mockSystemId; - } - if (config === 'FEATURE_USER_MIGRATION_ENABLED') { - return true; - } - return null; - }); - }; - const createMockSchoolDo = (school?: SchoolEntity): LegacySchoolDo => { const name = school ? school.name : 'testSchool'; const id = school ? school.id : 'someId'; @@ -134,10 +155,6 @@ describe('[ImportUserModule]', () => { }); }; - beforeEach(() => { - setConfig(); - }); - describe('[findAllImportUsers]', () => { it('Should request authorization service', async () => { const user = userFactory.buildWithId(); @@ -185,7 +202,7 @@ describe('[ImportUserModule]', () => { describe('[setMatch]', () => { describe('When not having same school for current user, user match and importuser', () => { it('should not change match', async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId(); const importUser = importUserFactory.buildWithId({ school }); const userRepoByIdSpy = jest.spyOn(userRepo, 'findById').mockResolvedValue(user); @@ -211,7 +228,7 @@ describe('[ImportUserModule]', () => { describe('When having same school for current user, user-match and importuser', () => { describe('When not having a user already assigned as match', () => { it('should set user as new match', async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const currentUser = userFactory.buildWithId({ school }); const usermatch = userFactory.buildWithId({ school }); const importUser = importUserFactory.buildWithId({ school }); @@ -252,7 +269,7 @@ describe('[ImportUserModule]', () => { describe('When having a user already assigned as match', () => { it('should not set user as new match twice', async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const currentUser = userFactory.buildWithId({ school }); const usermatch = userFactory.buildWithId({ school }); const importUser = importUserFactory.buildWithId({ school }); @@ -301,7 +318,7 @@ describe('[ImportUserModule]', () => { describe('When having permission Permission.SCHOOL_IMPORT_USERS_UPDATE', () => { describe('When not having same school for user and importuser', () => { it('should not change flag', async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId(); const importUser = importUserFactory.buildWithId({ school }); const userRepoByIdSpy = jest.spyOn(userRepo, 'findById').mockResolvedValue(user); @@ -324,7 +341,7 @@ describe('[ImportUserModule]', () => { }); describe('When having same school for user and importuser', () => { it('should enable flag', async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); const importUser = importUserFactory.buildWithId({ school }); const userRepoByIdSpy = jest.spyOn(userRepo, 'findById').mockResolvedValue(user); @@ -350,7 +367,7 @@ describe('[ImportUserModule]', () => { importUserSaveSpy.mockRestore(); }); it('should disable flag', async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); const importUser = importUserFactory.buildWithId({ school }); const userRepoByIdSpy = jest.spyOn(userRepo, 'findById').mockResolvedValue(user); @@ -383,7 +400,7 @@ describe('[ImportUserModule]', () => { describe('When having permission Permission.SCHOOL_IMPORT_USERS_UPDATE', () => { describe('When having same school for user and importuser', () => { it('should revoke match', async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); const importUser = importUserFactory.matched(MatchCreator.AUTO, user).buildWithId({ school }); const schoolServiceSpy = jest @@ -415,7 +432,7 @@ describe('[ImportUserModule]', () => { }); describe('When not having same school for user and importuser', () => { it('should not revoke match', async () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId(); const usermatch = userFactory.buildWithId({ school }); const importUser = importUserFactory.matched(MatchCreator.AUTO, usermatch).buildWithId({ school }); @@ -466,7 +483,7 @@ describe('[ImportUserModule]', () => { let accountServiceFindByUserIdSpy: jest.SpyInstance; beforeEach(() => { system = systemEntityFactory.buildWithId(); - school = schoolFactory.buildWithId({ systems: [system] }); + school = schoolEntityFactory.buildWithId({ systems: [system] }); school.externalId = 'foo'; school.inMaintenanceSince = new Date(); school.inUserMigration = true; @@ -500,13 +517,15 @@ describe('[ImportUserModule]', () => { permissionServiceSpy = authorizationService.checkAllPermissions.mockReturnValue(); importUserRepoFindImportUsersSpy = importUserRepo.findImportUsers.mockResolvedValue([[], 0]); accountServiceFindByUserIdSpy = accountService.findByUserId - .mockResolvedValue({ - id: 'dummyId', - userId: currentUser.id, - username: currentUser.email, - createdAt: new Date(), - updatedAt: new Date(), - }) + .mockResolvedValue( + new Account({ + id: 'dummyId', + userId: currentUser.id, + username: currentUser.email, + createdAt: new Date(), + updatedAt: new Date(), + }) + ) .mockResolvedValueOnce(null); importUserRepoDeleteImportUsersBySchoolSpy = importUserRepo.deleteImportUsersBySchool.mockResolvedValue(); importUserRepoDeleteImportUserSpy = importUserRepo.delete.mockResolvedValue(); @@ -586,6 +605,114 @@ describe('[ImportUserModule]', () => { }); }); + describe('saveAllUsersMatches', () => { + describe('when the instance is nbc', () => { + describe('when migrating users', () => { + const setup = () => { + const system = systemEntityFactory.buildWithId(); + const schoolEntity = schoolEntityFactory.buildWithId(); + const user = userFactory.buildWithId({ + school: schoolEntity, + }); + const school = legacySchoolDoFactory.build({ + id: schoolEntity.id, + externalId: 'externalId', + officialSchoolNumber: 'officialSchoolNumber', + inUserMigration: true, + inMaintenanceSince: new Date(), + systems: [system.id], + }); + const importUser = importUserFactory.buildWithId({ + school: schoolEntity, + user: userFactory.buildWithId({ + school: schoolEntity, + }), + matchedBy: MatchCreator.AUTO, + system, + }); + const importUserWithoutUser = importUserFactory.buildWithId({ + school: schoolEntity, + system, + }); + + userRepo.findById.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); + importUserRepo.findImportUsers.mockResolvedValueOnce([[importUser, importUserWithoutUser], 2]); + userImportFeatures.instance = 'n21'; + + return { + user, + importUser, + importUserWithoutUser, + }; + }; + + it('should migrate users with the user login migration', async () => { + const { user, importUser } = setup(); + + await uc.saveAllUsersMatches(user.id); + + expect(userMigrationService.migrateUser).toHaveBeenCalledWith( + importUser.user?.id, + importUser.externalId, + importUser.system.id + ); + }); + + it('should skip import users without linked users', async () => { + const { user, importUserWithoutUser } = setup(); + + await uc.saveAllUsersMatches(user.id); + + expect(userMigrationService.migrateUser).not.toHaveBeenCalledWith( + importUserWithoutUser.user?.id, + importUserWithoutUser.externalId, + importUserWithoutUser.system.id + ); + }); + }); + }); + + describe('when the user does not have an account', () => { + const setup = () => { + const system = systemEntityFactory.buildWithId(); + const schoolEntity = schoolEntityFactory.buildWithId(); + const user = userFactory.buildWithId({ + school: schoolEntity, + }); + const school = legacySchoolDoFactory.build({ + id: schoolEntity.id, + externalId: 'externalId', + officialSchoolNumber: 'officialSchoolNumber', + inUserMigration: true, + inMaintenanceSince: new Date(), + systems: [system.id], + }); + const importUser = importUserFactory.buildWithId({ + school: schoolEntity, + user, + matchedBy: MatchCreator.AUTO, + system, + }); + + userRepo.findById.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); + importUserRepo.findImportUsers.mockResolvedValueOnce([[importUser], 1]); + accountService.findByUserId.mockResolvedValueOnce(null); + + return { user }; + }; + + it('should create it for the user', async () => { + const { user } = setup(); + + await uc.saveAllUsersMatches(user.id); + + expect(accountService.save).toHaveBeenCalledWith(expect.objectContaining({ userId: user.id })); + }); + }); + }); + describe('[startSchoolInUserMigration]', () => { let system: SystemEntity; let school: SchoolEntity; @@ -597,9 +724,10 @@ describe('[ImportUserModule]', () => { let systemRepoSpy: jest.SpyInstance; const currentDate = new Date('2022-03-10T00:00:00.000Z'); let dateSpy: jest.SpyInstance; + beforeEach(() => { system = systemEntityFactory.buildWithId({ ldapConfig: {} }); - school = schoolFactory.buildWithId(); + school = schoolEntityFactory.buildWithId(); school.officialSchoolNumber = 'foo'; currentUser = userFactory.buildWithId({ school }); userRepoByIdSpy = userRepo.findById.mockResolvedValueOnce(currentUser); @@ -607,36 +735,41 @@ describe('[ImportUserModule]', () => { schoolServiceSaveSpy = schoolService.save.mockReturnValueOnce(Promise.resolve(createMockSchoolDo(school))); schoolServiceSpy = schoolService.getSchoolById.mockResolvedValue(createMockSchoolDo(school)); systemRepoSpy = systemRepo.findById.mockReturnValueOnce(Promise.resolve(system)); - setConfig(system.id); + userImportFeatures.userMigrationSystemId = system.id; dateSpy = jest.spyOn(global, 'Date').mockReturnValue(currentDate as unknown as string); }); + afterEach(() => { userRepoByIdSpy.mockRestore(); permissionServiceSpy.mockRestore(); schoolServiceSaveSpy.mockRestore(); schoolServiceSpy.mockRestore(); systemRepoSpy.mockRestore(); - configurationSpy.mockRestore(); dateSpy.mockRestore(); }); + it('Should fetch system id from configuration', async () => { await uc.startSchoolInUserMigration(currentUser.id); - expect(configurationSpy).toHaveBeenCalledWith('FEATURE_USER_MIGRATION_SYSTEM_ID'); - expect(systemRepoSpy).toHaveBeenCalledWith(system.id); + expect(userImportService.getMigrationSystem).toHaveBeenCalled(); }); + it('Should request authorization service', async () => { await uc.startSchoolInUserMigration(currentUser.id); expect(userRepoByIdSpy).toHaveBeenCalledWith(currentUser.id, true); expect(permissionServiceSpy).toHaveBeenCalledWith(currentUser, [Permission.SCHOOL_IMPORT_USERS_MIGRATE]); }); + it('Should save school params', async () => { schoolServiceSaveSpy.mockRestore(); schoolServiceSaveSpy = schoolService.save.mockImplementation((schoolDo: LegacySchoolDo) => Promise.resolve(schoolDo) ); + userImportService.getMigrationSystem.mockResolvedValueOnce(system); + await uc.startSchoolInUserMigration(currentUser.id); + const schoolParams: LegacySchoolDo = { ...createMockSchoolDo(school) }; schoolParams.inUserMigration = true; schoolParams.externalId = 'foo'; @@ -655,34 +788,40 @@ describe('[ImportUserModule]', () => { const result = uc.startSchoolInUserMigration(currentUser.id); await expect(result).rejects.toThrowError(MigrationAlreadyActivatedException); }); + it('should throw migrationAlreadyActivatedException with correct properties', () => { const logMessage = new MigrationAlreadyActivatedException().getLogMessage(); expect(logMessage).toBeDefined(); expect(logMessage).toHaveProperty('message', 'Migration is already activated for this school'); }); + it('should throw if school has no officialSchoolNumber ', async () => { school.officialSchoolNumber = undefined; schoolServiceSpy = schoolService.getSchoolById.mockResolvedValueOnce(createMockSchoolDo(school)); const result = uc.startSchoolInUserMigration(currentUser.id); await expect(result).rejects.toThrowError(MissingSchoolNumberException); }); + it('should throw missingSchoolNumberException with correct properties', () => { const logMessage = new MissingSchoolNumberException().getLogMessage(); expect(logMessage).toBeDefined(); expect(logMessage).toHaveProperty('message', 'The school is missing a official school number'); }); + it('should throw if school already has a persisted LDAP ', async () => { dateSpy.mockRestore(); - school = schoolFactory.buildWithId({ systems: [system] }); + school = schoolEntityFactory.buildWithId({ systems: [system] }); schoolServiceSpy = schoolService.getSchoolById.mockResolvedValueOnce(createMockSchoolDo(school)); const result = uc.startSchoolInUserMigration(currentUser.id, false); await expect(result).rejects.toThrowError(LdapAlreadyPersistedException); }); + it('should throw ldapAlreadyPersistedException with correct properties', () => { const logMessage = new LdapAlreadyPersistedException().getLogMessage(); expect(logMessage).toBeDefined(); expect(logMessage).toHaveProperty('message', 'LDAP is already Persisted'); }); + it('should not throw if school has no school number but its own LDAP', async () => { school.officialSchoolNumber = undefined; schoolServiceSpy = schoolService.getSchoolById.mockResolvedValueOnce(createMockSchoolDo(school)); @@ -691,6 +830,121 @@ describe('[ImportUserModule]', () => { }); }); + describe('startSchoolInUserMigration', () => { + describe('when the instance is nbc', () => { + describe('when the school has already migrated', () => { + const setup = () => { + const targetSystemId = new ObjectId().toHexString(); + const user = userFactory.buildWithId(); + const school = legacySchoolDoFactory.buildWithId({ + externalId: 'externalId', + officialSchoolNumber: 'officialSchoolNumber', + inUserMigration: undefined, + systems: [targetSystemId], + }); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + schoolId: school.id, + targetSystemId, + }); + + userImportFeatures.instance = 'n21'; + userRepo.findById.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + + return { + user, + school, + }; + }; + + it('should check the users permission', async () => { + const { user } = setup(); + + await uc.startSchoolInUserMigration(user.id); + + expect(authorizationService.checkAllPermissions).toHaveBeenCalledWith(user, [ + Permission.SCHOOL_IMPORT_USERS_MIGRATE, + ]); + }); + + it('should set the school in migration for the wizard', async () => { + const { user, school } = setup(); + + await uc.startSchoolInUserMigration(user.id); + + expect(schoolService.save).toHaveBeenCalledWith({ + ...school, + inUserMigration: true, + inMaintenanceSince: expect.any(Date), + }); + }); + }); + + describe('when the user login migration is not running', () => { + const setup = () => { + const targetSystemId = new ObjectId().toHexString(); + const user = userFactory.buildWithId(); + const school = legacySchoolDoFactory.buildWithId({ + externalId: 'externalId', + officialSchoolNumber: 'officialSchoolNumber', + inUserMigration: undefined, + systems: [targetSystemId], + }); + + userImportFeatures.instance = 'n21'; + userRepo.findById.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(null); + + return { + user, + school, + }; + }; + + it('should throw an error', async () => { + const { user } = setup(); + + await expect(uc.startSchoolInUserMigration(user.id)).rejects.toThrow(NotFoundLoggableException); + }); + }); + + describe('when the school has not migrated', () => { + const setup = () => { + const targetSystemId = new ObjectId().toHexString(); + const user = userFactory.buildWithId(); + const school = legacySchoolDoFactory.buildWithId({ + externalId: 'externalId', + officialSchoolNumber: 'officialSchoolNumber', + inUserMigration: undefined, + systems: [], + }); + const userLoginMigration = userLoginMigrationDOFactory.buildWithId({ + schoolId: school.id, + targetSystemId, + }); + + userImportFeatures.instance = 'n21'; + userRepo.findById.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); + userLoginMigrationService.findMigrationBySchool.mockResolvedValueOnce(userLoginMigration); + + return { + user, + school, + }; + }; + + it('should throw an error', async () => { + const { user } = setup(); + + await expect(uc.startSchoolInUserMigration(user.id)).rejects.toThrow(SchoolNotMigratedLoggableException); + }); + }); + }); + }); + describe('[endSchoolMaintenance]', () => { let school: SchoolEntity; let currentUser: User; @@ -699,7 +953,7 @@ describe('[ImportUserModule]', () => { let schoolServiceSaveSpy: jest.SpyInstance; let schoolServiceSpy: jest.SpyInstance; beforeEach(() => { - school = schoolFactory.buildWithId(); + school = schoolEntityFactory.buildWithId(); school.externalId = 'foo'; school.inMaintenanceSince = new Date(); school.inUserMigration = false; @@ -753,5 +1007,37 @@ describe('[ImportUserModule]', () => { await expect(result4).rejects.toThrowError(BadRequestException); }); }); + + describe('endSchoolInMaintenance', () => { + describe('when the instance is nbc', () => { + describe('when closing the maintenance', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const school = legacySchoolDoFactory.buildWithId({ + externalId: 'externalId', + officialSchoolNumber: 'officialSchoolNumber', + inUserMigration: false, + inMaintenanceSince: new Date(), + }); + + userRepo.findById.mockResolvedValueOnce(user); + schoolService.getSchoolById.mockResolvedValueOnce(school); + userImportFeatures.instance = 'n21'; + + return { + user, + }; + }; + + it('should reset the migration flag', async () => { + const { user } = setup(); + + await uc.endSchoolInMaintenance(user.id); + + expect(schoolService.save).toHaveBeenCalledWith(expect.objectContaining({ inUserMigration: undefined })); + }); + }); + }); + }); }); }); diff --git a/apps/server/src/modules/user-import/uc/user-import.uc.ts b/apps/server/src/modules/user-import/uc/user-import.uc.ts index e5ec10db892..84c5b10c288 100644 --- a/apps/server/src/modules/user-import/uc/user-import.uc.ts +++ b/apps/server/src/modules/user-import/uc/user-import.uc.ts @@ -1,25 +1,26 @@ -import { Configuration } from '@hpi-schul-cloud/commons'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto/account.dto'; +import { AccountService, Account, AccountSave } from '@modules/account'; import { AuthorizationService } from '@modules/authorization'; import { LegacySchoolService } from '@modules/legacy-school'; -import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { UserLoginMigrationService, UserMigrationService } from '@modules/user-login-migration'; +import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common'; import { UserAlreadyAssignedToImportUserError } from '@shared/common'; -import { LegacySchoolDo } from '@shared/domain/domainobject'; -import { Account, ImportUser, MatchCreator, SystemEntity, User } from '@shared/domain/entity'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { LegacySchoolDo, UserLoginMigrationDO } from '@shared/domain/domainobject'; +import { ImportUser, MatchCreator, SystemEntity, User } from '@shared/domain/entity'; import { IFindOptions, Permission } from '@shared/domain/interface'; -import { Counted, EntityId, IImportUserScope, MatchCreatorScope, NameMatch, SchoolFeature } from '@shared/domain/types'; +import { Counted, EntityId, IImportUserScope, MatchCreatorScope, NameMatch } from '@shared/domain/types'; import { ImportUserRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { Logger } from '@src/core/logger'; -import { AccountSaveDto } from '../../account/services/dto'; +import { IUserImportFeatures, UserImportFeatures } from '../config'; import { MigrationMayBeCompleted, MigrationMayNotBeCompleted, SchoolIdDoesNotMatchWithUserSchoolId, SchoolInUserMigrationEndLoggable, SchoolInUserMigrationStartLoggable, - UserMigrationIsNotEnabled, + SchoolNotMigratedLoggableException, } from '../loggable'; +import { UserImportService } from '../service'; import { LdapAlreadyPersistedException, MigrationAlreadyActivatedException, @@ -40,20 +41,15 @@ export class UserImportUc { private readonly schoolService: LegacySchoolService, private readonly systemRepo: LegacySystemRepo, private readonly userRepo: UserRepo, - private readonly logger: Logger + private readonly logger: Logger, + private readonly userImportService: UserImportService, + @Inject(UserImportFeatures) private readonly userImportFeatures: IUserImportFeatures, + private readonly userLoginMigrationService: UserLoginMigrationService, + private readonly userMigrationService: UserMigrationService ) { this.logger.setContext(UserImportUc.name); } - private checkFeatureEnabled(school: LegacySchoolDo): void | never { - const enabled = Configuration.get('FEATURE_USER_MIGRATION_ENABLED') as boolean; - const isLdapPilotSchool = school.features && school.features.includes(SchoolFeature.LDAP_UNIVENTION_MIGRATION); - if (!enabled && !isLdapPilotSchool) { - this.logger.warning(new UserMigrationIsNotEnabled()); - throw new InternalServerErrorException('User Migration not enabled'); - } - } - /** * Resolves with current users schools importusers and matched users. * @param currentUserId @@ -61,16 +57,18 @@ export class UserImportUc { * @param options * @returns */ - async findAllImportUsers( + public async findAllImportUsers( currentUserId: EntityId, query: IImportUserScope, options?: IFindOptions ): Promise> { const currentUser = await this.getCurrentUser(currentUserId, Permission.SCHOOL_IMPORT_USERS_VIEW); const school: LegacySchoolDo = await this.schoolService.getSchoolById(currentUser.school.id); - this.checkFeatureEnabled(school); + this.userImportService.checkFeatureEnabled(school); + // TODO Change ImportUserRepo to DO to fix this workaround const countedImportUsers = await this.importUserRepo.findImportUsers(currentUser.school, query, options); + return countedImportUsers; } @@ -81,10 +79,12 @@ export class UserImportUc { * @param userMatchId * @returns importuser and matched user */ - async setMatch(currentUserId: EntityId, importUserId: EntityId, userMatchId: EntityId): Promise { + public async setMatch(currentUserId: EntityId, importUserId: EntityId, userMatchId: EntityId): Promise { const currentUser = await this.getCurrentUser(currentUserId, Permission.SCHOOL_IMPORT_USERS_UPDATE); const school: LegacySchoolDo = await this.schoolService.getSchoolById(currentUser.school.id); - this.checkFeatureEnabled(school); + + this.userImportService.checkFeatureEnabled(school); + const importUser = await this.importUserRepo.findById(importUserId); const userMatch = await this.userRepo.findById(userMatchId, true); @@ -106,10 +106,12 @@ export class UserImportUc { return importUser; } - async removeMatch(currentUserId: EntityId, importUserId: EntityId): Promise { + public async removeMatch(currentUserId: EntityId, importUserId: EntityId): Promise { const currentUser = await this.getCurrentUser(currentUserId, Permission.SCHOOL_IMPORT_USERS_UPDATE); const school: LegacySchoolDo = await this.schoolService.getSchoolById(currentUser.school.id); - this.checkFeatureEnabled(school); + + this.userImportService.checkFeatureEnabled(school); + const importUser = await this.importUserRepo.findById(importUserId); // check same school if (school.id !== importUser.school.id) { @@ -123,10 +125,12 @@ export class UserImportUc { return importUser; } - async updateFlag(currentUserId: EntityId, importUserId: EntityId, flagged: boolean): Promise { + public async updateFlag(currentUserId: EntityId, importUserId: EntityId, flagged: boolean): Promise { const currentUser = await this.getCurrentUser(currentUserId, Permission.SCHOOL_IMPORT_USERS_UPDATE); const school: LegacySchoolDo = await this.schoolService.getSchoolById(currentUser.school.id); - this.checkFeatureEnabled(school); + + this.userImportService.checkFeatureEnabled(school); + const importUser = await this.importUserRepo.findById(importUserId); // check same school @@ -150,28 +154,34 @@ export class UserImportUc { * @param options * @returns */ - async findAllUnmatchedUsers( + public async findAllUnmatchedUsers( currentUserId: EntityId, query: NameMatch, options?: IFindOptions ): Promise> { const currentUser = await this.getCurrentUser(currentUserId, Permission.SCHOOL_IMPORT_USERS_VIEW); const school: LegacySchoolDo = await this.schoolService.getSchoolById(currentUser.school.id); - this.checkFeatureEnabled(school); + + this.userImportService.checkFeatureEnabled(school); + // TODO Change to UserService to fix this workaround const unmatchedCountedUsers = await this.userRepo.findWithoutImportUser(currentUser.school, query, options); + return unmatchedCountedUsers; } - async saveAllUsersMatches(currentUserId: EntityId): Promise { + public async saveAllUsersMatches(currentUserId: EntityId): Promise { const currentUser = await this.getCurrentUser(currentUserId, Permission.SCHOOL_IMPORT_USERS_MIGRATE); const school: LegacySchoolDo = await this.schoolService.getSchoolById(currentUser.school.id); - this.checkFeatureEnabled(school); + + this.userImportService.checkFeatureEnabled(school); + const filters: IImportUserScope = { matches: [MatchCreatorScope.MANUAL, MatchCreatorScope.AUTO] }; // TODO batch/paginated import? const options: IFindOptions = {}; // TODO Change ImportUserRepo to DO to fix this workaround const [importUsers, total] = await this.importUserRepo.findImportUsers(currentUser.school, filters, options); + let migratedUser = 0; if (total > 0) { this.logger.notice({ @@ -191,6 +201,7 @@ export class UserImportUc { migratedUser += 1; } } + this.logger.notice({ getLogMessage: () => { return { @@ -199,38 +210,58 @@ export class UserImportUc { }; }, }); + // TODO Change ImportUserRepo to DO to fix this workaround // Delete all remaining importUser-objects that dont need to be ported await this.importUserRepo.deleteImportUsersBySchool(currentUser.school); - await this.endSchoolInUserMigration(currentUserId); + + await this.endSchoolInUserMigration(school); } - private async endSchoolInUserMigration(currentUserId: EntityId): Promise { - const currentUser = await this.getCurrentUser(currentUserId, Permission.SCHOOL_IMPORT_USERS_MIGRATE); - const school: LegacySchoolDo = await this.schoolService.getSchoolById(currentUser.school.id); - this.checkFeatureEnabled(school); + private async endSchoolInUserMigration(school: LegacySchoolDo): Promise { if (!school.externalId || school.inUserMigration !== true || !school.inMaintenanceSince) { this.logger.warning(new MigrationMayBeCompleted(school.inUserMigration)); throw new BadRequestException('School cannot exit from user migration mode'); } + school.inUserMigration = false; + await this.schoolService.save(school); } async startSchoolInUserMigration(currentUserId: EntityId, useCentralLdap = true): Promise { - const currentUser = await this.getCurrentUser(currentUserId, Permission.SCHOOL_IMPORT_USERS_MIGRATE); + const useWithUserLoginMigration: boolean = this.isNbc(); + + if (useWithUserLoginMigration) { + useCentralLdap = false; + } + + const currentUser: User = await this.getCurrentUser(currentUserId, Permission.SCHOOL_IMPORT_USERS_MIGRATE); const school: LegacySchoolDo = await this.schoolService.getSchoolById(currentUser.school.id); - this.logger.notice(new SchoolInUserMigrationStartLoggable(currentUserId, school.name, useCentralLdap)); - this.checkFeatureEnabled(school); - this.checkSchoolNumber(school, useCentralLdap); + + this.userImportService.checkFeatureEnabled(school); + if (useCentralLdap || useWithUserLoginMigration) { + this.checkSchoolNumber(school); + } this.checkSchoolNotInMigration(school); - await this.checkNoExistingLdapBeforeStart(school); + if (useWithUserLoginMigration) { + await this.checkSchoolMigrated(currentUser.school.id, school); + } else { + await this.checkNoExistingLdapBeforeStart(school); + } + + this.logger.notice(new SchoolInUserMigrationStartLoggable(currentUserId, school.name, useCentralLdap)); + + if (!useWithUserLoginMigration) { + school.externalId = school.officialSchoolNumber; + } school.inUserMigration = true; school.inMaintenanceSince = new Date(); - school.externalId = school.officialSchoolNumber; + if (useCentralLdap) { - const migrationSystem = await this.getMigrationSystem(); + const migrationSystem: SystemEntity = await this.userImportService.getMigrationSystem(); + if (school.systems && !school.systems.includes(migrationSystem.id)) { school.systems.push(migrationSystem.id); } @@ -239,16 +270,40 @@ export class UserImportUc { await this.schoolService.save(school); } + private async checkSchoolMigrated(schoolId: EntityId, school: LegacySchoolDo): Promise { + const userLoginMigration: UserLoginMigrationDO | null = await this.userLoginMigrationService.findMigrationBySchool( + schoolId + ); + + if (!userLoginMigration) { + throw new NotFoundLoggableException('UserLoginMigration', { schoolId }); + } + + if (!school.systems?.includes(userLoginMigration.targetSystemId)) { + throw new SchoolNotMigratedLoggableException(schoolId); + } + } + async endSchoolInMaintenance(currentUserId: EntityId): Promise { const currentUser = await this.getCurrentUser(currentUserId, Permission.SCHOOL_IMPORT_USERS_MIGRATE); const school: LegacySchoolDo = await this.schoolService.getSchoolById(currentUser.school.id); - this.checkFeatureEnabled(school); + + this.userImportService.checkFeatureEnabled(school); + if (school.inUserMigration !== false || !school.inMaintenanceSince || !school.externalId) { this.logger.warning(new MigrationMayNotBeCompleted(school.inUserMigration)); throw new BadRequestException('Sync cannot be activated for school'); } + school.inMaintenanceSince = undefined; + + const isMigrationRestartable: boolean = this.isNbc(); + if (isMigrationRestartable) { + school.inUserMigration = undefined; + } + await this.schoolService.save(school); + this.logger.notice(new SchoolInUserMigrationEndLoggable(school.name)); } @@ -259,18 +314,26 @@ export class UserImportUc { return currentUser; } - private async updateUserAndAccount( - importUser: ImportUser, - school: LegacySchoolDo - ): Promise<[User, Account] | undefined> { + private async updateUserAndAccount(importUser: ImportUser, school: LegacySchoolDo): Promise { + const useWithUserLoginMigration: boolean = this.isNbc(); + + if (useWithUserLoginMigration) { + await this.updateUserAndAccountWithUserLoginMigration(importUser); + } else { + await this.updateUserAndAccountWithLdap(importUser, school); + } + } + + private async updateUserAndAccountWithLdap(importUser: ImportUser, school: LegacySchoolDo): Promise { if (!importUser.user || !importUser.loginName || !school.externalId) { return; } + const { user } = importUser; user.ldapDn = importUser.ldapDn; user.externalId = importUser.externalId; - const account: AccountDto = await this.getAccount(user); + const account: Account = await this.getAccount(user); account.systemId = importUser.system.id; account.password = undefined; @@ -281,25 +344,29 @@ export class UserImportUc { await this.importUserRepo.delete(importUser); } - private async getAccount(user: User): Promise { - let account: AccountDto | null = await this.accountService.findByUserId(user.id); + private async updateUserAndAccountWithUserLoginMigration(importUser: ImportUser): Promise { + if (!importUser.user) { + return; + } + + await this.userMigrationService.migrateUser(importUser.user.id, importUser.externalId, importUser.system.id); + } + + private async getAccount(user: User): Promise { + let account: Account | null = await this.accountService.findByUserId(user.id); if (!account) { - const newAccount: AccountSaveDto = new AccountSaveDto({ + const newAccount = { userId: user.id, username: user.email, - }); + } as AccountSave; + + await this.accountService.save(newAccount); - await this.accountService.saveWithValidation(newAccount); account = await this.accountService.findByUserIdOrFail(user.id); } - return account; - } - private async getMigrationSystem(): Promise { - const systemId = Configuration.get('FEATURE_USER_MIGRATION_SYSTEM_ID') as string; - const system = await this.systemRepo.findById(systemId); - return system; + return account; } private async checkNoExistingLdapBeforeStart(school: LegacySchoolDo): Promise { @@ -308,6 +375,7 @@ export class UserImportUc { // very unusual to have more than 1 system // eslint-disable-next-line no-await-in-loop const system: SystemEntity = await this.systemRepo.findById(systemId); + if (system.ldapConfig) { throw new LdapAlreadyPersistedException(); } @@ -315,15 +383,19 @@ export class UserImportUc { } } - private checkSchoolNumber(school: LegacySchoolDo, useCentralLdap: boolean): void | never { - if (useCentralLdap && !school.officialSchoolNumber) { + private checkSchoolNumber(school: LegacySchoolDo): void { + if (!school.officialSchoolNumber) { throw new MissingSchoolNumberException(); } } - private checkSchoolNotInMigration(school: LegacySchoolDo): void | never { + private checkSchoolNotInMigration(school: LegacySchoolDo): void { if (school.inUserMigration !== undefined && school.inUserMigration !== null) { throw new MigrationAlreadyActivatedException(); } } + + private isNbc(): boolean { + return this.userImportFeatures.instance === 'n21'; + } } diff --git a/apps/server/src/modules/user-import/user-import-config.module.ts b/apps/server/src/modules/user-import/user-import-config.module.ts new file mode 100644 index 00000000000..34ba1e5f994 --- /dev/null +++ b/apps/server/src/modules/user-import/user-import-config.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { UserImportConfiguration, UserImportFeatures } from './config'; + +@Module({ + providers: [ + { + provide: UserImportFeatures, + useValue: UserImportConfiguration.userImportFeatures, + }, + ], + exports: [UserImportFeatures], +}) +export class UserImportConfigModule {} diff --git a/apps/server/src/modules/user-import/user-import.module.ts b/apps/server/src/modules/user-import/user-import.module.ts index 48fa730c61f..ae94b79e0b8 100644 --- a/apps/server/src/modules/user-import/user-import.module.ts +++ b/apps/server/src/modules/user-import/user-import.module.ts @@ -1,16 +1,43 @@ +import { SchulconnexClientModule } from '@infra/schulconnex-client'; +import { AccountModule } from '@modules/account'; +import { AuthorizationModule } from '@modules/authorization'; import { LegacySchoolModule } from '@modules/legacy-school'; +import { OauthModule } from '@modules/oauth'; +import { UserModule } from '@modules/user'; +import { UserLoginMigrationModule } from '@modules/user-login-migration'; +import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { ImportUserRepo, LegacySchoolRepo, LegacySystemRepo, UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { AccountModule } from '../account'; -import { AuthorizationModule } from '../authorization'; import { ImportUserController } from './controller/import-user.controller'; -import { UserImportUc } from './uc/user-import.uc'; +import { SchulconnexFetchImportUsersService, UserImportService } from './service'; +import { UserImportFetchUc, UserImportUc } from './uc'; +import { UserImportConfigModule } from './user-import-config.module'; @Module({ - imports: [LoggerModule, AccountModule, LegacySchoolModule, AuthorizationModule], + imports: [ + LoggerModule, + AccountModule, + LegacySchoolModule, + AuthorizationModule, + UserImportConfigModule, + HttpModule, + UserModule, + OauthModule, + SchulconnexClientModule, + UserLoginMigrationModule, + ], controllers: [ImportUserController], - providers: [UserImportUc, ImportUserRepo, LegacySchoolRepo, LegacySystemRepo, UserRepo], + providers: [ + UserImportUc, + UserImportFetchUc, + ImportUserRepo, + LegacySchoolRepo, + LegacySystemRepo, + UserRepo, + UserImportService, + SchulconnexFetchImportUsersService, + ], exports: [], }) /** diff --git a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts index b8b10c4a379..c72c462294b 100644 --- a/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts +++ b/apps/server/src/modules/user-login-migration/controller/api-test/user-login-migration.api.spec.ts @@ -1,7 +1,7 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; +import { SanisResponse, SanisRole } from '@infra/schulconnex-client'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { OauthTokenResponse } from '@modules/oauth/service/dto'; -import { SanisResponse, SanisRole } from '@modules/provisioning'; import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -9,12 +9,12 @@ import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { + cleanupCollections, JwtTestFactory, + schoolEntityFactory, + systemEntityFactory, TestApiClient, UserAndAccountTestFactory, - cleanupCollections, - schoolFactory, - systemEntityFactory, userFactory, userLoginMigrationFactory, } from '@shared/testing'; @@ -73,7 +73,7 @@ describe('UserLoginMigrationController (API)', () => { const date: Date = new Date(2023, 5, 4); const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], }); const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ @@ -138,7 +138,7 @@ describe('UserLoginMigrationController (API)', () => { const date: Date = new Date(2023, 5, 4); const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], }); const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({ @@ -185,7 +185,7 @@ describe('UserLoginMigrationController (API)', () => { describe('when no user login migration exists', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school }); await em.persistAndFlush([school, adminAccount, adminUser]); @@ -234,7 +234,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -318,7 +318,7 @@ describe('UserLoginMigrationController (API)', () => { const date: Date = new Date(2023, 5, 4); const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -357,7 +357,7 @@ describe('UserLoginMigrationController (API)', () => { const date: Date = new Date(2023, 5, 4); const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -397,7 +397,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], }); @@ -477,7 +477,7 @@ describe('UserLoginMigrationController (API)', () => { const officialSchoolNumber = '12345'; const externalId = 'aef1f4fd-c323-466e-962b-a84354c0e713'; - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber, externalId, @@ -544,7 +544,7 @@ describe('UserLoginMigrationController (API)', () => { const officialSchoolNumber = '12345'; const externalId = 'aef1f4fd-c323-466e-962b-a84354c0e713'; - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber, externalId, @@ -619,7 +619,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -718,7 +718,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -756,7 +756,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -799,7 +799,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -851,7 +851,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -903,7 +903,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -933,7 +933,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -981,7 +981,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -1023,7 +1023,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -1099,7 +1099,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -1142,7 +1142,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -1208,7 +1208,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -1256,7 +1256,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); @@ -1295,7 +1295,7 @@ describe('UserLoginMigrationController (API)', () => { const setup = async () => { const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' }); const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' }); - const school: SchoolEntity = schoolFactory.buildWithId({ + const school: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [sourceSystem], officialSchoolNumber: '12345', }); diff --git a/apps/server/src/modules/user-login-migration/controller/dto/response/user-login-migration-search-list.response.ts b/apps/server/src/modules/user-login-migration/controller/dto/response/user-login-migration-search-list.response.ts index 9faa6951a8d..8138d72b7f1 100644 --- a/apps/server/src/modules/user-login-migration/controller/dto/response/user-login-migration-search-list.response.ts +++ b/apps/server/src/modules/user-login-migration/controller/dto/response/user-login-migration-search-list.response.ts @@ -1,9 +1,9 @@ -import { PaginationResponse } from '@shared/controller'; import { ApiProperty } from '@nestjs/swagger'; +import { PaginationResponse } from '@shared/controller'; import { UserLoginMigrationResponse } from './user-login-migration.response'; export class UserLoginMigrationSearchListResponse extends PaginationResponse { - @ApiProperty({ type: [UserLoginMigrationResponse] }) + @ApiProperty({ type: [UserLoginMigrationResponse], description: 'Contains user login migration responses' }) data: UserLoginMigrationResponse[]; constructor(data: UserLoginMigrationResponse[], total: number, skip?: number, limit?: number) { diff --git a/apps/server/src/modules/user-login-migration/controller/dto/response/user-login-migration.response.ts b/apps/server/src/modules/user-login-migration/controller/dto/response/user-login-migration.response.ts index efa9fd83720..81c1c92b63d 100644 --- a/apps/server/src/modules/user-login-migration/controller/dto/response/user-login-migration.response.ts +++ b/apps/server/src/modules/user-login-migration/controller/dto/response/user-login-migration.response.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class UserLoginMigrationResponse { - @ApiProperty() + @ApiProperty({ description: 'Id of the migration' }) id: string; @ApiPropertyOptional({ diff --git a/apps/server/src/modules/user-login-migration/index.ts b/apps/server/src/modules/user-login-migration/index.ts index c3841ebf249..bdcddcdf0a4 100644 --- a/apps/server/src/modules/user-login-migration/index.ts +++ b/apps/server/src/modules/user-login-migration/index.ts @@ -1,2 +1,3 @@ export * from './user-login-migration.module'; export * from './service'; +export { UserLoginMigrationConfig } from './user-login-migration.config'; diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-mandatory.loggable.spec.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-mandatory.loggable.spec.ts index a2b1cde606b..7e6210f374e 100644 --- a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-mandatory.loggable.spec.ts +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-mandatory.loggable.spec.ts @@ -1,4 +1,4 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { UserLoginMigrationMandatoryLoggable } from './user-login-migration-mandatory.loggable'; describe(UserLoginMigrationMandatoryLoggable.name, () => { diff --git a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-start.loggable.spec.ts b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-start.loggable.spec.ts index f15ac763e5c..74b251e183f 100644 --- a/apps/server/src/modules/user-login-migration/loggable/user-login-migration-start.loggable.spec.ts +++ b/apps/server/src/modules/user-login-migration/loggable/user-login-migration-start.loggable.spec.ts @@ -1,4 +1,4 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { UserLoginMigrationStartLoggable } from './user-login-migration-start.loggable'; describe(UserLoginMigrationStartLoggable.name, () => { diff --git a/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts b/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts index fbe85911e59..9fc93f4fded 100644 --- a/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts +++ b/apps/server/src/modules/user-login-migration/service/user-migration.service.spec.ts @@ -1,7 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto'; +import { AccountService, Account } from '@modules/account'; import { UserService } from '@modules/user'; import { Test, TestingModule } from '@nestjs/testing'; import { UserDO } from '@shared/domain/domainobject'; @@ -83,7 +82,7 @@ describe(UserMigrationService.name, () => { const accountId = new ObjectId().toHexString(); const sourceSystemId = new ObjectId().toHexString(); - const accountDto: AccountDto = new AccountDto({ + const accountDto: Account = new Account({ id: accountId, updatedAt: new Date(), createdAt: new Date(), @@ -93,7 +92,7 @@ describe(UserMigrationService.name, () => { }); userService.findById.mockResolvedValueOnce({ ...user }); - accountService.findByUserIdOrFail.mockResolvedValueOnce({ ...accountDto }); + accountService.findByUserIdOrFail.mockResolvedValueOnce(new Account(accountDto.getProps())); return { user, @@ -140,10 +139,12 @@ describe(UserMigrationService.name, () => { await service.migrateUser(userId, targetExternalId, targetSystemId); - expect(accountService.save).toHaveBeenCalledWith({ - ...accountDto, - systemId: targetSystemId, - }); + expect(accountService.save).toHaveBeenCalledWith( + new Account({ + ...accountDto.getProps(), + systemId: targetSystemId, + }) + ); }); }); @@ -169,7 +170,7 @@ describe(UserMigrationService.name, () => { const accountId = new ObjectId().toHexString(); const sourceSystemId = new ObjectId().toHexString(); - const accountDto: AccountDto = new AccountDto({ + const accountDto: Account = new Account({ id: accountId, updatedAt: new Date(), createdAt: new Date(), @@ -181,7 +182,7 @@ describe(UserMigrationService.name, () => { const error = new Error('Cannot save'); userService.findById.mockResolvedValueOnce({ ...user }); - accountService.findByUserIdOrFail.mockResolvedValueOnce({ ...accountDto }); + accountService.findByUserIdOrFail.mockResolvedValueOnce(new Account(accountDto.getProps())); userService.save.mockRejectedValueOnce(error); accountService.save.mockRejectedValueOnce(error); diff --git a/apps/server/src/modules/user-login-migration/service/user-migration.service.ts b/apps/server/src/modules/user-login-migration/service/user-migration.service.ts index 27a7f25b9d6..24b9466f8f8 100644 --- a/apps/server/src/modules/user-login-migration/service/user-migration.service.ts +++ b/apps/server/src/modules/user-login-migration/service/user-migration.service.ts @@ -1,5 +1,4 @@ -import { AccountService } from '@modules/account/services/account.service'; -import { AccountDto } from '@modules/account/services/dto'; +import { AccountService, Account } from '@modules/account'; import { UserService } from '@modules/user'; import { Injectable } from '@nestjs/common'; import { UserDO } from '@shared/domain/domainobject/user.do'; @@ -17,10 +16,10 @@ export class UserMigrationService { async migrateUser(currentUserId: EntityId, externalUserId: string, targetSystemId: EntityId): Promise { const userDO: UserDO = await this.userService.findById(currentUserId); - const account: AccountDto = await this.accountService.findByUserIdOrFail(currentUserId); + const account: Account = await this.accountService.findByUserIdOrFail(currentUserId); const userDOCopy: UserDO = new UserDO({ ...userDO }); - const accountCopy: AccountDto = new AccountDto({ ...account }); + const accountCopy: Account = new Account(account.getProps()); try { await this.doMigration(userDO, externalUserId, account, targetSystemId); @@ -34,7 +33,7 @@ export class UserMigrationService { private async doMigration( userDO: UserDO, externalUserId: string, - account: AccountDto, + account: Account, targetSystemId: string ): Promise { userDO.previousExternalId = userDO.externalId; @@ -46,11 +45,7 @@ export class UserMigrationService { await this.accountService.save(account); } - private async tryRollbackMigration( - currentUserId: EntityId, - userDOCopy: UserDO, - accountCopy: AccountDto - ): Promise { + private async tryRollbackMigration(currentUserId: EntityId, userDOCopy: UserDO, accountCopy: Account): Promise { try { await this.userService.save(userDOCopy); await this.accountService.save(accountCopy); diff --git a/apps/server/src/modules/user-login-migration/uc/toggle-user-login-migration.uc.spec.ts b/apps/server/src/modules/user-login-migration/uc/toggle-user-login-migration.uc.spec.ts index c11b4fa84b5..a970f654d9b 100644 --- a/apps/server/src/modules/user-login-migration/uc/toggle-user-login-migration.uc.spec.ts +++ b/apps/server/src/modules/user-login-migration/uc/toggle-user-login-migration.uc.spec.ts @@ -8,7 +8,7 @@ import { User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { legacySchoolDoFactory, setupEntities, userFactory, userLoginMigrationDOFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { UserLoginMigrationNotFoundLoggableException } from '../loggable'; import { UserLoginMigrationService } from '../service'; import { ToggleUserLoginMigrationUc } from './toggle-user-login-migration.uc'; diff --git a/apps/server/src/modules/user-login-migration/user-login-migration.config.ts b/apps/server/src/modules/user-login-migration/user-login-migration.config.ts new file mode 100644 index 00000000000..df503973cce --- /dev/null +++ b/apps/server/src/modules/user-login-migration/user-login-migration.config.ts @@ -0,0 +1,3 @@ +export interface UserLoginMigrationConfig { + MIGRATION_END_GRACE_PERIOD_MS: number; +} diff --git a/apps/server/src/modules/user-login-migration/user-login-migration.module.ts b/apps/server/src/modules/user-login-migration/user-login-migration.module.ts index 705e5cb4094..8338b39fcba 100644 --- a/apps/server/src/modules/user-login-migration/user-login-migration.module.ts +++ b/apps/server/src/modules/user-login-migration/user-login-migration.module.ts @@ -1,10 +1,10 @@ -import { Module } from '@nestjs/common'; -import { UserLoginMigrationRepo } from '@shared/repo'; -import { LoggerModule } from '@src/core/logger'; import { AccountModule } from '@modules/account'; import { LegacySchoolModule } from '@modules/legacy-school'; import { SystemModule } from '@modules/system'; import { UserModule } from '@modules/user'; +import { Module } from '@nestjs/common'; +import { UserLoginMigrationRepo } from '@shared/repo'; +import { LoggerModule } from '@src/core/logger'; import { MigrationCheckService, SchoolMigrationService, diff --git a/apps/server/src/modules/user/controller/admin-api-user.controller.ts b/apps/server/src/modules/user/controller/admin-api-user.controller.ts new file mode 100644 index 00000000000..701b34bad20 --- /dev/null +++ b/apps/server/src/modules/user/controller/admin-api-user.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AdminApiUserUc } from '../uc'; +import { AdminApiUserCreateBodyParams } from './dto/admin-api-user-create.body.params'; +import { AdminApiUserCreateResponse } from './dto/admin-api-user-create.response.dto'; + +@ApiTags('AdminApiUsers') +@UseGuards(AuthGuard('api-key')) +@Controller('/admin/users') +export class AdminApiUsersController { + constructor(private readonly uc: AdminApiUserUc) {} + + @Post('') + @ApiOperation({ + summary: 'create a user together with an account', + }) + async createUser(@Body() body: AdminApiUserCreateBodyParams): Promise { + const result = await this.uc.createUserAndAccount(body); + const mapped = new AdminApiUserCreateResponse(result); + return Promise.resolve(mapped); + } +} diff --git a/apps/server/src/modules/user/controller/api-test/admin-api-user.api.spec.ts b/apps/server/src/modules/user/controller/api-test/admin-api-user.api.spec.ts new file mode 100644 index 00000000000..8e338296642 --- /dev/null +++ b/apps/server/src/modules/user/controller/api-test/admin-api-user.api.spec.ts @@ -0,0 +1,103 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { User } from '@shared/domain/entity'; +import { RoleName } from '@shared/domain/interface'; +import { TestApiClient, TestXApiKeyClient, schoolEntityFactory } from '@shared/testing'; +import { AccountEntity } from '@modules/account/entity/account.entity'; +import { AdminApiServerTestModule } from '@src/modules/server/admin-api.server.module'; +import { nanoid } from 'nanoid'; +import { AdminApiUserCreateResponse } from '../dto/admin-api-user-create.response.dto'; + +const baseRouteName = '/admin/users'; + +describe('Admin API - Users (API)', () => { + let app: INestApplication; + let testXApiKeyClient: TestXApiKeyClient; + let testApiClient: TestApiClient; + let em: EntityManager; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [AdminApiServerTestModule], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + em = module.get(EntityManager); + testXApiKeyClient = new TestXApiKeyClient(app, baseRouteName); + testApiClient = new TestApiClient(app, baseRouteName); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('create user with account', () => { + describe('without token', () => { + it('should refuse with wrong token', async () => { + const client = new TestXApiKeyClient(app, baseRouteName, 'thisisaninvalidapikey'); + const response = await client.post(''); + expect(response.status).toEqual(401); + }); + it('should refuse without token', async () => { + const response = await testApiClient.post(''); + expect(response.status).toEqual(401); + }); + }); + + describe('with api token', () => { + const setup = async () => { + const school = schoolEntityFactory.buildWithId(); + await em.persistAndFlush(school); + + const schoolId = school.id; + const firstName = 'firstname'; + const lastName = 'lastName'; + const email = `mail${nanoid(12)}@domain.de`; + const roleNames = [RoleName.STUDENT]; + + const body = { schoolId, firstName, lastName, email, roleNames }; + + return { body }; + }; + + it('should return 201', async () => { + const { body } = await setup(); + const response = await testXApiKeyClient.post('', body); + + expect(response.status).toEqual(201); + }); + + it('should persist user', async () => { + const { body } = await setup(); + const response = await testXApiKeyClient.post('', body); + const { userId } = response.body as AdminApiUserCreateResponse; + + const loaded = await em.findOneOrFail(User, userId); + expect(loaded).toEqual( + expect.objectContaining({ + id: userId, + firstName: body.firstName, + lastName: body.lastName, + email: body.email, + }) + ); + }); + + it('should persist account', async () => { + const { body } = await setup(); + const response = await testXApiKeyClient.post('', body); + const { accountId } = response.body as AdminApiUserCreateResponse; + + const loaded = await em.findOneOrFail(AccountEntity, accountId); + expect(loaded).toEqual( + expect.objectContaining({ + id: accountId, + username: body.email, + }) + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/user/controller/api-test/user-language.api.spec.ts b/apps/server/src/modules/user/controller/api-test/user-language.api.spec.ts index 0268a3c8c29..fb8cd5c4a83 100644 --- a/apps/server/src/modules/user/controller/api-test/user-language.api.spec.ts +++ b/apps/server/src/modules/user/controller/api-test/user-language.api.spec.ts @@ -6,7 +6,8 @@ import { ICurrentUser } from '@modules/authentication'; import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ServerTestModule } from '@modules/server/server.module'; import { ApiValidationError } from '@shared/common'; -import { LanguageType, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; +import { LanguageType } from '@shared/domain/interface'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, userFactory } from '@shared/testing'; import { Request } from 'express'; import request from 'supertest'; diff --git a/apps/server/src/modules/user/controller/api-test/user-me.api.spec.ts b/apps/server/src/modules/user/controller/api-test/user-me.api.spec.ts index df86b8ecb15..9182109b3e2 100644 --- a/apps/server/src/modules/user/controller/api-test/user-me.api.spec.ts +++ b/apps/server/src/modules/user/controller/api-test/user-me.api.spec.ts @@ -10,7 +10,7 @@ import { JwtAuthGuard } from '@modules/authentication/guard/jwt-auth.guard'; import { ServerTestModule } from '@modules/server/server.module'; import { ResolvedUserResponse } from '@modules/user/controller/dto'; import { ApiValidationError } from '@shared/common'; -import { LanguageType } from '@shared/domain/entity'; +import { LanguageType } from '@shared/domain/interface'; import { cleanupCollections, mapUserToCurrentUser, roleFactory, userFactory } from '@shared/testing'; const baseRouteName = '/user/me'; diff --git a/apps/server/src/modules/user/controller/dto/admin-api-user-create.body.params.ts b/apps/server/src/modules/user/controller/dto/admin-api-user-create.body.params.ts new file mode 100644 index 00000000000..c0fe9f613e2 --- /dev/null +++ b/apps/server/src/modules/user/controller/dto/admin-api-user-create.body.params.ts @@ -0,0 +1,50 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { RoleName } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { IsEmail, IsEnum, IsMongoId, IsNotEmpty, IsString } from 'class-validator'; + +export class AdminApiUserCreateBodyParams { + @IsEmail() + @ApiProperty({ + description: 'The mail adress of the new user. Will also be used as username.', + required: true, + nullable: false, + }) + email!: string; + + @IsString() + @ApiProperty({ + description: '', + required: true, + nullable: false, + }) + firstName!: string; + + @IsString() + @ApiProperty({ + description: '', + required: true, + nullable: false, + }) + lastName!: string; + + @IsEnum(RoleName, { each: true }) + @IsNotEmpty() + @ApiProperty({ + description: 'The roles of the new user', + isArray: true, + enum: RoleName, + required: true, + nullable: false, + enumName: 'RoleName', + }) + roleNames!: RoleName[]; + + @IsMongoId() + @ApiProperty({ + description: 'id of the school the user should be created in', + required: true, + nullable: false, + }) + schoolId!: EntityId; +} diff --git a/apps/server/src/modules/user/controller/dto/admin-api-user-create.response.dto.ts b/apps/server/src/modules/user/controller/dto/admin-api-user-create.response.dto.ts new file mode 100644 index 00000000000..8260085911f --- /dev/null +++ b/apps/server/src/modules/user/controller/dto/admin-api-user-create.response.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AdminApiUserCreateResponse { + constructor(props: AdminApiUserCreateResponse) { + this.userId = props.userId; + this.accountId = props.accountId; + this.username = props.username; + this.initialPassword = props.initialPassword; + } + + @ApiProperty() + userId: string; + + @ApiProperty() + accountId: string; + + @ApiProperty() + username: string; + + @ApiProperty() + initialPassword: string; +} diff --git a/apps/server/src/modules/user/controller/dto/user.response.ts b/apps/server/src/modules/user/controller/dto/create-user.response.ts similarity index 100% rename from apps/server/src/modules/user/controller/dto/user.response.ts rename to apps/server/src/modules/user/controller/dto/create-user.response.ts diff --git a/apps/server/src/modules/user/controller/dto/index.ts b/apps/server/src/modules/user/controller/dto/index.ts index e72720e3806..a3f53c8e814 100644 --- a/apps/server/src/modules/user/controller/dto/index.ts +++ b/apps/server/src/modules/user/controller/dto/index.ts @@ -1,3 +1,3 @@ export * from './user.params'; -export * from './user.response'; export * from './resolved-user.response'; +export * from './create-user.response'; diff --git a/apps/server/src/modules/user/controller/dto/user.params.ts b/apps/server/src/modules/user/controller/dto/user.params.ts index 72a2dd85f00..a37e6a7207d 100644 --- a/apps/server/src/modules/user/controller/dto/user.params.ts +++ b/apps/server/src/modules/user/controller/dto/user.params.ts @@ -1,9 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; -import { LanguageType } from '@shared/domain/entity'; +import { LanguageType } from '@shared/domain/interface'; import { IsEnum } from 'class-validator'; export class ChangeLanguageParams { - @ApiProperty({ enum: LanguageType }) + @ApiProperty({ + enum: LanguageType, + enumName: 'LanguageType', + }) @IsEnum(LanguageType) language!: LanguageType; } diff --git a/apps/server/src/modules/user/controller/index.ts b/apps/server/src/modules/user/controller/index.ts index 581cff44b2d..3237065b259 100644 --- a/apps/server/src/modules/user/controller/index.ts +++ b/apps/server/src/modules/user/controller/index.ts @@ -1 +1,2 @@ export * from './user.controller'; +export * from './admin-api-user.controller'; diff --git a/apps/server/src/modules/user/index.ts b/apps/server/src/modules/user/index.ts index 016542dfb4d..e0db2a06b79 100644 --- a/apps/server/src/modules/user/index.ts +++ b/apps/server/src/modules/user/index.ts @@ -1,3 +1,3 @@ export * from './interfaces'; -export * from './user.module'; +export { UserModule } from './user.module'; export * from './service/user.service'; diff --git a/apps/server/src/modules/user/legacy/controller/admin-api-students.controller.ts b/apps/server/src/modules/user/legacy/controller/admin-api-students.controller.ts new file mode 100644 index 00000000000..cc7814b663a --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/admin-api-students.controller.ts @@ -0,0 +1,41 @@ +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; +import { Authenticate, CurrentUser, ICurrentUser } from '../../../authentication'; +import { RequestedRoleEnum } from '../enum'; +import { UserByIdParams, UserListResponse, UserResponse, UsersSearchQueryParams } from './dto'; +import { UsersAdminApiUc } from '../uc'; + +@ApiTags('AdminStudents') +@Authenticate('jwt') +@Controller('users/admin/students') +export class AdminApiStudentsController { + constructor(private readonly uc: UsersAdminApiUc) {} + + @Get() + @ApiOperation({ + summary: 'Returns all students which satisfies the given criteria.', + }) + @ApiResponse({ status: 200, type: UserListResponse, description: 'Returns a paged list of students.' }) + @ApiResponse({ status: 400, type: ValidationError, description: 'Request data has invalid format.' }) + @ApiResponse({ status: 403, type: ForbiddenOperationError, description: 'Not authorized.' }) + async searchStudents( + @CurrentUser() currentUser: ICurrentUser, + @Query() params: UsersSearchQueryParams + ): Promise { + return this.uc.findUsersByParams(RequestedRoleEnum.STUDENTS, currentUser.userId, params); + } + + @Get(':id') + @ApiOperation({ summary: 'Returns an student with given id.' }) + @ApiResponse({ status: 200, type: UserResponse, description: 'Returns the student.' }) + @ApiResponse({ status: 400, type: ValidationError, description: 'Request data has invalid format.' }) + @ApiResponse({ status: 403, type: ForbiddenOperationError, description: 'Not authorized.' }) + @ApiResponse({ status: 404, type: EntityNotFoundError, description: 'Student not found.' }) + async findStudentById( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: UserByIdParams + ): Promise { + return this.uc.findUserById(RequestedRoleEnum.STUDENTS, currentUser.userId, params); + } +} diff --git a/apps/server/src/modules/user/legacy/controller/admin-api-teachers.controller.ts b/apps/server/src/modules/user/legacy/controller/admin-api-teachers.controller.ts new file mode 100644 index 00000000000..8b3ea3cabd1 --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/admin-api-teachers.controller.ts @@ -0,0 +1,41 @@ +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { EntityNotFoundError, ForbiddenOperationError, ValidationError } from '@shared/common'; +import { Authenticate, CurrentUser, ICurrentUser } from '../../../authentication'; +import { RequestedRoleEnum } from '../enum'; +import { UserByIdParams, UserListResponse, UserResponse, UsersSearchQueryParams } from './dto'; +import { UsersAdminApiUc } from '../uc'; + +@ApiTags('AdminTeachers') +@Authenticate('jwt') +@Controller('users/admin/teachers') +export class AdminApiTeachersController { + constructor(private readonly uc: UsersAdminApiUc) {} + + @Get() + @ApiOperation({ + summary: 'Returns all teachers which satisfies the given criteria.', + }) + @ApiResponse({ status: 200, type: UserListResponse, description: 'Returns a paged list of teachers.' }) + @ApiResponse({ status: 400, type: ValidationError, description: 'Request data has invalid format.' }) + @ApiResponse({ status: 403, type: ForbiddenOperationError, description: 'Not authorized.' }) + async searchTeachers( + @CurrentUser() currentUser: ICurrentUser, + @Query() params: UsersSearchQueryParams + ): Promise { + return this.uc.findUsersByParams(RequestedRoleEnum.TEACHERS, currentUser.userId, params); + } + + @Get(':id') + @ApiOperation({ summary: 'Returns a teacher with given id.' }) + @ApiResponse({ status: 200, type: UserResponse, description: 'Returns the teacher.' }) + @ApiResponse({ status: 400, type: ValidationError, description: 'Request data has invalid format.' }) + @ApiResponse({ status: 403, type: ForbiddenOperationError, description: 'Not authorized.' }) + @ApiResponse({ status: 404, type: EntityNotFoundError, description: 'Teacher not found.' }) + async findTeacherById( + @CurrentUser() currentUser: ICurrentUser, + @Param() params: UserByIdParams + ): Promise { + return this.uc.findUserById(RequestedRoleEnum.TEACHERS, currentUser.userId, params); + } +} diff --git a/apps/server/src/modules/user/legacy/controller/api-test/admin-api-students.api.spec.ts b/apps/server/src/modules/user/legacy/controller/api-test/admin-api-students.api.spec.ts new file mode 100644 index 00000000000..98fc134f983 --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/api-test/admin-api-students.api.spec.ts @@ -0,0 +1,336 @@ +import { EntityManager } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { User } from '@shared/domain/entity'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { + accountFactory, + mapUserToCurrentUser, + roleFactory, + schoolEntityFactory, + schoolYearFactory, + userFactory, +} from '@shared/testing'; +import { AccountEntity } from '@src/modules/account/entity/account.entity'; +import { ICurrentUser } from '@src/modules/authentication'; +import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; +import { ServerTestModule } from '@src/modules/server/server.module'; +import { Request } from 'express'; +import request from 'supertest'; +import { classEntityFactory } from '../../../../class/entity/testing'; +import { UserListResponse, UserResponse, UsersSearchQueryParams } from '../dto'; + +describe('Users Admin Students Controller (API)', () => { + const basePath = '/users/admin/students'; + + let app: INestApplication; + let em: EntityManager; + + let adminAccount: AccountEntity; + let studentAccount1: AccountEntity; + let studentAccount2: AccountEntity; + + let adminUser: User; + let studentUser1: User; + let studentUser2: User; + + let currentUser: ICurrentUser; + + const defaultPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; + + const setupDb = async () => { + const currentYear = schoolYearFactory.withStartYear(2002).buildWithId(); + const school = schoolEntityFactory.buildWithId({ currentYear }); + + const adminRoles = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.STUDENT_LIST], + }); + const studentRoles = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + + adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); + studentUser1 = userFactory.buildWithId({ + firstName: 'Marla', + school, + roles: [studentRoles], + consent: { + userConsent: { + form: 'digital', + privacyConsent: true, + termsOfUseConsent: true, + dateOfPrivacyConsent: new Date('2017-01-01T00:06:37.148Z'), + dateOfTermsOfUseConsent: new Date('2017-01-01T00:06:37.148Z'), + }, + parentConsents: [ + { + _id: new ObjectId('5fa31aacb229544f2c697b48'), + form: 'digital', + privacyConsent: true, + termsOfUseConsent: true, + dateOfPrivacyConsent: new Date('2017-01-01T00:06:37.148Z'), + dateOfTermsOfUseConsent: new Date('2017-01-01T00:06:37.148Z'), + }, + ], + }, + }); + + studentUser2 = userFactory.buildWithId({ + firstName: 'Test', + school, + roles: [studentRoles], + consent: { + userConsent: { + form: 'digital', + privacyConsent: true, + termsOfUseConsent: true, + dateOfPrivacyConsent: new Date('2017-01-01T00:06:37.148Z'), + dateOfTermsOfUseConsent: new Date('2017-01-01T00:06:37.148Z'), + }, + }, + }); + + const studentClass = classEntityFactory.buildWithId({ + name: 'Group A', + schoolId: school.id, + year: currentYear.id, + userIds: [studentUser1._id], + gradeLevel: 12, + }); + + const mapUserToAccount = (user: User): AccountEntity => + accountFactory.buildWithId({ + userId: user.id, + username: user.email, + password: defaultPasswordHash, + }); + adminAccount = mapUserToAccount(adminUser); + studentAccount1 = mapUserToAccount(studentUser1); + studentAccount2 = mapUserToAccount(studentUser2); + + em.persist(school); + em.persist(currentYear); + em.persist([adminRoles, studentRoles]); + em.persist([adminUser, studentUser1, studentUser2]); + em.persist([adminAccount, studentAccount1, studentAccount2]); + em.persist(studentClass); + await em.flush(); + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate(context: ExecutionContext) { + const req: Request = context.switchToHttp().getRequest(); + req.user = currentUser; + return true; + }, + }) + .compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + + await setupDb(); + }); + + afterAll(async () => { + await app.close(); + em.clear(); + }); + + describe('[GET] :id', () => { + describe('when student exists', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(adminUser, adminAccount); + }; + + it('should return student ', async () => { + setup(); + const response = await request(app.getHttpServer()) // + .get(`${basePath}/${studentUser1.id}`) + .expect(200); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { _id } = response.body as UserResponse; + + expect(_id).toBe(studentUser1._id.toString()); + }); + }); + + describe('when user has no right permission', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(studentUser1, studentAccount1); + }; + + it('should reject request', async () => { + setup(); + await request(app.getHttpServer()) // + .get(`${basePath}/${studentUser1.id}`) + .expect(403); + }); + }); + + describe('when student does not exists', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(adminUser, adminAccount); + }; + + it('should reject request ', async () => { + setup(); + await request(app.getHttpServer()) // + .get(`${basePath}/000000000000000000000000`) + .expect(404); + }); + }); + }); + + describe('[GET]', () => { + describe('when sort param is provided', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(adminUser, adminAccount); + const query: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { firstName: 1 }, + }; + + return { + query, + }; + }; + + it('should return students in correct order', async () => { + const { query } = setup(); + const response = await request(app.getHttpServer()) // + .get(`${basePath}`) + .query(query) + .set('Accept', 'application/json') + .expect(200); + + const { data, total } = response.body as UserListResponse; + + expect(total).toBe(2); + expect(data.length).toBe(2); + expect(data[0]._id).toBe(studentUser1._id.toString()); + expect(data[1]._id).toBe(studentUser2._id.toString()); + }); + }); + + describe('when sorting by classes', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(adminUser, adminAccount); + const query: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { classes: 1 }, + }; + + return { + query, + }; + }; + + it('should return students', async () => { + const { query } = setup(); + const response = await request(app.getHttpServer()) // + .get(`${basePath}`) + .query(query) + .set('Accept', 'application/json') + .expect(200); + + const { data, total } = response.body as UserListResponse; + + expect(total).toBe(2); + expect(data.length).toBe(2); + }); + }); + + describe('when sorting by consentStatus', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(adminUser, adminAccount); + const query: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { consentStatus: 1 }, + }; + + return { + query, + }; + }; + + it('should return students', async () => { + const { query } = setup(); + const response = await request(app.getHttpServer()) // + .get(`${basePath}`) + .query(query) + .set('Accept', 'application/json') + .expect(200); + + const { data, total } = response.body as UserListResponse; + + expect(total).toBe(2); + expect(data.length).toBe(2); + }); + }); + + describe('when searching for users by wrong params', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(adminUser, adminAccount); + const query: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { firstName: 1 }, + classes: ['1A'], + }; + + return { + query, + }; + }; + + it('should return empty list', async () => { + const { query } = setup(); + const response = await request(app.getHttpServer()) // + .get(`${basePath}`) + .query(query) + .set('Accept', 'application/json') + .expect(200); + + const { data, total } = response.body as UserListResponse; + + expect(total).toBe(0); + expect(data.length).toBe(0); + }); + }); + + describe('when user has no right permission', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(studentUser1, studentAccount1); + const query: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { firstName: 1 }, + }; + + return { + query, + }; + }; + + it('should reject request', async () => { + const { query } = setup(); + await request(app.getHttpServer()) // + .get(`${basePath}`) + .query(query) + .send() + .expect(403); + }); + }); + }); +}); diff --git a/apps/server/src/modules/user/legacy/controller/api-test/admin-api-teachers.api.spec.ts b/apps/server/src/modules/user/legacy/controller/api-test/admin-api-teachers.api.spec.ts new file mode 100644 index 00000000000..f65ae145869 --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/api-test/admin-api-teachers.api.spec.ts @@ -0,0 +1,210 @@ +import { EntityManager } from '@mikro-orm/core'; +import { ExecutionContext, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { User } from '@shared/domain/entity'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { + accountFactory, + mapUserToCurrentUser, + roleFactory, + schoolEntityFactory, + schoolYearFactory, + userFactory, +} from '@shared/testing'; +import { AccountEntity } from '@src/modules/account/entity/account.entity'; +import { ICurrentUser } from '@src/modules/authentication'; +import { JwtAuthGuard } from '@src/modules/authentication/guard/jwt-auth.guard'; +import { ServerTestModule } from '@src/modules/server/server.module'; +import { Request } from 'express'; +import request from 'supertest'; +import { UserListResponse, UserResponse, UsersSearchQueryParams } from '../dto'; + +describe('Users Admin Teachers Controller (API)', () => { + const basePath = '/users/admin/teachers'; + + let app: INestApplication; + let em: EntityManager; + + let adminAccount: AccountEntity; + let teacherAccount1: AccountEntity; + let teacherAccount2: AccountEntity; + + let adminUser: User; + let teacherUser1: User; + let teacherUser2: User; + + let currentUser: ICurrentUser; + + const defaultPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; + + const setupDb = async () => { + const currentYear = schoolYearFactory.withStartYear(2002).buildWithId(); + const school = schoolEntityFactory.buildWithId({ currentYear }); + + const adminRoles = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.TEACHER_LIST], + }); + const teacherRoles = roleFactory.build({ name: RoleName.TEACHER, permissions: [] }); + + adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); + teacherUser1 = userFactory.buildWithId({ + firstName: 'Marla', + school, + roles: [teacherRoles], + consent: {}, + }); + + teacherUser2 = userFactory.buildWithId({ + firstName: 'Test', + school, + roles: [teacherRoles], + consent: {}, + }); + + const mapUserToAccount = (user: User): AccountEntity => + accountFactory.buildWithId({ + userId: user.id, + username: user.email, + password: defaultPasswordHash, + }); + adminAccount = mapUserToAccount(adminUser); + teacherAccount1 = mapUserToAccount(teacherUser1); + teacherAccount2 = mapUserToAccount(teacherUser2); + + em.persist(school); + em.persist(currentYear); + em.persist([adminRoles, teacherRoles]); + em.persist([adminUser, teacherUser1, teacherUser2]); + em.persist([adminAccount, teacherAccount1, teacherAccount2]); + await em.flush(); + }; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ServerTestModule], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate(context: ExecutionContext) { + const req: Request = context.switchToHttp().getRequest(); + req.user = currentUser; + return true; + }, + }) + .compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + em = app.get(EntityManager); + + await setupDb(); + }); + + afterAll(async () => { + await app.close(); + em.clear(); + }); + + describe('[GET] :id', () => { + describe('when teacher exists', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(adminUser, adminAccount); + }; + + it('should return teacher ', async () => { + setup(); + const response = await request(app.getHttpServer()) // + .get(`${basePath}/${teacherUser1.id}`) + .expect(200); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { _id } = response.body as UserResponse; + + expect(_id).toBe(teacherUser1._id.toString()); + }); + }); + + describe('when user has no right permission', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(teacherUser1, teacherAccount1); + }; + + it('should reject request', async () => { + setup(); + await request(app.getHttpServer()) // + .get(`${basePath}/${teacherUser1.id}`) + .expect(403); + }); + }); + + describe('when teacher does not exists', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(adminUser, adminAccount); + }; + + it('should reject request ', async () => { + setup(); + await request(app.getHttpServer()) // + .get(`${basePath}/000000000000000000000000`) + .expect(404); + }); + }); + }); + + describe('[GET]', () => { + describe('when sort param is provided', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(adminUser, adminAccount); + const query: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { firstName: 1 }, + }; + + return { + query, + }; + }; + + it('should return teachers in correct order', async () => { + const { query } = setup(); + const response = await request(app.getHttpServer()) // + .get(`${basePath}`) + .query(query) + .set('Accept', 'application/json') + .expect(200); + + const { data, total } = response.body as UserListResponse; + + expect(total).toBe(2); + expect(data.length).toBe(2); + expect(data[0]._id).toBe(teacherUser1._id.toString()); + expect(data[1]._id).toBe(teacherUser2._id.toString()); + }); + }); + describe('when user has no right permission', () => { + const setup = () => { + currentUser = mapUserToCurrentUser(teacherUser1, teacherAccount1); + const query: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { firstName: 1 }, + }; + + return { + query, + }; + }; + + it('should reject request', async () => { + const { query } = setup(); + await request(app.getHttpServer()) // + .get(`${basePath}`) + .query(query) + .send() + .expect(403); + }); + }); + }); +}); diff --git a/apps/server/src/modules/user/legacy/controller/dto/class.response.ts b/apps/server/src/modules/user/legacy/controller/dto/class.response.ts new file mode 100644 index 00000000000..25b4100d737 --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/dto/class.response.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ClassResponse { + @ApiProperty() + name?: string; + + @ApiProperty() + gradeLevel?: number; +} diff --git a/apps/server/src/modules/user/legacy/controller/dto/consents.response.ts b/apps/server/src/modules/user/legacy/controller/dto/consents.response.ts new file mode 100644 index 00000000000..41a19d9669a --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/dto/consents.response.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { UserConsentResponse } from './user-consent.response'; +import { ParentConsentResponse } from './parent-consent.response'; + +export class ConsentsResponse { + constructor({ userConsent, parentConsents }: ConsentsResponse) { + this.userConsent = userConsent; + this.parentConsents = parentConsents + ? parentConsents.map((parentConsent) => new ParentConsentResponse(parentConsent)) + : undefined; + } + + @ApiProperty() + userConsent?: UserConsentResponse; + + @ApiProperty({ type: [ParentConsentResponse] }) + parentConsents?: ParentConsentResponse[]; +} diff --git a/apps/server/src/modules/user/legacy/controller/dto/index.ts b/apps/server/src/modules/user/legacy/controller/dto/index.ts new file mode 100644 index 00000000000..82bf5130649 --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/dto/index.ts @@ -0,0 +1,8 @@ +export * from './class.response'; +export * from './consents.response'; +export * from './parent-consent.response'; +export * from './user.response'; +export * from './user-by-id.params'; +export * from './user-consent.response'; +export * from './user-list.response'; +export * from './users-search.query.params'; diff --git a/apps/server/src/modules/user/legacy/controller/dto/parent-consent.response.ts b/apps/server/src/modules/user/legacy/controller/dto/parent-consent.response.ts new file mode 100644 index 00000000000..37c682d5217 --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/dto/parent-consent.response.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { UserConsentResponse } from './user-consent.response'; + +export class ParentConsentResponse extends UserConsentResponse { + constructor({ + _id, + form, + privacyConsent, + termsOfUseConsent, + dateOfPrivacyConsent, + dateOfTermsOfUseConsent, + }: ParentConsentResponse) { + super({ form, privacyConsent, termsOfUseConsent, dateOfPrivacyConsent, dateOfTermsOfUseConsent }); + this._id = _id.toString(); + } + + @ApiProperty() + _id: string; +} diff --git a/apps/server/src/modules/user/legacy/controller/dto/user-by-id.params.ts b/apps/server/src/modules/user/legacy/controller/dto/user-by-id.params.ts new file mode 100644 index 00000000000..1359b63b4d7 --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/dto/user-by-id.params.ts @@ -0,0 +1,12 @@ +import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UserByIdParams { + @IsString() + @ApiProperty({ + description: 'The id of the user.', + required: true, + nullable: false, + }) + id!: string; +} diff --git a/apps/server/src/modules/user/legacy/controller/dto/user-consent.response.ts b/apps/server/src/modules/user/legacy/controller/dto/user-consent.response.ts new file mode 100644 index 00000000000..206b1c5e2d9 --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/dto/user-consent.response.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UserConsentResponse { + constructor({ + form, + privacyConsent, + termsOfUseConsent, + dateOfPrivacyConsent, + dateOfTermsOfUseConsent, + }: UserConsentResponse) { + this.form = form; + this.privacyConsent = privacyConsent; + this.termsOfUseConsent = termsOfUseConsent; + this.dateOfPrivacyConsent = dateOfPrivacyConsent; + this.dateOfTermsOfUseConsent = dateOfTermsOfUseConsent; + } + + @ApiProperty() + form: string; + + @ApiProperty() + privacyConsent: boolean; + + @ApiProperty() + termsOfUseConsent: boolean; + + @ApiProperty() + dateOfPrivacyConsent: Date; + + @ApiProperty() + dateOfTermsOfUseConsent: Date; +} diff --git a/apps/server/src/modules/user/legacy/controller/dto/user-list.response.ts b/apps/server/src/modules/user/legacy/controller/dto/user-list.response.ts new file mode 100644 index 00000000000..0e805a14fd0 --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/dto/user-list.response.ts @@ -0,0 +1,13 @@ +import { PaginationResponse } from '@shared/controller'; +import { ApiProperty } from '@nestjs/swagger'; +import { UserResponse } from './user.response'; + +export class UserListResponse extends PaginationResponse { + constructor(response: UserListResponse) { + super(response.total, response.skip, response.limit); + this.data = response.data?.length > 0 ? response.data.map((user) => new UserResponse(user)) : []; + } + + @ApiProperty({ type: [UserResponse] }) + data: UserResponse[]; +} diff --git a/apps/server/src/modules/user/legacy/controller/dto/user.response.ts b/apps/server/src/modules/user/legacy/controller/dto/user.response.ts new file mode 100644 index 00000000000..5521b20f3f9 --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/dto/user.response.ts @@ -0,0 +1,74 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ConsentsResponse } from './consents.response'; +import { ClassResponse } from './class.response'; + +export class UserResponse { + constructor({ + _id, + firstName, + lastName, + email, + createdAt, + birthday, + preferences, + consentStatus, + consent, + classes, + importHash, + lastLoginSystemChange, + outdatedSince, + }: UserResponse) { + this._id = _id.toString(); + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.createdAt = createdAt; + this.birthday = birthday; + this.preferences = preferences; + this.consentStatus = consentStatus; + this.consent = consent ? new ConsentsResponse(consent) : undefined; + this.classes = classes; + this.importHash = importHash; + this.lastLoginSystemChange = lastLoginSystemChange; + this.outdatedSince = outdatedSince; + } + + @ApiProperty() + _id: string; + + @ApiProperty() + firstName: string; + + @ApiProperty() + lastName: string; + + @ApiProperty() + email: string; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + birthday?: Date; + + @ApiProperty() + preferences?: Record; + + @ApiProperty() + consentStatus: string; + + @ApiProperty() + consent?: ConsentsResponse; + + @ApiProperty({ type: [ClassResponse] }) + classes?: ClassResponse[]; + + @ApiProperty() + importHash?: string; + + @ApiProperty() + lastLoginSystemChange?: Date; + + @ApiProperty() + outdatedSince?: Date; +} diff --git a/apps/server/src/modules/user/legacy/controller/dto/users-search.query.params.ts b/apps/server/src/modules/user/legacy/controller/dto/users-search.query.params.ts new file mode 100644 index 00000000000..a83de8ead7f --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/dto/users-search.query.params.ts @@ -0,0 +1,51 @@ +import { PaginationParams } from '@shared/controller'; +import { IsInt, IsOptional, IsString, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { SortOrderNumberType } from '@shared/domain/interface'; + +export class UsersSearchQueryParams extends PaginationParams { + @IsInt() + @Min(0) + @ApiPropertyOptional({ description: 'Page limit, defaults to 25.' }) + $limit?: number = 25; + + @IsInt() + @Min(0) + @ApiPropertyOptional({ description: 'Number of elements (not pages) to be skipped' }) + $skip?: number = 0; + + @IsOptional() + @ApiPropertyOptional({ description: 'Sort parameter.' }) + $sort?: SortOrderNumberType; + + @IsOptional() + @ApiPropertyOptional() + consentStatus?: Record<'$in', string[]>; + + @IsOptional() + @ApiPropertyOptional() + classes?: string[]; + + @IsOptional() + @ApiPropertyOptional() + createdAt?: Record; + + @IsOptional() + @ApiPropertyOptional() + lastLoginSystemChange?: Record; + + @IsOptional() + @ApiPropertyOptional() + outdatedSince?: Record; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + searchQuery?: string; + + @IsOptional() + @ApiPropertyOptional() + users?: string[]; +} + +export type RangeType = '$gt' | '$gte' | '$lt' | '$lte'; diff --git a/apps/server/src/modules/user/legacy/controller/index.ts b/apps/server/src/modules/user/legacy/controller/index.ts new file mode 100644 index 00000000000..b6caead3641 --- /dev/null +++ b/apps/server/src/modules/user/legacy/controller/index.ts @@ -0,0 +1,2 @@ +export * from './admin-api-students.controller'; +export * from './admin-api-teachers.controller'; diff --git a/apps/server/src/modules/user/legacy/enum/index.ts b/apps/server/src/modules/user/legacy/enum/index.ts new file mode 100644 index 00000000000..7e022366ae3 --- /dev/null +++ b/apps/server/src/modules/user/legacy/enum/index.ts @@ -0,0 +1 @@ +export * from './requested-role.enum'; diff --git a/apps/server/src/modules/user/legacy/enum/requested-role.enum.ts b/apps/server/src/modules/user/legacy/enum/requested-role.enum.ts new file mode 100644 index 00000000000..0b1feaec8ca --- /dev/null +++ b/apps/server/src/modules/user/legacy/enum/requested-role.enum.ts @@ -0,0 +1,4 @@ +export enum RequestedRoleEnum { + STUDENTS = 'students', + TEACHERS = 'teachers', +} diff --git a/apps/server/src/modules/user/legacy/index.ts b/apps/server/src/modules/user/legacy/index.ts new file mode 100644 index 00000000000..8ba657ac428 --- /dev/null +++ b/apps/server/src/modules/user/legacy/index.ts @@ -0,0 +1,3 @@ +export { UsersAdminService } from './service'; +export { UserSearchQuery, UserSortQuery } from './interfaces'; +export * from './users-admin.module'; diff --git a/apps/server/src/modules/user/legacy/interfaces/index.ts b/apps/server/src/modules/user/legacy/interfaces/index.ts new file mode 100644 index 00000000000..b292216227f --- /dev/null +++ b/apps/server/src/modules/user/legacy/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './user-search.query'; +export * from './user-sort.query'; diff --git a/apps/server/src/modules/user/legacy/interfaces/user-search.query.ts b/apps/server/src/modules/user/legacy/interfaces/user-search.query.ts new file mode 100644 index 00000000000..09031398c47 --- /dev/null +++ b/apps/server/src/modules/user/legacy/interfaces/user-search.query.ts @@ -0,0 +1,20 @@ +import { ObjectId } from 'bson'; +import { UserSortQuery } from './user-sort.query'; + +export interface UserSearchQuery { + _id?: any; + schoolId: ObjectId; + roles: ObjectId; + schoolYearId?: ObjectId; + sort?: UserSortQuery; + select: string[]; + skip?: number; + limit?: number; + consentStatus?: Record; + classes?: string[]; + searchQuery?: string; + searchFilterGate?: number; + createdAt?: Date; + outdatedSince?: Date; + lastLoginSystemChange?: Date; +} diff --git a/apps/server/src/modules/user/legacy/interfaces/user-sort.query.ts b/apps/server/src/modules/user/legacy/interfaces/user-sort.query.ts new file mode 100644 index 00000000000..71e747a3bb6 --- /dev/null +++ b/apps/server/src/modules/user/legacy/interfaces/user-sort.query.ts @@ -0,0 +1,5 @@ +import { SortOrderNumberType } from '@shared/domain/interface'; + +export interface UserSortQuery extends SortOrderNumberType { + sortBySearchQueryResult?: number; +} diff --git a/apps/server/src/modules/user/legacy/repo/helper/aggregation-helper.spec.ts b/apps/server/src/modules/user/legacy/repo/helper/aggregation-helper.spec.ts new file mode 100644 index 00000000000..22502a2947e --- /dev/null +++ b/apps/server/src/modules/user/legacy/repo/helper/aggregation-helper.spec.ts @@ -0,0 +1,206 @@ +import { ObjectId } from 'bson'; +import { UserSearchQuery } from '../..'; +import { createMultiDocumentAggregation } from './aggregation-helper'; + +describe('Aggregation helper', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('when searching by users ids', () => { + const setup = () => { + const exampleId = '5fa31aacb229544f2c697b48'; + + const query: UserSearchQuery = { + skip: 0, + limit: 5, + sort: { firstName: 1 }, + _id: [exampleId], + schoolId: new ObjectId(exampleId), + schoolYearId: new ObjectId(exampleId), + roles: new ObjectId(exampleId), + select: [ + 'consentStatus', + 'consent', + 'classes', + 'firstName', + 'lastName', + 'email', + 'createdAt', + 'importHash', + 'birthday', + 'preferences.registrationMailSend', + 'lastLoginSystemChange', + 'outdatedSince', + ], + }; + + const matchStage = { + $match: { + _id: { $in: [new ObjectId(exampleId)] }, + roles: new ObjectId(exampleId), + schoolId: new ObjectId(exampleId), + }, + }; + + return { + query, + matchStage, + }; + }; + + it('should provide match for ids', () => { + const { query, matchStage } = setup(); + + const aggregation = createMultiDocumentAggregation(query); + + expect(aggregation).toEqual(expect.arrayContaining([expect.objectContaining(matchStage)])); + }); + }); + + describe('when searching by searchQuery', () => { + const setup = () => { + const exampleId = '5fa31aacb229544f2c697b48'; + + const query: UserSearchQuery = { + skip: 0, + limit: 5, + sort: { firstName: 1, sortBySearchQueryResult: 1 }, + searchQuery: 'test', + searchFilterGate: 9, + schoolId: new ObjectId(exampleId), + schoolYearId: new ObjectId(exampleId), + roles: new ObjectId(exampleId), + select: [ + 'consentStatus', + 'consent', + 'classes', + 'firstName', + 'lastName', + 'email', + 'createdAt', + 'importHash', + 'birthday', + 'preferences.registrationMailSend', + 'lastLoginSystemChange', + 'outdatedSince', + ], + }; + + return { + query, + exampleId, + }; + }; + + it('should provide match for score text search in aggregation', () => { + const { query } = setup(); + + const aggregation = createMultiDocumentAggregation(query); + + expect(aggregation).toEqual( + expect.arrayContaining([expect.objectContaining({ $match: { score: { $gte: 9 } } })]) + ); + }); + }); + + describe('when searching by classes', () => { + const setup = () => { + const exampleId = '5fa31aacb229544f2c697b48'; + + const query: UserSearchQuery = { + skip: 0, + limit: 5, + sort: { firstName: 1 }, + classes: ['test'], + schoolId: new ObjectId(exampleId), + schoolYearId: new ObjectId(exampleId), + roles: new ObjectId(exampleId), + select: [ + 'consentStatus', + 'consent', + 'classes', + 'firstName', + 'lastName', + 'email', + 'createdAt', + 'importHash', + 'birthday', + 'preferences.registrationMailSend', + 'lastLoginSystemChange', + 'outdatedSince', + ], + }; + + const classesLookupStage = { + $lookup: { + as: 'classes', + from: 'classes', + let: { id: '$_id' }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ['$schoolId', new ObjectId(exampleId)] }, + { + $and: [ + { + $or: [{ $eq: ['$year', new ObjectId(exampleId)] }, { $eq: [{ $type: '$year' }, 'missing'] }], + }, + { + $or: [{ $max: '$gradeLevel' }, { $eq: [{ $type: '$gradeLevel' }, 'missing'] }], + }, + ], + }, + { + $or: [{ $in: ['$$id', '$userIds'] }, { $in: ['$$id', '$teacherIds'] }], + }, + ], + }, + }, + }, + { + $sort: { + year: -1, + gradeLevel: -1, + name: 1, + }, + }, + { + $project: { + gradeLevel: { + $convert: { + input: '$gradeLevel', + to: 'string', + onNull: '', + }, + }, + name: { + $convert: { + input: '$name', + to: 'string', + onNull: '', + }, + }, + }, + }, + ], + }, + }; + + return { + query, + classesLookupStage, + }; + }; + + it('should provide lookup stage in aggregation', () => { + const { query, classesLookupStage } = setup(); + + const aggregation = createMultiDocumentAggregation(query); + + expect(aggregation).toEqual(expect.arrayContaining([expect.objectContaining(classesLookupStage)])); + }); + }); +}); diff --git a/src/services/user/utils/aggregations.js b/apps/server/src/modules/user/legacy/repo/helper/aggregation-helper.ts similarity index 80% rename from src/services/user/utils/aggregations.js rename to apps/server/src/modules/user/legacy/repo/helper/aggregation-helper.ts index 9c9834bdee9..229cd34e8d0 100644 --- a/src/services/user/utils/aggregations.js +++ b/apps/server/src/modules/user/legacy/repo/helper/aggregation-helper.ts @@ -1,7 +1,10 @@ -const { ObjectId } = require('mongoose').Types; +/* eslint-disable */ +// The code in this file is copied from the legacy part of the server. As it does not meet our ESLint rules, ESLint is disabled for the whole file. +import { ObjectId } from 'bson'; +import { UserSearchQuery } from '../../interfaces'; const convertToIn = (value) => { - let list = []; + let list: any[] = []; if (Array.isArray(value)) { list = value; } else if (Array.isArray(value.$in)) { @@ -32,19 +35,24 @@ const stageBaseFilter = (aggregation, attr, value) => { * Convert a "select"-array to an object to handle it with aggregaitons * @param {Array} select */ -const convertSelect = (select) => select.reduce((acc, curr) => ({ ...acc, [curr]: 1 }), {}); +const convertSelect = (select) => + select.reduce((acc, curr) => { + return { ...acc, [curr]: 1 }; + }, {}); /** * Creates an reducer to filter parent consent * @param {string} type - consent type */ -const getParentReducer = (type) => ({ - $reduce: { - input: '$consent.parentConsents', - initialValue: false, - in: { $or: ['$$value', `$$this.${type}`] }, - }, -}); +const getParentReducer = (type) => { + return { + $reduce: { + input: '$consent.parentConsents', + initialValue: false, + in: { $or: ['$$value', `$$this.${type}`] }, + }, + }; +}; /** * To sort by consentStatus, this stage convert the status message to an number. @@ -145,14 +153,6 @@ const getConsentStatusSwitch = () => { }; }; -const stageAddConsentStatus = (aggregation) => { - aggregation.push({ - $addFields: { - consentStatus: getConsentStatusSwitch(), - }, - }); -}; - /** * Convert Select array to and aggregation Project and adds consentStatus if part of select * @@ -162,9 +162,7 @@ const stageAddConsentStatus = (aggregation) => { const stageAddSelectProjectWithConsentCreate = (aggregation, select) => { const project = convertSelect(select); - if (select.includes('consentStatus')) { - project.consentStatus = getConsentStatusSwitch(); - } + project.consentStatus = getConsentStatusSwitch(); aggregation.push({ $project: project, @@ -180,7 +178,7 @@ const stageSimpleProject = (aggregation, select) => { }); }; -const stageLookupClasses = (aggregation, schoolId, schoolYearId) => { +const stageLookupClasses = (aggregation, schoolId: ObjectId, schoolYearId: ObjectId | unknown) => { aggregation.push({ $lookup: { from: 'classes', @@ -190,14 +188,11 @@ const stageLookupClasses = (aggregation, schoolId, schoolYearId) => { $match: { $expr: { $and: [ - { $eq: ['$schoolId', ObjectId(schoolId.toString())] }, + { $eq: ['$schoolId', schoolId] }, { $and: [ { - $or: [ - { $eq: ['$year', ObjectId(schoolYearId.toString())] }, - { $eq: [{ $type: '$year' }, 'missing'] }, - ], + $or: [{ $eq: ['$year', schoolYearId] }, { $eq: [{ $type: '$year' }, 'missing'] }], }, { $or: [{ $max: '$gradeLevel' }, { $eq: [{ $type: '$gradeLevel' }, 'missing'] }], @@ -266,9 +261,11 @@ const stageLookupClasses = (aggregation, schoolId, schoolYearId) => { const stageSort = (aggregation, sort) => { const mSort = {}; for (const k in sort) { - if (k === 'searchQuery') { + if (k === 'sortBySearchQueryResult') { + // @ts-ignore mSort.score = { $meta: 'textScore' }; } else if (k === 'consentStatus') { + // @ts-ignore mSort.consentSortParam = Number(sort[k]); stageAddConsentSortParam(aggregation); } else if (k === 'classes') { @@ -350,11 +347,11 @@ const stageFilterSearch = (aggregation, amount) => { /** * Creates an Array for an Aggregation pipeline and can handle, select, sort, limit, skip and matches. - * To filter or sort by consentStatus, it have also to be seleceted first. + * To filter or sort by consentStatus, it has also to be seleceted first. * * @param {{select: Array, sort: Object, limit: Int, skip: Int, ...matches}} param0 */ -const createMultiDocumentAggregation = ({ +export const createMultiDocumentAggregation = ({ select, sort, limit = 25, @@ -365,16 +362,12 @@ const createMultiDocumentAggregation = ({ searchQuery, searchFilterGate, ...match -}) => { - // eslint-disable-next-line no-param-reassign - limit = Number(limit); - // eslint-disable-next-line no-param-reassign - skip = Number(skip); +}: UserSearchQuery) => { if (typeof match._id === 'string') { - match._id = ObjectId(match._id); + match._id = new ObjectId(match._id); } else if (Array.isArray(match._id)) { // build "$in" Query - const convertToObjectIds = (inArray) => inArray.map((id) => ObjectId(id)); + const convertToObjectIds = (inArray: any[]) => inArray.map((id) => new ObjectId(id)); match._id = { $in: convertToObjectIds(convertToIn(match._id)) }; } @@ -383,12 +376,14 @@ const createMultiDocumentAggregation = ({ if (searchQuery) { // to sort by this value, add 'searchQuery' to sort value + // @ts-ignore match.$text = { - $search: searchQuery, + $search: searchQuery }; } if (match) { + // @ts-ignore aggregation.push({ $match: match, }); @@ -400,10 +395,7 @@ const createMultiDocumentAggregation = ({ if (select) { stageAddSelectProjectWithConsentCreate(aggregation, select.concat(selectSortDiff)); - if (select.includes('classes')) stageLookupClasses(aggregation, match.schoolId, schoolYearId); - } else { - stageAddConsentStatus(aggregation); - if (match.schoolId && schoolYearId) stageLookupClasses(aggregation, match.schoolId, schoolYearId); + stageLookupClasses(aggregation, match.schoolId, schoolYearId); } if (consentStatus) { @@ -418,18 +410,11 @@ const createMultiDocumentAggregation = ({ stageSort(aggregation, sort); } - // if (selectSortDiff.length !== 0) { - // TODO: think about doing it after limit and skip stageSimpleProject(aggregation, select); - // } - if (!match._id || Array.isArray(match._id.$in)) stageFormatWithTotal(aggregation, limit, skip); - return aggregation; -}; + if (!match?._id || Array.isArray(match?._id?.$in)) { + stageFormatWithTotal(aggregation, limit, skip); + } -module.exports = { - convertSelect, - getParentReducer, - convertToIn, - createMultiDocumentAggregation, + return aggregation; }; diff --git a/apps/server/src/modules/user/legacy/repo/helper/index.ts b/apps/server/src/modules/user/legacy/repo/helper/index.ts new file mode 100644 index 00000000000..b6cc7ea4220 --- /dev/null +++ b/apps/server/src/modules/user/legacy/repo/helper/index.ts @@ -0,0 +1,2 @@ +export * from './aggregation-helper'; +export * from './search-query-helper'; diff --git a/apps/server/src/modules/user/legacy/repo/helper/search-query-helper.spec.ts b/apps/server/src/modules/user/legacy/repo/helper/search-query-helper.spec.ts new file mode 100644 index 00000000000..f3d32c47920 --- /dev/null +++ b/apps/server/src/modules/user/legacy/repo/helper/search-query-helper.spec.ts @@ -0,0 +1,226 @@ +import { ObjectId } from 'bson'; +import { UserSearchQuery } from '../../interfaces'; +import { SearchQueryHelper } from '.'; +import { RangeType, UsersSearchQueryParams } from '../../controller/dto'; + +describe('Search query helper', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('setSearchParametersIfExist', () => { + describe('when search parameters exists', () => { + const setup = () => { + const exampleId = '5fa31aacb229544f2c697b48'; + + const queryParams: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { firstName: 1 }, + searchQuery: 'test', + }; + + const query: UserSearchQuery = { + skip: 0, + limit: 5, + sort: { firstName: 1 }, + schoolId: new ObjectId(exampleId), + schoolYearId: new ObjectId(exampleId), + roles: new ObjectId(exampleId), + select: [ + 'consentStatus', + 'consent', + 'classes', + 'firstName', + 'lastName', + 'email', + 'createdAt', + 'importHash', + 'birthday', + 'preferences.registrationMailSend', + 'lastLoginSystemChange', + 'outdatedSince', + ], + }; + + return { + queryParams, + query, + }; + }; + + it('should fill searchQuery and searchFilterGate', () => { + const { queryParams, query } = setup(); + + SearchQueryHelper.setSearchParametersIfExist(query, queryParams); + + expect(query.searchQuery).toEqual('test t te tes est'); + expect(query.searchFilterGate).toEqual(9); + expect(query.sort).toEqual({ firstName: 1, sortBySearchQueryResult: 1 }); + }); + }); + + describe('when search parameters do not exists', () => { + const setup = () => { + const exampleId = '5fa31aacb229544f2c697b48'; + + const queryParams: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { firstName: 1 }, + }; + + const query: UserSearchQuery = { + skip: 0, + limit: 5, + sort: { firstName: 1 }, + schoolId: new ObjectId(exampleId), + schoolYearId: new ObjectId(exampleId), + roles: new ObjectId(exampleId), + select: [ + 'consentStatus', + 'consent', + 'classes', + 'firstName', + 'lastName', + 'email', + 'createdAt', + 'importHash', + 'birthday', + 'preferences.registrationMailSend', + 'lastLoginSystemChange', + 'outdatedSince', + ], + }; + + return { + queryParams, + query, + }; + }; + + it('should not fill searchQuery and searchFilterGate', () => { + const { queryParams, query } = setup(); + + SearchQueryHelper.setSearchParametersIfExist(query, queryParams); + + expect(query.searchQuery).toBeUndefined(); + expect(query.searchFilterGate).toBeUndefined(); + expect(query.sort).toEqual({ firstName: 1 }); + }); + }); + }); + + describe('setDateParametersIfExists', () => { + describe('when date parameters exists', () => { + const setup = () => { + const exampleId = '5fa31aacb229544f2c697b48'; + + const dateParam: Record = { + $gt: new Date('2024-02-08T23:00:00Z'), + $gte: new Date('2024-02-08T23:00:00Z'), + $lt: new Date('2024-02-08T23:00:00Z'), + $lte: new Date('2024-02-08T23:00:00Z'), + }; + + const queryParams: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { firstName: 1 }, + createdAt: dateParam, + lastLoginSystemChange: dateParam, + outdatedSince: dateParam, + }; + + const query: UserSearchQuery = { + skip: 0, + limit: 5, + sort: { firstName: 1 }, + schoolId: new ObjectId(exampleId), + schoolYearId: new ObjectId(exampleId), + roles: new ObjectId(exampleId), + select: [ + 'consentStatus', + 'consent', + 'classes', + 'firstName', + 'lastName', + 'email', + 'createdAt', + 'importHash', + 'birthday', + 'preferences.registrationMailSend', + 'lastLoginSystemChange', + 'outdatedSince', + ], + }; + + return { + queryParams, + query, + dateParam, + }; + }; + + it('should fill date params', () => { + const { queryParams, query, dateParam } = setup(); + + SearchQueryHelper.setDateParametersIfExists(query, queryParams); + + expect(query.createdAt).toEqual(dateParam); + expect(query.lastLoginSystemChange).toEqual(dateParam); + expect(query.outdatedSince).toEqual(dateParam); + }); + }); + + describe('when date parameters do not exists', () => { + const setup = () => { + const exampleId = '5fa31aacb229544f2c697b48'; + + const queryParams: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { firstName: 1 }, + }; + + const query: UserSearchQuery = { + skip: 0, + limit: 5, + sort: { firstName: 1 }, + schoolId: new ObjectId(exampleId), + schoolYearId: new ObjectId(exampleId), + roles: new ObjectId(exampleId), + select: [ + 'consentStatus', + 'consent', + 'classes', + 'firstName', + 'lastName', + 'email', + 'createdAt', + 'importHash', + 'birthday', + 'preferences.registrationMailSend', + 'lastLoginSystemChange', + 'outdatedSince', + ], + }; + + return { + queryParams, + query, + }; + }; + + it('should not fill date params', () => { + const { queryParams, query } = setup(); + + SearchQueryHelper.setDateParametersIfExists(query, queryParams); + + expect(query.createdAt).toBeUndefined(); + expect(query.lastLoginSystemChange).toBeUndefined(); + expect(query.outdatedSince).toBeUndefined(); + }); + }); + }); +}); diff --git a/apps/server/src/modules/user/legacy/repo/helper/search-query-helper.ts b/apps/server/src/modules/user/legacy/repo/helper/search-query-helper.ts new file mode 100644 index 00000000000..e05a29e4015 --- /dev/null +++ b/apps/server/src/modules/user/legacy/repo/helper/search-query-helper.ts @@ -0,0 +1,43 @@ +import { UserSearchQuery } from '../../interfaces'; +import { UsersSearchQueryParams } from '../../controller/dto'; + +export class SearchQueryHelper { + public static setSearchParametersIfExist(query: UserSearchQuery, params?: UsersSearchQueryParams) { + if (params?.searchQuery && params.searchQuery.trim().length !== 0) { + const amountOfSearchWords = params.searchQuery.split(' ').length; + const searchQueryElements = this.splitForSearchIndexes(params.searchQuery.trim()); + query.searchQuery = `${params.searchQuery} ${searchQueryElements.join(' ')}`; + // increase gate by searched word, to get better results + query.searchFilterGate = searchQueryElements.length * 2 + amountOfSearchWords; + // recreating sort here, to set searchQuery as first (main) parameter of sorting + query.sort = { + ...query.sort, + sortBySearchQueryResult: 1, + }; + } + } + + public static setDateParametersIfExists(query: UserSearchQuery, params: UsersSearchQueryParams) { + const dateParameters = ['createdAt', 'outdatedSince', 'lastLoginSystemChange']; + for (const dateParam of dateParameters) { + if (params[dateParam]) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + query[dateParam] = params[dateParam]; + } + } + } + + private static splitForSearchIndexes(...searchTexts: string[]) { + const arr: string[] = []; + searchTexts.forEach((item) => { + item.split(/[\s-]/g).forEach((it) => { + if (it.length === 0) return; + + arr.push(it.slice(0, 1)); + if (it.length > 1) arr.push(it.slice(0, 2)); + for (let i = 0; i < it.length - 2; i += 1) arr.push(it.slice(i, i + 3)); + }); + }); + return arr; + } +} diff --git a/apps/server/src/modules/user/legacy/repo/index.ts b/apps/server/src/modules/user/legacy/repo/index.ts new file mode 100644 index 00000000000..cde8e33c8ee --- /dev/null +++ b/apps/server/src/modules/user/legacy/repo/index.ts @@ -0,0 +1 @@ +export * from './users-admin.repo'; diff --git a/apps/server/src/modules/user/legacy/repo/users-admin.repo.spec.ts b/apps/server/src/modules/user/legacy/repo/users-admin.repo.spec.ts new file mode 100644 index 00000000000..d8a9f7a6a70 --- /dev/null +++ b/apps/server/src/modules/user/legacy/repo/users-admin.repo.spec.ts @@ -0,0 +1,299 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { EntityManager } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Role, SchoolEntity, SchoolYearEntity, User } from '@shared/domain/entity'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { accountFactory, roleFactory, schoolEntityFactory, schoolYearFactory, userFactory } from '@shared/testing'; +import { AccountEntity } from '@src/modules/account/entity/account.entity'; +import { classEntityFactory } from '../../../class/entity/testing'; +import { UserListResponse, UserResponse, UsersSearchQueryParams } from '../controller/dto'; +import { UsersAdminRepo } from './users-admin.repo'; + +describe('users admin repo', () => { + let module: TestingModule; + let repo: UsersAdminRepo; + + let em: EntityManager; + + let adminAccount: AccountEntity; + let studentAccount1: AccountEntity; + let studentAccount2: AccountEntity; + + let adminUser: User; + let studentUser1: User; + let studentUser2: User; + + let studentRole: Role; + let currentYear: SchoolYearEntity; + let school: SchoolEntity; + + const defaultPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; + + const setupDb = async () => { + currentYear = schoolYearFactory.withStartYear(2002).buildWithId(); + school = schoolEntityFactory.buildWithId({ currentYear }); + + const adminRoles = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [Permission.STUDENT_LIST], + }); + studentRole = roleFactory.build({ name: RoleName.STUDENT, permissions: [] }); + + adminUser = userFactory.buildWithId({ school, roles: [adminRoles] }); + studentUser1 = userFactory.buildWithId({ + firstName: 'Marla', + school, + roles: [studentRole], + consent: { + userConsent: { + form: 'digital', + privacyConsent: true, + termsOfUseConsent: true, + dateOfPrivacyConsent: new Date('2017-01-01T00:06:37.148Z'), + dateOfTermsOfUseConsent: new Date('2017-01-01T00:06:37.148Z'), + }, + parentConsents: [ + { + _id: new ObjectId('5fa31aacb229544f2c697b48'), + form: 'digital', + privacyConsent: true, + termsOfUseConsent: true, + dateOfPrivacyConsent: new Date('2017-01-01T00:06:37.148Z'), + dateOfTermsOfUseConsent: new Date('2017-01-01T00:06:37.148Z'), + }, + ], + }, + }); + + studentUser2 = userFactory.buildWithId({ + firstName: 'Test', + school, + roles: [studentRole], + consent: { + userConsent: { + form: 'digital', + privacyConsent: true, + termsOfUseConsent: true, + dateOfPrivacyConsent: new Date('2017-01-01T00:06:37.148Z'), + dateOfTermsOfUseConsent: new Date('2017-01-01T00:06:37.148Z'), + }, + }, + }); + + const studentClass = classEntityFactory.buildWithId({ + name: 'Group A', + schoolId: school.id, + year: currentYear.id, + userIds: [studentUser1._id], + gradeLevel: 12, + }); + + const mapUserToAccount = (user: User): AccountEntity => + accountFactory.buildWithId({ + userId: user.id, + username: user.email, + password: defaultPasswordHash, + }); + adminAccount = mapUserToAccount(adminUser); + studentAccount1 = mapUserToAccount(studentUser1); + studentAccount2 = mapUserToAccount(studentUser2); + + em.persist(school); + em.persist(currentYear); + em.persist([adminRoles, studentRole]); + em.persist([adminUser, studentUser1, studentUser2]); + em.persist([adminAccount, studentAccount1, studentAccount2]); + em.persist(studentClass); + await em.flush(); + }; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [UsersAdminRepo], + }).compile(); + repo = module.get(UsersAdminRepo); + em = module.get(EntityManager); + + await setupDb(); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should be defined', () => { + expect(repo).toBeDefined(); + expect(typeof repo.getUserByIdWithNestedData).toEqual('function'); + expect(typeof repo.getUsersWithNestedData).toEqual('function'); + }); + + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(User); + }); + + describe('when student exists', () => { + it('should return student ', async () => { + const response = await repo.getUserByIdWithNestedData(studentRole.id, school.id, currentYear.id, studentUser1.id); + + const userResponse = response as UserResponse[]; + + expect(userResponse[0]._id.toString()).toBe(studentUser1._id.toString()); + expect(userResponse[0].firstName).toBe(studentUser1.firstName); + expect(userResponse[0].lastName).toBe(studentUser1.lastName); + expect(userResponse[0].consentStatus).toBe('ok'); + }); + }); + + describe('when sort param is provided', () => { + const setup = () => { + const query: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { firstName: 1 }, + }; + + return { + query, + }; + }; + + it('should return students in correct order', async () => { + const { query } = setup(); + const response = await repo.getUsersWithNestedData(studentRole.id, school.id, currentYear.id, query); + + const userListResponse = response as UserListResponse[]; + const data = userListResponse[0].data; + + expect(userListResponse[0].total).toBe(2); + expect(data.length).toBe(2); + expect(data[0]._id.toString()).toBe(studentUser1._id.toString()); + expect(data[1]._id.toString()).toBe(studentUser2._id.toString()); + }); + }); + + describe('when sorting by classes', () => { + const setup = () => { + const query: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { classes: 1 }, + }; + + return { + query, + }; + }; + + it('should return students', async () => { + const { query } = setup(); + const response = await repo.getUsersWithNestedData(studentRole.id, school.id, currentYear.id, query); + + const userListResponse = response as UserListResponse[]; + const data = userListResponse[0].data; + + expect(userListResponse[0].total).toBe(2); + expect(data.length).toBe(2); + }); + }); + + describe('when sorting by consentStatus', () => { + const setup = () => { + const query: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { consentStatus: 1 }, + }; + + return { + query, + }; + }; + + it('should return students', async () => { + const { query } = setup(); + const response = await repo.getUsersWithNestedData(studentRole.id, school.id, currentYear.id, query); + + const userListResponse = response as UserListResponse[]; + const data = userListResponse[0].data; + + expect(userListResponse[0].total).toBe(2); + expect(data.length).toBe(2); + }); + }); + + describe('when search params are too tight', () => { + const setup = () => { + const query: UsersSearchQueryParams = { + $skip: 0, + $limit: 5, + $sort: { firstName: 1 }, + classes: ['1A', '2A'], + consentStatus: { $in: ['ok', 'parentsAgreed', 'missing'] }, + createdAt: { + $gt: new Date('2024-02-08T23:00:00Z'), + $gte: new Date('2024-02-08T23:00:00Z'), + $lt: new Date('2024-02-08T23:00:00Z'), + $lte: new Date('2024-02-08T23:00:00Z'), + }, + lastLoginSystemChange: { + $gt: new Date('2024-02-08T23:00:00Z'), + $gte: new Date('2024-02-08T23:00:00Z'), + $lt: new Date('2024-02-08T23:00:00Z'), + $lte: new Date('2024-02-08T23:00:00Z'), + }, + outdatedSince: { + $gt: new Date('2024-02-08T23:00:00Z'), + $gte: new Date('2024-02-08T23:00:00Z'), + $lt: new Date('2024-02-08T23:00:00Z'), + $lte: new Date('2024-02-08T23:00:00Z'), + }, + }; + + return { + query, + }; + }; + + it('should return empty list', async () => { + const { query } = setup(); + const response = await repo.getUsersWithNestedData(studentRole.id, school.id, currentYear.id, query); + + const userListResponse = response as UserListResponse[]; + const data = userListResponse[0].data; + + expect(userListResponse[0].total).toBe(0); + expect(data.length).toBe(0); + }); + }); + + describe('when skip params are too big', () => { + const setup = () => { + const query: UsersSearchQueryParams = { + $skip: 50000, + $limit: 5, + $sort: { firstName: 1 }, + }; + + return { + query, + }; + }; + + it('should return empty list', async () => { + const { query } = setup(); + const response = await repo.getUsersWithNestedData(studentRole.id, school.id, currentYear.id, query); + + const userListResponse = response as UserListResponse[]; + const data = userListResponse[0].data; + + expect(data.length).toBe(0); + expect(userListResponse[0].total).toBe(2); + }); + }); +}); diff --git a/apps/server/src/modules/user/legacy/repo/users-admin.repo.ts b/apps/server/src/modules/user/legacy/repo/users-admin.repo.ts new file mode 100644 index 00000000000..3ea72b8b399 --- /dev/null +++ b/apps/server/src/modules/user/legacy/repo/users-admin.repo.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; +import { User } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; +import { BaseRepo } from '@shared/repo/base.repo'; +import { ObjectId } from 'bson'; +import { createMultiDocumentAggregation, SearchQueryHelper } from './helper'; +import { UsersSearchQueryParams } from '../controller/dto'; +import { UserSearchQuery } from '../interfaces'; + +@Injectable() +export class UsersAdminRepo extends BaseRepo { + get entityName() { + return User; + } + + async getUserByIdWithNestedData( + roleId: string | undefined, + schoolId: EntityId, + schoolYearId: EntityId | undefined, + _id?: string + ): Promise { + const query: UserSearchQuery = { + limit: undefined, + skip: undefined, + sort: undefined, + _id, + schoolId: new ObjectId(schoolId), + roles: new ObjectId(roleId), + schoolYearId: new ObjectId(schoolYearId), + select: [ + 'consentStatus', + 'consent', + 'classes', + 'firstName', + 'lastName', + 'email', + 'createdAt', + 'importHash', + 'birthday', + 'preferences.registrationMailSend', + 'lastLoginSystemChange', + 'outdatedSince', + ], + }; + + const aggregation = createMultiDocumentAggregation(query); + + return this._em.aggregate(User, aggregation); + } + + async getUsersWithNestedData( + roleId: string | undefined, + schoolId: EntityId, + schoolYearId: EntityId | undefined, + params: UsersSearchQueryParams + ): Promise { + const query: UserSearchQuery = { + schoolId: new ObjectId(schoolId), + roles: new ObjectId(roleId), + schoolYearId: new ObjectId(schoolYearId), + select: [ + 'consentStatus', + 'consent', + 'classes', + 'firstName', + 'lastName', + 'email', + 'createdAt', + 'importHash', + 'birthday', + 'preferences.registrationMailSend', + 'lastLoginSystemChange', + 'outdatedSince', + ], + skip: params?.$skip ?? params?.skip, + limit: params?.$limit ?? params?.limit, + }; + + if (params?.users) query._id = params.users; + if (params?.consentStatus) query.consentStatus = params.consentStatus; + if (params?.classes) query.classes = params.classes; + if (params.$sort) { + query.sort = { + ...params?.$sort, + }; + } + SearchQueryHelper.setSearchParametersIfExist(query, params); + SearchQueryHelper.setDateParametersIfExists(query, params); + + const aggregation = createMultiDocumentAggregation(query); + + return this._em.aggregate(User, aggregation); + } +} diff --git a/apps/server/src/modules/user/legacy/service/index.ts b/apps/server/src/modules/user/legacy/service/index.ts new file mode 100644 index 00000000000..bc7f54f472e --- /dev/null +++ b/apps/server/src/modules/user/legacy/service/index.ts @@ -0,0 +1 @@ +export * from './users-admin.service'; diff --git a/apps/server/src/modules/user/legacy/service/users-admin.service.ts b/apps/server/src/modules/user/legacy/service/users-admin.service.ts new file mode 100644 index 00000000000..ca8eecc1989 --- /dev/null +++ b/apps/server/src/modules/user/legacy/service/users-admin.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { Logger } from '@src/core/logger'; +import { User } from '@shared/domain/entity'; +import { EntityNotFoundError } from '@shared/common'; +import { UsersAdminRepo } from '../repo'; +import { UserListResponse, UserResponse, UsersSearchQueryParams } from '../controller/dto'; + +@Injectable() +export class UsersAdminService { + constructor(private readonly usersAdminRepo: UsersAdminRepo, private readonly logger: Logger) { + this.logger.setContext(UsersAdminService.name); + } + + async getUsersWithNestedData( + roleId: string | undefined, + schoolId: EntityId, + schoolYearId: EntityId | undefined, + params: UsersSearchQueryParams + ): Promise { + const usersResponse = (await this.usersAdminRepo.getUsersWithNestedData( + roleId, + schoolId, + schoolYearId, + params + )) as UserListResponse[]; + return new UserListResponse(usersResponse[0]); + } + + async getUserWithNestedData( + roleId: string | undefined, + schoolId: EntityId, + schoolYearId: EntityId | undefined, + userId?: string + ): Promise { + const user = (await this.usersAdminRepo.getUserByIdWithNestedData( + roleId, + schoolId, + schoolYearId, + userId + )) as UserResponse[]; + if (user.length < 1) { + throw new EntityNotFoundError(User.name); + } + return new UserResponse(user[0]); + } +} diff --git a/apps/server/src/modules/user/legacy/uc/index.ts b/apps/server/src/modules/user/legacy/uc/index.ts new file mode 100644 index 00000000000..f3df2ab9bcd --- /dev/null +++ b/apps/server/src/modules/user/legacy/uc/index.ts @@ -0,0 +1 @@ +export * from './users-admin-api.uc'; diff --git a/apps/server/src/modules/user/legacy/uc/users-admin-api.uc.ts b/apps/server/src/modules/user/legacy/uc/users-admin-api.uc.ts new file mode 100644 index 00000000000..9ad82052448 --- /dev/null +++ b/apps/server/src/modules/user/legacy/uc/users-admin-api.uc.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { User } from '@shared/domain/entity'; +import { Permission, RoleName } from '@shared/domain/interface'; +import { UserRepo } from '@shared/repo'; +import { RoleService } from '../../../role'; +import { + AuthorizableReferenceType, + AuthorizationContextBuilder, + AuthorizationService, + ForbiddenLoggableException, +} from '../../../authorization'; +import { RequestedRoleEnum } from '../enum'; +import { UserByIdParams, UserListResponse, UserResponse, UsersSearchQueryParams } from '../controller/dto'; +import { UsersAdminService } from '../service'; + +@Injectable() +export class UsersAdminApiUc { + constructor( + private readonly userRepo: UserRepo, + private readonly roleService: RoleService, + private readonly adminUsersService: UsersAdminService, + private readonly authorizationService: AuthorizationService + ) {} + + public async findUsersByParams( + requestedRole: RequestedRoleEnum, + currentUserId: string, + params: UsersSearchQueryParams + ): Promise { + const currentUser = await this.userRepo.findById(currentUserId, true); + this.validateAccessToContext(requestedRole, currentUser); + const { school } = currentUser; + const currentSchoolYear = school.currentYear; + const currentSchoolYearId = currentSchoolYear?.id; + const role = await this.getRoleForRequestedRole(requestedRole); + + return this.adminUsersService.getUsersWithNestedData(role?.id, school.id, currentSchoolYearId, params); + } + + public async findUserById( + requestedRole: RequestedRoleEnum, + currentUserId: string, + params: UserByIdParams + ): Promise { + const currentUser = await this.userRepo.findById(currentUserId, true); + this.validateAccessToContext(requestedRole, currentUser); + const { school } = currentUser; + const currentSchoolYearId = school.currentYear?.id; + const role = await this.getRoleForRequestedRole(requestedRole); + + return this.adminUsersService.getUserWithNestedData(role?.id, school.id, currentSchoolYearId, params.id); + } + + private validateAccessToContext(context: RequestedRoleEnum, currentUser: User) { + const permission = this.getPermissionForRequestedRole(context); + try { + this.authorizationService.checkAllPermissions(currentUser, [permission]); + } catch (e) { + // temporary fix for the problem with checkAllPermissions method (throws UnauthorizedException instead of ForbiddenLoggableException) + const permissionContext = AuthorizationContextBuilder.read([permission]); + throw new ForbiddenLoggableException(currentUser.id, AuthorizableReferenceType.User, permissionContext); + } + } + + private getPermissionForRequestedRole(requestedRole: RequestedRoleEnum) { + if (requestedRole === RequestedRoleEnum.TEACHERS) { + return Permission.TEACHER_LIST; + } + + return Permission.STUDENT_LIST; + } + + private getRoleForRequestedRole(requestedRole: RequestedRoleEnum) { + if (requestedRole === RequestedRoleEnum.TEACHERS) { + return this.roleService.findByName(RoleName.TEACHER); + } + + return this.roleService.findByName(RoleName.STUDENT); + } +} diff --git a/apps/server/src/modules/user/legacy/users-admin-api.module.ts b/apps/server/src/modules/user/legacy/users-admin-api.module.ts new file mode 100644 index 00000000000..25d49af90dc --- /dev/null +++ b/apps/server/src/modules/user/legacy/users-admin-api.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { UserRepo } from '@shared/repo'; +import { RoleModule } from '../../role'; +import { AuthorizationModule } from '../../authorization'; +import { AdminApiStudentsController, AdminApiTeachersController } from './controller'; +import { UsersAdminApiUc } from './uc'; +import { UsersAdminModule } from './users-admin.module'; + +@Module({ + imports: [UsersAdminModule, RoleModule, AuthorizationModule], + controllers: [AdminApiStudentsController, AdminApiTeachersController], + providers: [UserRepo, UsersAdminApiUc], + exports: [], +}) +export class UsersAdminApiModule {} diff --git a/apps/server/src/modules/user/legacy/users-admin.module.ts b/apps/server/src/modules/user/legacy/users-admin.module.ts new file mode 100644 index 00000000000..0513e7af57a --- /dev/null +++ b/apps/server/src/modules/user/legacy/users-admin.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; +import { UserRepo } from '@shared/repo'; +import { UsersAdminService } from './service'; +import { UsersAdminRepo } from './repo'; + +@Module({ + imports: [LoggerModule], + providers: [UserRepo, UsersAdminService, UsersAdminRepo], + exports: [UsersAdminService], +}) +export class UsersAdminModule {} diff --git a/apps/server/src/modules/user/mapper/resolved-user.mapper.ts b/apps/server/src/modules/user/mapper/resolved-user.mapper.ts index a6518b23a72..9c26b49ba32 100644 --- a/apps/server/src/modules/user/mapper/resolved-user.mapper.ts +++ b/apps/server/src/modules/user/mapper/resolved-user.mapper.ts @@ -9,7 +9,7 @@ export class ResolvedUserMapper { dto.lastName = user.lastName; dto.createdAt = user.createdAt; dto.updatedAt = user.updatedAt; - dto.schoolId = user.school.toString(); + dto.schoolId = user.school.id; dto.roles = roles.map((role) => { return { name: role.name, id: role.id }; }); diff --git a/apps/server/src/modules/user/service/index.ts b/apps/server/src/modules/user/service/index.ts new file mode 100644 index 00000000000..e17ee5c7aca --- /dev/null +++ b/apps/server/src/modules/user/service/index.ts @@ -0,0 +1 @@ +export * from './user.service'; diff --git a/apps/server/src/modules/user/service/user.service.spec.ts b/apps/server/src/modules/user/service/user.service.spec.ts index 4ef690fe99f..3b8816bf1bd 100644 --- a/apps/server/src/modules/user/service/user.service.spec.ts +++ b/apps/server/src/modules/user/service/user.service.spec.ts @@ -1,22 +1,35 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { EntityManager } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; -import { AccountDto, AccountService } from '@modules/account'; +import { AccountService, Account } from '@modules/account'; import { OauthCurrentUser } from '@modules/authentication/interface'; import { RoleService } from '@modules/role'; +import { NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { UserDO } from '@shared/domain/domainobject/user.do'; -import { LanguageType, Role, User } from '@shared/domain/entity'; -import { IFindOptions, Permission, RoleName, SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; +import { Role, User } from '@shared/domain/entity'; +import { IFindOptions, LanguageType, Permission, RoleName, SortOrder } from '@shared/domain/interface'; import { UserRepo } from '@shared/repo'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; import { roleFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; import { Logger } from '@src/core/logger'; -import { UserDto } from '../uc/dto/user.dto'; -import { UserQuery } from './user-query.type'; +import { EventBus } from '@nestjs/cqrs'; +import { RegistrationPinService } from '@modules/registration-pin'; +import { + DomainDeletionReportBuilder, + DomainName, + DomainOperationReportBuilder, + OperationType, + DataDeletedEvent, + DeletionErrorLoggableException, +} from '@modules/deletion'; +import { deletionRequestFactory } from '@modules/deletion/domain/testing'; +import { CalendarService } from '@src/infra/calendar'; import { UserService } from './user.service'; +import { UserQuery } from './user-query.type'; +import { UserDto } from '../uc/dto/user.dto'; describe('UserService', () => { let service: UserService; @@ -27,6 +40,9 @@ describe('UserService', () => { let config: DeepMocked; let roleService: DeepMocked; let accountService: DeepMocked; + let registrationPinService: DeepMocked; + let calendarService: DeepMocked; + let eventBus: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -56,10 +72,24 @@ describe('UserService', () => { provide: AccountService, useValue: createMock(), }, + { + provide: RegistrationPinService, + useValue: createMock(), + }, { provide: Logger, useValue: createMock(), }, + { + provide: EventBus, + useValue: { + publish: jest.fn(), + }, + }, + { + provide: CalendarService, + useValue: createMock(), + }, ], }).compile(); service = module.get(UserService); @@ -69,6 +99,9 @@ describe('UserService', () => { config = module.get(ConfigService); roleService = module.get(RoleService); accountService = module.get(AccountService); + registrationPinService = module.get(RegistrationPinService); + eventBus = module.get(EventBus); + calendarService = module.get(CalendarService); await setupEntities(); }); @@ -102,6 +135,45 @@ describe('UserService', () => { }); }); + describe('getUserEntityWithRoles', () => { + describe('when user with roles exists', () => { + const setup = () => { + const roles = roleFactory.buildListWithId(2); + const user = userFactory.buildWithId({ roles }); + + userRepo.findById.mockResolvedValueOnce(user); + + return { user, userId: user.id }; + }; + + it('should return the user with included roles', async () => { + const { user, userId } = setup(); + + const result = await service.getUserEntityWithRoles(userId); + + expect(result).toEqual(user); + expect(result.getRoles()).toHaveLength(2); + }); + }); + + describe('when repo throws an error', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const error = new NotFoundException(); + + userRepo.findById.mockRejectedValueOnce(error); + + return { userId, error }; + }; + + it('should throw an error', async () => { + const { userId, error } = setup(); + + await expect(() => service.getUserEntityWithRoles(userId)).rejects.toThrowError(error); + }); + }); + }); + describe('getUser', () => { let user: User; @@ -192,7 +264,7 @@ describe('UserService', () => { permissions: [Permission.DASHBOARD_VIEW], }); const user: UserDO = userDoFactory.buildWithId({ roles: [role] }); - const account: AccountDto = new AccountDto({ + const account: Account = new Account({ id: 'accountId', systemId, username: 'username', @@ -386,47 +458,233 @@ describe('UserService', () => { }); }); - describe('deleteUser', () => { + describe('removeUserRegistrationPin', () => { + describe('when registrationPinService.deleteUserData return DomainDeletionReport', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userId = user.id; + const userRegistrationPinId = new ObjectId().toHexString(); + + const results = [ + DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [userRegistrationPinId]), + ]), + ]; + + const expectedResult = DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [userRegistrationPinId]), + ]); + + userRepo.findByIdOrNull.mockResolvedValueOnce(user); + userRepo.getParentEmailsFromUser.mockResolvedValueOnce([]); + registrationPinService.deleteUserData.mockResolvedValue(results[0]); + + return { + expectedResult, + userId, + user, + }; + }; + + it('should return domainOperation object with information about deleted registrationsPin', async () => { + const { userId, expectedResult } = setup(); + + const result = await service.removeUserRegistrationPin(userId); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('when no emails for registrationPin found', () => { + const setup = () => { + const user = userFactory.buildWithId({ email: undefined }); + const userId = user.id; + + const expectedResult = DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 0, []), + ]); + + userRepo.findByIdOrNull.mockResolvedValueOnce(user); + userRepo.getParentEmailsFromUser.mockResolvedValueOnce([]); + + return { + expectedResult, + userId, + user, + }; + }; + + it('should return domainOperation object with proper information: count=0, and empty refs array', async () => { + const { userId, expectedResult } = setup(); + + const result = await service.removeUserRegistrationPin(userId); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('removeCalendarEvents', () => { + describe('when calendarService.deleteUserData return DomainDeletionReport', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const userId = user.id; + const deletedEventId = new ObjectId().toHexString(); + + const results = [ + DomainDeletionReportBuilder.build(DomainName.CALENDAR, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [deletedEventId]), + ]), + ]; + + const expectedResult = DomainDeletionReportBuilder.build(DomainName.CALENDAR, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [deletedEventId]), + ]); + + calendarService.deleteUserData.mockResolvedValue(results[0]); + + return { + expectedResult, + userId, + user, + }; + }; + + it('should return domainOperation object with information about deleted calendarEvents', async () => { + const { userId, expectedResult } = setup(); + + const result = await service.removeCalendarEvents(userId); + + expect(result).toEqual(expectedResult); + }); + }); + }); + + describe('deleteUserData', () => { describe('when user is missing', () => { const setup = () => { - const user: UserDO = userDoFactory.build({ id: undefined }); - const userId: EntityId = user.id as EntityId; + const user: User = userFactory.buildWithId(); + const userId: EntityId = user.id; + userRepo.findByIdOrNull.mockResolvedValueOnce(null); userRepo.deleteUser.mockResolvedValue(0); + const expectedResult = DomainDeletionReportBuilder.build(DomainName.USER, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 0, []), + ]); + return { + expectedResult, userId, }; }; - it('should return 0', async () => { + it('should call userRepo.findByIdOrNull with userId', async () => { const { userId } = setup(); - const result = await service.deleteUser(userId); + await service.deleteUserData(userId); + + expect(userRepo.findByIdOrNull).toHaveBeenCalledWith(userId, true); + }); + + it('should return domainOperation object with information about deleted user', async () => { + const { expectedResult, userId } = setup(); + + const result = await service.deleteUserData(userId); - expect(result).toEqual(0); + expect(result).toEqual(expectedResult); + }); + + it('should Not call userRepo.deleteUser with userId', async () => { + const { userId } = setup(); + + await service.deleteUserData(userId); + + expect(userRepo.deleteUser).not.toHaveBeenCalled(); }); }); - describe('when deleting by userId', () => { + describe('when user exists', () => { const setup = () => { - const user1: User = userFactory.asStudent().buildWithId(); + const user = userFactory.buildWithId(); + + const registrationPinDeleted = DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [new ObjectId().toHexString()]), + ]); - userRepo.findById.mockResolvedValue(user1); + const calendarEventsDeleted = DomainDeletionReportBuilder.build(DomainName.CALENDAR, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [new ObjectId().toHexString()]), + ]); + + const expectedResult = DomainDeletionReportBuilder.build( + DomainName.USER, + [DomainOperationReportBuilder.build(OperationType.DELETE, 1, [user.id])], + [registrationPinDeleted, calendarEventsDeleted] + ); + + jest.spyOn(service, 'removeUserRegistrationPin').mockResolvedValueOnce(registrationPinDeleted); + jest.spyOn(service, 'removeCalendarEvents').mockResolvedValueOnce(calendarEventsDeleted); + + userRepo.findByIdOrNull.mockResolvedValueOnce(user); userRepo.deleteUser.mockResolvedValue(1); return { - user1, + expectedResult, + user, }; }; - it('should delete user by userId', async () => { - const { user1 } = setup(); + it('should call userRepo.findByIdOrNull with userId', async () => { + const { user } = setup(); + + await service.deleteUserData(user.id); + + expect(userRepo.findByIdOrNull).toHaveBeenCalledWith(user.id, true); + }); + + it('should call userRepo.deleteUser with userId', async () => { + const { user } = setup(); + + await service.deleteUserData(user.id); + + expect(userRepo.deleteUser).toHaveBeenCalledWith(user.id); + }); + + it('should return domainOperation object with information about deleted user', async () => { + const { expectedResult, user } = setup(); - const result = await service.deleteUser(user1.id); + const result = await service.deleteUserData(user.id); - expect(userRepo.deleteUser).toHaveBeenCalledWith(user1.id); - expect(result).toEqual(1); + expect(result).toEqual(expectedResult); + }); + }); + + describe('when user exists but userRepo.deleteUser return 0', () => { + const setup = () => { + const user = userFactory.buildWithId(); + + const registrationPinDeleted = DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 1, [new ObjectId().toHexString()]), + ]); + + jest.spyOn(service, 'removeUserRegistrationPin').mockResolvedValueOnce(registrationPinDeleted); + userRepo.findByIdOrNull.mockResolvedValueOnce(user); + userRepo.deleteUser.mockResolvedValue(0); + + const expectedError = new DeletionErrorLoggableException( + `Failed to delete user '${user.id}' from User collection` + ); + + return { + expectedError, + user, + }; + }; + + it('should throw an error', async () => { + const { expectedError, user } = setup(); + + await expect(service.deleteUserData(user.id)).rejects.toThrowError(expectedError); }); }); }); @@ -459,4 +717,191 @@ describe('UserService', () => { expect(result).toEqual(parentEmail); }); }); + + describe('findUserBySchoolAndName', () => { + describe('when searching for users by school and name', () => { + const setup = () => { + const firstName = 'Frist'; + const lastName = 'Last'; + const users: User[] = userFactory.buildListWithId(2, { firstName, lastName }); + + userRepo.findUserBySchoolAndName.mockResolvedValue(users); + + return { + firstName, + lastName, + users, + }; + }; + + it('should return a list of users', async () => { + const { firstName, lastName, users } = setup(); + + const result: User[] = await service.findUserBySchoolAndName(new ObjectId().toHexString(), firstName, lastName); + + expect(result).toEqual(users); + }); + }); + }); + + describe('findMultipleByExternalIds', () => { + describe('when a users with external id exist', () => { + const setup = () => { + const userA = userFactory.buildWithId({ externalId: '111' }); + const userB = userFactory.buildWithId({ externalId: '222' }); + + const externalIds: string[] = ['111', '222']; + const expectedResult = [userA.id, userB.id]; + + userRepo.findByExternalIds.mockResolvedValue(expectedResult); + + return { + expectedResult, + externalIds, + }; + }; + + it('should call userRepo.findByExternalIds', async () => { + const { externalIds } = setup(); + + await service.findMultipleByExternalIds(externalIds); + + expect(userRepo.findByExternalIds).toBeCalledWith(externalIds); + }); + + it('should return array with Users id', async () => { + const { externalIds, expectedResult } = setup(); + + const result = await service.findMultipleByExternalIds(externalIds); + expect(result).toEqual(expectedResult); + }); + }); + + describe('when users with this external id do not exist', () => { + it('should return empty array', async () => { + userRepo.findByExternalIds.mockResolvedValue([]); + + const result = await service.findMultipleByExternalIds(['externalId1', 'externalId2']); + + expect(result).toHaveLength(0); + }); + }); + }); + + describe('updateLastSyncedAt', () => { + describe('when a users with thess external id exist', () => { + const setup = () => { + const userA = userFactory.buildWithId({ externalId: '111' }); + const userB = userFactory.buildWithId({ externalId: '222' }); + + const userIds = [userA.id, userB.id]; + + return { + userIds, + }; + }; + + it('should call userRepo.updateAllUserByLastSyncedAt', async () => { + const { userIds } = setup(); + + await service.updateLastSyncedAt(userIds); + + expect(userRepo.updateAllUserByLastSyncedAt).toBeCalledWith(userIds); + }); + }); + }); + + describe('handle', () => { + const setup = () => { + const targetRefId = new ObjectId().toHexString(); + const targetRefDomain = DomainName.FILERECORDS; + const deletionRequest = deletionRequestFactory.build({ targetRefId, targetRefDomain }); + const deletionRequestId = deletionRequest.id; + + const expectedData = DomainDeletionReportBuilder.build(DomainName.FILERECORDS, [ + DomainOperationReportBuilder.build(OperationType.UPDATE, 2, [ + new ObjectId().toHexString(), + new ObjectId().toHexString(), + ]), + ]); + + return { + deletionRequestId, + expectedData, + targetRefId, + }; + }; + + describe('when UserDeletedEvent is received', () => { + it('should call deleteUserData in userService', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(service.deleteUserData).toHaveBeenCalledWith(targetRefId); + }); + + it('should call eventBus.publish with DataDeletedEvent', async () => { + const { deletionRequestId, expectedData, targetRefId } = setup(); + + jest.spyOn(service, 'deleteUserData').mockResolvedValueOnce(expectedData); + + await service.handle({ deletionRequestId, targetRefId }); + + expect(eventBus.publish).toHaveBeenCalledWith(new DataDeletedEvent(deletionRequestId, expectedData)); + }); + }); + }); + + describe('findByExternalIdsAndProvidedBySystemId', () => { + const setup = () => { + const systemId = new ObjectId().toHexString(); + const userA = userFactory.buildWithId({ externalId: '111' }); + const userB = userFactory.buildWithId({ externalId: '222' }); + + const externalIds: string[] = ['111', '222']; + const foundUsers = [userA.id, userB.id]; + + return { + externalIds, + foundUsers, + systemId, + }; + }; + + describe('when find users By externalIds and systemId', () => { + it('should call findMultipleByExternalIds in userService with externalIds', async () => { + const { externalIds, foundUsers, systemId } = setup(); + + jest.spyOn(service, 'findMultipleByExternalIds').mockResolvedValueOnce(foundUsers); + + await service.findByExternalIdsAndProvidedBySystemId(externalIds, systemId); + + expect(service.findMultipleByExternalIds).toHaveBeenCalledWith(externalIds); + }); + + it('should call accountService.findByUserIdsAndSystemId with foundUsers and systemId', async () => { + const { externalIds, foundUsers, systemId } = setup(); + + jest.spyOn(service, 'findMultipleByExternalIds').mockResolvedValueOnce(foundUsers); + + await service.findByExternalIdsAndProvidedBySystemId(externalIds, systemId); + + expect(accountService.findByUserIdsAndSystemId).toHaveBeenCalledWith(foundUsers, systemId); + }); + + it('should return array with verified Users', async () => { + const { externalIds, foundUsers, systemId } = setup(); + + jest.spyOn(service, 'findMultipleByExternalIds').mockResolvedValueOnce(foundUsers); + jest.spyOn(accountService, 'findByUserIdsAndSystemId').mockResolvedValueOnce(foundUsers); + + const result = await service.findByExternalIdsAndProvidedBySystemId(externalIds, systemId); + + expect(result).toEqual(foundUsers); + }); + }); + }); }); diff --git a/apps/server/src/modules/user/service/user.service.ts b/apps/server/src/modules/user/service/user.service.ts index 796b1d92181..c1e1a6c2372 100644 --- a/apps/server/src/modules/user/service/user.service.ts +++ b/apps/server/src/modules/user/service/user.service.ts @@ -1,38 +1,69 @@ -import { AccountService } from '@modules/account'; -import { AccountDto } from '@modules/account/services/dto'; +import { AccountService, Account } from '@modules/account'; // invalid import import { OauthCurrentUser } from '@modules/authentication/interface'; import { CurrentUserMapper } from '@modules/authentication/mapper'; -import { RoleDto } from '@modules/role/service/dto/role.dto'; -import { RoleService } from '@modules/role/service/role.service'; +import { RoleDto, RoleService } from '@modules/role'; import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Page, RoleReference, UserDO } from '@shared/domain/domainobject'; -import { LanguageType, User } from '@shared/domain/entity'; -import { IFindOptions } from '@shared/domain/interface'; -import { DomainModel, EntityId, StatusModel } from '@shared/domain/types'; +import { EntityId } from '@shared/domain/types'; import { UserRepo } from '@shared/repo'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; import { Logger } from '@src/core/logger'; -import { DataDeletionDomainOperationLoggable } from '@shared/common/loggable'; -import { UserConfig } from '../interfaces'; -import { UserMapper } from '../mapper/user.mapper'; -import { UserDto } from '../uc/dto/user.dto'; +import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { RegistrationPinService } from '@modules/registration-pin'; +import { User } from '@shared/domain/entity'; +import { IFindOptions, LanguageType } from '@shared/domain/interface'; +import { + UserDeletedEvent, + DeletionService, + DataDeletedEvent, + DomainDeletionReport, + DataDeletionDomainOperationLoggable, + DomainName, + DomainDeletionReportBuilder, + DomainOperationReportBuilder, + OperationType, + DeletionErrorLoggableException, + DomainOperationReport, + StatusModel, + OperationReportHelper, +} from '@modules/deletion'; +import { CalendarService } from '@src/infra/calendar'; import { UserQuery } from './user-query.type'; +import { UserDto } from '../uc/dto/user.dto'; +import { UserMapper } from '../mapper/user.mapper'; +import { UserConfig } from '../interfaces'; @Injectable() -export class UserService { +@EventsHandler(UserDeletedEvent) +export class UserService implements DeletionService, IEventHandler { constructor( private readonly userRepo: UserRepo, private readonly userDORepo: UserDORepo, private readonly configService: ConfigService, private readonly roleService: RoleService, private readonly accountService: AccountService, - private readonly logger: Logger + private readonly registrationPinService: RegistrationPinService, + private readonly calendarService: CalendarService, + private readonly logger: Logger, + private readonly eventBus: EventBus ) { this.logger.setContext(UserService.name); } + public async handle({ deletionRequestId, targetRefId }: UserDeletedEvent): Promise { + const dataDeleted = await this.deleteUserData(targetRefId); + await this.eventBus.publish(new DataDeletedEvent(deletionRequestId, dataDeleted)); + } + + async getUserEntityWithRoles(userId: EntityId): Promise { + // only roles required, no need for the other populates + const userWithRoles = await this.userRepo.findById(userId, true); + + return userWithRoles; + } + async me(userId: EntityId): Promise<[User, string[]]> { const user = await this.userRepo.findById(userId, true); const permissions = user.resolvePermissions(); @@ -52,7 +83,7 @@ export class UserService { async getResolvedUser(userId: EntityId): Promise { const user: UserDO = await this.findById(userId); - const account: AccountDto = await this.accountService.findByUserIdOrFail(userId); + const account: Account = await this.accountService.findByUserIdOrFail(userId); const resolvedUser: OauthCurrentUser = CurrentUserMapper.mapToOauthCurrentUser(account.id, user, account.systemId); @@ -128,28 +159,118 @@ export class UserService { } } - async deleteUser(userId: EntityId): Promise { + public async deleteUserData(userId: EntityId): Promise { this.logger.info( - new DataDeletionDomainOperationLoggable('Deleting user', DomainModel.USER, userId, StatusModel.PENDING) + new DataDeletionDomainOperationLoggable('Deleting user', DomainName.USER, userId, StatusModel.PENDING) ); - const deletedUserNumber = await this.userRepo.deleteUser(userId); + + const userToDelete: User | null = await this.userRepo.findByIdOrNull(userId, true); + + if (userToDelete === null) { + const result = DomainDeletionReportBuilder.build(DomainName.USER, [ + DomainOperationReportBuilder.build(OperationType.DELETE, 0, []), + ]); + + this.logger.info( + new DataDeletionDomainOperationLoggable( + 'User already deleted', + DomainName.USER, + userId, + StatusModel.FINISHED, + 0, + 0 + ) + ); + + return result; + } + + const registrationPinDeleted = await this.removeUserRegistrationPin(userId); + + const calendarEventsDeleted = await this.removeCalendarEvents(userId); + + const numberOfDeletedUsers = await this.userRepo.deleteUser(userId); + + if (numberOfDeletedUsers === 0) { + throw new DeletionErrorLoggableException(`Failed to delete user '${userId}' from User collection`); + } + + const result = DomainDeletionReportBuilder.build( + DomainName.USER, + [DomainOperationReportBuilder.build(OperationType.DELETE, numberOfDeletedUsers, [userId])], + [registrationPinDeleted, calendarEventsDeleted] + ); + this.logger.info( new DataDeletionDomainOperationLoggable( 'Successfully deleted user', - DomainModel.USER, + DomainName.USER, userId, StatusModel.FINISHED, 0, - deletedUserNumber + numberOfDeletedUsers ) ); - return deletedUserNumber; + return result; } - async getParentEmailsFromUser(userId: EntityId): Promise { + public async getParentEmailsFromUser(userId: EntityId): Promise { const parentEmails = this.userRepo.getParentEmailsFromUser(userId); return parentEmails; } + + public async findUserBySchoolAndName(schoolId: EntityId, firstName: string, lastName: string): Promise { + const users: User[] = await this.userRepo.findUserBySchoolAndName(schoolId, firstName, lastName); + + return users; + } + + public async findByExternalIdsAndProvidedBySystemId(externalIds: string[], systemId: string): Promise { + const foundUsers = await this.findMultipleByExternalIds(externalIds); + + const verifiedUsers = await this.accountService.findByUserIdsAndSystemId(foundUsers, systemId); + + return verifiedUsers; + } + + public async findMultipleByExternalIds(externalIds: string[]): Promise { + return this.userRepo.findByExternalIds(externalIds); + } + + public async updateLastSyncedAt(userIds: string[]): Promise { + await this.userRepo.updateAllUserByLastSyncedAt(userIds); + } + + public async removeUserRegistrationPin(userId: EntityId): Promise { + const userToDeletion = await this.userRepo.findByIdOrNull(userId); + const parentEmails = await this.getParentEmailsFromUser(userId); + let emailsToDeletion: string[] = []; + if (userToDeletion && userToDeletion.email) { + emailsToDeletion = [userToDeletion.email, ...parentEmails]; + } + + let extractedOperationReport: DomainOperationReport[] = []; + if (emailsToDeletion.length > 0) { + const results = await Promise.all( + emailsToDeletion.map((email) => this.registrationPinService.deleteUserData(email)) + ); + + extractedOperationReport = OperationReportHelper.extractOperationReports(results); + } else { + extractedOperationReport = [DomainOperationReportBuilder.build(OperationType.DELETE, 0, [])]; + } + + return DomainDeletionReportBuilder.build(DomainName.REGISTRATIONPIN, extractedOperationReport); + } + + public async removeCalendarEvents(userId: EntityId): Promise { + let extractedOperationReport: DomainOperationReport[] = []; + const results = await this.calendarService.deleteUserData(userId); + + extractedOperationReport = OperationReportHelper.extractOperationReports([results]); + + return DomainDeletionReportBuilder.build(DomainName.CALENDAR, extractedOperationReport); + } } diff --git a/apps/server/src/modules/user/uc/admin-api-user.uc.spec.ts b/apps/server/src/modules/user/uc/admin-api-user.uc.spec.ts new file mode 100644 index 00000000000..d4cea8296c6 --- /dev/null +++ b/apps/server/src/modules/user/uc/admin-api-user.uc.spec.ts @@ -0,0 +1,108 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { RoleName } from '@shared/domain/interface'; +import { accountDoFactory, roleFactory, setupEntities, userDoFactory } from '@shared/testing'; +import { AccountService } from '@modules/account'; +import { RoleService } from '@src/modules/role'; +import { UserService } from '../service/user.service'; +import { AdminApiUserUc } from './admin-api-user.uc'; + +describe('admin api user uc', () => { + let module: TestingModule; + let uc: AdminApiUserUc; + let userService: DeepMocked; + let accountService: DeepMocked; + let roleService: DeepMocked; + + afterAll(async () => { + await module.close(); + }); + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + AdminApiUserUc, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: AccountService, + useValue: createMock(), + }, + { + provide: RoleService, + useValue: createMock(), + }, + ], + }).compile(); + + uc = module.get(AdminApiUserUc); + userService = module.get(UserService); + accountService = module.get(AccountService); + roleService = module.get(RoleService); + await setupEntities(); + }); + + describe('createUserAndAccount', () => { + const setup = () => { + const schoolId = 'schoolId'; + const firstName = 'firstname'; + const lastName = 'lastName'; + const email = 'mail@domain.de'; + const roleNames = [RoleName.STUDENT]; + const role = roleFactory.buildWithId({ name: RoleName.STUDENT }); + roleService.findByNames.mockResolvedValue([role]); + + const user = userDoFactory.buildWithId(); + userService.save.mockResolvedValue(user); + + const accountDto = accountDoFactory.build(); + accountService.save.mockResolvedValue(accountDto); + return { schoolId, firstName, lastName, email, roleNames, role, user, accountDto }; + }; + + it('should return data', async () => { + const { schoolId, firstName, lastName, email, roleNames, accountDto, user } = setup(); + + const result = await uc.createUserAndAccount({ schoolId, firstName, lastName, email, roleNames }); + + expect(result).toEqual( + expect.objectContaining({ + userId: user.id, + accountId: accountDto.id, + username: accountDto.username, + initialPassword: expect.any(String), + }) + ); + }); + + it('should have persisted user', async () => { + const { schoolId, firstName, lastName, email, roleNames } = setup(); + + await uc.createUserAndAccount({ schoolId, firstName, lastName, email, roleNames }); + + expect(userService.save).toHaveBeenCalledWith( + expect.objectContaining({ + schoolId, + firstName, + lastName, + email, + }) + ); + }); + + it('should have persisted account', async () => { + const { schoolId, firstName, lastName, email, roleNames, user } = setup(); + + await uc.createUserAndAccount({ schoolId, firstName, lastName, email, roleNames }); + + expect(accountService.save).toHaveBeenCalledWith( + expect.objectContaining({ + userId: user.id, + username: email, + }) + ); + }); + }); +}); diff --git a/apps/server/src/modules/user/uc/admin-api-user.uc.ts b/apps/server/src/modules/user/uc/admin-api-user.uc.ts new file mode 100644 index 00000000000..adf4242a0f5 --- /dev/null +++ b/apps/server/src/modules/user/uc/admin-api-user.uc.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { RoleReference } from '@shared/domain/domainobject'; +import { RoleName } from '@shared/domain/interface'; +import { EntityId } from '@shared/domain/types'; +import { AccountService, AccountSave } from '@modules/account'; +import { RoleService } from '@src/modules/role'; +import { nanoid } from 'nanoid'; +import { UserService } from '../service'; + +@Injectable() +export class AdminApiUserUc { + constructor( + private readonly accountService: AccountService, + private readonly roleService: RoleService, + private readonly userService: UserService + ) {} + + public async createUserAndAccount(props: { + email: string; + firstName: string; + lastName: string; + roleNames: RoleName[]; + schoolId: EntityId; + }): Promise { + const roleDtos = await this.roleService.findByNames(props.roleNames); + const roles = roleDtos.map((r) => { + if (!r.id) throw new Error(); + return new RoleReference({ ...r, id: r.id }); + }); + const user = await this.userService.save({ ...props, roles }); + if (!user.id) throw new Error(); + const initialPassword = nanoid(12); + const account = await this.accountService.save({ + username: props.email, + userId: user.id, + password: initialPassword, + } as AccountSave); + return { + userId: user.id, + accountId: account.id, + username: account.username, + initialPassword, + }; + } +} + +export type CreateddUserAndAccount = { + userId: EntityId; + accountId: EntityId; + username: string; + initialPassword: string; +}; diff --git a/apps/server/src/modules/user/uc/dto/user.dto.ts b/apps/server/src/modules/user/uc/dto/user.dto.ts index 246fc2d5efa..a94b7356ddd 100644 --- a/apps/server/src/modules/user/uc/dto/user.dto.ts +++ b/apps/server/src/modules/user/uc/dto/user.dto.ts @@ -1,4 +1,4 @@ -import { LanguageType } from '@shared/domain/entity'; +import { LanguageType } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; export class UserDto { diff --git a/apps/server/src/modules/user/uc/index.ts b/apps/server/src/modules/user/uc/index.ts index 82e2226157b..bff0a637f2e 100644 --- a/apps/server/src/modules/user/uc/index.ts +++ b/apps/server/src/modules/user/uc/index.ts @@ -1 +1,2 @@ export * from './user.uc'; +export * from './admin-api-user.uc'; diff --git a/apps/server/src/modules/user/uc/user.uc.spec.ts b/apps/server/src/modules/user/uc/user.uc.spec.ts index d47f3cf09b4..0bdd80a99f1 100644 --- a/apps/server/src/modules/user/uc/user.uc.spec.ts +++ b/apps/server/src/modules/user/uc/user.uc.spec.ts @@ -3,8 +3,8 @@ import { UserService } from '@modules/user/service/user.service'; import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { LanguageType, User } from '@shared/domain/entity'; -import { Permission } from '@shared/domain/interface'; +import { User } from '@shared/domain/entity'; +import { LanguageType, Permission } from '@shared/domain/interface'; import { UserRepo } from '@shared/repo'; import { roleFactory, setupEntities, userFactory } from '@shared/testing'; import { UserUc } from './user.uc'; diff --git a/apps/server/src/modules/user/uc/user.uc.ts b/apps/server/src/modules/user/uc/user.uc.ts index d0fd1aad29b..4e771c46296 100644 --- a/apps/server/src/modules/user/uc/user.uc.ts +++ b/apps/server/src/modules/user/uc/user.uc.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { LanguageType, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; +import { LanguageType } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { UserRepo } from '@shared/repo'; import { ChangeLanguageParams } from '../controller/dto'; diff --git a/apps/server/src/modules/user/user-admin-api.module.ts b/apps/server/src/modules/user/user-admin-api.module.ts new file mode 100644 index 00000000000..197f6a34478 --- /dev/null +++ b/apps/server/src/modules/user/user-admin-api.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AccountModule } from '../account'; +import { RoleModule } from '../role'; +import { AdminApiUsersController } from './controller'; +import { AdminApiUserUc } from './uc'; +import { UserModule } from './user.module'; + +@Module({ + imports: [UserModule, RoleModule, AccountModule], + controllers: [AdminApiUsersController], + providers: [AdminApiUserUc], +}) +export class UserAdminApiModule {} diff --git a/apps/server/src/modules/user/user-api.module.ts b/apps/server/src/modules/user/user-api.module.ts index e6a494544c3..bf79bcd4b3f 100644 --- a/apps/server/src/modules/user/user-api.module.ts +++ b/apps/server/src/modules/user/user-api.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; +import { AccountModule } from '../account'; +import { RoleModule } from '../role'; import { UserController } from './controller'; import { UserUc } from './uc'; import { UserModule } from './user.module'; @Module({ - imports: [UserModule], + imports: [UserModule, RoleModule, AccountModule], controllers: [UserController], providers: [UserUc], }) diff --git a/apps/server/src/modules/user/user.module.ts b/apps/server/src/modules/user/user.module.ts index d58c24546c6..364ae05f0f0 100644 --- a/apps/server/src/modules/user/user.module.ts +++ b/apps/server/src/modules/user/user.module.ts @@ -1,14 +1,25 @@ -import { Module } from '@nestjs/common'; +import { AccountModule } from '@modules/account'; +import { LegacySchoolModule } from '@modules/legacy-school'; +import { RoleModule } from '@modules/role/role.module'; +import { forwardRef, Module } from '@nestjs/common'; import { UserRepo } from '@shared/repo'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; import { LoggerModule } from '@src/core/logger'; -import { AccountModule } from '@modules/account'; -import { RoleModule } from '@modules/role/role.module'; -import { LegacySchoolModule } from '@modules/legacy-school'; +import { CqrsModule } from '@nestjs/cqrs'; +import { RegistrationPinModule } from '@modules/registration-pin'; +import { CalendarModule } from '@src/infra/calendar'; import { UserService } from './service/user.service'; @Module({ - imports: [LegacySchoolModule, RoleModule, AccountModule, LoggerModule], + imports: [ + forwardRef(() => LegacySchoolModule), + RoleModule, + AccountModule, + LoggerModule, + CqrsModule, + RegistrationPinModule, + CalendarModule, + ], providers: [UserRepo, UserDORepo, UserService], exports: [UserService, UserRepo], }) diff --git a/apps/server/src/modules/video-conference/controller/api-test/video-conference.api.spec.ts b/apps/server/src/modules/video-conference/controller/api-test/video-conference.api.spec.ts index 25865af926e..3c9ab041aa6 100644 --- a/apps/server/src/modules/video-conference/controller/api-test/video-conference.api.spec.ts +++ b/apps/server/src/modules/video-conference/controller/api-test/video-conference.api.spec.ts @@ -2,7 +2,7 @@ import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { ServerTestModule } from '@modules/server'; import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { Account, Course, Role, SchoolEntity, TargetModels, User, VideoConference } from '@shared/domain/entity'; +import { Course, Role, SchoolEntity, TargetModels, User, VideoConference } from '@shared/domain/entity'; import { Permission, RoleName, VideoConferenceScope } from '@shared/domain/interface'; import { SchoolFeature } from '@shared/domain/types'; import { @@ -10,7 +10,7 @@ import { cleanupCollections, courseFactory, roleFactory, - schoolFactory, + schoolEntityFactory, TestApiClient, UserAndAccountTestFactory, userFactory, @@ -18,6 +18,7 @@ import { import { videoConferenceFactory } from '@shared/testing/factory/video-conference.factory'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { AccountEntity } from '@modules/account/entity/account.entity'; import { Response } from 'supertest'; import { VideoConferenceCreateParams, VideoConferenceJoinResponse } from '../dto'; @@ -151,7 +152,7 @@ describe('VideoConferenceController (API)', () => { describe('when the logoutUrl is from a wrong origin', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.START_MEETING, @@ -191,7 +192,7 @@ describe('VideoConferenceController (API)', () => { describe('when conference params are given', () => { describe('when school has not enabled the school feature videoconference', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.START_MEETING, @@ -230,7 +231,7 @@ describe('VideoConferenceController (API)', () => { describe('when user has not the required permission', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); const studentRole: Role = roleFactory.buildWithId({ name: RoleName.STUDENT, permissions: [Permission.JOIN_MEETING], @@ -272,7 +273,7 @@ describe('VideoConferenceController (API)', () => { describe('when user has the required permission', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.START_MEETING, @@ -311,7 +312,7 @@ describe('VideoConferenceController (API)', () => { describe('when conference is for scope and scopeId is already running', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.START_MEETING, @@ -362,7 +363,7 @@ describe('VideoConferenceController (API)', () => { describe('when scope and scopeId are given', () => { describe('when school has not enabled the school feature videoconference', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.START_MEETING, @@ -399,7 +400,7 @@ describe('VideoConferenceController (API)', () => { describe('when user has the required permission', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.START_MEETING, @@ -439,7 +440,7 @@ describe('VideoConferenceController (API)', () => { describe('when conference is not running', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.START_MEETING, @@ -488,7 +489,7 @@ describe('VideoConferenceController (API)', () => { describe('when scope and scopeId are given', () => { describe('when school has not enabled the school feature videoconference', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.START_MEETING, @@ -524,7 +525,7 @@ describe('VideoConferenceController (API)', () => { describe('when user has the required permission', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.START_MEETING, @@ -561,7 +562,7 @@ describe('VideoConferenceController (API)', () => { describe('when guest want meeting info of conference without waiting room', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); const expertRole: Role = roleFactory.buildWithId({ name: RoleName.EXPERT, @@ -569,7 +570,7 @@ describe('VideoConferenceController (API)', () => { }); const expertUser: User = userFactory.buildWithId({ school, roles: [expertRole] }); - const expertAccount: Account = accountFactory.buildWithId({ userId: expertUser.id }); + const expertAccount: AccountEntity = accountFactory.buildWithId({ userId: expertUser.id }); const course: Course = courseFactory.buildWithId({ school, students: [expertUser] }); const videoConference: VideoConference = videoConferenceFactory.buildWithId({ @@ -602,7 +603,7 @@ describe('VideoConferenceController (API)', () => { describe('when conference is not running', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.START_MEETING, @@ -651,7 +652,7 @@ describe('VideoConferenceController (API)', () => { describe('when scope and scopeId are given', () => { describe('when school has not enabled the school feature videoconference', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [] }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.START_MEETING, @@ -688,7 +689,7 @@ describe('VideoConferenceController (API)', () => { describe('when a user without required permission wants to end a conference', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ school }, [ Permission.JOIN_MEETING, @@ -722,7 +723,7 @@ describe('VideoConferenceController (API)', () => { describe('when a user with required permission wants to end a conference', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ features: [SchoolFeature.VIDEOCONFERENCE] }); const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({ school }, [ Permission.START_MEETING, diff --git a/apps/server/src/modules/video-conference/index.ts b/apps/server/src/modules/video-conference/index.ts index 356fb8f1618..16071ac6627 100644 --- a/apps/server/src/modules/video-conference/index.ts +++ b/apps/server/src/modules/video-conference/index.ts @@ -1 +1,3 @@ -export * from './video-conference.module'; +export { VideoConferenceModule } from './video-conference.module'; +export { IVideoConferenceSettings } from './interface'; +export { default as VideoConferenceConfiguration } from './video-conference-config'; diff --git a/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts b/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts index f353a545e46..d0daef948aa 100644 --- a/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts +++ b/apps/server/src/modules/video-conference/service/video-conference.service.spec.ts @@ -16,7 +16,7 @@ import { courseFactory, roleFactory, setupEntities, userDoFactory, userFactory } import { teamFactory } from '@shared/testing/factory/team.factory'; import { teamUserFactory } from '@shared/testing/factory/teamuser.factory'; import { videoConferenceDOFactory } from '@shared/testing/factory/video-conference.do.factory'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BBBRole } from '../bbb'; import { ErrorStatus } from '../error'; import { IVideoConferenceSettings, VideoConferenceOptions, VideoConferenceSettings } from '../interface'; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-create.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-create.uc.spec.ts index 9f9af2fd9c6..5af0215ba79 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-create.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-create.uc.spec.ts @@ -6,7 +6,7 @@ import { UserDO } from '@shared/domain/domainobject'; import {} from '@shared/domain/entity'; import { VideoConferenceScope } from '@shared/domain/interface'; import { userDoFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BBBCreateResponse, BBBMeetingInfoResponse, BBBResponse, BBBRole, BBBStatus } from '../bbb'; import { ErrorStatus } from '../error/error-status.enum'; import { VideoConferenceOptions } from '../interface'; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-end.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-end.uc.spec.ts index 9f6cd1e55b7..ee552b35c17 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-end.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-end.uc.spec.ts @@ -6,7 +6,7 @@ import { UserDO } from '@shared/domain/domainobject'; import {} from '@shared/domain/entity'; import { VideoConferenceScope } from '@shared/domain/interface'; import { userDoFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BBBBaseResponse, BBBResponse, BBBRole, BBBStatus } from '../bbb'; import { ErrorStatus } from '../error/error-status.enum'; import { BBBService, VideoConferenceService } from '../service'; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-info.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-info.uc.spec.ts index b80fcc297bd..4a9c7b77591 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-info.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-info.uc.spec.ts @@ -7,7 +7,7 @@ import {} from '@shared/domain/entity'; import { Permission, VideoConferenceScope } from '@shared/domain/interface'; import { userDoFactory } from '@shared/testing'; import { videoConferenceDOFactory } from '@shared/testing/factory/video-conference.do.factory'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BBBMeetingInfoResponse, BBBResponse, BBBRole, BBBStatus } from '../bbb'; import { ErrorStatus } from '../error/error-status.enum'; import { defaultVideoConferenceOptions, VideoConferenceOptions } from '../interface'; diff --git a/apps/server/src/modules/video-conference/uc/video-conference-join.uc.spec.ts b/apps/server/src/modules/video-conference/uc/video-conference-join.uc.spec.ts index b3a3b13d3c4..a057092864a 100644 --- a/apps/server/src/modules/video-conference/uc/video-conference-join.uc.spec.ts +++ b/apps/server/src/modules/video-conference/uc/video-conference-join.uc.spec.ts @@ -7,7 +7,7 @@ import {} from '@shared/domain/entity'; import { Permission, VideoConferenceScope } from '@shared/domain/interface'; import { userDoFactory } from '@shared/testing'; import { videoConferenceDOFactory } from '@shared/testing/factory/video-conference.do.factory'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BBBJoinConfig, BBBJoinResponse, BBBResponse, BBBRole } from '../bbb'; import { ErrorStatus } from '../error/error-status.enum'; import { VideoConferenceOptions } from '../interface'; diff --git a/apps/server/src/modules/video-conference/video-conference-api.module.ts b/apps/server/src/modules/video-conference/video-conference-api.module.ts index d4594a10a34..a94128120ae 100644 --- a/apps/server/src/modules/video-conference/video-conference-api.module.ts +++ b/apps/server/src/modules/video-conference/video-conference-api.module.ts @@ -1,8 +1,8 @@ -import { Module } from '@nestjs/common'; -import { UserModule } from '@modules/user'; import { AuthorizationModule } from '@modules/authorization'; +import { UserModule } from '@modules/user'; +import { Module } from '@nestjs/common'; import { VideoConferenceController } from './controller'; -import { VideoConferenceCreateUc, VideoConferenceJoinUc, VideoConferenceEndUc, VideoConferenceInfoUc } from './uc'; +import { VideoConferenceCreateUc, VideoConferenceEndUc, VideoConferenceInfoUc, VideoConferenceJoinUc } from './uc'; import { VideoConferenceModule } from './video-conference.module'; @Module({ diff --git a/apps/server/src/modules/video-conference/video-conference.module.ts b/apps/server/src/modules/video-conference/video-conference.module.ts index d7e4671c0f2..db0a539e5fc 100644 --- a/apps/server/src/modules/video-conference/video-conference.module.ts +++ b/apps/server/src/modules/video-conference/video-conference.module.ts @@ -1,21 +1,21 @@ -import { Module } from '@nestjs/common'; -import { HttpModule } from '@nestjs/axios'; import { CalendarModule } from '@infra/calendar'; -import { VideoConferenceRepo } from '@shared/repo/videoconference/video-conference.repo'; import { AuthorizationModule } from '@modules/authorization'; import { AuthorizationReferenceModule } from '@modules/authorization/authorization-reference.module'; -import { TeamsRepo } from '@shared/repo'; import { LegacySchoolModule } from '@modules/legacy-school'; -import { LoggerModule } from '@src/core/logger'; -import { ConverterUtil } from '@shared/common'; import { UserModule } from '@modules/user'; +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { ConverterUtil } from '@shared/common'; +import { TeamsRepo } from '@shared/repo'; +import { VideoConferenceRepo } from '@shared/repo/videoconference/video-conference.repo'; +import { LoggerModule } from '@src/core/logger'; +import { LearnroomModule } from '../learnroom'; import { BBBService, BbbSettings } from './bbb'; +import { VideoConferenceDeprecatedController } from './controller'; +import { VideoConferenceSettings } from './interface'; import { VideoConferenceService } from './service'; import { VideoConferenceDeprecatedUc } from './uc'; -import { VideoConferenceDeprecatedController } from './controller'; import VideoConferenceConfiguration from './video-conference-config'; -import { VideoConferenceSettings } from './interface'; -import { LearnroomModule } from '../learnroom'; @Module({ imports: [ diff --git a/apps/server/src/shared/common/decorators/index.ts b/apps/server/src/shared/common/decorators/index.ts index b0ecd84e1e8..ae58a11c175 100644 --- a/apps/server/src/shared/common/decorators/index.ts +++ b/apps/server/src/shared/common/decorators/index.ts @@ -1 +1 @@ -export * from './timeout.decorator'; +export { RequestTimeout } from './timeout.decorator'; diff --git a/apps/server/src/shared/common/decorators/timeout.decorator.ts b/apps/server/src/shared/common/decorators/timeout.decorator.ts index ec2251b71e6..cb13ff2f375 100644 --- a/apps/server/src/shared/common/decorators/timeout.decorator.ts +++ b/apps/server/src/shared/common/decorators/timeout.decorator.ts @@ -1,5 +1,5 @@ import { applyDecorators, SetMetadata } from '@nestjs/common'; -export function RequestTimeout(ms: number) { - return applyDecorators(SetMetadata('timeout', ms)); +export function RequestTimeout(requestTimeoutEnvironmentName: string) { + return applyDecorators(SetMetadata('requestTimeoutEnvironmentName', requestTimeoutEnvironmentName)); } diff --git a/apps/server/src/shared/common/guards/index.ts b/apps/server/src/shared/common/guards/index.ts new file mode 100644 index 00000000000..077ed22f605 --- /dev/null +++ b/apps/server/src/shared/common/guards/index.ts @@ -0,0 +1,4 @@ +export { TypeGuard } from './type.guard'; + +// Guards at different places exists insdide the modules, as validation as utils. +// Please consolidate it and make it explicit as guard. diff --git a/apps/server/src/shared/common/guards/type.guard.spec.ts b/apps/server/src/shared/common/guards/type.guard.spec.ts new file mode 100644 index 00000000000..26617fb71a1 --- /dev/null +++ b/apps/server/src/shared/common/guards/type.guard.spec.ts @@ -0,0 +1,87 @@ +import { TypeGuard } from './type.guard'; + +describe('TypeGuard', () => { + describe('isNumber', () => { + describe('when passing type of value is a number', () => { + it('should be return true', () => { + expect(TypeGuard.isNumber(123)).toBe(true); + }); + + it('should be return true', () => { + expect(TypeGuard.isNumber(-1)).toBe(true); + }); + + it('should be return true', () => { + expect(TypeGuard.isNumber(NaN)).toBe(true); + }); + }); + + describe('when passing type of value is NOT a number', () => { + it('should be return false', () => { + expect(TypeGuard.isNumber(undefined)).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isNumber(null)).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isNumber({})).toBe(false); + }); + + it('should be return false', () => { + expect(TypeGuard.isNumber('string')).toBe(false); + }); + }); + }); + + describe('checkNumber', () => { + describe('when passing type of value is a number', () => { + it('should be return true', () => { + expect(TypeGuard.checkNumber(123)).toEqual(undefined); + }); + + it('should be return true', () => { + expect(TypeGuard.checkNumber(-1)).toEqual(undefined); + }); + + it('should be return true', () => { + expect(TypeGuard.checkNumber(NaN)).toEqual(undefined); + }); + }); + + describe('when passing type of value is NOT a number', () => { + it('should be return false', () => { + expect(() => TypeGuard.checkNumber(undefined)).toThrowError('Type is not a number'); + }); + + it('should be return false', () => { + expect(() => TypeGuard.checkNumber(null)).toThrowError('Type is not a number'); + }); + + it('should be return false', () => { + expect(() => TypeGuard.checkNumber({})).toThrowError('Type is not a number'); + }); + + it('should be return false', () => { + expect(() => TypeGuard.checkNumber('string')).toThrowError('Type is not a number'); + }); + }); + }); + + describe('isArrayWithElements', () => { + describe('when passing type of value is an array with elements', () => { + it('should be return true', () => { + expect(TypeGuard.isArrayWithElements([1, 2, 3])).toBe(true); + }); + + it('should be return true', () => { + expect(TypeGuard.isArrayWithElements(['a', 'b', 'c'])).toBe(true); + }); + + it('should be return true', () => { + expect(TypeGuard.isArrayWithElements([{ a: 1 }, { b: 2 }])).toBe(true); + }); + }); + }); +}); diff --git a/apps/server/src/shared/common/guards/type.guard.ts b/apps/server/src/shared/common/guards/type.guard.ts new file mode 100644 index 00000000000..2d93c223614 --- /dev/null +++ b/apps/server/src/shared/common/guards/type.guard.ts @@ -0,0 +1,19 @@ +export class TypeGuard { + static checkNumber(value: unknown): void { + if (!TypeGuard.isNumber(value)) { + throw new Error('Type is not a number'); + } + } + + static isNumber(value: unknown): boolean { + const isNumber = typeof value === 'number'; + + return isNumber; + } + + static isArrayWithElements(value: unknown): value is [] { + const isArrayWithElements = Array.isArray(value) && value.length > 0; + + return isArrayWithElements; + } +} diff --git a/apps/server/src/shared/common/index.ts b/apps/server/src/shared/common/index.ts index 5a3dcf904e9..95ae196d3a1 100644 --- a/apps/server/src/shared/common/index.ts +++ b/apps/server/src/shared/common/index.ts @@ -1,5 +1,6 @@ -export * from './decorators'; +export { RequestTimeout } from './decorators'; export * from './error'; +export * from './guards'; export * from './interceptor'; -export * from './validator'; export * from './utils'; +export * from './validator'; diff --git a/apps/server/src/shared/common/interceptor/duration-logging.interceptor.spec.ts b/apps/server/src/shared/common/interceptor/duration-logging.interceptor.spec.ts index 45ca9182d3f..4181041f1cd 100644 --- a/apps/server/src/shared/common/interceptor/duration-logging.interceptor.spec.ts +++ b/apps/server/src/shared/common/interceptor/duration-logging.interceptor.spec.ts @@ -1,32 +1,58 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; -import { INestApplication } from '@nestjs/common'; -import { DurationLoggingInterceptor } from '@shared/common'; +import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common'; import { LegacyLogger } from '@src/core/logger'; -import request from 'supertest'; -import { createTestModule } from './timeout.interceptor.spec'; +import { Test } from '@nestjs/testing'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { DurationLoggingInterceptor } from './duration-logging.interceptor'; +import { TestApiClient } from '../../testing'; + +@Controller() +class TestController { + @Get() + test(): { message: string } { + return { message: 'MyMessage' }; + } +} describe('DurationLoggingInterceptor', () => { describe('when integrate DurationLoggingInterceptor', () => { let app: INestApplication; - let interceptor: DurationLoggingInterceptor; - - const logger: DeepMocked = createMock(); + let logger: DeepMocked = createMock(); + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + providers: [ + { + provide: LegacyLogger, + useValue: createMock(), + }, + { + provide: APP_INTERCEPTOR, + useFactory: (legacyLogger: LegacyLogger) => new DurationLoggingInterceptor(legacyLogger), + inject: [LegacyLogger], + }, + ], + controllers: [TestController], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); - beforeEach(() => { - interceptor = new DurationLoggingInterceptor(logger); + logger = app.get(LegacyLogger); + testApiClient = new TestApiClient(app, ''); }); - afterEach(() => {}); + afterAll(async () => { + await app.close(); + }); it(`should not transform the response and produce before- and after-log`, async () => { - app = (await createTestModule(interceptor)).createNestApplication(); - - await app.init(); - await request(app.getHttpServer()).get('/').expect(200).expect('Schulcloud Server API'); + const response = await testApiClient.get(); + expect(response.status).toBe(HttpStatus.OK); + expect(response.body).toEqual({ message: 'MyMessage' }); expect(logger.log).toBeCalledTimes(2); - - await app.close(); }); }); }); diff --git a/apps/server/src/shared/common/interceptor/interfaces/index.ts b/apps/server/src/shared/common/interceptor/interfaces/index.ts index 8a545ebfa7e..c62a76c65bf 100644 --- a/apps/server/src/shared/common/interceptor/interfaces/index.ts +++ b/apps/server/src/shared/common/interceptor/interfaces/index.ts @@ -1 +1 @@ -export * from './interceptor-config'; +export { InterceptorConfig } from './interceptor-config'; diff --git a/apps/server/src/shared/common/interceptor/interfaces/interceptor-config.ts b/apps/server/src/shared/common/interceptor/interfaces/interceptor-config.ts index 510ada13458..48447d15daa 100644 --- a/apps/server/src/shared/common/interceptor/interfaces/interceptor-config.ts +++ b/apps/server/src/shared/common/interceptor/interfaces/interceptor-config.ts @@ -1,4 +1,3 @@ export interface InterceptorConfig { INCOMING_REQUEST_TIMEOUT: number; - INCOMING_REQUEST_TIMEOUT_COPY_API: number; } diff --git a/apps/server/src/shared/common/interceptor/timeout.interceptor.spec.ts b/apps/server/src/shared/common/interceptor/timeout.interceptor.spec.ts index 9bad0e73142..f3370927aab 100644 --- a/apps/server/src/shared/common/interceptor/timeout.interceptor.spec.ts +++ b/apps/server/src/shared/common/interceptor/timeout.interceptor.spec.ts @@ -1,8 +1,10 @@ -import { Controller, Get, HttpStatus, INestApplication, NestInterceptor } from '@nestjs/common'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { APP_INTERCEPTOR } from '@nestjs/core'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; import { RequestTimeout, TimeoutInterceptor } from '@shared/common'; -import request from 'supertest'; +import { TestApiClient } from '@shared/testing'; const delay = (ms: number) => new Promise((resolve) => { @@ -10,74 +12,132 @@ const delay = (ms: number) => }); @Controller() -class DelayController { - /** default route to test public access */ +class TestController { @Get() - async getHello(): Promise { + async testDefault(): Promise<{ message: string }> { await delay(100); - return 'Schulcloud Server API'; + + return { message: 'MyMessage' }; } - @RequestTimeout(1) - @Get('/timeout') - async getHelloWithTimeout(): Promise { + @Get('overriden') + @RequestTimeout('MY_CONFIG_NAME') + async testOverriden(): Promise<{ message: string }> { await delay(100); - return 'Schulcloud Server API'; + + return { message: 'MyMessage' }; } } -export const createTestModule = (interceptor: NestInterceptor): Promise => - Test.createTestingModule({ - providers: [ - { - provide: APP_INTERCEPTOR, - useValue: interceptor, - }, - ], - controllers: [DelayController], - }).compile(); - describe('TimeoutInterceptor', () => { - describe('when integrate TimeoutInterceptor', () => { - let app: INestApplication; + let app: INestApplication; + let configServiceMock: DeepMocked; + let testApiClient: TestApiClient; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + providers: [ + { + provide: ConfigService, + useValue: createMock(), + }, + { + provide: APP_INTERCEPTOR, + useFactory: (configService: ConfigService) => new TimeoutInterceptor(configService), + inject: [ConfigService], + }, + ], + controllers: [TestController], + }).compile(); + + app = moduleFixture.createNestApplication(); + configServiceMock = app.get(ConfigService); + await app.init(); + + testApiClient = new TestApiClient(app, ''); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('when response is faster then the request timeout', () => { + const setup = () => { + configServiceMock.getOrThrow.mockReturnValueOnce(1000); + }; + + it('should respond with status code 200', async () => { + setup(); - beforeEach(async () => {}); + const response = await testApiClient.get(); - afterEach(async () => { - await app.close(); + expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ message: 'MyMessage' }); }); + }); - it('should respond with error code if request runs into timeout', async () => { - const interceptor = new TimeoutInterceptor(1); + describe('when response is slower then the request timeout', () => { + const setup = () => { + configServiceMock.getOrThrow.mockReturnValueOnce(1); + }; - app = (await createTestModule(interceptor)).createNestApplication(); - await app.init(); + it('should respond with status request timeout', async () => { + setup(); - const response = await request(app.getHttpServer()).get('/'); + const response = await testApiClient.get(); expect(response.status).toEqual(HttpStatus.REQUEST_TIMEOUT); }); + }); + + describe('when override the default timeout ', () => { + const setup = () => { + configServiceMock.getOrThrow.mockImplementationOnce((key: string) => { + const result = key === 'MY_CONFIG_NAME' ? 1000 : 1; - it('should pass if request does not run into timeout', async () => { - const interceptor = new TimeoutInterceptor(30000); + return result; + }); + }; - app = (await createTestModule(interceptor)).createNestApplication(); - await app.init(); + it('should respond with status code 200', async () => { + setup(); - const response = await request(app.getHttpServer()).get('/'); + const response = await testApiClient.get('overriden'); expect(response.status).toEqual(HttpStatus.OK); + expect(response.body).toEqual({ message: 'MyMessage' }); }); + }); - it('should respond with error code if request runs into timeout by timeout decorator', async () => { - const interceptor = new TimeoutInterceptor(30000); + describe('when override the default timeout', () => { + const setup = () => { + configServiceMock.getOrThrow.mockImplementationOnce((key: string) => { + const result = key === 'MY_CONFIG_NAME' ? 1 : 1000; - app = (await createTestModule(interceptor)).createNestApplication(); - await app.init(); + return result; + }); + }; - const response = await request(app.getHttpServer()).get('/timeout'); + it('should respond with status request timeout', async () => { + setup(); + + const response = await testApiClient.get('overriden'); expect(response.status).toEqual(HttpStatus.REQUEST_TIMEOUT); }); }); + + describe('when requested config is not a number', () => { + const setup = () => { + configServiceMock.getOrThrow.mockReturnValueOnce('string'); + }; + + it('should respond with status request timeout', async () => { + setup(); + + const response = await testApiClient.get('overriden'); + + expect(response.status).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + }); + }); }); diff --git a/apps/server/src/shared/common/interceptor/timeout.interceptor.ts b/apps/server/src/shared/common/interceptor/timeout.interceptor.ts index 45d839b9349..c3558ecce85 100644 --- a/apps/server/src/shared/common/interceptor/timeout.interceptor.ts +++ b/apps/server/src/shared/common/interceptor/timeout.interceptor.ts @@ -1,7 +1,10 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor, RequestTimeoutException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { Reflector } from '@nestjs/core'; import { Observable, throwError, TimeoutError } from 'rxjs'; import { catchError, timeout } from 'rxjs/operators'; +import { InterceptorConfig } from './interfaces'; +import { TypeGuard } from '../guards'; /** * This interceptor leaves the request execution after a given timeout in ms. @@ -9,14 +12,23 @@ import { catchError, timeout } from 'rxjs/operators'; */ @Injectable() export class TimeoutInterceptor implements NestInterceptor { - constructor(private readonly requestTimeout: number) {} + defaultConfigKey: keyof InterceptorConfig = 'INCOMING_REQUEST_TIMEOUT'; + + constructor(private readonly configService: ConfigService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const reflector = new Reflector(); - const timeoutValue = - reflector.get('timeout', context.getHandler()) || reflector.get('timeout', context.getClass()); + const requestTimeoutEnvironmentName = + reflector.get('requestTimeoutEnvironmentName', context.getHandler()) || + reflector.get('requestTimeoutEnvironmentName', context.getClass()); + + // type of requestTimeoutEnvironmentName is always invalid and can be different + const timeoutMS = this.configService.getOrThrow(requestTimeoutEnvironmentName || this.defaultConfigKey); + + TypeGuard.checkNumber(timeoutMS); + return next.handle().pipe( - timeout(timeoutValue || this.requestTimeout), + timeout(timeoutMS), catchError((err: Error) => { if (err instanceof TimeoutError) { return throwError(() => new RequestTimeoutException()); diff --git a/apps/server/src/shared/common/loggable-exception/feature-disabled.loggable-exception.spec.ts b/apps/server/src/shared/common/loggable-exception/feature-disabled.loggable-exception.spec.ts new file mode 100644 index 00000000000..bb5e649d08d --- /dev/null +++ b/apps/server/src/shared/common/loggable-exception/feature-disabled.loggable-exception.spec.ts @@ -0,0 +1,30 @@ +import { FeatureDisabledLoggableException } from './feature-disabled.loggable-exception'; + +describe(FeatureDisabledLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const featureName = 'FEATURE_TEST_ENABLED'; + + const exception = new FeatureDisabledLoggableException(featureName); + + return { + exception, + featureName, + }; + }; + + it('should log the correct message', () => { + const { exception, featureName } = setup(); + + const result = exception.getLogMessage(); + + expect(result).toEqual({ + type: 'FEATURE_DISABLED', + stack: expect.any(String), + data: { + featureName, + }, + }); + }); + }); +}); diff --git a/apps/server/src/shared/common/loggable-exception/feature-disabled.loggable-exception.ts b/apps/server/src/shared/common/loggable-exception/feature-disabled.loggable-exception.ts new file mode 100644 index 00000000000..d841c254f72 --- /dev/null +++ b/apps/server/src/shared/common/loggable-exception/feature-disabled.loggable-exception.ts @@ -0,0 +1,21 @@ +import { ForbiddenException } from '@nestjs/common'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage } from '@src/core/logger/types'; + +export class FeatureDisabledLoggableException extends ForbiddenException implements Loggable { + constructor(private readonly featureName: string) { + super(); + } + + getLogMessage(): ErrorLogMessage { + const message: ErrorLogMessage = { + type: 'FEATURE_DISABLED', + stack: this.stack, + data: { + featureName: this.featureName, + }, + }; + + return message; + } +} diff --git a/apps/server/src/shared/common/loggable-exception/index.ts b/apps/server/src/shared/common/loggable-exception/index.ts index 680def0687f..0456d32a31d 100644 --- a/apps/server/src/shared/common/loggable-exception/index.ts +++ b/apps/server/src/shared/common/loggable-exception/index.ts @@ -1,2 +1,3 @@ export * from './not-found.loggable-exception'; export * from './validation-error.loggable-exception'; +export { FeatureDisabledLoggableException } from './feature-disabled.loggable-exception'; diff --git a/apps/server/src/shared/common/loggable/index.ts b/apps/server/src/shared/common/loggable/index.ts index 988b184d393..5f21a462625 100644 --- a/apps/server/src/shared/common/loggable/index.ts +++ b/apps/server/src/shared/common/loggable/index.ts @@ -1,2 +1 @@ export { ReferencedEntityNotFoundLoggable } from './referenced-entity-not-found-loggable'; -export { DataDeletionDomainOperationLoggable } from './data-deletion-domain-operation-loggable'; diff --git a/apps/server/src/shared/common/loggable/referenced-entity-not-found-loggable.spec.ts b/apps/server/src/shared/common/loggable/referenced-entity-not-found-loggable.spec.ts index f58e29f0db8..1dd1e90d03a 100644 --- a/apps/server/src/shared/common/loggable/referenced-entity-not-found-loggable.spec.ts +++ b/apps/server/src/shared/common/loggable/referenced-entity-not-found-loggable.spec.ts @@ -1,4 +1,4 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { ReferencedEntityNotFoundLoggable } from './referenced-entity-not-found-loggable'; import { EntityId } from '../../domain/types'; diff --git a/apps/server/src/shared/common/utils/sort-helper.spec.ts b/apps/server/src/shared/common/utils/sort-helper.spec.ts index 2f7ef11d091..f7afeb73a72 100644 --- a/apps/server/src/shared/common/utils/sort-helper.spec.ts +++ b/apps/server/src/shared/common/utils/sort-helper.spec.ts @@ -11,6 +11,14 @@ describe('SortHelper', () => { }); }); + describe('when a is defined and b is null', () => { + it('should return more than 0', () => { + const result: number = SortHelper.genericSortFunction(1, null, SortOrder.asc); + + expect(result).toBeGreaterThan(0); + }); + }); + describe('when a is undefined and b is defined', () => { it('should return less than 0', () => { const result: number = SortHelper.genericSortFunction(undefined, 1, SortOrder.asc); @@ -19,6 +27,14 @@ describe('SortHelper', () => { }); }); + describe('when a is null and b is defined', () => { + it('should return less than 0', () => { + const result: number = SortHelper.genericSortFunction(null, 1, SortOrder.asc); + + expect(result).toBeLessThan(0); + }); + }); + describe('when a and b are both undefined', () => { it('should return 0', () => { const result: number = SortHelper.genericSortFunction(undefined, undefined, SortOrder.asc); @@ -27,23 +43,31 @@ describe('SortHelper', () => { }); }); - describe('when a is a greater number than b', () => { + describe('when a and b are both null', () => { + it('should return 0', () => { + const result: number = SortHelper.genericSortFunction(null, null, SortOrder.asc); + + expect(result).toEqual(0); + }); + }); + + describe('when number a is a greater number than number b', () => { it('should return greater than 0', () => { - const result: number = SortHelper.genericSortFunction(2, 1, SortOrder.asc); + const result: number = SortHelper.genericSortFunction(1, 0, SortOrder.asc); expect(result).toBeGreaterThan(0); }); }); - describe('when b is a greater number than a', () => { + describe('when number b is a greater number than number a', () => { it('should return less than 0', () => { - const result: number = SortHelper.genericSortFunction(1, 2, SortOrder.asc); + const result: number = SortHelper.genericSortFunction(0, 1, SortOrder.asc); expect(result).toBeLessThan(0); }); }); - describe('when a is later in the alphabet as b', () => { + describe('when string a is later in the alphabet as string b', () => { it('should return greater than 0', () => { const result: number = SortHelper.genericSortFunction('B', 'A', SortOrder.asc); @@ -51,7 +75,7 @@ describe('SortHelper', () => { }); }); - describe('when b is later in the alphabet as a', () => { + describe('when string b is later in the alphabet as string a', () => { it('should return less than 0', () => { const result: number = SortHelper.genericSortFunction('A', 'B', SortOrder.asc); @@ -59,7 +83,55 @@ describe('SortHelper', () => { }); }); - describe('when a is greater than b, but the order is reversed', () => { + describe('when object a as value string is later in the alphabet as object b as value string', () => { + it('should return greater than 0', () => { + const result: number = SortHelper.genericSortFunction({ test: 'B' }, { test: 'A' }, SortOrder.asc); + + expect(result).toBeGreaterThan(0); + }); + }); + + describe('when object b as value string is later in the alphabet as object a as value string', () => { + it('should return less than 0', () => { + const result: number = SortHelper.genericSortFunction({ test: 'A' }, { test: 'B' }, SortOrder.asc); + + expect(result).toBeLessThan(0); + }); + }); + + describe('when array a as value string is later in the alphabet as array b as value string', () => { + it('should return greater than 0', () => { + const result: number = SortHelper.genericSortFunction(['B'], ['A'], SortOrder.asc); + + expect(result).toBeGreaterThan(0); + }); + }); + + describe('when array b as value string is later in the alphabet as array a as value string', () => { + it('should return less than 0', () => { + const result: number = SortHelper.genericSortFunction(['A'], ['B'], SortOrder.asc); + + expect(result).toBeLessThan(0); + }); + }); + + describe('when array b as value string is later in the alphabet as array a as value string', () => { + it('should return less than 0', () => { + const result: number = SortHelper.genericSortFunction([], ['A'], SortOrder.asc); + + expect(result).toBeLessThan(0); + }); + }); + + describe('when array b as value string is later in the alphabet as array a as value string', () => { + it('should return greater than 0', () => { + const result: number = SortHelper.genericSortFunction(['A'], [], SortOrder.asc); + + expect(result).toBeGreaterThan(0); + }); + }); + + describe('when number a is greater than number b, but the order is reversed', () => { it('should return less than 0', () => { const result: number = SortHelper.genericSortFunction(2, 1, SortOrder.desc); diff --git a/apps/server/src/shared/common/utils/sort-helper.ts b/apps/server/src/shared/common/utils/sort-helper.ts index f6726aee67a..56015f538cb 100644 --- a/apps/server/src/shared/common/utils/sort-helper.ts +++ b/apps/server/src/shared/common/utils/sort-helper.ts @@ -4,14 +4,16 @@ export class SortHelper { public static genericSortFunction(a: T, b: T, sortOrder: SortOrder = SortOrder.asc): number { let order: number; - if (typeof a !== 'undefined' && typeof b === 'undefined') { + if (a != null && b == null) { order = 1; - } else if (typeof a === 'undefined' && typeof b !== 'undefined') { + } else if (a == null && b != null) { order = -1; } else if (typeof a === 'string' && typeof b === 'string') { order = a.localeCompare(b); } else if (typeof a === 'number' && typeof b === 'number') { order = a - b; + } else if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) { + order = Object.values(a).join().localeCompare(Object.values(b).join()); } else { order = 0; } diff --git a/apps/server/src/shared/domain/builder/domain-operation.builder.spec.ts b/apps/server/src/shared/domain/builder/domain-operation.builder.spec.ts deleted file mode 100644 index 940224c3cf7..00000000000 --- a/apps/server/src/shared/domain/builder/domain-operation.builder.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { DomainModel } from '@shared/domain/types'; -import { ObjectId } from 'bson'; -import { DomainOperationBuilder } from '.'; - -describe(DomainOperationBuilder.name, () => { - afterAll(() => { - jest.clearAllMocks(); - }); - - const setup = () => { - const domain = DomainModel.PSEUDONYMS; - const modifiedCount = 0; - const modifiedRef = []; - const deletedRef = [new ObjectId().toHexString(), new ObjectId().toHexString()]; - const deletedCount = 2; - - return { domain, modifiedCount, deletedCount, modifiedRef, deletedRef }; - }; - - it('should build generic domainOperation with all attributes', () => { - const { domain, modifiedCount, deletedCount, modifiedRef, deletedRef } = setup(); - - const result = DomainOperationBuilder.build(domain, modifiedCount, deletedCount, modifiedRef, deletedRef); - - expect(result.domain).toEqual(domain); - expect(result.modifiedCount).toEqual(modifiedCount); - expect(result.deletedCount).toEqual(deletedCount); - }); -}); diff --git a/apps/server/src/shared/domain/builder/domain-operation.builder.ts b/apps/server/src/shared/domain/builder/domain-operation.builder.ts deleted file mode 100644 index b9c6619482f..00000000000 --- a/apps/server/src/shared/domain/builder/domain-operation.builder.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DomainOperation } from '@shared/domain/interface'; -import { DomainModel } from '@shared/domain/types'; - -export class DomainOperationBuilder { - static build( - domain: DomainModel, - modifiedCount: number, - deletedCount: number, - modifiedRef?: string[], - deletedRef?: string[] - ): DomainOperation { - const domainOperation = { domain, modifiedCount, deletedCount, modifiedRef, deletedRef }; - - return domainOperation; - } -} diff --git a/apps/server/src/shared/domain/builder/index.ts b/apps/server/src/shared/domain/builder/index.ts deleted file mode 100644 index 5f9d180968d..00000000000 --- a/apps/server/src/shared/domain/builder/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './domain-operation.builder'; diff --git a/apps/server/src/shared/domain/domain-objects.spec.ts b/apps/server/src/shared/domain/domain-objects.spec.ts index fd4e8602288..1412647e7c4 100644 --- a/apps/server/src/shared/domain/domain-objects.spec.ts +++ b/apps/server/src/shared/domain/domain-objects.spec.ts @@ -1,4 +1,4 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DomainObject } from './domain-object'; import { EntityId } from './types'; diff --git a/apps/server/src/shared/domain/domainobject/board/board-composite.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/board-composite.do.spec.ts index 0cc5b19b8c4..2601bf0fdce 100644 --- a/apps/server/src/shared/domain/domainobject/board/board-composite.do.spec.ts +++ b/apps/server/src/shared/domain/domainobject/board/board-composite.do.spec.ts @@ -1,4 +1,4 @@ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BoardComposite, BoardCompositeProps } from './board-composite.do'; import { AnyBoardDo } from './types'; diff --git a/apps/server/src/shared/domain/domainobject/board/card.do.ts b/apps/server/src/shared/domain/domainobject/board/card.do.ts index bba64a9dd9b..2e18326c416 100644 --- a/apps/server/src/shared/domain/domainobject/board/card.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/card.do.ts @@ -49,6 +49,8 @@ export interface CardProps extends BoardCompositeProps { height: number; } +export type CardInitProps = Omit; + export function isCard(reference: unknown): reference is Card { return reference instanceof Card; } diff --git a/apps/server/src/shared/domain/domainobject/board/column-board.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/column-board.do.spec.ts index be266f2253f..c56f9efa787 100644 --- a/apps/server/src/shared/domain/domainobject/board/column-board.do.spec.ts +++ b/apps/server/src/shared/domain/domainobject/board/column-board.do.spec.ts @@ -1,6 +1,6 @@ import { createMock } from '@golevelup/ts-jest'; import { columnBoardFactory, columnFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { ColumnBoard } from './column-board.do'; import { BoardCompositeVisitor, BoardCompositeVisitorAsync, BoardExternalReferenceType } from './types'; @@ -45,4 +45,14 @@ describe(ColumnBoard.name, () => { expect(columnBoard.context).toEqual(context); }); }); + + describe('set isVisible', () => { + it('should store isVisible', () => { + const columnBoard = columnBoardFactory.build(); + + columnBoard.isVisible = true; + + expect(columnBoard.isVisible).toBe(true); + }); + }); }); diff --git a/apps/server/src/shared/domain/domainobject/board/column-board.do.ts b/apps/server/src/shared/domain/domainobject/board/column-board.do.ts index decd3c23d6f..de420852694 100644 --- a/apps/server/src/shared/domain/domainobject/board/column-board.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/column-board.do.ts @@ -19,6 +19,14 @@ export class ColumnBoard extends BoardComposite { this.props.context = context; } + get isVisible(): boolean { + return this.props.isVisible; + } + + set isVisible(isVisible: boolean) { + this.props.isVisible = isVisible; + } + isAllowedAsChild(domainObject: AnyBoardDo): boolean { const allowed = domainObject instanceof Column; return allowed; @@ -36,6 +44,7 @@ export class ColumnBoard extends BoardComposite { export interface ColumnBoardProps extends BoardCompositeProps { title: string; context: BoardExternalReference; + isVisible: boolean; } export function isColumnBoard(reference: unknown): reference is ColumnBoard { diff --git a/apps/server/src/shared/domain/domainobject/board/column.do.ts b/apps/server/src/shared/domain/domainobject/board/column.do.ts index ffc79078612..0b736f43ecc 100644 --- a/apps/server/src/shared/domain/domainobject/board/column.do.ts +++ b/apps/server/src/shared/domain/domainobject/board/column.do.ts @@ -29,6 +29,8 @@ export interface ColumnProps extends BoardCompositeProps { title: string; } +export type ColumnInitProps = Omit; + export function isColumn(reference: unknown): reference is Column { return reference instanceof Column; } diff --git a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts index 4f71b96bf8f..91efd8c6e96 100644 --- a/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts +++ b/apps/server/src/shared/domain/domainobject/board/content-element.factory.ts @@ -1,6 +1,6 @@ import { Injectable, NotImplementedException } from '@nestjs/common'; import { InputFormat } from '@shared/domain/types'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { ExternalToolElement } from './external-tool-element.do'; import { DrawingElement } from './drawing-element.do'; import { FileElement } from './file-element.do'; diff --git a/apps/server/src/shared/domain/domainobject/board/index.ts b/apps/server/src/shared/domain/domainobject/board/index.ts index bb82ee91e7c..7985c051dbd 100644 --- a/apps/server/src/shared/domain/domainobject/board/index.ts +++ b/apps/server/src/shared/domain/domainobject/board/index.ts @@ -12,3 +12,4 @@ export * from './submission-container-element.do'; export * from './submission-item.do'; export * from './submission-item.factory'; export * from './types'; +export * from './media-board'; diff --git a/apps/server/src/shared/domain/domainobject/board/media-board/index.ts b/apps/server/src/shared/domain/domainobject/board/media-board/index.ts new file mode 100644 index 00000000000..4fe833d35c0 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/media-board/index.ts @@ -0,0 +1,8 @@ +export { MediaBoard, MediaBoardProps } from './media-board.do'; +export { MediaLine, MediaLineProps, MediaLineInitProps, isMediaLine } from './media-line.do'; +export { + MediaExternalToolElement, + MediaExternalToolElementProps, + MediaExternalToolElementInitProps, + isMediaExternalToolElement, +} from './media-external-tool-element.do'; diff --git a/apps/server/src/shared/domain/domainobject/board/media-board/media-board.do.ts b/apps/server/src/shared/domain/domainobject/board/media-board/media-board.do.ts new file mode 100644 index 00000000000..6522a447adf --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/media-board/media-board.do.ts @@ -0,0 +1,27 @@ +import { BoardComposite, BoardCompositeProps } from '../board-composite.do'; +import type { AnyBoardDo, BoardCompositeVisitor, BoardCompositeVisitorAsync, BoardExternalReference } from '../types'; +import { MediaLine } from './media-line.do'; + +export class MediaBoard extends BoardComposite { + get context(): BoardExternalReference { + return this.props.context; + } + + isAllowedAsChild(domainObject: AnyBoardDo): boolean { + const allowed: boolean = domainObject instanceof MediaLine; + + return allowed; + } + + accept(visitor: BoardCompositeVisitor): void { + visitor.visitMediaBoard(this); + } + + async acceptAsync(visitor: BoardCompositeVisitorAsync): Promise { + await visitor.visitMediaBoardAsync(this); + } +} + +export interface MediaBoardProps extends BoardCompositeProps { + context: BoardExternalReference; +} diff --git a/apps/server/src/shared/domain/domainobject/board/media-board/media-external-tool-element.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/media-board/media-external-tool-element.do.spec.ts new file mode 100644 index 00000000000..7cb3da16522 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/media-board/media-external-tool-element.do.spec.ts @@ -0,0 +1,13 @@ +import { mediaExternalToolElementFactory } from '@shared/testing'; +import { MediaExternalToolElement } from './media-external-tool-element.do'; + +describe(MediaExternalToolElement.name, () => { + describe('when trying to add a child to a media external tool element', () => { + it('should throw an error ', () => { + const externalToolElement = mediaExternalToolElementFactory.build(); + const externalToolElementChild = mediaExternalToolElementFactory.build(); + + expect(() => externalToolElement.addChild(externalToolElementChild)).toThrow(); + }); + }); +}); diff --git a/apps/server/src/shared/domain/domainobject/board/media-board/media-external-tool-element.do.ts b/apps/server/src/shared/domain/domainobject/board/media-board/media-external-tool-element.do.ts new file mode 100644 index 00000000000..e399864f750 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/media-board/media-external-tool-element.do.ts @@ -0,0 +1,31 @@ +import { EntityId } from '../../../types'; +import { BoardComposite, BoardCompositeProps } from '../board-composite.do'; +import type { BoardCompositeVisitor, BoardCompositeVisitorAsync } from '../types'; + +export class MediaExternalToolElement extends BoardComposite { + get contextExternalToolId(): EntityId { + return this.props.contextExternalToolId; + } + + isAllowedAsChild(): boolean { + return false; + } + + accept(visitor: BoardCompositeVisitor): void { + visitor.visitMediaExternalToolElement(this); + } + + async acceptAsync(visitor: BoardCompositeVisitorAsync): Promise { + await visitor.visitMediaExternalToolElementAsync(this); + } +} + +export interface MediaExternalToolElementProps extends BoardCompositeProps { + contextExternalToolId: EntityId; +} + +export type MediaExternalToolElementInitProps = Omit; + +export function isMediaExternalToolElement(reference: unknown): reference is MediaExternalToolElement { + return reference instanceof MediaExternalToolElement; +} diff --git a/apps/server/src/shared/domain/domainobject/board/media-board/media-line.do.ts b/apps/server/src/shared/domain/domainobject/board/media-board/media-line.do.ts new file mode 100644 index 00000000000..1e8402a1ff9 --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/media-board/media-line.do.ts @@ -0,0 +1,37 @@ +import { BoardComposite, BoardCompositeProps } from '../board-composite.do'; +import type { AnyBoardDo, BoardCompositeVisitor, BoardCompositeVisitorAsync } from '../types'; +import { MediaExternalToolElement } from './media-external-tool-element.do'; + +export class MediaLine extends BoardComposite { + get title(): string { + return this.props.title; + } + + set title(title: string) { + this.props.title = title; + } + + isAllowedAsChild(domainObject: AnyBoardDo): boolean { + const allowed: boolean = domainObject instanceof MediaExternalToolElement; + + return allowed; + } + + accept(visitor: BoardCompositeVisitor): void { + visitor.visitMediaLine(this); + } + + async acceptAsync(visitor: BoardCompositeVisitorAsync): Promise { + await visitor.visitMediaLineAsync(this); + } +} + +export interface MediaLineProps extends BoardCompositeProps { + title: string; +} + +export type MediaLineInitProps = Omit; + +export function isMediaLine(reference: unknown): reference is MediaLine { + return reference instanceof MediaLine; +} diff --git a/apps/server/src/shared/domain/domainobject/board/submission-item.do.spec.ts b/apps/server/src/shared/domain/domainobject/board/submission-item.do.spec.ts index 2b949f19556..df7b4b6f95e 100644 --- a/apps/server/src/shared/domain/domainobject/board/submission-item.do.spec.ts +++ b/apps/server/src/shared/domain/domainobject/board/submission-item.do.spec.ts @@ -1,6 +1,6 @@ import { createMock } from '@golevelup/ts-jest'; import { submissionContainerElementFactory, submissionItemFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { SubmissionItem } from './submission-item.do'; import { BoardCompositeVisitor, BoardCompositeVisitorAsync } from './types'; diff --git a/apps/server/src/shared/domain/domainobject/board/submission-item.factory.ts b/apps/server/src/shared/domain/domainobject/board/submission-item.factory.ts index fb660fc921c..c9c5a4b3f4d 100644 --- a/apps/server/src/shared/domain/domainobject/board/submission-item.factory.ts +++ b/apps/server/src/shared/domain/domainobject/board/submission-item.factory.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { SubmissionItem } from './submission-item.do'; @Injectable() diff --git a/apps/server/src/shared/domain/domainobject/board/types/any-board-do.ts b/apps/server/src/shared/domain/domainobject/board/types/any-board-do.ts index c3504070f36..a9083e4d3d8 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/any-board-do.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/any-board-do.ts @@ -3,5 +3,6 @@ import { ColumnBoard } from '../column-board.do'; import { Column } from '../column.do'; import { SubmissionItem } from '../submission-item.do'; import { AnyContentElementDo } from './any-content-element-do'; +import { AnyMediaBoardDo } from './any-media-board-do'; -export type AnyBoardDo = ColumnBoard | Column | Card | AnyContentElementDo | SubmissionItem; +export type AnyBoardDo = ColumnBoard | Column | Card | AnyContentElementDo | SubmissionItem | AnyMediaBoardDo; diff --git a/apps/server/src/shared/domain/domainobject/board/types/any-media-board-do.ts b/apps/server/src/shared/domain/domainobject/board/types/any-media-board-do.ts new file mode 100644 index 00000000000..be18e6de06b --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/types/any-media-board-do.ts @@ -0,0 +1,4 @@ +import type { MediaBoard, MediaLine } from '../media-board'; +import type { AnyMediaContentElementDo } from './any-media-content-element-do'; + +export type AnyMediaBoardDo = MediaBoard | MediaLine | AnyMediaContentElementDo; diff --git a/apps/server/src/shared/domain/domainobject/board/types/any-media-content-element-do.ts b/apps/server/src/shared/domain/domainobject/board/types/any-media-content-element-do.ts new file mode 100644 index 00000000000..f8b7b137cdc --- /dev/null +++ b/apps/server/src/shared/domain/domainobject/board/types/any-media-content-element-do.ts @@ -0,0 +1,10 @@ +import { MediaExternalToolElement } from '../media-board'; +import type { AnyBoardDo } from './any-board-do'; + +export type AnyMediaContentElementDo = MediaExternalToolElement; + +export const isAnyMediaContentElement = (element: AnyBoardDo): element is AnyMediaContentElementDo => { + const result: boolean = element instanceof MediaExternalToolElement; + + return result; +}; diff --git a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts index 5e2547bbf6b..0d0eef8377e 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/board-composite-visitor.ts @@ -1,15 +1,19 @@ -import { DrawingElement } from '../drawing-element.do'; import type { Card } from '../card.do'; import type { ColumnBoard } from '../column-board.do'; import type { Column } from '../column.do'; +import type { DrawingElement } from '../drawing-element.do'; import type { ExternalToolElement } from '../external-tool-element.do'; import type { FileElement } from '../file-element.do'; import type { LinkElement } from '../link-element.do'; +import type { MediaBoard, MediaExternalToolElement, MediaLine } from '../media-board'; import type { RichTextElement } from '../rich-text-element.do'; import type { SubmissionContainerElement } from '../submission-container-element.do'; import type { SubmissionItem } from '../submission-item.do'; -export interface BoardCompositeVisitor { +export interface BoardCompositeVisitor extends MediaBoardCompositeVisitor, ColumnBoardCompositeVisitor {} +export interface BoardCompositeVisitorAsync extends MediaBoardCompositeVisitorAsync, ColumnBoardCompositeVisitorAsync {} + +export interface ColumnBoardCompositeVisitor { visitColumnBoard(columnBoard: ColumnBoard): void; visitColumn(column: Column): void; visitCard(card: Card): void; @@ -22,7 +26,7 @@ export interface BoardCompositeVisitor { visitExternalToolElement(externalToolElement: ExternalToolElement): void; } -export interface BoardCompositeVisitorAsync { +export interface ColumnBoardCompositeVisitorAsync { visitColumnBoardAsync(columnBoard: ColumnBoard): Promise; visitColumnAsync(column: Column): Promise; visitCardAsync(card: Card): Promise; @@ -34,3 +38,15 @@ export interface BoardCompositeVisitorAsync { visitSubmissionItemAsync(submissionItem: SubmissionItem): Promise; visitExternalToolElementAsync(externalToolElement: ExternalToolElement): Promise; } + +export interface MediaBoardCompositeVisitor { + visitMediaBoard(mediaBoard: MediaBoard): void; + visitMediaLine(mediaLine: MediaLine): void; + visitMediaExternalToolElement(mediaElement: MediaExternalToolElement): void; +} + +export interface MediaBoardCompositeVisitorAsync { + visitMediaBoardAsync(mediaBoard: MediaBoard): Promise; + visitMediaLineAsync(mediaLine: MediaLine): Promise; + visitMediaExternalToolElementAsync(mediaElement: MediaExternalToolElement): Promise; +} diff --git a/apps/server/src/shared/domain/domainobject/board/types/board-do-authorizable.ts b/apps/server/src/shared/domain/domainobject/board/types/board-do-authorizable.ts index 86fb303471f..da44db61602 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/board-do-authorizable.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/board-do-authorizable.ts @@ -1,43 +1,51 @@ import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; import { EntityId } from '@shared/domain/types'; +import { ColumnBoard } from '../column-board.do'; +import { MediaBoard } from '../media-board'; +import { AnyBoardDo } from './any-board-do'; export enum BoardRoles { EDITOR = 'editor', READER = 'reader', } -/** - deprecated: This is a temporary solution. This will be replaced with a more proper permission system. -*/ -export enum UserRoleEnum { - TEACHER = 'teacher', - STUDENT = 'student', - SUBSTITUTION_TEACHER = 'subsitution teacher', -} -export interface UserBoardRoles { +export interface UserWithBoardRoles { firstName?: string; lastName?: string; roles: BoardRoles[]; userId: EntityId; - userRoleEnum: UserRoleEnum; } export interface BoardDoAuthorizableProps extends AuthorizableObject { id: EntityId; - users: UserBoardRoles[]; - requiredUserRole?: UserRoleEnum; + users: UserWithBoardRoles[]; + boardDo: AnyBoardDo; + rootDo: ColumnBoard | MediaBoard; + parentDo?: AnyBoardDo; } export class BoardDoAuthorizable extends DomainObject { - get users(): UserBoardRoles[] { + get users(): UserWithBoardRoles[] { return this.props.users; } - get requiredUserRole(): UserRoleEnum | undefined { - return this.props.requiredUserRole; + get boardDo(): AnyBoardDo { + return this.props.boardDo; + } + + set boardDo(value: AnyBoardDo) { + this.props.boardDo = value; + } + + get parentDo(): AnyBoardDo | undefined { + return this.props.parentDo; + } + + set parentDo(value: AnyBoardDo | undefined) { + this.props.parentDo = value; } - set requiredUserRole(userRoleEnum: UserRoleEnum | undefined) { - this.props.requiredUserRole = userRoleEnum; + get rootDo(): ColumnBoard | MediaBoard { + return this.props.rootDo; } } diff --git a/apps/server/src/shared/domain/domainobject/board/types/board-external-reference.ts b/apps/server/src/shared/domain/domainobject/board/types/board-external-reference.ts index 5d16a6853d9..4f3c11de420 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/board-external-reference.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/board-external-reference.ts @@ -2,6 +2,7 @@ import { EntityId } from '@shared/domain/types'; export enum BoardExternalReferenceType { 'Course' = 'course', + 'User' = 'user', } export interface BoardExternalReference { diff --git a/apps/server/src/shared/domain/domainobject/board/types/index.ts b/apps/server/src/shared/domain/domainobject/board/types/index.ts index dcb44e1656d..0c3298673a0 100644 --- a/apps/server/src/shared/domain/domainobject/board/types/index.ts +++ b/apps/server/src/shared/domain/domainobject/board/types/index.ts @@ -1,5 +1,7 @@ export * from './any-board-do'; export * from './any-content-element-do'; +export { AnyMediaBoardDo } from './any-media-board-do'; +export { AnyMediaContentElementDo, isAnyMediaContentElement } from './any-media-content-element-do'; export * from './board-composite-visitor'; export * from './board-do-authorizable'; export * from './board-external-reference'; diff --git a/apps/server/src/shared/domain/domainobject/legacy-school.do.ts b/apps/server/src/shared/domain/domainobject/legacy-school.do.ts index 25ac51e494e..89d41f7b8e2 100644 --- a/apps/server/src/shared/domain/domainobject/legacy-school.do.ts +++ b/apps/server/src/shared/domain/domainobject/legacy-school.do.ts @@ -30,6 +30,8 @@ export class LegacySchoolDo extends BaseDO { // TODO: N21-990 Refactoring: Create domain objects for schoolYear and federalState federalState: FederalStateEntity; + ldapLastSync?: string; + constructor(params: LegacySchoolDo) { super(); this.id = params.id; @@ -44,5 +46,6 @@ export class LegacySchoolDo extends BaseDO { this.systems = params.systems; this.userLoginMigrationId = params.userLoginMigrationId; this.federalState = params.federalState; + this.ldapLastSync = params.ldapLastSync; } } diff --git a/apps/server/src/shared/domain/domainobject/user.do.ts b/apps/server/src/shared/domain/domainobject/user.do.ts index 5c0deab549b..016ad3c29a0 100644 --- a/apps/server/src/shared/domain/domainobject/user.do.ts +++ b/apps/server/src/shared/domain/domainobject/user.do.ts @@ -1,4 +1,4 @@ -import { LanguageType } from '@shared/domain/entity'; +import { LanguageType } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; import { BaseDO } from './base.do'; import { RoleReference } from './role-reference'; diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 7163ffb9f12..19923d1c07b 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -4,13 +4,14 @@ import { SchoolSystemOptionsEntity } from '@modules/legacy-school/entity'; import { ExternalToolPseudonymEntity, PseudonymEntity } from '@modules/pseudonym/entity'; import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { ShareToken } from '@modules/sharing/entity/share-token.entity'; +import { TldrawDrawing } from '@modules/tldraw/entities'; import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; -import { DeletionLogEntity, DeletionRequestEntity } from '@src/modules/deletion/entity'; +import { AccountEntity } from '@modules/account/entity/account.entity'; +import { DeletionLogEntity } from '@src/modules/deletion/repo/entity/deletion-log.entity'; +import { DeletionRequestEntity } from '@src/modules/deletion/repo/entity/deletion-request.entity'; import { RocketChatUserEntity } from '@src/modules/rocketchat-user/entity'; -import { TldrawDrawing } from '@modules/tldraw/entities'; -import { Account } from './account.entity'; import { BoardNode, CardNode, @@ -20,6 +21,9 @@ import { ExternalToolElementNodeEntity, FileElementNode, LinkElementNode, + MediaBoardNode, + MediaExternalToolElementNode, + MediaLineNode, RichTextElementNode, SubmissionContainerElementNode, SubmissionItemNode, @@ -30,10 +34,9 @@ import { DashboardGridElementModel, DashboardModelEntity } from './dashboard.mod import { CountyEmbeddable, FederalStateEntity } from './federal-state.entity'; import { ImportUser } from './import-user.entity'; import { - Board, - BoardElement, ColumnboardBoardElement, - ColumnBoardTarget, + LegacyBoard, + LegacyBoardElement, LessonBoardElement, TaskBoardElement, } from './legacy-board'; @@ -54,14 +57,13 @@ import { User } from './user.entity'; import { VideoConference } from './video-conference.entity'; export const ALL_ENTITIES = [ - Account, - Board, - BoardElement, + AccountEntity, + LegacyBoard, + LegacyBoardElement, BoardNode, CardNode, ColumnboardBoardElement, ColumnBoardNode, - ColumnBoardTarget, ColumnNode, ClassEntity, DeletionRequestEntity, @@ -73,6 +75,9 @@ export const ALL_ENTITIES = [ SubmissionContainerElementNode, SubmissionItemNode, ExternalToolElementNodeEntity, + MediaBoardNode, + MediaLineNode, + MediaExternalToolElementNode, ContextExternalToolEntity, CountyEmbeddable, Course, diff --git a/apps/server/src/shared/domain/entity/base.entity.spec.ts b/apps/server/src/shared/domain/entity/base.entity.spec.ts index 9fd8d3db619..dec7f12a5b1 100644 --- a/apps/server/src/shared/domain/entity/base.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/base.entity.spec.ts @@ -1,6 +1,6 @@ import { Entity } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; import { setupEntities } from '@shared/testing'; -import { ObjectId } from 'mongodb'; import { BaseEntity } from './base.entity'; @Entity() diff --git a/apps/server/src/shared/domain/entity/base.entity.ts b/apps/server/src/shared/domain/entity/base.entity.ts index 5508ab6a3dc..3e41b7dc7a2 100644 --- a/apps/server/src/shared/domain/entity/base.entity.ts +++ b/apps/server/src/shared/domain/entity/base.entity.ts @@ -29,10 +29,10 @@ export abstract class BaseEntityWithTimestamps implements Auth @SerializedPrimaryKey() id!: string; - @Property() + @Property({ type: Date }) createdAt = new Date(); - @Property({ onUpdate: () => new Date() }) + @Property({ type: Date, onUpdate: () => new Date() }) updatedAt = new Date(); } diff --git a/apps/server/src/shared/domain/entity/boardnode/boardnode.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/boardnode.entity.spec.ts index 9fcc113e41c..131789557ac 100644 --- a/apps/server/src/shared/domain/entity/boardnode/boardnode.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/boardnode/boardnode.entity.spec.ts @@ -17,6 +17,7 @@ describe(BoardNode.name, () => { parent: board, title: 'column #1', context: { type: BoardExternalReferenceType.Course, id: 'course1' }, + isVisible: true, }); column.title = 'hate to get useless sonar lint errors'; }).toThrowError(); diff --git a/apps/server/src/shared/domain/entity/boardnode/boardnode.entity.ts b/apps/server/src/shared/domain/entity/boardnode/boardnode.entity.ts index 9997a678ff8..9877f6a1e4f 100644 --- a/apps/server/src/shared/domain/entity/boardnode/boardnode.entity.ts +++ b/apps/server/src/shared/domain/entity/boardnode/boardnode.entity.ts @@ -3,12 +3,11 @@ import { InternalServerErrorException } from '@nestjs/common'; import { AnyBoardDo } from '../../domainobject'; import { EntityId } from '../../types'; import { BaseEntityWithTimestamps } from '../base.entity'; -import { BoardDoBuilder } from './types'; -import { BoardNodeType } from './types/board-node-type'; +import { BoardDoBuilder, BoardNodeType } from './types'; const PATH_SEPARATOR = ','; -@Entity({ tableName: 'boardnodes', discriminatorColumn: 'type' }) +@Entity({ tableName: 'boardnodes', discriminatorColumn: 'type', abstract: true }) export abstract class BoardNode extends BaseEntityWithTimestamps { constructor(props: BoardNodeProps) { super(); diff --git a/apps/server/src/shared/domain/entity/boardnode/column-board-node.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/column-board-node.entity.spec.ts new file mode 100644 index 00000000000..aa82f8eeeea --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/column-board-node.entity.spec.ts @@ -0,0 +1,23 @@ +import { columnBoardNodeFactory, setupEntities } from '@shared/testing'; +import { ColumnBoardNode } from './column-board-node.entity'; + +describe(ColumnBoardNode.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('publish', () => { + it('should set isVisible to true', () => { + const columnBoard = columnBoardNodeFactory.build(); + columnBoard.publish(); + expect(columnBoard.isVisible).toBe(true); + }); + }); + describe('unpublish', () => { + it('should set isVisible to false', () => { + const columnBoard = columnBoardNodeFactory.build(); + columnBoard.unpublish(); + expect(columnBoard.isVisible).toBe(false); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/boardnode/column-board-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/column-board-node.entity.ts index 11764f10ae1..8539fefe2a2 100644 --- a/apps/server/src/shared/domain/entity/boardnode/column-board-node.entity.ts +++ b/apps/server/src/shared/domain/entity/boardnode/column-board-node.entity.ts @@ -1,22 +1,26 @@ import { Entity, Property } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; import { AnyBoardDo, BoardExternalReference, BoardExternalReferenceType, } from '@shared/domain/domainobject/board/types'; -import { ObjectId } from 'bson'; -import { BoardNode, BoardNodeProps } from './boardnode.entity'; -import { BoardDoBuilder } from './types'; -import { BoardNodeType } from './types/board-node-type'; +import { LearnroomElement } from '../../interface'; +import { BoardNode } from './boardnode.entity'; +import { type RootBoardNodeProps } from './root-board-node.entity'; +import { BoardDoBuilder, BoardNodeType } from './types'; +// TODO Use an abstract base class for root nodes that have a contextId and a contextType. Multiple STI abstract base classes are blocked by MikroORM 6.1.2 (issue #3745) @Entity({ discriminatorValue: BoardNodeType.COLUMN_BOARD }) -export class ColumnBoardNode extends BoardNode { +export class ColumnBoardNode extends BoardNode implements LearnroomElement { constructor(props: ColumnBoardNodeProps) { super(props); this.type = BoardNodeType.COLUMN_BOARD; this._contextType = props.context.type; this._contextId = new ObjectId(props.context.id); + + this.isVisible = props.isVisible ?? false; } @Property({ fieldName: 'contextType' }) @@ -25,6 +29,9 @@ export class ColumnBoardNode extends BoardNode { @Property({ fieldName: 'context' }) _contextId: ObjectId; + @Property({ type: 'boolean', nullable: false }) + isVisible = false; + get context(): BoardExternalReference { return { type: this._contextType, @@ -36,8 +43,22 @@ export class ColumnBoardNode extends BoardNode { const domainObject = builder.buildColumnBoard(this); return domainObject; } + + /** + * @deprecated - this is here only for the sake of the legacy-board (lernraum) + */ + publish(): void { + this.isVisible = true; + } + + /** + * @deprecated - this is here only for the sake of the legacy-board (lernraum) + */ + unpublish(): void { + this.isVisible = false; + } } -export interface ColumnBoardNodeProps extends BoardNodeProps { - context: BoardExternalReference; +export interface ColumnBoardNodeProps extends RootBoardNodeProps { + isVisible: boolean; } diff --git a/apps/server/src/shared/domain/entity/boardnode/external-tool-element-node.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/external-tool-element-node.entity.spec.ts index 83a51d42e6e..954e4ddc19f 100644 --- a/apps/server/src/shared/domain/entity/boardnode/external-tool-element-node.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/boardnode/external-tool-element-node.entity.spec.ts @@ -1,6 +1,7 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { contextExternalToolEntityFactory } from '@modules/tool/context-external-tool/testing'; import { ExternalToolElement } from '@shared/domain/domainobject'; -import { contextExternalToolEntityFactory, externalToolElementFactory, setupEntities } from '@shared/testing'; +import { externalToolElementFactory, setupEntities } from '@shared/testing'; import { ExternalToolElementNodeEntity, ExternalToolElementNodeEntityProps } from './external-tool-element-node.entity'; import { BoardDoBuilder, BoardNodeType } from './types'; diff --git a/apps/server/src/shared/domain/entity/boardnode/index.ts b/apps/server/src/shared/domain/entity/boardnode/index.ts index 85b74b0adb9..01356508e90 100644 --- a/apps/server/src/shared/domain/entity/boardnode/index.ts +++ b/apps/server/src/shared/domain/entity/boardnode/index.ts @@ -10,3 +10,5 @@ export * from './rich-text-element-node.entity'; export * from './submission-container-element-node.entity'; export * from './submission-item-node.entity'; export * from './types'; +export * from './media-board'; +export * from './root-board-node.entity'; diff --git a/apps/server/src/shared/domain/entity/boardnode/media-board/index.ts b/apps/server/src/shared/domain/entity/boardnode/media-board/index.ts new file mode 100644 index 00000000000..36aceea2094 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/media-board/index.ts @@ -0,0 +1,6 @@ +export { MediaBoardNode } from './media-board-node.entity'; +export { MediaLineNode, MediaLineNodeProps } from './media-line-node.entity'; +export { + MediaExternalToolElementNode, + MediaExternalToolElementNodeProps, +} from './media-external-tool-element-node.entity'; diff --git a/apps/server/src/shared/domain/entity/boardnode/media-board/media-board-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/media-board/media-board-node.entity.ts new file mode 100644 index 00000000000..733013eee26 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/media-board/media-board-node.entity.ts @@ -0,0 +1,42 @@ +import { Entity, Property } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { + type AnyBoardDo, + BoardExternalReference, + BoardExternalReferenceType, + type MediaBoard, +} from '../../../domainobject'; +import { BoardNode } from '../boardnode.entity'; +import { type RootBoardNodeProps } from '../root-board-node.entity'; +import { type BoardDoBuilder, BoardNodeType } from '../types'; + +// TODO Use an abstract base class for root nodes that have a contextId and a contextType. Multiple STI abstract base classes are blocked by MikroORM 6.1.2 (issue #3745) +@Entity({ discriminatorValue: BoardNodeType.MEDIA_BOARD }) +export class MediaBoardNode extends BoardNode { + constructor(props: RootBoardNodeProps) { + super(props); + this.type = BoardNodeType.MEDIA_BOARD; + + this._contextType = props.context.type; + this._contextId = new ObjectId(props.context.id); + } + + @Property({ fieldName: 'contextType' }) + _contextType: BoardExternalReferenceType; + + @Property({ fieldName: 'context' }) + _contextId: ObjectId; + + get context(): BoardExternalReference { + return { + type: this._contextType, + id: this._contextId.toHexString(), + }; + } + + useDoBuilder(builder: BoardDoBuilder): AnyBoardDo { + const domainObject: MediaBoard = builder.buildMediaBoard(this); + + return domainObject; + } +} diff --git a/apps/server/src/shared/domain/entity/boardnode/media-board/media-external-tool-element-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/media-board/media-external-tool-element-node.entity.ts new file mode 100644 index 00000000000..970495005c1 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/media-board/media-external-tool-element-node.entity.ts @@ -0,0 +1,26 @@ +import { Entity, ManyToOne } from '@mikro-orm/core'; +import { ContextExternalToolEntity } from '@modules/tool/context-external-tool/entity'; +import type { AnyBoardDo, MediaExternalToolElement } from '../../../domainobject'; +import { BoardNode, type BoardNodeProps } from '../boardnode.entity'; +import { type BoardDoBuilder, BoardNodeType } from '../types'; + +@Entity({ discriminatorValue: BoardNodeType.MEDIA_EXTERNAL_TOOL_ELEMENT }) +export class MediaExternalToolElementNode extends BoardNode { + @ManyToOne() + contextExternalTool: ContextExternalToolEntity; + + constructor(props: MediaExternalToolElementNodeProps) { + super(props); + this.type = BoardNodeType.MEDIA_EXTERNAL_TOOL_ELEMENT; + this.contextExternalTool = props.contextExternalTool; + } + + useDoBuilder(builder: BoardDoBuilder): AnyBoardDo { + const domainObject: MediaExternalToolElement = builder.buildMediaExternalToolElement(this); + return domainObject; + } +} + +export interface MediaExternalToolElementNodeProps extends BoardNodeProps { + contextExternalTool: ContextExternalToolEntity; +} diff --git a/apps/server/src/shared/domain/entity/boardnode/media-board/media-line-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/media-board/media-line-node.entity.ts new file mode 100644 index 00000000000..1e695778e7a --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/media-board/media-line-node.entity.ts @@ -0,0 +1,26 @@ +import { Entity, Property } from '@mikro-orm/core'; +import type { AnyBoardDo, MediaLine } from '../../../domainobject'; +import { BoardNode, type BoardNodeProps } from '../boardnode.entity'; +import { type BoardDoBuilder, BoardNodeType } from '../types'; + +@Entity({ discriminatorValue: BoardNodeType.MEDIA_LINE }) +export class MediaLineNode extends BoardNode { + constructor(props: MediaLineNodeProps) { + super(props); + this.type = BoardNodeType.MEDIA_LINE; + + this.title = props.title; + } + + @Property({ nullable: false }) + title: string; + + useDoBuilder(builder: BoardDoBuilder): AnyBoardDo { + const domainObject: MediaLine = builder.buildMediaLine(this); + return domainObject; + } +} + +export interface MediaLineNodeProps extends BoardNodeProps { + title: string; +} diff --git a/apps/server/src/shared/domain/entity/boardnode/root-board-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/root-board-node.entity.ts new file mode 100644 index 00000000000..77808057040 --- /dev/null +++ b/apps/server/src/shared/domain/entity/boardnode/root-board-node.entity.ts @@ -0,0 +1,9 @@ +import { BoardExternalReference } from '@shared/domain/domainobject/board/types'; +import { BoardNodeProps } from './boardnode.entity'; + +// TODO Use an abstract base class for root nodes that have a contextId and a contextType. Multiple STI abstract base classes are blocked by MikroORM 6.1.2 (issue #3745) +// export abstract class RootBoardNode extends BoardNode { ... } + +export interface RootBoardNodeProps extends BoardNodeProps { + context: BoardExternalReference; +} diff --git a/apps/server/src/shared/domain/entity/boardnode/submission-container-element-node.entity.ts b/apps/server/src/shared/domain/entity/boardnode/submission-container-element-node.entity.ts index 0dc60b67539..b47cf916784 100644 --- a/apps/server/src/shared/domain/entity/boardnode/submission-container-element-node.entity.ts +++ b/apps/server/src/shared/domain/entity/boardnode/submission-container-element-node.entity.ts @@ -5,7 +5,7 @@ import { BoardDoBuilder, BoardNodeType } from './types'; @Entity({ discriminatorValue: BoardNodeType.SUBMISSION_CONTAINER_ELEMENT }) export class SubmissionContainerElementNode extends BoardNode { - @Property({ nullable: true }) + @Property({ type: Date, nullable: true }) dueDate: Date | null; constructor(props: SubmissionContainerNodeProps) { diff --git a/apps/server/src/shared/domain/entity/boardnode/submission-item-node.entity.spec.ts b/apps/server/src/shared/domain/entity/boardnode/submission-item-node.entity.spec.ts index 97ceeed2bbe..ebf302861b2 100644 --- a/apps/server/src/shared/domain/entity/boardnode/submission-item-node.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/boardnode/submission-item-node.entity.spec.ts @@ -1,6 +1,6 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { submissionItemFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { SubmissionItemNode } from './submission-item-node.entity'; import { BoardDoBuilder, BoardNodeType } from './types'; diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts index 1b61566d442..2f8cf002a50 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-do.builder.ts @@ -6,6 +6,9 @@ import type { ExternalToolElement, FileElement, LinkElement, + MediaBoard, + MediaExternalToolElement, + MediaLine, RichTextElement, SubmissionContainerElement, SubmissionItem, @@ -17,6 +20,7 @@ import type { DrawingElementNode } from '../drawing-element-node.entity'; import type { ExternalToolElementNodeEntity } from '../external-tool-element-node.entity'; import type { FileElementNode } from '../file-element-node.entity'; import type { LinkElementNode } from '../link-element-node.entity'; +import type { MediaBoardNode, MediaExternalToolElementNode, MediaLineNode } from '../media-board'; import type { RichTextElementNode } from '../rich-text-element-node.entity'; import type { SubmissionContainerElementNode } from '../submission-container-element-node.entity'; import type { SubmissionItemNode } from '../submission-item-node.entity'; @@ -32,4 +36,8 @@ export interface BoardDoBuilder { buildSubmissionContainerElement(boardNode: SubmissionContainerElementNode): SubmissionContainerElement; buildSubmissionItem(boardNode: SubmissionItemNode): SubmissionItem; buildExternalToolElement(boardNode: ExternalToolElementNodeEntity): ExternalToolElement; + + buildMediaBoard(boardNode: MediaBoardNode): MediaBoard; + buildMediaLine(boardNode: MediaLineNode): MediaLine; + buildMediaExternalToolElement(boardNode: MediaExternalToolElementNode): MediaExternalToolElement; } diff --git a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts index f76f5330d5e..a80128c9da7 100644 --- a/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts +++ b/apps/server/src/shared/domain/entity/boardnode/types/board-node-type.ts @@ -9,4 +9,8 @@ export enum BoardNodeType { SUBMISSION_CONTAINER_ELEMENT = 'submission-container-element', SUBMISSION_ITEM = 'submission-item', EXTERNAL_TOOL = 'external-tool', + + MEDIA_BOARD = 'media-board', + MEDIA_LINE = 'media-line', + MEDIA_EXTERNAL_TOOL_ELEMENT = 'media-external-tool-element', } diff --git a/apps/server/src/shared/domain/entity/consent/consent.entity.ts b/apps/server/src/shared/domain/entity/consent/consent.entity.ts new file mode 100644 index 00000000000..575cafb9c80 --- /dev/null +++ b/apps/server/src/shared/domain/entity/consent/consent.entity.ts @@ -0,0 +1,17 @@ +import { Embeddable, Embedded } from '@mikro-orm/core'; +import { ParentConsentEntity } from './parent-consent.entity'; +import { UserConsentEntity } from './user-consent.entity'; + +@Embeddable() +export class ConsentEntity { + @Embedded(() => UserConsentEntity, { nullable: true, object: true }) + userConsent?: UserConsentEntity; + + @Embedded(() => ParentConsentEntity, { array: true, nullable: true, object: true }) + parentConsents?: ParentConsentEntity[]; + + constructor(props: ConsentEntity) { + this.userConsent = props.userConsent; + this.parentConsents = props.parentConsents; + } +} diff --git a/apps/server/src/shared/domain/entity/consent/index.ts b/apps/server/src/shared/domain/entity/consent/index.ts new file mode 100644 index 00000000000..f11d6cfa227 --- /dev/null +++ b/apps/server/src/shared/domain/entity/consent/index.ts @@ -0,0 +1,3 @@ +export { ConsentEntity } from './consent.entity'; +export { UserConsentEntity } from './user-consent.entity'; +export { ParentConsentEntity } from './parent-consent.entity'; diff --git a/apps/server/src/shared/domain/entity/consent/parent-consent.entity.ts b/apps/server/src/shared/domain/entity/consent/parent-consent.entity.ts new file mode 100644 index 00000000000..10863ad84b4 --- /dev/null +++ b/apps/server/src/shared/domain/entity/consent/parent-consent.entity.ts @@ -0,0 +1,32 @@ +import { Embeddable, Property } from '@mikro-orm/core'; +import { ObjectId } from '@mikro-orm/mongodb'; + +@Embeddable() +export class ParentConsentEntity { + @Property() + _id: ObjectId; + + @Property() + form: string; + + @Property() + privacyConsent: boolean; + + @Property() + termsOfUseConsent: boolean; + + @Property() + dateOfPrivacyConsent: Date; + + @Property() + dateOfTermsOfUseConsent: Date; + + constructor(props: ParentConsentEntity) { + this._id = props._id; + this.form = props.form; + this.privacyConsent = props.privacyConsent; + this.termsOfUseConsent = props.termsOfUseConsent; + this.dateOfPrivacyConsent = props.dateOfPrivacyConsent; + this.dateOfTermsOfUseConsent = props.dateOfTermsOfUseConsent; + } +} diff --git a/apps/server/src/shared/domain/entity/consent/user-consent.entity.ts b/apps/server/src/shared/domain/entity/consent/user-consent.entity.ts new file mode 100644 index 00000000000..7fb5fd79efd --- /dev/null +++ b/apps/server/src/shared/domain/entity/consent/user-consent.entity.ts @@ -0,0 +1,27 @@ +import { Embeddable, Property } from '@mikro-orm/core'; + +@Embeddable() +export class UserConsentEntity { + @Property() + form: string; + + @Property() + privacyConsent: boolean; + + @Property() + termsOfUseConsent: boolean; + + @Property() + dateOfPrivacyConsent: Date; + + @Property() + dateOfTermsOfUseConsent: Date; + + constructor(props: UserConsentEntity) { + this.form = props.form; + this.privacyConsent = props.privacyConsent; + this.termsOfUseConsent = props.termsOfUseConsent; + this.dateOfPrivacyConsent = props.dateOfPrivacyConsent; + this.dateOfTermsOfUseConsent = props.dateOfTermsOfUseConsent; + } +} diff --git a/apps/server/src/shared/domain/entity/course.entity.spec.ts b/apps/server/src/shared/domain/entity/course.entity.spec.ts index 4a1ff02bdd6..0b7f7f432eb 100644 --- a/apps/server/src/shared/domain/entity/course.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/course.entity.spec.ts @@ -1,14 +1,13 @@ import { MikroORM } from '@mikro-orm/core'; import { InternalServerErrorException } from '@nestjs/common'; -import { courseFactory, courseGroupFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { courseFactory, courseGroupFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Course } from './course.entity'; import { CourseGroup } from './coursegroup.entity'; const DEFAULT = { color: '#ACACAC', name: 'Kurse', - description: '', }; describe('CourseEntity', () => { @@ -33,11 +32,11 @@ describe('CourseEntity', () => { describe('defaults', () => { it('should return defaults values', () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const course = new Course({ school }); expect(course.name).toEqual(DEFAULT.name); - expect(course.description).toEqual(DEFAULT.description); + expect(course.description).toEqual(undefined); expect(course.color).toEqual(DEFAULT.color); }); }); diff --git a/apps/server/src/shared/domain/entity/course.entity.ts b/apps/server/src/shared/domain/entity/course.entity.ts index e0fb4d4bcc3..84a9005e48e 100644 --- a/apps/server/src/shared/domain/entity/course.entity.ts +++ b/apps/server/src/shared/domain/entity/course.entity.ts @@ -26,6 +26,7 @@ export interface CourseProperties { features?: CourseFeatures[]; classes?: ClassEntity[]; groups?: GroupEntity[]; + syncedWithGroup?: GroupEntity; } // that is really really shit default handling :D constructor, getter, js default, em default...what the hell @@ -33,10 +34,9 @@ export interface CourseProperties { const DEFAULT = { color: '#ACACAC', name: 'Kurse', - description: '', }; -const enum CourseFeatures { +export enum CourseFeatures { VIDEOCONFERENCE = 'videoconference', } @@ -51,10 +51,10 @@ export class UsersList { @Entity({ tableName: 'courses' }) export class Course extends BaseEntityWithTimestamps implements Learnroom, EntityWithSchool, TaskParent, LessonParent { @Property() - name: string = DEFAULT.name; + name: string; - @Property() - description: string = DEFAULT.description; + @Property({ nullable: true }) + description?: string; @Index() @ManyToOne(() => SchoolEntity, { fieldName: 'schoolId' }) @@ -102,9 +102,12 @@ export class Course extends BaseEntityWithTimestamps implements Learnroom, Entit @ManyToMany(() => GroupEntity, undefined, { fieldName: 'groupIds' }) groups = new Collection(this); + @ManyToOne(() => GroupEntity, { nullable: true }) + syncedWithGroup?: GroupEntity; + constructor(props: CourseProperties) { super(); - if (props.name) this.name = props.name; + this.name = props.name ?? DEFAULT.name; if (props.description) this.description = props.description; this.school = props.school; this.students.set(props.students || []); @@ -117,6 +120,7 @@ export class Course extends BaseEntityWithTimestamps implements Learnroom, Entit if (props.features) this.features = props.features; this.classes.set(props.classes || []); this.groups.set(props.groups || []); + this.syncedWithGroup = props.syncedWithGroup; } public getStudentIds(): EntityId[] { @@ -222,6 +226,7 @@ export class Course extends BaseEntityWithTimestamps implements Learnroom, Entit untilDate: this.untilDate, startDate: this.startDate, copyingSince: this.copyingSince, + isSynchronized: !!this.syncedWithGroup, }; } diff --git a/apps/server/src/shared/domain/entity/dashboard.entity.spec.ts b/apps/server/src/shared/domain/entity/dashboard.entity.spec.ts index 3383e9d51c3..7ce0ce0e2c2 100644 --- a/apps/server/src/shared/domain/entity/dashboard.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/dashboard.entity.spec.ts @@ -12,6 +12,7 @@ const getLearnroomMock = (id: string): Learnroom => { title: 'Reference', shortTitle: 'Re', displayColor: '#FFFFFF', + isSynchronized: false, }; }, }; diff --git a/apps/server/src/shared/domain/entity/dashboard.entity.ts b/apps/server/src/shared/domain/entity/dashboard.entity.ts index cb49032e9fc..0664788e300 100644 --- a/apps/server/src/shared/domain/entity/dashboard.entity.ts +++ b/apps/server/src/shared/domain/entity/dashboard.entity.ts @@ -32,6 +32,7 @@ export type GridElementContent = { group?: LearnroomMetadata[]; groupId?: string; copyingSince?: Date; + isSynchronized: boolean; }; export class GridElement implements IGridElement { @@ -123,14 +124,15 @@ export class GridElement implements IGridElement { }; return metadata; } - const groupData = this.references.map((reference) => reference.getMetadata()); - const checkShortTitle = this.title ? this.title.substring(0, 2) : ''; - const groupMetadata = { + const groupData: LearnroomMetadata[] = this.references.map((reference) => reference.getMetadata()); + const checkShortTitle: string = this.title ? this.title.substring(0, 2) : ''; + const groupMetadata: GridElementContent = { groupId: this.getId(), title: this.title, shortTitle: checkShortTitle, displayColor: 'exampleColor', group: groupData, + isSynchronized: false, }; return groupMetadata; } diff --git a/apps/server/src/shared/domain/entity/dashboard.model.entity.ts b/apps/server/src/shared/domain/entity/dashboard.model.entity.ts index b0463ff5355..effd2db5473 100644 --- a/apps/server/src/shared/domain/entity/dashboard.model.entity.ts +++ b/apps/server/src/shared/domain/entity/dashboard.model.entity.ts @@ -72,7 +72,7 @@ export class DashboardModelEntity extends BaseEntityWithTimestamps { if (props.gridElements) this.gridElements.set(props.gridElements); } - @OneToMany('DashboardGridElementModel', 'dashboard') + @OneToMany('DashboardGridElementModel', 'dashboard', { orphanRemoval: true }) gridElements: Collection = new Collection(this); // userId diff --git a/apps/server/src/shared/domain/entity/dashboardElement.entity.spec.ts b/apps/server/src/shared/domain/entity/dashboardElement.entity.spec.ts index dc5391284a7..8f9a93b6e29 100644 --- a/apps/server/src/shared/domain/entity/dashboardElement.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/dashboardElement.entity.spec.ts @@ -12,6 +12,7 @@ const learnroomMock = (id: string, name: string) => { title: name, shortTitle: name.substr(0, 2), displayColor: '#ACACAC', + isSynchronized: false, }; }, }; diff --git a/apps/server/src/shared/domain/entity/import-user.entity.spec.ts b/apps/server/src/shared/domain/entity/import-user.entity.spec.ts index c241bb96471..cdd22330733 100644 --- a/apps/server/src/shared/domain/entity/import-user.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/import-user.entity.spec.ts @@ -1,4 +1,4 @@ -import { importUserFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; +import { importUserFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { MatchCreator } from '.'; describe('ImportUser entity', () => { @@ -43,7 +43,7 @@ describe('ImportUser entity', () => { describe('match', () => { it('should set and unset both, user and matchedBy', () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ school }); const importUser = importUserFactory.matched(MatchCreator.AUTO, user).buildWithId({ school }); expect(importUser.user).toEqual(user); diff --git a/apps/server/src/shared/domain/entity/import-user.entity.ts b/apps/server/src/shared/domain/entity/import-user.entity.ts index 34d1f8b64e8..78aa5036cb4 100644 --- a/apps/server/src/shared/domain/entity/import-user.entity.ts +++ b/apps/server/src/shared/domain/entity/import-user.entity.ts @@ -123,4 +123,8 @@ export class ImportUser extends BaseEntityWithTimestamps implements EntityWithSc this.user = undefined; this.matchedBy = undefined; } + + static isImportUserRole(role: RoleName): role is IImportUserRoleName { + return role === RoleName.ADMINISTRATOR || role === RoleName.STUDENT || role === RoleName.TEACHER; + } } diff --git a/apps/server/src/shared/domain/entity/index.ts b/apps/server/src/shared/domain/entity/index.ts index 91acc0ecb77..619f345d671 100644 --- a/apps/server/src/shared/domain/entity/index.ts +++ b/apps/server/src/shared/domain/entity/index.ts @@ -1,4 +1,3 @@ -export * from './account.entity'; export * from './all-entities'; export * from './base.entity'; export * from './boardnode'; @@ -25,3 +24,4 @@ export * from './user-login-migration.entity'; export * from './user.entity'; export * from './video-conference.entity'; export * from './external-source.entity'; +export * from './consent'; diff --git a/apps/server/src/shared/domain/entity/legacy-board/boardElement.entity.spec.ts b/apps/server/src/shared/domain/entity/legacy-board/boardElement.entity.spec.ts index 81612ad1457..1bdc4d8ed55 100644 --- a/apps/server/src/shared/domain/entity/legacy-board/boardElement.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/legacy-board/boardElement.entity.spec.ts @@ -1,5 +1,5 @@ -import { columnBoardTargetFactory, lessonFactory, setupEntities, taskFactory } from '@shared/testing'; -import { BoardElementType } from './boardelement.entity'; +import { columnBoardNodeFactory, lessonFactory, setupEntities, taskFactory } from '@shared/testing'; +import { LegacyBoardElementType } from './legacy-boardelement.entity'; import { ColumnboardBoardElement } from './column-board-boardelement'; import { LessonBoardElement } from './lesson-boardelement.entity'; import { TaskBoardElement } from './task-boardelement.entity'; @@ -15,7 +15,7 @@ describe('TaskBoardElementEntity', () => { const boardElement = new TaskBoardElement({ target: task }); - expect(boardElement.boardElementType).toEqual(BoardElementType.Task); + expect(boardElement.boardElementType).toEqual(LegacyBoardElementType.Task); }); }); }); @@ -31,7 +31,7 @@ describe('LessonBoardElementEntity', () => { const boardElement = new LessonBoardElement({ target: lesson }); - expect(boardElement.boardElementType).toEqual(BoardElementType.Lesson); + expect(boardElement.boardElementType).toEqual(LegacyBoardElementType.Lesson); }); }); }); @@ -43,11 +43,11 @@ describe('ColumnboardBoardElementEntity', () => { describe('constructor', () => { it('should have correct type', () => { - const columnBoardTarget = columnBoardTargetFactory.build({ title: 'target', published: true }); + const columnBoardTarget = columnBoardNodeFactory.build({ title: 'target' }); const boardElement = new ColumnboardBoardElement({ target: columnBoardTarget }); - expect(boardElement.boardElementType).toEqual(BoardElementType.ColumnBoard); + expect(boardElement.boardElementType).toEqual(LegacyBoardElementType.ColumnBoard); }); }); }); diff --git a/apps/server/src/shared/domain/entity/legacy-board/column-board-boardelement.ts b/apps/server/src/shared/domain/entity/legacy-board/column-board-boardelement.ts index 0a36d771293..d4a4ed40d2a 100644 --- a/apps/server/src/shared/domain/entity/legacy-board/column-board-boardelement.ts +++ b/apps/server/src/shared/domain/entity/legacy-board/column-board-boardelement.ts @@ -1,14 +1,14 @@ import { Entity, ManyToOne } from '@mikro-orm/core'; -import { BoardElement, BoardElementType } from './boardelement.entity'; -import { ColumnBoardTarget } from './column-board-target.entity'; +import { LegacyBoardElement, LegacyBoardElementType } from './legacy-boardelement.entity'; +import { ColumnBoardNode } from '../boardnode/column-board-node.entity'; -@Entity({ discriminatorValue: BoardElementType.ColumnBoard }) -export class ColumnboardBoardElement extends BoardElement { - constructor(props: { target: ColumnBoardTarget }) { +@Entity({ discriminatorValue: LegacyBoardElementType.ColumnBoard }) +export class ColumnboardBoardElement extends LegacyBoardElement { + constructor(props: { target: ColumnBoardNode }) { super(props); - this.boardElementType = BoardElementType.ColumnBoard; + this.boardElementType = LegacyBoardElementType.ColumnBoard; } - @ManyToOne('ColumnBoardTarget') - target!: ColumnBoardTarget; + @ManyToOne('ColumnBoardNode') + target!: ColumnBoardNode; } diff --git a/apps/server/src/shared/domain/entity/legacy-board/column-board-target.entity.spec.ts b/apps/server/src/shared/domain/entity/legacy-board/column-board-target.entity.spec.ts deleted file mode 100644 index f83784c7917..00000000000 --- a/apps/server/src/shared/domain/entity/legacy-board/column-board-target.entity.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { columnBoardTargetFactory, setupEntities } from '@shared/testing'; -import { ObjectId } from 'bson'; -import { ColumnBoardTarget } from './column-board-target.entity'; - -describe(ColumnBoardTarget.name, () => { - beforeAll(async () => { - await setupEntities(); - }); - - describe('publish', () => { - it('should set the state to published', () => { - const target = columnBoardTargetFactory.build(); - target.publish(); - expect(target.published).toBe(true); - }); - }); - - describe('unpublish', () => { - it('should set the state to unpublished', () => { - const target = columnBoardTargetFactory.build({ published: true }); - target.unpublish(); - expect(target.published).toBe(false); - }); - }); - - describe('getColumnBoardId', () => { - it('should return the columnBoardId property', () => { - const columnBoardId = new ObjectId().toHexString(); - const target = columnBoardTargetFactory.build({ columnBoardId }); - expect(target.columnBoardId).toEqual(columnBoardId); - }); - }); -}); diff --git a/apps/server/src/shared/domain/entity/legacy-board/column-board-target.entity.ts b/apps/server/src/shared/domain/entity/legacy-board/column-board-target.entity.ts deleted file mode 100644 index 6008c97c550..00000000000 --- a/apps/server/src/shared/domain/entity/legacy-board/column-board-target.entity.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Entity, Property } from '@mikro-orm/core'; -import { LearnroomElement } from '@shared/domain/interface'; -import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; -import { BaseEntityWithTimestamps } from '../base.entity'; - -type ColumnBoardTargetProps = { - columnBoardId: EntityId; - title?: string; -}; - -@Entity() -export class ColumnBoardTarget extends BaseEntityWithTimestamps implements LearnroomElement { - constructor(props: ColumnBoardTargetProps) { - super(); - this._columnBoardId = new ObjectId(props.columnBoardId); - this.title = props.title ?? ''; - } - - @Property() - title: string; - - publish(): void { - this.published = true; - } - - unpublish(): void { - this.published = false; - } - - @Property() - published = false; - - @Property({ fieldName: 'columnBoard' }) - _columnBoardId: ObjectId; - - get columnBoardId(): EntityId { - return this._columnBoardId.toHexString(); - } -} - -export function isColumnBoardTarget(reference: unknown): reference is ColumnBoardTarget { - return reference instanceof ColumnBoardTarget; -} diff --git a/apps/server/src/shared/domain/entity/legacy-board/index.ts b/apps/server/src/shared/domain/entity/legacy-board/index.ts index 2e13e473bf1..bf6b64bda35 100644 --- a/apps/server/src/shared/domain/entity/legacy-board/index.ts +++ b/apps/server/src/shared/domain/entity/legacy-board/index.ts @@ -1,6 +1,5 @@ -export * from './board.entity'; -export * from './boardelement.entity'; +export * from './legacy-board.entity'; +export * from './legacy-boardelement.entity'; export * from './column-board-boardelement'; -export * from './column-board-target.entity'; export * from './lesson-boardelement.entity'; export * from './task-boardelement.entity'; diff --git a/apps/server/src/shared/domain/entity/legacy-board/board.entity.spec.ts b/apps/server/src/shared/domain/entity/legacy-board/legacy-board.entity.spec.ts similarity index 96% rename from apps/server/src/shared/domain/entity/legacy-board/board.entity.spec.ts rename to apps/server/src/shared/domain/entity/legacy-board/legacy-board.entity.spec.ts index 3ac1d252d9d..26f5157ef27 100644 --- a/apps/server/src/shared/domain/entity/legacy-board/board.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/legacy-board/legacy-board.entity.spec.ts @@ -3,7 +3,7 @@ import { boardFactory, columnboardBoardElementFactory, columnBoardFactory, - columnBoardTargetFactory, + columnBoardNodeFactory, courseFactory, lessonBoardElementFactory, lessonFactory, @@ -175,7 +175,7 @@ describe('Board Entity', () => { }); it('should add columnboards to board', () => { - const columnBoardTarget = columnBoardTargetFactory.buildWithId(); + const columnBoardTarget = columnBoardNodeFactory.buildWithId(); const board = boardFactory.buildWithId({ references: [] }); board.syncBoardElementReferences([columnBoardTarget]); @@ -184,7 +184,7 @@ describe('Board Entity', () => { }); it('should NOT add columnboards to board that is already there', () => { - const target = columnBoardTargetFactory.buildWithId(); + const target = columnBoardNodeFactory.buildWithId(); const boardElement = columnboardBoardElementFactory.buildWithId({ target }); const board = boardFactory.buildWithId({ references: [boardElement] }); @@ -194,8 +194,8 @@ describe('Board Entity', () => { }); it('should add new columnboards to the beginning of the list', () => { - const newTarget = columnBoardTargetFactory.buildWithId(); - const existingTarget = columnBoardTargetFactory.buildWithId(); + const newTarget = columnBoardNodeFactory.buildWithId(); + const existingTarget = columnBoardNodeFactory.buildWithId(); const existingElement = columnboardBoardElementFactory.buildWithId({ target: existingTarget }); const board = boardFactory.buildWithId({ references: [existingElement] }); diff --git a/apps/server/src/shared/domain/entity/legacy-board/board.entity.ts b/apps/server/src/shared/domain/entity/legacy-board/legacy-board.entity.ts similarity index 74% rename from apps/server/src/shared/domain/entity/legacy-board/board.entity.ts rename to apps/server/src/shared/domain/entity/legacy-board/legacy-board.entity.ts index 22b3b9cfc17..df3208a5b73 100644 --- a/apps/server/src/shared/domain/entity/legacy-board/board.entity.ts +++ b/apps/server/src/shared/domain/entity/legacy-board/legacy-board.entity.ts @@ -6,19 +6,19 @@ import { BaseEntityWithTimestamps } from '../base.entity'; import type { Course } from '../course.entity'; import { LessonEntity } from '../lesson.entity'; import { Task } from '../task.entity'; -import { BoardElement, BoardElementReference } from './boardelement.entity'; +import { LegacyBoardElement, LegacyBoardElementReference } from './legacy-boardelement.entity'; import { ColumnboardBoardElement } from './column-board-boardelement'; -import { ColumnBoardTarget } from './column-board-target.entity'; import { LessonBoardElement } from './lesson-boardelement.entity'; import { TaskBoardElement } from './task-boardelement.entity'; +import { ColumnBoardNode } from '../boardnode/column-board-node.entity'; export type BoardProps = { - references: BoardElement[]; + references: LegacyBoardElement[]; course: Course; }; @Entity({ tableName: 'board' }) -export class Board extends BaseEntityWithTimestamps { +export class LegacyBoard extends BaseEntityWithTimestamps { constructor(props: BoardProps) { super(); this.references.set(props.references); @@ -28,8 +28,8 @@ export class Board extends BaseEntityWithTimestamps { @OneToOne({ type: 'Course', fieldName: 'courseId', wrappedReference: true, unique: true, owner: true }) course: IdentifiedReference; - @ManyToMany('BoardElement', undefined, { fieldName: 'referenceIds' }) - references = new Collection(this); + @ManyToMany('LegacyBoardElement', undefined, { fieldName: 'referenceIds' }) + references = new Collection(this); getByTargetId(id: EntityId): LearnroomElement { const element = this.getElementByTargetId(id); @@ -64,41 +64,41 @@ export class Board extends BaseEntityWithTimestamps { return isEqual; } - private getElementByTargetId(id: EntityId): BoardElement { + private getElementByTargetId(id: EntityId): LegacyBoardElement { const element = this.getElements().find((el) => el.target.id === id); if (!element) throw new NotFoundException('board does not contain such element'); return element; } - syncBoardElementReferences(boardElementTargets: BoardElementReference[]) { + syncBoardElementReferences(boardElementTargets: LegacyBoardElementReference[]) { this.removeDeletedReferences(boardElementTargets); this.appendNotContainedBoardElements(boardElementTargets); } - private removeDeletedReferences(boardElementTargets: BoardElementReference[]) { + private removeDeletedReferences(boardElementTargets: LegacyBoardElementReference[]) { const references = this.references.getItems(); const onlyExistingReferences = references.filter((ref) => boardElementTargets.includes(ref.target)); this.references.set(onlyExistingReferences); } - private appendNotContainedBoardElements(boardElementTargets: BoardElementReference[]): void { + private appendNotContainedBoardElements(boardElementTargets: LegacyBoardElementReference[]): void { const references = this.references.getItems(); - const isNotContained = (element: BoardElementReference) => !references.some((ref) => ref.target === element); - const mapToBoardElement = (target: BoardElementReference) => this.createBoardElementFor(target); + const isNotContained = (element: LegacyBoardElementReference) => !references.some((ref) => ref.target === element); + const mapToBoardElement = (target: LegacyBoardElementReference) => this.createBoardElementFor(target); const elementsToAdd = boardElementTargets.filter(isNotContained).map(mapToBoardElement); const newList = [...elementsToAdd, ...references]; this.references.set(newList); } - private createBoardElementFor(boardElementTarget: BoardElementReference): BoardElement { + private createBoardElementFor(boardElementTarget: LegacyBoardElementReference): LegacyBoardElement { if (boardElementTarget instanceof Task) { return new TaskBoardElement({ target: boardElementTarget }); } if (boardElementTarget instanceof LessonEntity) { return new LessonBoardElement({ target: boardElementTarget }); } - if (boardElementTarget instanceof ColumnBoardTarget) { + if (boardElementTarget instanceof ColumnBoardNode) { return new ColumnboardBoardElement({ target: boardElementTarget }); } throw new Error('not a valid boardElementReference'); diff --git a/apps/server/src/shared/domain/entity/legacy-board/boardelement.entity.ts b/apps/server/src/shared/domain/entity/legacy-board/legacy-boardelement.entity.ts similarity index 53% rename from apps/server/src/shared/domain/entity/legacy-board/boardelement.entity.ts rename to apps/server/src/shared/domain/entity/legacy-board/legacy-boardelement.entity.ts index a3c9674a419..43ebb4f5bb7 100644 --- a/apps/server/src/shared/domain/entity/legacy-board/boardelement.entity.ts +++ b/apps/server/src/shared/domain/entity/legacy-board/legacy-boardelement.entity.ts @@ -3,33 +3,34 @@ import { EntityId } from '../../types'; import { BaseEntityWithTimestamps } from '../base.entity'; import { LessonEntity } from '../lesson.entity'; import { Task } from '../task.entity'; -import { ColumnBoardTarget } from './column-board-target.entity'; +import { ColumnBoardNode } from '../boardnode/column-board-node.entity'; -export type BoardElementReference = Task | LessonEntity | ColumnBoardTarget; +export type LegacyBoardElementReference = Task | LessonEntity | ColumnBoardNode; -export enum BoardElementType { +export enum LegacyBoardElementType { 'Task' = 'task', 'Lesson' = 'lesson', 'ColumnBoard' = 'columnboard', } -export type BoardElementProps = { - target: EntityId | BoardElementReference; +export type LegacyBoardElementProps = { + target: EntityId | LegacyBoardElementReference; }; @Entity({ discriminatorColumn: 'boardElementType', abstract: true, + tableName: 'board-element', }) -export abstract class BoardElement extends BaseEntityWithTimestamps { +export abstract class LegacyBoardElement extends BaseEntityWithTimestamps { /** id reference to a collection populated later with name */ - target!: BoardElementReference; + target!: LegacyBoardElementReference; /** name of a collection which is referenced in target */ @Enum() - boardElementType!: BoardElementType; + boardElementType!: LegacyBoardElementType; - constructor(props: BoardElementProps) { + constructor(props: LegacyBoardElementProps) { super(); Object.assign(this, { target: props.target }); } diff --git a/apps/server/src/shared/domain/entity/legacy-board/lesson-boardelement.entity.ts b/apps/server/src/shared/domain/entity/legacy-board/lesson-boardelement.entity.ts index b10a69d92bb..3d6762d2a2a 100644 --- a/apps/server/src/shared/domain/entity/legacy-board/lesson-boardelement.entity.ts +++ b/apps/server/src/shared/domain/entity/legacy-board/lesson-boardelement.entity.ts @@ -1,12 +1,12 @@ import { Entity, ManyToOne } from '@mikro-orm/core'; import { LessonEntity } from '../lesson.entity'; -import { BoardElement, BoardElementType } from './boardelement.entity'; +import { LegacyBoardElement, LegacyBoardElementType } from './legacy-boardelement.entity'; -@Entity({ discriminatorValue: BoardElementType.Lesson }) -export class LessonBoardElement extends BoardElement { +@Entity({ discriminatorValue: LegacyBoardElementType.Lesson }) +export class LessonBoardElement extends LegacyBoardElement { constructor(props: { target: LessonEntity }) { super(props); - this.boardElementType = BoardElementType.Lesson; + this.boardElementType = LegacyBoardElementType.Lesson; } @ManyToOne('LessonEntity') diff --git a/apps/server/src/shared/domain/entity/legacy-board/task-boardelement.entity.ts b/apps/server/src/shared/domain/entity/legacy-board/task-boardelement.entity.ts index 54a4e521911..6495e54c837 100644 --- a/apps/server/src/shared/domain/entity/legacy-board/task-boardelement.entity.ts +++ b/apps/server/src/shared/domain/entity/legacy-board/task-boardelement.entity.ts @@ -1,12 +1,12 @@ import { Entity, ManyToOne } from '@mikro-orm/core'; import { Task } from '../task.entity'; -import { BoardElement, BoardElementType } from './boardelement.entity'; +import { LegacyBoardElement, LegacyBoardElementType } from './legacy-boardelement.entity'; -@Entity({ discriminatorValue: BoardElementType.Task }) -export class TaskBoardElement extends BoardElement { +@Entity({ discriminatorValue: LegacyBoardElementType.Task }) +export class TaskBoardElement extends LegacyBoardElement { constructor(props: { target: Task }) { super(props); - this.boardElementType = BoardElementType.Task; + this.boardElementType = LegacyBoardElementType.Task; } // FIXME Due to a weird behaviour in the mikro-orm validation we have to diff --git a/apps/server/src/shared/domain/entity/lesson.entity.ts b/apps/server/src/shared/domain/entity/lesson.entity.ts index eae1cb6169b..f3eb4aa1d9e 100644 --- a/apps/server/src/shared/domain/entity/lesson.entity.ts +++ b/apps/server/src/shared/domain/entity/lesson.entity.ts @@ -87,7 +87,7 @@ export class LessonEntity extends BaseEntityWithTimestamps implements LearnroomE name: string; @Index() - @Property() + @Property({ type: 'boolean' }) hidden = false; @Index() diff --git a/apps/server/src/shared/domain/entity/news.entity.spec.ts b/apps/server/src/shared/domain/entity/news.entity.spec.ts new file mode 100644 index 00000000000..36ced085831 --- /dev/null +++ b/apps/server/src/shared/domain/entity/news.entity.spec.ts @@ -0,0 +1,47 @@ +import { setupEntities, teamNewsFactory, userFactory } from '@shared/testing'; +import { News } from './news.entity'; + +describe(News.name, () => { + beforeAll(async () => { + await setupEntities(); + }); + + describe('removeCreatorReference', () => { + describe('when called on a news that contains some creator with given refId', () => { + const setup = () => { + const creator = userFactory.buildWithId(); + const news = teamNewsFactory.build({ + creator, + }); + + return { creator, news }; + }; + it('should properly remove this creator reference', () => { + const { creator, news } = setup(); + + news.removeCreatorReference(creator.id); + + expect(news.creator).toEqual(undefined); + }); + }); + }); + describe('removeUpdaterReference', () => { + describe('when called on a news that contains some creator with given refId', () => { + const setup = () => { + const updater = userFactory.buildWithId(); + const news = teamNewsFactory.build({ + updater, + }); + + return { updater, news }; + }; + it('should properly remove this updater reference', () => { + const { updater, news } = setup(); + + news.removeUpdaterReference(updater.id); + + expect(news.updater).toEqual(undefined); + }); + }); + }); +}); diff --git a/apps/server/src/shared/domain/entity/news.entity.ts b/apps/server/src/shared/domain/entity/news.entity.ts index 682192770a1..0fc1d12dc29 100644 --- a/apps/server/src/shared/domain/entity/news.entity.ts +++ b/apps/server/src/shared/domain/entity/news.entity.ts @@ -12,9 +12,8 @@ export interface NewsProperties { content: string; displayAt: Date; school: EntityId | SchoolEntity; - creator: EntityId | User; + creator?: EntityId | User; target: EntityId | NewsTarget; - externalId?: string; source?: 'internal' | 'rss'; sourceDescription?: string; @@ -61,14 +60,26 @@ export abstract class News extends BaseEntityWithTimestamps { @ManyToOne(() => SchoolEntity, { fieldName: 'schoolId' }) school!: SchoolEntity; - @ManyToOne('User', { fieldName: 'creatorId' }) - creator!: User; + @ManyToOne('User', { fieldName: 'creatorId', nullable: true }) + creator?: User; @ManyToOne('User', { fieldName: 'updaterId', nullable: true }) updater?: User; permissions: string[] = []; + public removeCreatorReference(creatorId: EntityId): void { + if (creatorId === this.creator?.id) { + this.creator = undefined; + } + } + + public removeUpdaterReference(updaterId: EntityId): void { + if (updaterId === this.updater?.id) { + this.updater = undefined; + } + } + constructor(props: NewsProperties) { super(); this.title = props.title; diff --git a/apps/server/src/shared/domain/entity/role.entity.spec.ts b/apps/server/src/shared/domain/entity/role.entity.spec.ts index b0bb71775ed..c7cbd19bb2e 100644 --- a/apps/server/src/shared/domain/entity/role.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/role.entity.spec.ts @@ -1,6 +1,6 @@ import { MikroORM } from '@mikro-orm/core'; import { roleFactory, setupEntities } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Permission, RoleName } from '../interface'; import { Role } from './role.entity'; // import { Permission, } from '..'; diff --git a/apps/server/src/shared/domain/entity/school.entity.ts b/apps/server/src/shared/domain/entity/school.entity.ts index 63753f5efd4..0d86a420c9d 100644 --- a/apps/server/src/shared/domain/entity/school.entity.ts +++ b/apps/server/src/shared/domain/entity/school.entity.ts @@ -15,6 +15,7 @@ import { SchoolSystemOptionsEntity } from '@modules/legacy-school/entity'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; import { SchoolFeature, SchoolPurpose } from '@shared/domain/types'; import { FileStorageType } from '@src/modules/school/domain/type/file-storage-type.enum'; +import { LanguageType } from '../interface'; import { BaseEntityWithTimestamps } from './base.entity'; import { CountyEmbeddable, FederalStateEntity } from './federal-state.entity'; import { SchoolYearEntity } from './schoolyear.entity'; @@ -40,8 +41,9 @@ export interface SchoolProperties { logo_dataUrl?: string; logo_name?: string; fileStorageType?: FileStorageType; - language?: string; + language?: LanguageType; timezone?: string; + ldapLastSync?: string; } @Embeddable() @@ -77,6 +79,9 @@ export class SchoolEntity extends BaseEntityWithTimestamps { @Property({ nullable: true, fieldName: 'ldapSchoolIdentifier' }) externalId?: string; + @Property({ nullable: true }) + ldapLastSync?: string; + @Property({ nullable: true }) previousExternalId?: string; @@ -100,6 +105,7 @@ export class SchoolEntity extends BaseEntityWithTimestamps { (userLoginMigration: UserLoginMigrationEntity) => userLoginMigration.school, { orphanRemoval: true, + eager: true, } ) userLoginMigration?: UserLoginMigrationEntity; @@ -126,7 +132,7 @@ export class SchoolEntity extends BaseEntityWithTimestamps { fileStorageType?: FileStorageType; @Property({ nullable: true }) - language?: string; + language?: LanguageType; @Property({ nullable: true }) timezone?: string; @@ -158,5 +164,6 @@ export class SchoolEntity extends BaseEntityWithTimestamps { this.fileStorageType = props.fileStorageType; this.language = props.language; this.timezone = props.timezone; + this.ldapLastSync = props.ldapLastSync; } } diff --git a/apps/server/src/shared/domain/entity/submission.entity.spec.ts b/apps/server/src/shared/domain/entity/submission.entity.spec.ts index f65eea0b2c9..f6320e1fdd9 100644 --- a/apps/server/src/shared/domain/entity/submission.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/submission.entity.spec.ts @@ -2,7 +2,7 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { InternalServerErrorException } from '@nestjs/common'; import { courseGroupFactory, - schoolFactory, + schoolEntityFactory, setupEntities, submissionFactory, taskFactory, @@ -21,7 +21,7 @@ describe('Submission entity', () => { describe('constructor is called', () => { const setup = () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const student = userFactory.build(); const task = taskFactory.buildWithId(); const teamMember = userFactory.build(); @@ -490,7 +490,7 @@ describe('Submission entity', () => { const courseGroup = courseGroupFactory.build(); const submission = submissionFactory.studentWithId().buildWithId({ courseGroup }); - const creatorId = submission.student.id; + const creatorId = submission.student?.id; const spy = jest.spyOn(courseGroup, 'getStudentIds').mockReturnValueOnce(studentIds); @@ -591,4 +591,58 @@ describe('Submission entity', () => { }); }); }); + describe('removeStudentById', () => { + const setup = () => { + const user = userFactory.buildWithId(); + const submission = submissionFactory.buildWithId({ student: user }); + + return { submission, user }; + }; + describe('when userId matches studentId', () => { + it('should remove student', () => { + const { user, submission } = setup(); + submission.removeStudentById(user.id); + + expect(submission.student).toBeUndefined(); + }); + }); + describe('when userId not matches studentId', () => { + it('should not remove student', () => { + const { user, submission } = setup(); + + submission.removeStudentById(new ObjectId().toString()); + + expect(submission.student).toEqual(user); + }); + }); + }); + describe('removeUserFromTeamMembers', () => { + const setup = () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const submission = submissionFactory.buildWithId({ student: user1, teamMembers: [user1, user2] }); + + return { submission, user1, user2 }; + }; + describe('when userId matches teamMemberId', () => { + it('should remove student', () => { + const { user1, submission, user2 } = setup(); + submission.removeUserFromTeamMembers(user1.id); + + expect(submission.teamMembers.length).toEqual(1); + expect(submission.teamMembers[0]).toEqual(user2); + }); + }); + describe('when userId not matches teamMemberId', () => { + it('should not remove student', () => { + const { user1, submission, user2 } = setup(); + + submission.removeUserFromTeamMembers(new ObjectId().toString()); + + expect(submission.teamMembers.length).toEqual(2); + expect(submission.teamMembers[0]).toEqual(user1); + expect(submission.teamMembers[1]).toEqual(user2); + }); + }); + }); }); diff --git a/apps/server/src/shared/domain/entity/submission.entity.ts b/apps/server/src/shared/domain/entity/submission.entity.ts index 12893ed3bd5..66ad4c5e542 100644 --- a/apps/server/src/shared/domain/entity/submission.entity.ts +++ b/apps/server/src/shared/domain/entity/submission.entity.ts @@ -11,7 +11,7 @@ import type { User } from './user.entity'; export interface SubmissionProperties { school: SchoolEntity; task: Task; - student: User; + student?: User; courseGroup?: CourseGroup; teamMembers?: User[]; comment: string; @@ -33,8 +33,8 @@ export class Submission extends BaseEntityWithTimestamps { @Index() task: Task; - @ManyToOne('User', { fieldName: 'studentId' }) - student: User; + @ManyToOne('User', { fieldName: 'studentId', nullable: true }) + student?: User; @ManyToOne('CourseGroup', { fieldName: 'courseGroupId', nullable: true }) courseGroup?: CourseGroup; @@ -60,6 +60,9 @@ export class Submission extends BaseEntityWithTimestamps { constructor(props: SubmissionProperties) { super(); this.school = props.school; + if (props.student !== undefined) { + this.student = props.student; + } this.student = props.student; this.comment = props.comment; this.task = props.task; @@ -112,10 +115,13 @@ export class Submission extends BaseEntityWithTimestamps { // Bad that the logic is needed to expose the userIds, but is used in task for now. // Check later if it can be replaced and remove all related code. public getSubmitterIds(): EntityId[] { - const creatorId = this.student.id; + const creatorId = this.student?.id; const teamMemberIds = this.getTeamMemberIds(); const courseGroupMemberIds = this.getCourseGroupStudentIds(); - const memberIds = [creatorId, ...teamMemberIds, ...courseGroupMemberIds]; + const memberIds = + creatorId !== undefined + ? [creatorId, ...teamMemberIds, ...courseGroupMemberIds] + : [...teamMemberIds, ...courseGroupMemberIds]; const uniqueMemberIds = [...new Set(memberIds)]; @@ -140,4 +146,28 @@ export class Submission extends BaseEntityWithTimestamps { return isGradedForUser; } + + public isGroupSubmission(): boolean { + return this.hasCourseGroup() || (!this.hasCourseGroup() && this.teamMembers.length > 1); + } + + public isSingleSubmissionOwnedByUser(): boolean { + return !this.hasCourseGroup() && this.teamMembers.length === 1; + } + + private hasCourseGroup(): boolean { + return !!this.courseGroup; + } + + public removeStudentById(userId: EntityId): void { + if (userId === this.student?.id) { + this.student = undefined; + } + } + + public removeUserFromTeamMembers(userId: EntityId): void { + const modifiedArray = this.teamMembers.getItems().filter((user) => user.id !== userId); + + this.teamMembers.set(modifiedArray); + } } diff --git a/apps/server/src/shared/domain/entity/task.entity.spec.ts b/apps/server/src/shared/domain/entity/task.entity.spec.ts index a8a1007a898..9686164769c 100644 --- a/apps/server/src/shared/domain/entity/task.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/task.entity.spec.ts @@ -3,7 +3,7 @@ import { courseFactory, courseGroupFactory, lessonFactory, - schoolFactory, + schoolEntityFactory, setupEntities, submissionFactory, taskFactory, @@ -858,7 +858,7 @@ describe('Task Entity', () => { describe('getSchoolId', () => { it('schould return schoolId from school', () => { - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const task = taskFactory.buildWithId({ school }); const schoolId = task.getSchoolId(); diff --git a/apps/server/src/shared/domain/entity/task.entity.ts b/apps/server/src/shared/domain/entity/task.entity.ts index c5ae56b7286..b1537a9a387 100644 --- a/apps/server/src/shared/domain/entity/task.entity.ts +++ b/apps/server/src/shared/domain/entity/task.entity.ts @@ -57,7 +57,7 @@ export class Task extends BaseEntityWithTimestamps implements LearnroomElement, @Index() dueDate?: Date; - @Property() + @Property({ type: 'boolean' }) private = true; @Property({ nullable: true }) diff --git a/apps/server/src/shared/domain/entity/user.entity.spec.ts b/apps/server/src/shared/domain/entity/user.entity.spec.ts index c10a26bc2c4..b29ce982766 100644 --- a/apps/server/src/shared/domain/entity/user.entity.spec.ts +++ b/apps/server/src/shared/domain/entity/user.entity.spec.ts @@ -1,8 +1,9 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { MikroORM } from '@mikro-orm/core'; -import { roleFactory, schoolFactory, setupEntities, userFactory } from '@shared/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { roleFactory, schoolEntityFactory, setupEntities, userFactory } from '@shared/testing'; import { Role } from '.'; -import { Permission } from '../interface'; +import { LanguageType, Permission, RoleName } from '../interface'; import { User } from './user.entity'; describe('User Entity', () => { @@ -24,7 +25,7 @@ describe('User Entity', () => { }); it('should create a user when passing required properties', () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const user = new User({ firstName: 'John', lastName: 'Cale', @@ -91,4 +92,170 @@ describe('User Entity', () => { expect(permissions.sort()).toEqual([permissionA, permissionB, permissionC].sort()); }); }); + + describe('when user is an admin', () => { + describe('when school permissions are false', () => { + const setup = () => { + const role = roleFactory.build({ + name: RoleName.ADMINISTRATOR, + permissions: [permissionA, Permission.STUDENT_LIST, Permission.LERNSTORE_VIEW], + }); + const school = schoolEntityFactory.build({ + permissions: { + teacher: { [Permission.STUDENT_LIST]: false }, + student: { [Permission.LERNSTORE_VIEW]: false }, + }, + }); + const user = userFactory.build({ roles: [role], school }); + + return { user }; + }; + + it('should return the permissions of the user and not remove the school permissions', () => { + const { user } = setup(); + + const result = user.resolvePermissions(); + + expect(result.sort()).toEqual([permissionA, Permission.STUDENT_LIST, Permission.LERNSTORE_VIEW].sort()); + }); + }); + }); + + describe('when user is a teacher', () => { + describe('when school permissions `STUDENT_LIST` is true', () => { + const setup = () => { + const role = roleFactory.build({ name: RoleName.TEACHER, permissions: [permissionA] }); + const school = schoolEntityFactory.build({ + permissions: { teacher: { [Permission.STUDENT_LIST]: true }, student: { [Permission.LERNSTORE_VIEW]: true } }, + }); + const user = userFactory.build({ roles: [role], school }); + + return { user }; + }; + + it('should return the permissions of the user and the school permissions', () => { + const { user } = setup(); + + const result = user.resolvePermissions(); + + expect(result.sort()).toEqual([permissionA, Permission.STUDENT_LIST].sort()); + }); + }); + + describe('when school permissions `STUDENT_LIST` is false', () => { + const setup = () => { + const role = roleFactory.build({ name: RoleName.TEACHER, permissions: [permissionA, Permission.STUDENT_LIST] }); + const school = schoolEntityFactory.build({ + permissions: { + teacher: { [Permission.STUDENT_LIST]: false }, + student: { [Permission.LERNSTORE_VIEW]: true }, + }, + }); + const user = userFactory.build({ roles: [role], school }); + + Configuration.set('TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT', false); + return { user }; + }; + + it('should return the permissions of the user and the school permissions', () => { + const { user } = setup(); + + const result = user.resolvePermissions(); + + expect(result.sort()).toEqual([permissionA].sort()); + }); + }); + }); + + describe('when user is a student', () => { + describe('when school permissions `LERNSTORE_VIEW` is true', () => { + const setup = () => { + const role = roleFactory.build({ name: RoleName.STUDENT, permissions: [permissionA] }); + const school = schoolEntityFactory.build({ + permissions: { teacher: { [Permission.STUDENT_LIST]: true }, student: { [Permission.LERNSTORE_VIEW]: true } }, + }); + const user = userFactory.build({ roles: [role], school }); + + return { user }; + }; + + it('should return the permissions of the user and the school permissions', () => { + const { user } = setup(); + + const result = user.resolvePermissions(); + + expect(result.sort()).toEqual([permissionA, Permission.LERNSTORE_VIEW].sort()); + }); + }); + + describe('when school permissions `LERNSTORE_VIEW` is false', () => { + const setup = () => { + const role = roleFactory.build({ + name: RoleName.STUDENT, + permissions: [permissionA, Permission.LERNSTORE_VIEW], + }); + const school = schoolEntityFactory.build({ + permissions: { + teacher: { [Permission.STUDENT_LIST]: true }, + student: { [Permission.LERNSTORE_VIEW]: false }, + }, + }); + const user = userFactory.build({ roles: [role], school }); + + return { user }; + }; + + it('should return the permissions of the user and the school permissions', () => { + const { user } = setup(); + + const result = user.resolvePermissions(); + + expect(result.sort()).toEqual([permissionA].sort()); + }); + }); + }); + + describe('getRoles', () => { + const setup = () => { + const roles = roleFactory.buildListWithId(2); + const user = userFactory.build({ roles }); + + return { user }; + }; + + it('should return the roles as array', () => { + const { user } = setup(); + + const result = user.getRoles(); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(Role); + }); + }); + + describe('getInfo', () => { + const setup = () => { + const expectedResult = { + customAvatarBackgroundColor: '#fe8a71', + firstName: 'a', + lastName: 'b', + id: '', + language: LanguageType.EN, + }; + + const user = userFactory.buildWithId(expectedResult); + expectedResult.id = user.id; + + return { user, expectedResult }; + }; + + it('should return a less critical subset of informations about the user', () => { + const { user, expectedResult } = setup(); + + const result = user.getInfo(); + + expect(result).toEqual(expectedResult); + }); + }); }); diff --git a/apps/server/src/shared/domain/entity/user.entity.ts b/apps/server/src/shared/domain/entity/user.entity.ts index dd5c0ec66b3..32783a13a69 100644 --- a/apps/server/src/shared/domain/entity/user.entity.ts +++ b/apps/server/src/shared/domain/entity/user.entity.ts @@ -1,17 +1,13 @@ +import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { Collection, Embedded, Entity, Index, ManyToMany, ManyToOne, Property } from '@mikro-orm/core'; -import { EntityWithSchool } from '../interface'; +import { EntityWithSchool, LanguageType, Permission, RoleName } from '../interface'; +import { EntityId } from '../types'; import { BaseEntityWithTimestamps } from './base.entity'; +import { ConsentEntity } from './consent'; import { Role } from './role.entity'; import { SchoolEntity } from './school.entity'; import { UserParentsEntity } from './user-parents.entity'; -export enum LanguageType { - DE = 'de', - EN = 'en', - ES = 'es', - UK = 'uk', -} - export interface UserProperties { email: string; firstName: string; @@ -28,7 +24,18 @@ export interface UserProperties { outdatedSince?: Date; previousExternalId?: string; birthday?: Date; + customAvatarBackgroundColor?: string; parents?: UserParentsEntity[]; + lastSyncedAt?: Date; + consent?: ConsentEntity; +} + +interface UserInfo { + id: EntityId; + firstName: string; + lastName: string; + language?: LanguageType; + customAvatarBackgroundColor?: string; } @Entity({ tableName: 'users' }) @@ -86,7 +93,7 @@ export class User extends BaseEntityWithTimestamps implements EntityWithSchool { @Property({ nullable: true }) forcePasswordChange?: boolean; - @Property({ nullable: true }) + @Property({ type: 'object', nullable: true }) preferences?: Record; @Property({ nullable: true }) @@ -102,9 +109,18 @@ export class User extends BaseEntityWithTimestamps implements EntityWithSchool { @Property({ nullable: true }) birthday?: Date; + @Property({ nullable: true }) + customAvatarBackgroundColor?: string; // in legacy it is NOT optional, but all new users stored without default value + + @Embedded(() => ConsentEntity, { nullable: true, object: true }) + consent?: ConsentEntity; + @Embedded(() => UserParentsEntity, { array: true, nullable: true }) parents?: UserParentsEntity[]; + @Property({ nullable: true }) + lastSyncedAt?: Date; + constructor(props: UserProperties) { super(); this.firstName = props.firstName; @@ -122,7 +138,10 @@ export class User extends BaseEntityWithTimestamps implements EntityWithSchool { this.outdatedSince = props.outdatedSince; this.previousExternalId = props.previousExternalId; this.birthday = props.birthday; + this.customAvatarBackgroundColor = props.customAvatarBackgroundColor; this.parents = props.parents; + this.lastSyncedAt = props.lastSyncedAt; + this.consent = props.consent; } public resolvePermissions(): string[] { @@ -132,14 +151,68 @@ export class User extends BaseEntityWithTimestamps implements EntityWithSchool { let permissions: string[] = []; - const roles = this.roles.getItems(); + const roles = this.getRoles(); roles.forEach((role) => { const rolePermissions = role.resolvePermissions(); permissions = [...permissions, ...rolePermissions]; }); - const uniquePermissions = [...new Set(permissions)]; + const setOfPermission = this.resolveSchoolPermissions(permissions, roles); + + const uniquePermissions = [...setOfPermission]; return uniquePermissions; } + + // TODO: refactor it in https://ticketsystem.dbildungscloud.de/browse/BC-7021 + private resolveSchoolPermissions(permissions: string[], roles: Role[]) { + const setOfPermission = new Set(permissions); + const schoolPermissions = this.school.permissions; + + if (roles.some((role) => role.name === RoleName.ADMINISTRATOR)) { + return setOfPermission; + } + + if (this.school.permissions) { + if (roles.some((role) => role.name === RoleName.STUDENT)) { + if (schoolPermissions?.student?.LERNSTORE_VIEW) { + setOfPermission.add(Permission.LERNSTORE_VIEW); + } else { + setOfPermission.delete(Permission.LERNSTORE_VIEW); + } + } + + if (roles.some((role) => role.name === RoleName.TEACHER)) { + const canStudentListByDefault = Configuration.get( + 'TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT' + ) as boolean; + + if (schoolPermissions?.teacher?.STUDENT_LIST || canStudentListByDefault) { + setOfPermission.add(Permission.STUDENT_LIST); + } else { + setOfPermission.delete(Permission.STUDENT_LIST); + } + } + } + + return setOfPermission; + } + + public getRoles(): Role[] { + const roles = this.roles.getItems(); + + return roles; + } + + public getInfo(): UserInfo { + const userInfo = { + id: this.id, + firstName: this.firstName, + lastName: this.lastName, + language: this.language, + customAvatarBackgroundColor: this.customAvatarBackgroundColor, + }; + + return userInfo; + } } diff --git a/apps/server/src/shared/domain/interface/domain-operation.ts b/apps/server/src/shared/domain/interface/domain-operation.ts deleted file mode 100644 index d4900ed2314..00000000000 --- a/apps/server/src/shared/domain/interface/domain-operation.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { DomainModel } from '../types'; - -export interface DomainOperation { - domain: DomainModel; - modifiedCount: number; - deletedCount: number; - modifiedRef?: string[]; - deletedRef?: string[]; -} diff --git a/apps/server/src/shared/domain/interface/find-options.ts b/apps/server/src/shared/domain/interface/find-options.ts index 37fc49a5909..28822a35b0d 100644 --- a/apps/server/src/shared/domain/interface/find-options.ts +++ b/apps/server/src/shared/domain/interface/find-options.ts @@ -14,3 +14,10 @@ export interface IFindOptions { pagination?: Pagination; order?: SortOrderMap; } + +export interface IFindQuery { + pagination?: Pagination; + nameQuery?: string; +} + +export type SortOrderNumberType = Partial>; diff --git a/apps/server/src/shared/domain/interface/index.ts b/apps/server/src/shared/domain/interface/index.ts index 72b97b4fa75..e5eb9ca8da5 100644 --- a/apps/server/src/shared/domain/interface/index.ts +++ b/apps/server/src/shared/domain/interface/index.ts @@ -1,8 +1,8 @@ export * from './account'; export * from './entity'; export * from './find-options'; +export * from './language-type.enum'; export * from './learnroom'; export * from './permission.enum'; export * from './rolename.enum'; export * from './video-conference-scope.enum'; -export * from './domain-operation'; diff --git a/apps/server/src/shared/domain/interface/language-type.enum.ts b/apps/server/src/shared/domain/interface/language-type.enum.ts new file mode 100644 index 00000000000..9ad9334b1ce --- /dev/null +++ b/apps/server/src/shared/domain/interface/language-type.enum.ts @@ -0,0 +1,6 @@ +export enum LanguageType { + DE = 'de', + EN = 'en', + ES = 'es', + UK = 'uk', +} diff --git a/apps/server/src/shared/domain/interface/learnroom.ts b/apps/server/src/shared/domain/interface/learnroom.ts index a0c8e794d68..aa224c7d4d4 100644 --- a/apps/server/src/shared/domain/interface/learnroom.ts +++ b/apps/server/src/shared/domain/interface/learnroom.ts @@ -4,6 +4,9 @@ export interface Learnroom { getMetadata: () => LearnroomMetadata; } +/** + * @Deprecated + */ export interface LearnroomElement { publish: () => void; unpublish: () => void; diff --git a/apps/server/src/shared/domain/interface/permission.enum.ts b/apps/server/src/shared/domain/interface/permission.enum.ts index ae46c54dd79..eefb2673c7c 100644 --- a/apps/server/src/shared/domain/interface/permission.enum.ts +++ b/apps/server/src/shared/domain/interface/permission.enum.ts @@ -1,6 +1,8 @@ export enum Permission { ACCOUNT_CREATE = 'ACCOUNT_CREATE', + ACCOUNT_DELETE = 'ACCOUNT_DELETE', ACCOUNT_EDIT = 'ACCOUNT_EDIT', + ACCOUNT_VIEW = 'ACCOUNT_VIEW', ADD_SCHOOL_MEMBERS = 'ADD_SCHOOL_MEMBERS', ADMIN_EDIT = 'ADMIN_EDIT', ADMIN_VIEW = 'ADMIN_VIEW', @@ -151,6 +153,7 @@ export enum Permission { USERGROUP_CREATE = 'USERGROUP_CREATE', USERGROUP_EDIT = 'USERGROUP_EDIT', USERGROUP_VIEW = 'USERGROUP_VIEW', + USER_CHANGE_OWN_NAME = 'USER_CHANGE_OWN_NAME', USER_CREATE = 'USER_CREATE', USER_LOGIN_MIGRATION_ADMIN = 'USER_LOGIN_MIGRATION_ADMIN', USER_MIGRATE = 'USER_MIGRATE', diff --git a/apps/server/src/shared/domain/types/index.ts b/apps/server/src/shared/domain/types/index.ts index 4957fc6de59..b47a0d4821b 100644 --- a/apps/server/src/shared/domain/types/index.ts +++ b/apps/server/src/shared/domain/types/index.ts @@ -10,5 +10,3 @@ export * from './school-purpose.enum'; export * from './system.type'; export * from './task.types'; export * from './value-of'; -export * from './domain'; -export * from './status-model.enum'; diff --git a/apps/server/src/shared/domain/types/learnroom.types.ts b/apps/server/src/shared/domain/types/learnroom.types.ts index b967d4970f0..cc81a291728 100644 --- a/apps/server/src/shared/domain/types/learnroom.types.ts +++ b/apps/server/src/shared/domain/types/learnroom.types.ts @@ -11,4 +11,5 @@ export type LearnroomMetadata = { startDate?: Date; untilDate?: Date; copyingSince?: Date; + isSynchronized: boolean; }; diff --git a/apps/server/src/shared/domain/types/school-feature.enum.ts b/apps/server/src/shared/domain/types/school-feature.enum.ts index d14d61fd75c..6e5391cbc06 100644 --- a/apps/server/src/shared/domain/types/school-feature.enum.ts +++ b/apps/server/src/shared/domain/types/school-feature.enum.ts @@ -8,5 +8,4 @@ export enum SchoolFeature { OAUTH_PROVISIONING_ENABLED = 'oauthProvisioningEnabled', SHOW_OUTDATED_USERS = 'showOutdatedUsers', ENABLE_LDAP_SYNC_DURING_MIGRATION = 'enableLdapSyncDuringMigration', - IS_TEAM_CREATION_BY_STUDENTS_ENABLED = 'isTeamCreationByStudentsEnabled', } diff --git a/apps/server/src/shared/domain/types/system.type.ts b/apps/server/src/shared/domain/types/system.type.ts index f1302b8ca79..2f6064922a4 100644 --- a/apps/server/src/shared/domain/types/system.type.ts +++ b/apps/server/src/shared/domain/types/system.type.ts @@ -3,5 +3,3 @@ export enum SystemTypeEnum { OAUTH = 'oauth', // systems for direct authentication via OAuth, OIDC = 'oidc', // systems for direct authentication via OpenID Connect, } - -export type SystemType = SystemTypeEnum; diff --git a/apps/server/src/shared/repo/base-domain-object.repo.integration.spec.ts b/apps/server/src/shared/repo/base-domain-object.repo.integration.spec.ts new file mode 100644 index 00000000000..17d8f51cfa1 --- /dev/null +++ b/apps/server/src/shared/repo/base-domain-object.repo.integration.spec.ts @@ -0,0 +1,359 @@ +import { createMock } from '@golevelup/ts-jest'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { Entity, EntityData, EntityName, Property } from '@mikro-orm/core'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Injectable } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; +import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; +import { LegacyLogger } from '@src/core/logger'; + +describe('BaseDomainObjectRepo', () => { + interface TestEntityProperties { + id: EntityId; + name: string; + } + + interface TestDOProps extends AuthorizableObject { + name: string; + createdAt?: Date; + updatedAt?: Date; + } + + @Entity() + class TestEntity extends BaseEntityWithTimestamps { + @Property() + name: string; + + constructor(props: TestEntityProperties) { + super(); + this.name = props.name; + } + } + + class TestDO extends DomainObject {} + + @Injectable() + class TestRepo extends BaseDomainObjectRepo { + get entityName(): EntityName { + return TestEntity; + } + + mapEntityToDO(entity: TestEntity): TestDO { + const { id, name, createdAt, updatedAt } = entity; + return new TestDO({ id, name, createdAt, updatedAt }); + } + + mapDOToEntityProperties(entityDO: TestDO): EntityData { + const { id, name, createdAt, updatedAt } = entityDO.getProps(); + return { + id, + name, + createdAt, + updatedAt, + }; + } + } + + let repo: TestRepo; + let em: EntityManager; + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + MongoMemoryDatabaseModule.forRoot({ + entities: [TestEntity], + }), + ], + providers: [ + TestRepo, + { + provide: LegacyLogger, + useValue: createMock(), + }, + ], + }).compile(); + + repo = module.get(TestRepo); + em = module.get(EntityManager); + }); + + beforeEach(async () => { + await em.nativeDelete(TestEntity, {}); + em.clear(); + jest.clearAllMocks(); + }); + + afterAll(async () => { + await module.close(); + }); + + describe('entityName', () => { + it('should implement entityName getter', () => { + expect(repo.entityName).toBe(TestEntity); + }); + }); + + describe('save', () => { + describe('when create new entity', () => { + const setup = () => { + const oid = new ObjectId(); + + const dob = new TestDO({ id: oid.toHexString(), name: 'test' }); + const spyCreate = jest.spyOn(em, 'create'); + + const { id, ...expected } = dob.getProps(); + return { dob, spyCreate, oid, expected }; + }; + + it('should call em.create', async () => { + const { dob, spyCreate, oid, expected } = setup(); + + const savedDob = await repo.save(dob); + + expect(savedDob).toBeInstanceOf(TestDO); + expect(spyCreate).toHaveBeenCalledWith(TestEntity, { ...expected, _id: oid }); + }); + + it('should find a persisted entity', async () => { + const { dob } = setup(); + + const savedDob = await repo.save(dob); + + const entity = await em.findOne(TestEntity, { id: savedDob.id }); + + expect(entity).toBeInstanceOf(TestEntity); + expect(entity?.id).toEqual(savedDob.id); + }); + + it('should save a single domain object', async () => { + const { dob } = setup(); + + const savedDob = await repo.save(dob); + + expect(savedDob).toBeInstanceOf(TestDO); + + expect(savedDob.getProps()).toEqual(dob.getProps()); + }); + }); + + describe('when update existing entity', () => { + const setup = async () => { + const entity = new TestEntity({ name: 'test', id: new ObjectId().toHexString() }); + await em.persistAndFlush(entity); + + const dob = new TestDO({ id: entity.id, name: 'test' }); + const spyAssign = jest.spyOn(em, 'assign'); + + const { id, ...expected } = dob.getProps(); + + return { entity, dob, spyAssign, expected }; + }; + + it('should call em.assign', async () => { + const { dob, spyAssign, expected } = await setup(); + + const savedDob = await repo.save(dob); + + expect(savedDob).toBeInstanceOf(TestDO); + expect(spyAssign).toHaveBeenCalledWith(expect.any(TestEntity), expected); + }); + + it('should save a single domain object', async () => { + const { dob } = await setup(); + + const savedDob = await repo.save(dob); + + expect(savedDob).toBeInstanceOf(TestDO); + expect(savedDob.getProps()).toEqual(dob.getProps()); + }); + + it('should update a single domain object', async () => { + const { dob } = await setup(); + + const updatedDob = new TestDO({ id: dob.id, name: 'updated' }); + const savedDob = await repo.save(updatedDob); + + expect(savedDob).toBeInstanceOf(TestDO); + expect(savedDob.getProps()).toEqual(updatedDob.getProps()); + }); + + it('should take protected properties from entity', async () => { + const { dob, entity } = await setup(); + const createdAt = new Date(0); + const updatedAt = new Date(0); + + const updatedDob = new TestDO({ id: dob.id, name: 'updated', createdAt, updatedAt }); + + const savedDob = await repo.save(updatedDob); + + const resultEntity = await em.findOne(TestEntity, { id: savedDob.id }); + + expect(resultEntity).toBeInstanceOf(TestEntity); + expect(resultEntity?.createdAt).toEqual(entity.createdAt); + expect(resultEntity?.updatedAt).toEqual(entity.updatedAt); + }); + }); + }); + + describe('saveAll', () => { + describe('when create multiple domain objects', () => { + const setup = () => { + const id1 = new ObjectId().toHexString(); + const id2 = new ObjectId().toHexString(); + const dobs = [new TestDO({ id: id1, name: 'test1' }), new TestDO({ id: id2, name: 'test2' })]; + const spyCreate = jest.spyOn(em, 'create'); + + const expected = dobs.map((dob) => { + const props = dob.getProps(); + return { _id: new ObjectId(props.id), name: props.name }; + }); + + return { dobs, spyCreate, expected }; + }; + + it('should call em.create with expected params', async () => { + const { dobs, spyCreate, expected } = setup(); + + const savedDobs = await repo.saveAll(dobs); + + expect(savedDobs).toHaveLength(dobs.length); + savedDobs.forEach((savedDob, index) => { + expect(savedDob.id).toEqual(expected[index]._id.toHexString()); + expect(spyCreate).toBeCalledWith(TestEntity, expected[index]); + }); + }); + + it('should save multiple domain objects', async () => { + const { dobs } = setup(); + + const savedDobs = await repo.saveAll(dobs); + + expect(savedDobs).toHaveLength(dobs.length); + savedDobs.forEach((savedDob, index) => { + expect(savedDob).toBeInstanceOf(TestDO); + expect(savedDob.getProps()).toEqual(dobs[index].getProps()); + }); + }); + }); + + describe('when update multiple domain objects', () => { + const setup = async () => { + const entities = [ + new TestEntity({ name: 'test1', id: new ObjectId().toHexString() }), + new TestEntity({ name: 'test2', id: new ObjectId().toHexString() }), + ]; + await em.persistAndFlush(entities); + + const spyCreate = jest.spyOn(em, 'assign'); + + const dobs = entities.map((entity, index) => new TestDO({ id: entity.id, name: `test${index}` })); + + const expected = dobs.map((dob) => { + const props = dob.getProps(); + return { name: props.name }; + }); + return { entities, dobs, spyCreate, expected }; + }; + + it('should call em.create', async () => { + const { dobs, spyCreate, expected } = await setup(); + + const savedDobs = await repo.saveAll(dobs); + + expect(savedDobs).toHaveLength(dobs.length); + savedDobs.forEach((savedDob, index) => { + expect(savedDob).toBeInstanceOf(TestDO); + expect(spyCreate).toHaveBeenCalledWith(expect.any(TestEntity), expected[index]); + }); + }); + + it('should save multiple domain objects', async () => { + const { dobs } = await setup(); + + const savedDobs = await repo.saveAll(dobs); + + expect(savedDobs).toHaveLength(dobs.length); + savedDobs.forEach((savedDob, index) => { + expect(savedDob).toBeInstanceOf(TestDO); + expect(savedDob.getProps()).toEqual(dobs[index].getProps()); + }); + }); + + it('should update multiple domain objects', async () => { + const { dobs } = await setup(); + + const updatedDobs = dobs.map((dob) => new TestDO({ id: dob.id, name: 'updated' })); + const savedDobs = await repo.saveAll(updatedDobs); + + expect(savedDobs).toHaveLength(updatedDobs.length); + savedDobs.forEach((savedDob, index) => { + expect(savedDob).toBeInstanceOf(TestDO); + expect(savedDob.getProps()).toEqual(updatedDobs[index].getProps()); + }); + }); + }); + }); + + describe('delete', () => { + const setup = async () => { + const entities = [ + new TestEntity({ name: 'test1', id: new ObjectId().toHexString() }), + new TestEntity({ name: 'test2', id: new ObjectId().toHexString() }), + ]; + await em.persistAndFlush(entities); + + const dobs = entities.map((entity) => new TestDO({ id: entity.id, name: 'test' })); + + return { entities, dobs }; + }; + + it('should delete a single domain object', async () => { + const { dobs } = await setup(); + + await repo.delete(dobs[0]); + + const entities = await em.find(TestEntity, {}); + expect(entities).toHaveLength(1); + expect(entities[0].id).toEqual(dobs[1].id); + }); + + it('should delete multiple domain objects', async () => { + const { dobs } = await setup(); + + await repo.delete(dobs); + + const entities = await em.find(TestEntity, {}); + expect(entities).toHaveLength(0); + }); + + it('should throw error when id is not set', async () => { + await setup(); + // @ts-expect-error - testing invalid input + const notFoundDob = new TestDO({ name: 'test' }); + + await expect(repo.delete(notFoundDob)).rejects.toThrowError('Cannot delete object without id'); + }); + }); + + describe('findById', () => { + const setup = async () => { + const entity = new TestEntity({ name: 'test', id: new ObjectId().toHexString() }); + await em.persistAndFlush(entity); + + return { entity }; + }; + + it('should find an entity by id', async () => { + const { entity } = await setup(); + + const foundEntity = await repo.findById(entity.id); + + expect(foundEntity).toBeInstanceOf(TestEntity); + expect(foundEntity.id).toEqual(entity.id); + }); + }); +}); diff --git a/apps/server/src/shared/repo/base-domain-object.repo.interface.ts b/apps/server/src/shared/repo/base-domain-object.repo.interface.ts new file mode 100644 index 00000000000..3280bb04f51 --- /dev/null +++ b/apps/server/src/shared/repo/base-domain-object.repo.interface.ts @@ -0,0 +1,9 @@ +import { AuthorizableObject, DomainObject } from '../domain/domain-object'; + +export interface BaseDomainObjectRepoInterface> { + save(domainObject: D): Promise; + + saveAll(domainObjects: D[]): Promise; + + delete(domainObjects: D[] | D): Promise; +} diff --git a/apps/server/src/shared/repo/base-domain-object.repo.ts b/apps/server/src/shared/repo/base-domain-object.repo.ts new file mode 100644 index 00000000000..5fcdea0688a --- /dev/null +++ b/apps/server/src/shared/repo/base-domain-object.repo.ts @@ -0,0 +1,76 @@ +import { EntityData, EntityName, FilterQuery, Primary, RequiredEntityData, Utils } from '@mikro-orm/core'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { BaseEntity, baseEntityProperties } from '@shared/domain/entity'; +import { EntityId } from '@shared/domain/types'; +import { BaseDomainObjectRepoInterface } from './base-domain-object.repo.interface'; + +@Injectable() +export abstract class BaseDomainObjectRepo, E extends BaseEntity> + implements BaseDomainObjectRepoInterface +{ + constructor(protected readonly em: EntityManager) {} + + protected abstract get entityName(): EntityName; + + protected abstract mapDOToEntityProperties(entityDO: D): EntityData; + + async save(domainObject: D): Promise { + const savedDomainObjects = await this.saveAll([domainObject]); + return savedDomainObjects[0]; + } + + async saveAll(domainObjects: D[]): Promise { + const promises = domainObjects.map((dob) => this.createOrUpdateEntity(dob)); + + const results = await Promise.all(promises); + + await this.em.flush(); + + return results.map((result) => result.domainObject); + } + + private async createOrUpdateEntity(domainObject: D): Promise<{ domainObject: D; persistedEntity: E }> { + const entityData = this.mapDOToEntityProperties(domainObject); + this.removeProtectedEntityFields(entityData); + const { id } = domainObject; + const existingEntity = await this.em.findOne(this.entityName, { id } as FilterQuery); + + const persistedEntity = existingEntity + ? this.em.assign(existingEntity, entityData) + : this.em.create(this.entityName, { ...entityData, id } as RequiredEntityData); + + return { domainObject, persistedEntity }; + } + + async delete(domainObjects: D[] | D): Promise { + const ids: Primary[] = Utils.asArray(domainObjects).map((dob) => { + if (!dob.id) { + throw new InternalServerErrorException('Cannot delete object without id'); + } + return dob.id as Primary; + }); + + const entities = ids.map((eid) => this.em.getReference(this.entityName, eid)); + + await this.em.remove(entities).flush(); + } + + async findById(id: EntityId): Promise { + const entity: E = await this.em.findOneOrFail(this.entityName, { id } as FilterQuery); + + return entity; + } + + /** + * Ignore base entity properties when updating entity + */ + private removeProtectedEntityFields(entityData: EntityData) { + Object.keys(entityData).forEach((key) => { + if (baseEntityProperties.includes(key)) { + delete entityData[key]; + } + }); + } +} diff --git a/apps/server/src/shared/repo/base.repo.ts b/apps/server/src/shared/repo/base.repo.ts index c3fadd3403d..2395a8e284e 100644 --- a/apps/server/src/shared/repo/base.repo.ts +++ b/apps/server/src/shared/repo/base.repo.ts @@ -1,9 +1,8 @@ import { EntityName, FilterQuery } from '@mikro-orm/core'; -import { EntityManager } from '@mikro-orm/mongodb'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Injectable } from '@nestjs/common'; import { BaseEntity } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; /** * This repo will be replaced in the future by a more domain driven repo, which is currently discussed in the arc chapter. diff --git a/apps/server/src/shared/repo/board/index.ts b/apps/server/src/shared/repo/board/index.ts deleted file mode 100644 index a5673f20739..00000000000 --- a/apps/server/src/shared/repo/board/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './board.repo'; diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts index 04926002677..3f19a93f47b 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.integration.spec.ts @@ -5,18 +5,14 @@ import { CustomParameterEntry } from '@modules/tool/common/domain'; import { ToolContextType } from '@modules/tool/common/enum'; import { ContextExternalTool, ContextExternalToolProps } from '@modules/tool/context-external-tool/domain'; import { ContextExternalToolEntity, ContextExternalToolType } from '@modules/tool/context-external-tool/entity'; +import { contextExternalToolEntityFactory } from '@modules/tool/context-external-tool/testing'; import { ContextExternalToolQuery } from '@modules/tool/context-external-tool/uc/dto/context-external-tool.types'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; +import { schoolExternalToolEntityFactory } from '@modules/tool/school-external-tool/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { SchoolEntity } from '@shared/domain/entity'; import { ExternalToolRepoMapper } from '@shared/repo/externaltool/external-tool.repo.mapper'; -import { - cleanupCollections, - contextExternalToolEntityFactory, - contextExternalToolFactory, - schoolExternalToolEntityFactory, - schoolFactory, -} from '@shared/testing'; +import { cleanupCollections, contextExternalToolFactory, schoolEntityFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { ContextExternalToolRepo } from './context-external-tool.repo'; @@ -51,7 +47,7 @@ describe('ContextExternalToolRepo', () => { }); const createExternalTools = () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const schoolExternalTool1: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school }); const schoolExternalTool2: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ school }); const contextExternalTool1: ContextExternalToolEntity = contextExternalToolEntityFactory.buildWithId({ @@ -448,84 +444,50 @@ describe('ContextExternalToolRepo', () => { }); }); - describe('countBySchoolToolIdsAndContextType', () => { - describe('when a ContextExternalTool is found for course context', () => { + describe('findBySchoolToolIdsAndContextType', () => { + describe('when a ContextExternalTool is found for the selected context', () => { const setup = async () => { - const schoolExternalTool = schoolExternalToolEntityFactory.buildWithId(); - const schoolExternalTool1 = schoolExternalToolEntityFactory.buildWithId(); - - const contextExternalTool = contextExternalToolEntityFactory.buildList(4, { - contextType: ContextExternalToolType.COURSE, - schoolTool: schoolExternalTool, - }); - - const contextExternalTool3 = contextExternalToolEntityFactory.buildList(2, { - contextType: ContextExternalToolType.COURSE, - schoolTool: schoolExternalTool1, - }); - - await em.persistAndFlush([ - schoolExternalTool, - schoolExternalTool1, - ...contextExternalTool, - ...contextExternalTool3, - ]); - - return { - schoolExternalTool, - schoolExternalTool1, - }; - }; - - it('should return correct results', async () => { - const { schoolExternalTool, schoolExternalTool1 } = await setup(); - - const result = await repo.countBySchoolToolIdsAndContextType(ContextExternalToolType.COURSE, [ - schoolExternalTool.id, - schoolExternalTool1.id, - ]); - - expect(result).toEqual(6); - }); - }); - - describe('when a ContextExternalTool is found for board context', () => { - const setup = async () => { - const schoolExternalTool = schoolExternalToolEntityFactory.buildWithId(); - const schoolExternalTool1 = schoolExternalToolEntityFactory.buildWithId(); - - const contextExternalTool1 = contextExternalToolEntityFactory.buildList(3, { - contextType: ContextExternalToolType.BOARD_ELEMENT, - schoolTool: schoolExternalTool, - }); - - const contextExternalTool2 = contextExternalToolEntityFactory.buildList(2, { - contextType: ContextExternalToolType.BOARD_ELEMENT, - schoolTool: schoolExternalTool1, - }); + const schoolExternalTool1: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId(); + const schoolExternalTool2: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId(); + + const contextExternalToolsInCourses: ContextExternalToolEntity[] = contextExternalToolEntityFactory.buildList( + 4, + { + contextType: ContextExternalToolType.COURSE, + schoolTool: schoolExternalTool1, + } + ); + + const contextExternalToolsOnBoards: ContextExternalToolEntity[] = contextExternalToolEntityFactory.buildList( + 2, + { + contextType: ContextExternalToolType.BOARD_ELEMENT, + schoolTool: schoolExternalTool2, + } + ); await em.persistAndFlush([ - schoolExternalTool, schoolExternalTool1, - ...contextExternalTool1, - ...contextExternalTool2, + schoolExternalTool2, + ...contextExternalToolsInCourses, + ...contextExternalToolsOnBoards, ]); return { - schoolExternalTool, schoolExternalTool1, + schoolExternalTool2, }; }; - it('should return correct results', async () => { - const { schoolExternalTool, schoolExternalTool1 } = await setup(); + it('should return the context external tools of that context', async () => { + const { schoolExternalTool1, schoolExternalTool2 } = await setup(); - const result = await repo.countBySchoolToolIdsAndContextType(ContextExternalToolType.BOARD_ELEMENT, [ - schoolExternalTool.id, - schoolExternalTool1.id, - ]); + const result: ContextExternalTool[] = await repo.findBySchoolToolIdsAndContextType( + [schoolExternalTool1.id, schoolExternalTool2.id], + ContextExternalToolType.COURSE + ); - expect(result).toEqual(5); + expect(result).toHaveLength(4); }); }); }); diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts index 1dcb54f6755..7360da6dbfa 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.repo.ts @@ -41,12 +41,15 @@ export class ContextExternalToolRepo extends BaseDORepo { + const entities = await this._em.find(this.entityName, { schoolTool: { $in: schoolExternalToolIds }, contextType }); + + const dos: ContextExternalTool[] = entities.map((entity: ContextExternalToolEntity) => this.mapEntityToDO(entity)); - return contextExternalToolCount; + return dos; } public override async findById(id: EntityId): Promise { diff --git a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.scope.spec.ts b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.scope.spec.ts index 25c77bf5beb..1083e3a85df 100644 --- a/apps/server/src/shared/repo/contextexternaltool/context-external-tool.scope.spec.ts +++ b/apps/server/src/shared/repo/contextexternaltool/context-external-tool.scope.spec.ts @@ -1,6 +1,6 @@ -import { schoolExternalToolEntityFactory } from '@shared/testing'; import { ToolContextType } from '@modules/tool/common/enum'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; +import { schoolExternalToolEntityFactory } from '@modules/tool/school-external-tool/testing'; import { ContextExternalToolScope } from './context-external-tool.scope'; describe('CourseExternalToolScope', () => { diff --git a/apps/server/src/shared/repo/course/course.repo.integration.spec.ts b/apps/server/src/shared/repo/course/course.repo.integration.spec.ts index ea284dd547d..128d742001d 100644 --- a/apps/server/src/shared/repo/course/course.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/course/course.repo.integration.spec.ts @@ -1,11 +1,10 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { Course } from '@shared/domain/entity'; import { SortOrder } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; - -import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections, courseFactory, courseGroupFactory, userFactory } from '@shared/testing'; import { CourseRepo } from './course.repo'; @@ -76,6 +75,7 @@ describe('course repo', () => { 'features', 'classes', 'groups', + 'syncedWithGroup', ].sort(); expect(keysOfFirstElements).toEqual(expectedResult); }); diff --git a/apps/server/src/shared/repo/dashboard/dashboard.model.mapper.ts b/apps/server/src/shared/repo/dashboard/dashboard.model.mapper.ts index 8bdadf724f5..a052fba34f4 100644 --- a/apps/server/src/shared/repo/dashboard/dashboard.model.mapper.ts +++ b/apps/server/src/shared/repo/dashboard/dashboard.model.mapper.ts @@ -1,7 +1,7 @@ import { EntityManager, wrap } from '@mikro-orm/core'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { - Course, + Course as CourseEntity, DashboardEntity, DashboardGridElementModel, DashboardModelEntity, @@ -16,15 +16,16 @@ import { LearnroomTypes } from '@shared/domain/types'; export class DashboardModelMapper { constructor(protected readonly em: EntityManager) {} - async mapReferenceToEntity(modelEntity: Course): Promise { - const domainEntity = await wrap(modelEntity).init(); + async mapReferenceToEntity(modelEntity: CourseEntity): Promise { + const domainEntity: CourseEntity = await wrap(modelEntity).init(); + return domainEntity; } async mapElementToEntity(modelEntity: DashboardGridElementModel): Promise { - const referenceModels = await modelEntity.references.loadItems(); - const references = await Promise.all(referenceModels.map((ref) => this.mapReferenceToEntity(ref))); - const result = { + const referenceModels: CourseEntity[] = await modelEntity.references.loadItems(); + const references: CourseEntity[] = await Promise.all(referenceModels.map((ref) => this.mapReferenceToEntity(ref))); + const result: GridElementWithPosition = { pos: { x: modelEntity.xPos, y: modelEntity.yPos }, gridElement: GridElement.FromPersistedGroup(modelEntity.id, modelEntity.title, references), }; @@ -39,10 +40,10 @@ export class DashboardModelMapper { return new DashboardEntity(modelEntity.id, { grid, userId: modelEntity.user.id }); } - mapReferenceToModel(reference: Learnroom): Course { + mapReferenceToModel(reference: Learnroom): CourseEntity { const metadata = reference.getMetadata(); if (metadata.type === LearnroomTypes.Course) { - const course = reference as Course; + const course = reference as CourseEntity; return course; } throw new InternalServerErrorException('unknown learnroom type'); diff --git a/apps/server/src/shared/repo/dashboard/dashboard.repo.integration.spec.ts b/apps/server/src/shared/repo/dashboard/dashboard.repo.integration.spec.ts index d82502ab351..4f16c9c83cc 100644 --- a/apps/server/src/shared/repo/dashboard/dashboard.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/dashboard/dashboard.repo.integration.spec.ts @@ -245,6 +245,53 @@ describe('dashboard repo', () => { }); }); + describe('getUsersDashboardIfExist', () => { + describe('when user has no dashboard', () => { + const setup = async () => { + const user = userFactory.build(); + await em.persistAndFlush(user); + + return { user }; + }; + + it('should return null', async () => { + const { user } = await setup(); + + const result = await repo.getUsersDashboardIfExist(user.id); + + expect(result).toBeNull(); + }); + }); + + describe('when user has a dashboard already', () => { + const setup = async () => { + const user = userFactory.build(); + const course = courseFactory.build({ students: [user], name: 'Mathe' }); + await em.persistAndFlush([user, course]); + const dashboard = new DashboardEntity(new ObjectId().toString(), { + grid: [ + { + pos: { x: 1, y: 3 }, + gridElement: GridElement.FromSingleReference(course), + }, + ], + userId: user.id, + }); + await repo.persistAndFlush(dashboard); + + return { user, dashboard }; + }; + + it('should return the existing dashboard', async () => { + const { user, dashboard } = await setup(); + + const result = await repo.getUsersDashboardIfExist(user.id); + expect(result?.id).toEqual(dashboard.id); + expect(result?.userId).toEqual(dashboard.userId); + }); + }); + }); + describe('deleteDashboardByUserId', () => { const setup = async () => { const userWithoutDashoard = userFactory.build(); diff --git a/apps/server/src/shared/repo/dashboard/dashboard.repo.ts b/apps/server/src/shared/repo/dashboard/dashboard.repo.ts index 10a01d379e7..680af09012d 100644 --- a/apps/server/src/shared/repo/dashboard/dashboard.repo.ts +++ b/apps/server/src/shared/repo/dashboard/dashboard.repo.ts @@ -13,6 +13,7 @@ const generateEmptyDashboard = (userId: EntityId) => { export interface IDashboardRepo { getUsersDashboard(userId: EntityId): Promise; + getUsersDashboardIfExist(userId: EntityId): Promise; getDashboardById(id: EntityId): Promise; persistAndFlush(entity: DashboardEntity): Promise; deleteDashboardByUserId(userId: EntityId): Promise; @@ -53,6 +54,15 @@ export class DashboardRepo implements IDashboardRepo { return dashboard; } + async getUsersDashboardIfExist(userId: EntityId): Promise { + const dashboardModel = await this.em.findOne(DashboardModelEntity, { user: userId }); + if (dashboardModel) { + return this.mapper.mapDashboardToEntity(dashboardModel); + } + + return dashboardModel; + } + async deleteDashboardByUserId(userId: EntityId): Promise { const promise = await this.em.nativeDelete(DashboardModelEntity, { user: userId }); diff --git a/apps/server/src/shared/repo/dashboard/dashboardElement.repo.ts b/apps/server/src/shared/repo/dashboard/dashboardElement.repo.ts index 27f5baf93f2..25c97bcdcdb 100644 --- a/apps/server/src/shared/repo/dashboard/dashboardElement.repo.ts +++ b/apps/server/src/shared/repo/dashboard/dashboardElement.repo.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { EntityManager } from '@mikro-orm/core'; import { DashboardGridElementModel } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; @Injectable() export class DashboardElementRepo { diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts index a17320075d6..33d24fef100 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.integration.spec.ts @@ -13,12 +13,13 @@ import { } from '@modules/tool/common/enum'; import { BasicToolConfig, ExternalTool, Lti11ToolConfig, Oauth2ToolConfig } from '@modules/tool/external-tool/domain'; import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; +import { externalToolEntityFactory } from '@modules/tool/external-tool/testing'; import { Test, TestingModule } from '@nestjs/testing'; import { Page } from '@shared/domain/domainobject'; import { IFindOptions, SortOrder } from '@shared/domain/interface'; import { ExternalToolRepo, ExternalToolRepoMapper } from '@shared/repo'; -import { cleanupCollections, externalToolEntityFactory } from '@shared/testing'; +import { cleanupCollections } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; describe('ExternalToolRepo', () => { @@ -204,7 +205,6 @@ describe('ExternalToolRepo', () => { key: 'key', lti_message_type: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, privacy_permission: LtiPrivacyPermission.PSEUDONYMOUS, - resource_link_id: 'resource_link_id', launch_presentation_locale: 'de-DE', }); const { domainObject } = setupDO(config); diff --git a/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts b/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts index b9113606c7e..0e420a46cc9 100644 --- a/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts +++ b/apps/server/src/shared/repo/externaltool/external-tool.repo.mapper.ts @@ -1,16 +1,23 @@ -import { UnprocessableEntityException } from '@nestjs/common'; +import { EntityData } from '@mikro-orm/core'; import { CustomParameter, CustomParameterEntry } from '@modules/tool/common/domain'; import { CustomParameterEntryEntity } from '@modules/tool/common/entity'; import { ToolConfigType } from '@modules/tool/common/enum'; -import { BasicToolConfig, ExternalTool, Lti11ToolConfig, Oauth2ToolConfig } from '@modules/tool/external-tool/domain'; +import { + BasicToolConfig, + ExternalTool, + ExternalToolMedium, + Lti11ToolConfig, + Oauth2ToolConfig, +} from '@modules/tool/external-tool/domain'; import { BasicToolConfigEntity, CustomParameterEntity, ExternalToolEntity, Lti11ToolConfigEntity, Oauth2ToolConfigEntity, + ExternalToolMediumEntity, } from '@modules/tool/external-tool/entity'; -import { EntityData } from '@mikro-orm/core'; +import { UnprocessableEntityException } from '@nestjs/common'; // TODO: maybe rename because of usage in external tool repo and school external tool repo export class ExternalToolRepoMapper { @@ -34,6 +41,7 @@ export class ExternalToolRepoMapper { return new ExternalTool({ id: entity.id, name: entity.name, + description: entity.description, url: entity.url, logoUrl: entity.logoUrl, logo: entity.logoBase64, @@ -44,6 +52,18 @@ export class ExternalToolRepoMapper { openNewTab: entity.openNewTab, version: entity.version, restrictToContexts: entity.restrictToContexts, + medium: this.mapExternalToolMediumEntityToDO(entity.medium), + }); + } + + private static mapExternalToolMediumEntityToDO(entity?: ExternalToolMediumEntity): ExternalToolMedium | undefined { + if (!entity) { + return undefined; + } + + return new ExternalToolMedium({ + mediumId: entity.mediumId, + publisher: entity.publisher, }); } @@ -70,7 +90,6 @@ export class ExternalToolRepoMapper { key: lti11Config.key, secret: lti11Config.secret, lti_message_type: lti11Config.lti_message_type, - resource_link_id: lti11Config.resource_link_id, privacy_permission: lti11Config.privacy_permission, launch_presentation_locale: lti11Config.launch_presentation_locale, }); @@ -95,6 +114,7 @@ export class ExternalToolRepoMapper { return { name: entityDO.name, + description: entityDO.description, url: entityDO.url, logoUrl: entityDO.logoUrl, logoBase64: entityDO.logo, @@ -105,9 +125,21 @@ export class ExternalToolRepoMapper { openNewTab: entityDO.openNewTab, version: entityDO.version, restrictToContexts: entityDO.restrictToContexts, + medium: this.mapExternalToolMediumDOToEntity(entityDO.medium), }; } + private static mapExternalToolMediumDOToEntity(medium?: ExternalToolMedium): ExternalToolMediumEntity | undefined { + if (!medium) { + return undefined; + } + + return new ExternalToolMediumEntity({ + mediumId: medium.mediumId, + publisher: medium.publisher, + }); + } + static mapBasicToolConfigDOToEntity(lti11Config: BasicToolConfig): BasicToolConfigEntity { return new BasicToolConfigEntity({ type: lti11Config.type, @@ -131,7 +163,6 @@ export class ExternalToolRepoMapper { key: lti11Config.key, secret: lti11Config.secret, lti_message_type: lti11Config.lti_message_type, - resource_link_id: lti11Config.resource_link_id, privacy_permission: lti11Config.privacy_permission, launch_presentation_locale: lti11Config.launch_presentation_locale, }); diff --git a/apps/server/src/shared/repo/importuser/importuser.repo.integration.spec.ts b/apps/server/src/shared/repo/importuser/importuser.repo.integration.spec.ts index 1e5d40462db..9f70c446495 100644 --- a/apps/server/src/shared/repo/importuser/importuser.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/importuser/importuser.repo.integration.spec.ts @@ -1,12 +1,11 @@ -import { EntityManager } from '@mikro-orm/mongodb'; -import { Test, TestingModule } from '@nestjs/testing'; -import { cleanupCollections, importUserFactory, schoolFactory, userFactory } from '@shared/testing'; - import { MongoMemoryDatabaseModule } from '@infra/database'; import { MikroORM, NotFoundError } from '@mikro-orm/core'; +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; import { IImportUserRoleName, ImportUser, MatchCreator, SchoolEntity, User } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { MatchCreatorScope } from '@shared/domain/types'; +import { cleanupCollections, importUserFactory, schoolEntityFactory, userFactory } from '@shared/testing'; import { ImportUserRepo } from '.'; describe('ImportUserRepo', () => { @@ -16,7 +15,7 @@ describe('ImportUserRepo', () => { let orm: MikroORM; const persistedReferences = async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const user = userFactory.build({ school }); await em.persistAndFlush([school, user]); return { user, school }; @@ -105,7 +104,7 @@ describe('ImportUserRepo', () => { describe('[findImportUsers] find importUsers scope integration', () => { describe('bySchool', () => { it('should respond with given schools importUsers', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ school }); const otherSchoolsImportUser = importUserFactory.build(); await em.persistAndFlush([school, importUser, otherSchoolsImportUser]); @@ -114,15 +113,15 @@ describe('ImportUserRepo', () => { expect(count).toEqual(1); }); it('should not respond with other schools than requested', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ school }); - const otherSchoolsImportUser = importUserFactory.build({ school: schoolFactory.build() }); + const otherSchoolsImportUser = importUserFactory.build({ school: schoolEntityFactory.build() }); await em.persistAndFlush([school, importUser, otherSchoolsImportUser]); const [results] = await repo.findImportUsers(school); expect(results).not.toContain(otherSchoolsImportUser); }); it('should not respond with any school for wrong id given', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ school }); const otherSchoolsImportUser = importUserFactory.build(); await em.persistAndFlush([school, importUser, otherSchoolsImportUser]); @@ -131,7 +130,7 @@ describe('ImportUserRepo', () => { ).rejects.toThrowError('invalid school id'); }); it('should not respond with any school for wrong id given', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ school }); const otherSchoolsImportUser = importUserFactory.build(); await em.persistAndFlush([school, importUser, otherSchoolsImportUser]); @@ -143,7 +142,7 @@ describe('ImportUserRepo', () => { describe('byFirstName', () => { it('should find fully matching firstnames "exact match"', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ firstName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ firstName: 'Peter', school }); await em.persistAndFlush([school, importUser, otherImportUser]); @@ -153,7 +152,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(1); }); it('should find partially matching firstnames "ignoring case"', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ firstName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ firstName: 'Marie', school }); await em.persistAndFlush([school, importUser, otherImportUser]); @@ -163,7 +162,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(1); }); it('should find partially matching firstname "starts-with"', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ firstName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ firstName: 'Marie', school }); const otherImportUser2 = importUserFactory.build({ firstName: 'Peter', school }); @@ -175,7 +174,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should find partially matching firstname "ends-with"', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ firstName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ firstName: 'Luise', school }); const otherImportUser2 = importUserFactory.build({ firstName: 'Peter', school }); @@ -187,7 +186,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should skip firstname filter for undefined values', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ firstName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ firstName: 'Peter', school }); await em.persistAndFlush([school, importUser, otherImportUser]); @@ -197,7 +196,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should skip firstname filter for empty string', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ firstName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ firstName: 'Peter', school }); await em.persistAndFlush([school, importUser, otherImportUser]); @@ -207,7 +206,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should skip special chars from filter', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ firstName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ firstName: 'Peter', school }); await em.persistAndFlush([school, importUser, otherImportUser]); @@ -217,7 +216,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should keep characters as filter with language letters, numbers, space and minus', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ firstName: 'Marie-Luise ÃĄÃ ÃĸäÃŖÃĨçÊèÃĒÃĢíÃŦÃŽÃ¯ÃąÃŗÃ˛Ã´ÃļÃĩÃēÚÃģÃŧÃŊÃŋÃĻœÁÀÂÄÃÅÇÉÈÊËÍÌÎÏÑÓÒÔÖÕÚÙÛÜÝŸÆŒ0987654321', school, @@ -234,7 +233,7 @@ describe('ImportUserRepo', () => { }); describe('byLastName', () => { it('should find fully matching lastNames "exact match"', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ lastName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ lastName: 'Peter', school }); await em.persistAndFlush([school, importUser, otherImportUser]); @@ -244,7 +243,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(1); }); it('should find partially matching lastNames "ignoring case"', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ lastName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ lastName: 'Marie', school }); await em.persistAndFlush([school, importUser, otherImportUser]); @@ -254,7 +253,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(1); }); it('should find partially matching lastName "starts-with"', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ lastName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ lastName: 'Marie', school }); const otherImportUser2 = importUserFactory.build({ lastName: 'Peter', school }); @@ -266,7 +265,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should find partially matching lastName "ends-with"', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ lastName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ lastName: 'Luise', school }); const otherImportUser2 = importUserFactory.build({ lastName: 'Peter', school }); @@ -278,7 +277,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should skip lastName filter for undefined values', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ lastName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ lastName: 'Peter', school }); await em.persistAndFlush([school, importUser, otherImportUser]); @@ -288,7 +287,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should skip lastName filter for empty string', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ lastName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ lastName: 'Peter', school }); await em.persistAndFlush([school, importUser, otherImportUser]); @@ -298,7 +297,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should skip special chars from filter', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ lastName: 'Marie-Luise', school }); const otherImportUser = importUserFactory.build({ lastName: 'Peter', school }); await em.persistAndFlush([school, importUser, otherImportUser]); @@ -308,7 +307,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should keep characters as filter with language letters, numbers, space and minus', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ lastName: 'Marie-Luise ÃĄÃ ÃĸäÃŖÃĨçÊèÃĒÃĢíÃŦÃŽÃ¯ÃąÃŗÃ˛Ã´ÃļÃĩÃēÚÃģÃŧÃŊÃŋÃĻœÁÀÂÄÃÅÇÉÈÊËÍÌÎÏÑÓÒÔÖÕÚÙÛÜÝŸÆŒ0987654321', school, @@ -326,7 +325,7 @@ describe('ImportUserRepo', () => { }); describe('byLoginName', () => { it('should find fully matching loginNames "exact match"', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ ldapDn: 'uid=MarieLuise12,cn=schueler,cn=users,ou=1,dc=training,dc=ucs', school, @@ -339,7 +338,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(1); }); it('should find partially matching loginNames "ignoring case"', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ ldapDn: 'uid=MarieLuise12,cn=schueler,cn=users,ou=1,dc=training,dc=ucs', school, @@ -352,7 +351,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(1); }); it('should find partially matching loginName "starts-with"', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ ldapDn: 'uid=MarieLuise12,cn=schueler,cn=users,ou=1,dc=training,dc=ucs', school, @@ -370,7 +369,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should find partially matching loginName "ends-with"', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ ldapDn: 'uid=MarieLuise12,cn=schueler,cn=users,ou=1,dc=training,dc=ucs', school, @@ -388,7 +387,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should skip loginName filter for undefined values', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ ldapDn: 'uid=MarieLuise12,cn=schueler,cn=users,ou=1,dc=training,dc=ucs', school, @@ -401,7 +400,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should skip loginName filter for empty string', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ ldapDn: 'uid=MarieLuise12,cn=schueler,cn=users,ou=1,dc=training,dc=ucs', school, @@ -414,7 +413,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should skip special chars from filter', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ ldapDn: 'uid=MarieLuise12,cn=schueler,cn=users,ou=1,dc=training,dc=ucs', school, @@ -427,7 +426,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should keep characters as filter with language letters, numbers, space and minus', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ ldapDn: 'uid=Marie-Luise ÃĄÃ ÃĸäÃŖÃĨçÊèÃĒÃĢíÃŦÃŽÃ¯ÃąÃŗÃ˛Ã´ÃļÃĩÃēÚÃģÃŧÃŊÃŋÃĻœÁÀÂÄÃÅÇÉÈÊËÍÌÎÏÑÓÒÔÖÕÚÙÛÜÝŸÆŒ0987654321,foo', school, @@ -445,7 +444,7 @@ describe('ImportUserRepo', () => { describe('byRole', () => { it('should contain importusers with role name administrator', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ roleNames: [RoleName.ADMINISTRATOR], school, @@ -463,7 +462,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); // no other role name or no role name }); it('should contain importusers with role name student', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ roleNames: [RoleName.STUDENT], school, @@ -478,7 +477,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); // no other role name or no role name }); it('should contain importusers with role name teacher', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ roleNames: [RoleName.TEACHER], school, @@ -496,7 +495,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); // no other role name or no role name }); it('should fail for all other, invalid role names', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); await em.persistAndFlush(school); await expect(async () => repo.findImportUsers(school, { role: 'foo' as unknown as IImportUserRoleName }) @@ -505,7 +504,7 @@ describe('ImportUserRepo', () => { }); describe('byClasses', () => { it('should skip whitespace as filter', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ classNames: ['1a'], school }); const otherImportUser = importUserFactory.build({ classNames: ['2a'], school }); await em.persistAndFlush([importUser, otherImportUser]); @@ -515,7 +514,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should match classes with full match by ignore case', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ classNames: ['1a'], school }); const otherImportUser = importUserFactory.build({ classNames: ['2a'], school }); await em.persistAndFlush([importUser, otherImportUser]); @@ -525,7 +524,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(1); }); it('should match classes with starts-with by ignore case', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ classNames: ['1a'], school }); const otherImportUser = importUserFactory.build({ classNames: ['2a'], school }); await em.persistAndFlush([importUser, otherImportUser]); @@ -535,7 +534,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(1); }); it('should match classes with ends-with by ignore case', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ classNames: ['1a'], school }); const otherImportUser = importUserFactory.build({ classNames: ['2a'], school }); await em.persistAndFlush([importUser, otherImportUser]); @@ -545,7 +544,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(2); }); it('should trim filter value', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ classNames: ['1a'], school }); const otherImportUser = importUserFactory.build({ classNames: ['2a'], school }); await em.persistAndFlush([importUser, otherImportUser]); @@ -665,7 +664,7 @@ describe('ImportUserRepo', () => { expect(count).toEqual(3); // like without filter }); it('should skip all other, invalid match names', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ school }); await em.persistAndFlush([school, importUser]); const [results] = await repo.findImportUsers(school, { matches: ['foo'] as unknown as [MatchCreatorScope] }); @@ -675,7 +674,7 @@ describe('ImportUserRepo', () => { describe('isFlagged', () => { it('should respond with and without flagged importusers by default', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ school, }); @@ -689,7 +688,7 @@ describe('ImportUserRepo', () => { expect(results).toContain(flaggedImportUser); }); it('should respond with flagged importusers only', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUser = importUserFactory.build({ school, }); @@ -706,7 +705,7 @@ describe('ImportUserRepo', () => { describe('options: limit and offset', () => { it('should apply limit', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUsers = importUserFactory.buildList(10, { school }); await em.persistAndFlush(importUsers); const [results, count] = await repo.findImportUsers(school, {}, { pagination: { limit: 3 } }); @@ -720,7 +719,7 @@ describe('ImportUserRepo', () => { expect(count2).toEqual(10); }); it('should apply offset', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const importUsers = importUserFactory.buildList(10, { school }); await em.persistAndFlush(importUsers); const [results, count] = await repo.findImportUsers(school, {}, { pagination: { skip: 3 } }); @@ -741,7 +740,7 @@ describe('ImportUserRepo', () => { }); describe('on user (match_userId)', () => { it('[SPARSE] should allow to unset items (acceppt null or undefined multiple times)', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); await em.persistAndFlush(school); const users = userFactory.buildList(10, { school }); await em.persistAndFlush(users); @@ -765,7 +764,7 @@ describe('ImportUserRepo', () => { }); it('[UNIQUE] should prohibit same match of one user ', async () => { await orm.getSchemaGenerator().ensureIndexes(); - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); await em.persistAndFlush(school); const user = userFactory.build({ school }); await em.persistAndFlush(user); @@ -779,4 +778,24 @@ describe('ImportUserRepo', () => { }); }); }); + + describe('saveImportUsers', () => { + describe('with existing importusers', () => { + const setup = () => { + const school = schoolEntityFactory.build(); + const importUser = importUserFactory.build({ school }); + const otherImportUser = importUserFactory.build({ school }); + + return { importUser, otherImportUser }; + }; + + it('should persist importUsers', async () => { + const { importUser, otherImportUser } = setup(); + + await repo.saveImportUsers([importUser, otherImportUser]); + + await expect(em.findAndCount(ImportUser, {})).resolves.toEqual([[importUser, otherImportUser], 2]); + }); + }); + }); }); diff --git a/apps/server/src/shared/repo/importuser/importuser.repo.ts b/apps/server/src/shared/repo/importuser/importuser.repo.ts index c0ba08fa1fe..24609727546 100644 --- a/apps/server/src/shared/repo/importuser/importuser.repo.ts +++ b/apps/server/src/shared/repo/importuser/importuser.repo.ts @@ -71,4 +71,8 @@ export class ImportUserRepo extends BaseRepo { async deleteImportUsersBySchool(school: SchoolEntity): Promise { await this._em.nativeDelete(ImportUser, { school }); } + + public async saveImportUsers(importUsers: ImportUser[]): Promise { + await this._em.persistAndFlush(importUsers); + } } diff --git a/apps/server/src/shared/repo/index.ts b/apps/server/src/shared/repo/index.ts index ce9304ec7ef..de8af17b788 100644 --- a/apps/server/src/shared/repo/index.ts +++ b/apps/server/src/shared/repo/index.ts @@ -6,7 +6,7 @@ export * from './base.do.repo'; export * from './base.repo'; -export * from './board'; +export * from './legacy-board'; export * from './course'; export * from './coursegroup'; export * from './dashboard'; diff --git a/apps/server/src/shared/repo/legacy-board/index.ts b/apps/server/src/shared/repo/legacy-board/index.ts new file mode 100644 index 00000000000..d577e7e1ab8 --- /dev/null +++ b/apps/server/src/shared/repo/legacy-board/index.ts @@ -0,0 +1 @@ +export * from './legacy-board.repo'; diff --git a/apps/server/src/shared/repo/board/board.repo.spec.ts b/apps/server/src/shared/repo/legacy-board/legacy-board.repo.spec.ts similarity index 89% rename from apps/server/src/shared/repo/board/board.repo.spec.ts rename to apps/server/src/shared/repo/legacy-board/legacy-board.repo.spec.ts index f5cee46a11f..dbe1aaf12d9 100644 --- a/apps/server/src/shared/repo/board/board.repo.spec.ts +++ b/apps/server/src/shared/repo/legacy-board/legacy-board.repo.spec.ts @@ -1,6 +1,6 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { Board, LessonEntity, Task } from '@shared/domain/entity'; +import { LegacyBoard, LessonEntity, Task } from '@shared/domain/entity'; import { boardFactory, cleanupCollections, @@ -12,19 +12,19 @@ import { import { MongoMemoryDatabaseModule } from '@infra/database'; -import { BoardRepo } from './board.repo'; +import { LegacyBoardRepo } from './legacy-board.repo'; -describe('BoardRepo', () => { +describe('LegacyRoomBoardRepo', () => { let module: TestingModule; - let repo: BoardRepo; + let repo: LegacyBoardRepo; let em: EntityManager; beforeAll(async () => { module = await Test.createTestingModule({ imports: [MongoMemoryDatabaseModule.forRoot()], - providers: [BoardRepo], + providers: [LegacyBoardRepo], }).compile(); - repo = module.get(BoardRepo); + repo = module.get(LegacyBoardRepo); em = module.get(EntityManager); }); @@ -34,11 +34,11 @@ describe('BoardRepo', () => { afterEach(async () => { await cleanupCollections(em); - await em.nativeDelete(Board, {}); + await em.nativeDelete(LegacyBoard, {}); }); it('should implement entityName getter', () => { - expect(repo.entityName).toBe(Board); + expect(repo.entityName).toBe(LegacyBoard); }); describe('findByCourseId', () => { @@ -77,7 +77,7 @@ describe('BoardRepo', () => { em.clear(); - const result = await em.findOneOrFail(Board, { id: board.id }); + const result = await em.findOneOrFail(LegacyBoard, { id: board.id }); expect(result.id).toEqual(board.id); }); diff --git a/apps/server/src/shared/repo/board/board.repo.ts b/apps/server/src/shared/repo/legacy-board/legacy-board.repo.ts similarity index 63% rename from apps/server/src/shared/repo/board/board.repo.ts rename to apps/server/src/shared/repo/legacy-board/legacy-board.repo.ts index 653193cf483..49c64437f6e 100644 --- a/apps/server/src/shared/repo/board/board.repo.ts +++ b/apps/server/src/shared/repo/legacy-board/legacy-board.repo.ts @@ -1,42 +1,49 @@ import { Injectable } from '@nestjs/common'; -import { Board, ColumnboardBoardElement, Course, LessonBoardElement, TaskBoardElement } from '@shared/domain/entity'; +import { + LegacyBoard, + ColumnboardBoardElement, + Course, + LessonBoardElement, + TaskBoardElement, +} from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; import { BaseRepo } from '../base.repo'; @Injectable() -export class BoardRepo extends BaseRepo { +export class LegacyBoardRepo extends BaseRepo { get entityName() { - return Board; + return LegacyBoard; } - async findByCourseId(courseId: EntityId): Promise { + async findByCourseId(courseId: EntityId): Promise { + // TODO this auto-creation of board should be moved to uc instead of in repo const board = await this.getOrCreateCourseBoard(courseId); await this.populateBoard(board); return board; } - private async getOrCreateCourseBoard(courseId: EntityId): Promise { - let board = await this._em.findOne(Board, { course: courseId }); + private async getOrCreateCourseBoard(courseId: EntityId): Promise { + let board = await this._em.findOne(LegacyBoard, { course: courseId }); if (!board) { board = await this.createBoardForCourse(courseId); } return board; } - private async createBoardForCourse(courseId: EntityId): Promise { + private async createBoardForCourse(courseId: EntityId): Promise { const course = await this._em.findOneOrFail(Course, courseId); - const board = new Board({ course, references: [] }); + const board = new LegacyBoard({ course, references: [] }); await this._em.persistAndFlush(board); return board; } - async findById(id: EntityId): Promise { - const board = await this._em.findOneOrFail(Board, { id }); + async findById(id: EntityId): Promise { + const board = await this._em.findOneOrFail(LegacyBoard, { id }); await this.populateBoard(board); return board; } - private async populateBoard(board: Board) { + private async populateBoard(board: LegacyBoard) { await board.references.init(); const elements = board.references.getItems(); const taskElements = elements.filter((el) => el instanceof TaskBoardElement); diff --git a/apps/server/src/shared/repo/news/news-scope.ts b/apps/server/src/shared/repo/news/news-scope.ts index 7f999205fa3..714b1168f28 100644 --- a/apps/server/src/shared/repo/news/news-scope.ts +++ b/apps/server/src/shared/repo/news/news-scope.ts @@ -41,4 +41,11 @@ export class NewsScope extends Scope { } return this; } + + byUpdater(updaterId: EntityId): NewsScope { + if (updaterId !== undefined) { + this.addQuery({ updater: updaterId }); + } + return this; + } } diff --git a/apps/server/src/shared/repo/news/news.repo.integration.spec.ts b/apps/server/src/shared/repo/news/news.repo.integration.spec.ts index 41df6d26b7c..25240d5a1c6 100644 --- a/apps/server/src/shared/repo/news/news.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/news/news.repo.integration.spec.ts @@ -223,8 +223,9 @@ describe('NewsRepo', () => { targetIds: [news.target.id], }; const pagination = { skip: 0, limit: 20 }; + const creatorId = news.creator?.id as string; - const [result, count] = await repo.findAllUnpublishedByUser([target], news.creator.id, { pagination }); + const [result, count] = await repo.findAllUnpublishedByUser([target], creatorId, { pagination }); expect(count).toBeGreaterThanOrEqual(result.length); expect(result.length).toEqual(1); @@ -241,7 +242,8 @@ describe('NewsRepo', () => { targetModel: NewsTargetModel.School, targetIds: [news.target.id], }; - const [result, count] = await repo.findAllUnpublishedByUser([target], news.creator.id, { pagination }); + const creatorId = news.creator?.id as string; + const [result, count] = await repo.findAllUnpublishedByUser([target], creatorId, { pagination }); expect(count).toBeGreaterThanOrEqual(result.length); expect(result.length).toEqual(1); expect(result[0].id).toEqual(news.id); @@ -256,8 +258,9 @@ describe('NewsRepo', () => { targetModel: NewsTargetModel.Course, targetIds: [news.target.id], }; + const creatorId = news.creator?.id as string; const pagination = { skip: 0, limit: 20 }; - const [result, count] = await repo.findAllUnpublishedByUser([target], news.creator.id, { pagination }); + const [result, count] = await repo.findAllUnpublishedByUser([target], creatorId, { pagination }); expect(count).toBeGreaterThanOrEqual(result.length); expect(result.length).toEqual(1); expect(result[0].id).toEqual(news.id); @@ -301,4 +304,49 @@ describe('NewsRepo', () => { await expect(repo.findOneById(failNewsId)).rejects.toThrow(NotFoundError); }); }); + + describe('findByCreatorOrUpdaterId', () => { + const setup = async () => { + const user1 = userFactory.buildWithId(); + const user2 = userFactory.buildWithId(); + const news1 = teamNewsFactory.build({ + creator: user1, + }); + const news2 = teamNewsFactory.build({ + updater: user2, + }); + const news3 = teamNewsFactory.build({ + updater: user1, + }); + + await em.persistAndFlush([news1, news2, news3]); + em.clear(); + + return { news1, news2, news3, user1, user2 }; + }; + it('should find a news entity by creatorId and updaterId', async () => { + const { news1, user1, news3 } = await setup(); + + const result = await repo.findByCreatorOrUpdaterId(user1.id); + expect(result).toBeDefined(); + expect(result[0][0].id).toEqual(news1.id); + expect(result[0][1].id).toEqual(news3.id); + expect(result[0].length).toEqual(2); + }); + + it('should find a news entity by updaterId', async () => { + const { user2, news2 } = await setup(); + + const result = await repo.findByCreatorOrUpdaterId(user2.id); + expect(result).toBeDefined(); + expect(result[0][0].id).toEqual(news2.id); + expect(result[0].length).toEqual(1); + }); + + it('should throw an exception if not found', async () => { + const failNewsId = new ObjectId().toString(); + const result = await repo.findByCreatorOrUpdaterId(failNewsId); + expect(result[1]).toEqual(0); + }); + }); }); diff --git a/apps/server/src/shared/repo/news/news.repo.ts b/apps/server/src/shared/repo/news/news.repo.ts index d4cc86cc8f8..7c3e27c176e 100644 --- a/apps/server/src/shared/repo/news/news.repo.ts +++ b/apps/server/src/shared/repo/news/news.repo.ts @@ -74,4 +74,13 @@ export class NewsRepo extends BaseRepo { await this._em.populate(courseNews, [discriminatorColumn]); return [newsEntities, count]; } + + async findByCreatorOrUpdaterId(userId: EntityId): Promise> { + const scope = new NewsScope('$or'); + scope.byCreator(userId); + scope.byUpdater(userId); + + const countedNewsList = await this.findNewsAndCount(scope.query); + return countedNewsList; + } } diff --git a/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts b/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts index 24c1e5c2866..6bf0f502a04 100644 --- a/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/school/legacy-school.repo.integration.spec.ts @@ -15,13 +15,13 @@ import { } from '@shared/domain/entity'; import { legacySchoolDoFactory, - schoolFactory, + schoolEntityFactory, schoolYearFactory, systemEntityFactory, userLoginMigrationFactory, } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; -import { LegacySchoolRepo } from '..'; +import { LegacySchoolRepo } from './legacy-school.repo'; describe('LegacySchoolRepo', () => { let module: TestingModule; @@ -82,7 +82,7 @@ describe('LegacySchoolRepo', () => { it('should create a school with embedded object', async () => { const schoolYear = schoolYearFactory.build(); - const school = schoolFactory.build({ + const school = schoolEntityFactory.build({ name: 'test', currentYear: schoolYear, previousExternalId: 'someId', @@ -114,7 +114,7 @@ describe('LegacySchoolRepo', () => { describe('findByExternalId', () => { it('should find school by external ID', async () => { const system: SystemEntity = systemEntityFactory.buildWithId(); - const schoolEntity: SchoolEntity = schoolFactory.build({ externalId: 'externalId' }); + const schoolEntity: SchoolEntity = schoolEntityFactory.build({ externalId: 'externalId' }); schoolEntity.systems.add(system); await em.persistAndFlush(schoolEntity); @@ -139,7 +139,7 @@ describe('LegacySchoolRepo', () => { describe('findBySchoolNumber', () => { it('should find school by schoolnumber', async () => { - const schoolEntity: SchoolEntity = schoolFactory.build({ officialSchoolNumber: '12345' }); + const schoolEntity: SchoolEntity = schoolEntityFactory.build({ officialSchoolNumber: '12345' }); await em.persistAndFlush(schoolEntity); @@ -157,8 +157,8 @@ describe('LegacySchoolRepo', () => { describe('when there is more than school with the same officialSchoolNumber', () => { const setup = async () => { const officialSchoolNumber = '12345'; - const schoolEntity: SchoolEntity = schoolFactory.build({ officialSchoolNumber }); - const schoolEntity2: SchoolEntity = schoolFactory.build({ officialSchoolNumber }); + const schoolEntity: SchoolEntity = schoolEntityFactory.build({ officialSchoolNumber }); + const schoolEntity2: SchoolEntity = schoolEntityFactory.build({ officialSchoolNumber }); await em.persistAndFlush([schoolEntity, schoolEntity2]); @@ -183,7 +183,7 @@ describe('LegacySchoolRepo', () => { it('should map school entity to school domain object', () => { const system: SystemEntity = systemEntityFactory.buildWithId(); const schoolYear: SchoolYearEntity = schoolYearFactory.buildWithId(); - const schoolEntity: SchoolEntity = schoolFactory.buildWithId({ + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId({ systems: [system], features: [], currentYear: schoolYear, @@ -212,7 +212,7 @@ describe('LegacySchoolRepo', () => { }); it('should return an empty array for systems when entity systems is not initialized', () => { - const schoolEntity: SchoolEntity = schoolFactory.buildWithId({ systems: undefined }); + const schoolEntity: SchoolEntity = schoolEntityFactory.buildWithId({ systems: undefined }); const schoolDO = repo.mapEntityToDO(schoolEntity); diff --git a/apps/server/src/shared/repo/school/legacy-school.repo.ts b/apps/server/src/shared/repo/school/legacy-school.repo.ts index 83fb34cec33..e61ec482f99 100644 --- a/apps/server/src/shared/repo/school/legacy-school.repo.ts +++ b/apps/server/src/shared/repo/school/legacy-school.repo.ts @@ -51,6 +51,7 @@ export class LegacySchoolRepo extends BaseDORepo { systems: entity.systems.isInitialized() ? entity.systems.getItems().map((system: SystemEntity) => system.id) : [], userLoginMigrationId: entity.userLoginMigration?.id, federalState: entity.federalState, + ldapLastSync: entity.ldapLastSync, }); } @@ -71,6 +72,7 @@ export class LegacySchoolRepo extends BaseDORepo { ? this._em.getReference(UserLoginMigrationEntity, entityDO.userLoginMigrationId) : undefined, federalState: entityDO.federalState, + ldapLastSync: entityDO.ldapLastSync, }; } } diff --git a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.integration.spec.ts b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.integration.spec.ts index 31040835ea2..a751d4fa2ee 100644 --- a/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/schoolexternaltool/school-external-tool.repo.integration.spec.ts @@ -3,18 +3,15 @@ import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { CustomParameterEntry } from '@modules/tool/common/domain'; import { ExternalToolEntity } from '@modules/tool/external-tool/entity'; +import { externalToolEntityFactory } from '@modules/tool/external-tool/testing'; import { SchoolExternalTool } from '@modules/tool/school-external-tool/domain'; import { SchoolExternalToolEntity } from '@modules/tool/school-external-tool/entity'; +import { schoolExternalToolEntityFactory } from '@modules/tool/school-external-tool/testing'; import { SchoolExternalToolQuery } from '@modules/tool/school-external-tool/uc/dto/school-external-tool.types'; import { Test, TestingModule } from '@nestjs/testing'; import { type SchoolEntity } from '@shared/domain/entity'; import { ExternalToolRepoMapper } from '@shared/repo/externaltool/external-tool.repo.mapper'; -import { - cleanupCollections, - externalToolEntityFactory, - schoolExternalToolEntityFactory, - schoolFactory, -} from '@shared/testing'; +import { cleanupCollections, schoolEntityFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { SchoolExternalToolRepo } from './school-external-tool.repo'; @@ -50,7 +47,7 @@ describe('SchoolExternalToolRepo', () => { const createTools = () => { const externalToolEntity: ExternalToolEntity = externalToolEntityFactory.buildWithId(); - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const schoolExternalTool1: SchoolExternalToolEntity = schoolExternalToolEntityFactory.buildWithId({ tool: externalToolEntity, school, diff --git a/apps/server/src/shared/repo/scope.ts b/apps/server/src/shared/repo/scope.ts index 9c444dfb1dd..b66e7c31309 100644 --- a/apps/server/src/shared/repo/scope.ts +++ b/apps/server/src/shared/repo/scope.ts @@ -6,7 +6,7 @@ type EmptyResultQueryType = typeof EmptyResultQuery; type ScopeOperator = '$and' | '$or'; export class Scope { - private _queries: FilterQuery[] = []; + private _queries: (FilterQuery | EmptyResultQueryType)[] = []; private _operator: ScopeOperator; diff --git a/apps/server/src/shared/repo/submission/submission.repo.integration.spec.ts b/apps/server/src/shared/repo/submission/submission.repo.integration.spec.ts index 5ce4aef23cd..9e9c7b9213a 100644 --- a/apps/server/src/shared/repo/submission/submission.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/submission/submission.repo.integration.spec.ts @@ -107,7 +107,7 @@ describe('submission repo', () => { expect(count).toEqual(1); expect(result.length).toEqual(1); - expect(result[0].student.id).toEqual(student.id); + expect(result[0].student?.id).toEqual(student.id); }); it('should return submissions when the user is a team member', async () => { diff --git a/apps/server/src/shared/repo/submission/submission.repo.ts b/apps/server/src/shared/repo/submission/submission.repo.ts index 0508e19e7c8..6107fe350cf 100644 --- a/apps/server/src/shared/repo/submission/submission.repo.ts +++ b/apps/server/src/shared/repo/submission/submission.repo.ts @@ -23,6 +23,7 @@ export class SubmissionRepo extends BaseRepo { const [submissions, count] = await this._em.findAndCount(this.entityName, { task: { $in: taskIds }, }); + await this.populateReferences(submissions); return [submissions, count]; diff --git a/apps/server/src/shared/repo/types/StorageProviderEncryptedString.type.ts b/apps/server/src/shared/repo/types/StorageProviderEncryptedString.type.ts index 232e22a86c3..81813a4dddc 100644 --- a/apps/server/src/shared/repo/types/StorageProviderEncryptedString.type.ts +++ b/apps/server/src/shared/repo/types/StorageProviderEncryptedString.type.ts @@ -6,16 +6,15 @@ import CryptoJs from 'crypto-js'; * Serialization type to transparent encrypt string values in database. */ export class StorageProviderEncryptedStringType extends Type { - // TODO modularize service? - private key: string; - - constructor(customKey?: string) { + constructor(private customKey?: string) { super(); - if (customKey) { - this.key = customKey; - } else { - this.key = Configuration.get('S3_KEY') as string; + } + + private get key() { + if (this.customKey) { + return this.customKey; } + return Configuration.get('S3_KEY') as string; } convertToDatabaseValue(value: string | undefined): string { diff --git a/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts index baa3efbcd89..65af7b0fc67 100644 --- a/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user-do.repo.integration.spec.ts @@ -7,13 +7,13 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundError } from '@shared/common'; import { Page } from '@shared/domain/domainobject/page'; import { UserDO } from '@shared/domain/domainobject/user.do'; -import { LanguageType, Role, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; -import { IFindOptions, RoleName, SortOrder } from '@shared/domain/interface'; +import { Role, SchoolEntity, SystemEntity, User } from '@shared/domain/entity'; +import { IFindOptions, LanguageType, RoleName, SortOrder } from '@shared/domain/interface'; import { UserDORepo } from '@shared/repo/user/user-do.repo'; import { cleanupCollections, roleFactory, - schoolFactory, + schoolEntityFactory, systemEntityFactory, userDoFactory, userFactory, @@ -108,7 +108,7 @@ describe('UserRepo', () => { beforeEach(async () => { system = systemEntityFactory.buildWithId(); - school = schoolFactory.buildWithId(); + school = schoolEntityFactory.buildWithId(); school.systems.add(system); user = userFactory.buildWithId({ externalId, school }); @@ -152,7 +152,7 @@ describe('UserRepo', () => { beforeEach(async () => { system = systemEntityFactory.buildWithId(); - school = schoolFactory.buildWithId(); + school = schoolEntityFactory.buildWithId(); school.systems.add(system); user = userFactory.buildWithId({ externalId, school }); @@ -234,7 +234,7 @@ describe('UserRepo', () => { email: 'email@email.email', firstName: 'firstName', lastName: 'lastName', - school: schoolFactory.buildWithId(), + school: schoolEntityFactory.buildWithId(), ldapDn: 'ldapDn', externalId: 'externalId', language: LanguageType.DE, diff --git a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts index 84e7c43f00f..589d9bfb9d7 100644 --- a/apps/server/src/shared/repo/user/user.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/user/user.repo.integration.spec.ts @@ -3,16 +3,16 @@ import { NotFoundError } from '@mikro-orm/core'; import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { MatchCreator, SystemEntity, User } from '@shared/domain/entity'; +import { UserParentsEntityProps } from '@shared/domain/entity/user-parents.entity'; import { SortOrder } from '@shared/domain/interface'; import { cleanupCollections, importUserFactory, roleFactory, - schoolFactory, + schoolEntityFactory, systemEntityFactory, userFactory, } from '@shared/testing'; -import { UserParentsEntityProps } from '@shared/domain/entity/user-parents.entity'; import { UserRepo } from './user.repo'; describe('user repo', () => { @@ -64,6 +64,7 @@ describe('user repo', () => { 'firstNameSearchValues', 'lastName', 'lastNameSearchValues', + 'lastSyncedAt', 'email', 'emailSearchValues', 'school', @@ -71,6 +72,7 @@ describe('user repo', () => { 'ldapDn', 'externalId', 'forcePasswordChange', + 'customAvatarBackgroundColor', 'importHash', 'parents', 'preferences', @@ -80,6 +82,7 @@ describe('user repo', () => { 'outdatedSince', 'previousExternalId', 'birthday', + 'consent', ].sort() ); }); @@ -128,6 +131,43 @@ describe('user repo', () => { }); }); + describe('findByIdOrNull', () => { + describe('when user not found', () => { + const setup = () => { + const id = new ObjectId().toHexString(); + + return { id }; + }; + + it('should return null', async () => { + const { id } = setup(); + + const result = await repo.findByIdOrNull(id); + + expect(result).toBeNull(); + }); + }); + + describe('when user was found', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + + await em.persistAndFlush([user]); + em.clear(); + + return { user }; + }; + + it('should return user', async () => { + const { user } = await setup(); + + const result = await repo.findByIdOrNull(user.id, true); + + expect(result?.id).toEqual(user.id); + }); + }); + }); + describe('findByExternalIdorFail', () => { let sys: SystemEntity; let userA: User; @@ -135,7 +175,7 @@ describe('user repo', () => { beforeEach(async () => { sys = systemEntityFactory.build(); await em.persistAndFlush([sys]); - const school = schoolFactory.build({ systems: [sys] }); + const school = schoolEntityFactory.build({ systems: [sys] }); // const school = schoolFactory.withSystem().build(); userA = userFactory.build({ school, externalId: '111' }); @@ -155,8 +195,10 @@ describe('user repo', () => { 'firstNameSearchValues', 'lastName', 'lastNameSearchValues', + 'lastSyncedAt', 'email', 'emailSearchValues', + 'customAvatarBackgroundColor', 'school', '_id', 'ldapDn', @@ -171,6 +213,7 @@ describe('user repo', () => { 'outdatedSince', 'previousExternalId', 'birthday', + 'consent', ].sort() ); }); @@ -194,8 +237,9 @@ describe('user repo', () => { describe('findWithoutImportUser', () => { const persistUserAndSchool = async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const user = userFactory.build({ school }); + await em.persistAndFlush([user, school]); em.clear(); return { user, school }; @@ -203,6 +247,7 @@ describe('user repo', () => { it('should find users not referenced in importusers', async () => { const { user } = await persistUserAndSchool(); + const [result, count] = await repo.findWithoutImportUser(user.school); expect(result.map((u) => u.id)).toContain(user.id); expect(count).toEqual(1); @@ -231,7 +276,7 @@ describe('user repo', () => { }); it('should exclude deleted users', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const user = userFactory.build({ school, deletedAt: new Date() }); await em.persistAndFlush([school, user]); em.clear(); @@ -241,7 +286,7 @@ describe('user repo', () => { }); it('should filter users by firstName contains or lastName contains, ignore case', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const user = userFactory.build({ firstName: 'Papa', lastName: 'Pane', school }); const otherUser = userFactory.build({ school }); await em.persistAndFlush([user, otherUser]); @@ -279,7 +324,7 @@ describe('user repo', () => { }); it('should sort returned users by firstname, lastname', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const user = userFactory.build({ school, firstName: 'Anna', lastName: 'Schmidt' }); const otherUser = userFactory.build({ school, firstName: 'Peter', lastName: 'Ball' }); await em.persistAndFlush([user, otherUser]); @@ -314,7 +359,7 @@ describe('user repo', () => { }); it('should skip returned two users by one', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const user = userFactory.build({ school }); const otherUser = userFactory.build({ school }); await em.persistAndFlush([user, otherUser]); @@ -327,7 +372,7 @@ describe('user repo', () => { }); it('should limit returned users from two to one', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const user = userFactory.build({ school }); const otherUser = userFactory.build({ school }); await em.persistAndFlush([user, otherUser]); @@ -340,7 +385,7 @@ describe('user repo', () => { }); it('should throw an error by passing invalid schoolId', async () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); // id do not exist await expect(repo.findWithoutImportUser(school)).rejects.toThrowError(); }); @@ -413,49 +458,73 @@ describe('user repo', () => { }); }); - describe('delete', () => { - const setup = async () => { - const user1: User = userFactory.buildWithId(); - const user2: User = userFactory.buildWithId(); - const user3: User = userFactory.buildWithId(); - await em.persistAndFlush([user1, user2, user3]); + describe('deleteUser', () => { + describe('when user does not exist', () => { + const setup = () => { + const user = userFactory.buildWithId(); - return { - user1, - user2, - user3, + return { + user, + }; }; - }; - it('should delete user', async () => { - const { user1, user2, user3 } = await setup(); - const deleteResult = await repo.deleteUser(user1.id); - expect(deleteResult).toEqual(1); - - const result1 = await em.find(User, { id: user1.id }); - expect(result1).toHaveLength(0); - - const result2 = await repo.findById(user2.id); - expect(result2).toMatchObject({ - firstName: user2.firstName, - lastName: user2.lastName, - email: user2.email, - roles: user2.roles, - school: user2.school, + + it('should return zero', async () => { + const { user } = setup(); + + const result = await repo.deleteUser(user.id); + + expect(result).toEqual(0); + }); + }); + describe('when user exists', () => { + const setup = async () => { + const user1: User = userFactory.buildWithId(); + const user2: User = userFactory.buildWithId(); + const user3: User = userFactory.buildWithId(); + await em.persistAndFlush([user1, user2, user3]); + em.clear(); + + return { + user1, + user2, + user3, + }; + }; + it('should delete user', async () => { + const { user1 } = await setup(); + await repo.deleteUser(user1.id); + + const result1 = await em.find(User, { id: user1.id }); + expect(result1).toHaveLength(0); + }); + + it('should return one deleted user', async () => { + const { user1 } = await setup(); + const result = await repo.deleteUser(user1.id); + expect(result).toEqual(1); }); - const result3 = await repo.findById(user3.id); - expect(result3).toMatchObject({ - firstName: user3.firstName, - lastName: user3.lastName, - email: user3.email, - roles: user3.roles, - school: user3.school, + it('should not affect other users', async () => { + const { user1, user2, user3 } = await setup(); + await repo.deleteUser(user1.id); + + const emUser2 = await em.find(User, { id: user2.id }); + const emUser3 = await em.find(User, { id: user3.id }); + expect(emUser2).toHaveLength(1); + expect(emUser3).toHaveLength(1); + + const resultUser2 = await repo.findById(user2.id); + const resultUser3 = await repo.findById(user3.id); + + expect(resultUser2.id).toEqual(user2.id); + expect(resultUser3.id).toEqual(user3.id); }); }); }); describe('getParentEmailsFromUser', () => { const setup = async () => { + const id = new ObjectId().toHexString(); const parentOfUser: UserParentsEntityProps = { firstName: 'firstName', lastName: 'lastName', @@ -471,12 +540,13 @@ describe('user repo', () => { em.clear(); return { + id, user, expectedParentEmail, }; }; - describe('when searching user parent email', () => { + describe('when searching parent email for existing user', () => { it('should return array witn parent email', async () => { const { user, expectedParentEmail } = await setup(); const result = await repo.getParentEmailsFromUser(user.id); @@ -484,5 +554,129 @@ describe('user repo', () => { expect(result).toEqual(expectedParentEmail); }); }); + + describe('when searching parent email for not existing user', () => { + it('should return null', async () => { + const { id } = await setup(); + + const result = await repo.getParentEmailsFromUser(id); + + expect(result).toHaveLength(0); + }); + }); + }); + + describe('getParentEmailsFromUser', () => { + describe('when a user meets the criteria', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + + await em.persistAndFlush(user); + em.clear(); + + return { + user, + }; + }; + + it('should return the user', async () => { + const { user } = await setup(); + + const result = await repo.findUserBySchoolAndName(user.school.id, user.firstName, user.lastName); + + expect(result).toHaveLength(1); + }); + }); + + describe('when no user meets the criteria', () => { + const setup = async () => { + const user = userFactory.buildWithId(); + + await em.persistAndFlush(user); + em.clear(); + + return { + user, + }; + }; + + it('should return an empty array', async () => { + const { user } = await setup(); + + const result = await repo.findUserBySchoolAndName(user.school.id, 'Unknown', 'User'); + + expect(result).toEqual([]); + }); + }); + }); + + describe('findByExternalIds', () => { + describe('when users exist', () => { + const setup = async () => { + const userA = userFactory.buildWithId({ externalId: '111' }); + const userB = userFactory.buildWithId({ externalId: '222' }); + const userC = userFactory.buildWithId({ externalId: '333' }); + + await em.persistAndFlush([userA, userB, userC]); + em.clear(); + + const externalIds: string[] = ['111', '222']; + + const expectedResult = [userA.id, userB.id]; + + return { + expectedResult, + externalIds, + }; + }; + + it('should return array with ', async () => { + const { expectedResult, externalIds } = await setup(); + + const result = await repo.findByExternalIds(externalIds); + expect(result).toEqual(expectedResult); + }); + }); + + describe('when users do not exist', () => { + it('should return empty array', async () => { + const result = await repo.findByExternalIds(['externalId1', 'externalId2']); + + expect(result).toHaveLength(0); + }); + }); + }); + + describe('updateAllUserByLastSyncedAt', () => { + describe('when updating many users by field lastSyncedAt', () => { + const setup = async () => { + const userA = userFactory.buildWithId(); + const userB = userFactory.buildWithId(); + const userC = userFactory.buildWithId(); + + await em.persistAndFlush([userA, userB, userC]); + em.clear(); + + const userIds = [userA.id, userC.id]; + + return { + userIds, + userA, + userC, + }; + }; + + it('should update lastSyncedAt field', async () => { + const { userIds, userA, userC } = await setup(); + + await repo.updateAllUserByLastSyncedAt(userIds); + + const resultForUserA = await repo.findById(userA.id); + expect(resultForUserA.lastSyncedAt instanceof Date).toBe(true); + + const resultForUserC = await repo.findById(userC.id); + expect(resultForUserC.lastSyncedAt instanceof Date).toBe(true); + }); + }); }); }); diff --git a/apps/server/src/shared/repo/user/user.repo.ts b/apps/server/src/shared/repo/user/user.repo.ts index 567c57253b4..4809929c1f5 100644 --- a/apps/server/src/shared/repo/user/user.repo.ts +++ b/apps/server/src/shared/repo/user/user.repo.ts @@ -25,6 +25,21 @@ export class UserRepo extends BaseRepo { return user; } + async findByIdOrNull(id: EntityId, populate = false): Promise { + const user: User | null = await this._em.findOne(User, { id }); + + if (!user) { + return null; + } + + if (populate) { + await this._em.populate(user, ['roles', 'school.systems', 'school.currentYear']); + await this.populateRoles(user.roles.getItems()); + } + + return user; + } + async findByExternalIdOrFail(externalId: string, systemId: string): Promise { const [users] = await this._em.findAndCount(User, { externalId }, { populate: ['school.systems'] }); const resultUser = users.find((user) => { @@ -142,7 +157,13 @@ export class UserRepo extends BaseRepo { const userDocuments = await this._em.aggregate(User, pipeline); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const users = userDocuments.map((userDocument) => this._em.map(User, userDocument)); + const users = userDocuments.map((userDocument) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { createdAt, updatedAt, ...newUserDocument } = userDocument; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return this._em.map(User, newUserDocument); + }); await this._em.populate(users, ['roles']); return [users, count]; } @@ -156,15 +177,19 @@ export class UserRepo extends BaseRepo { } async deleteUser(userId: EntityId): Promise { - const deletedUserNumber: Promise = this._em.nativeDelete(User, { + const deletedUserNumber = await this._em.nativeDelete(User, { id: userId, }); + return deletedUserNumber; } async getParentEmailsFromUser(userId: EntityId): Promise { - const user = await this._em.findOneOrFail(User, { id: userId }); - const parentsEmails = user.parents?.map((parent) => parent.email) ?? []; + const user: User | null = await this._em.findOne(User, { id: userId }); + let parentsEmails: string[] = []; + if (user !== null) { + parentsEmails = user.parents?.map((parent) => parent.email) ?? []; + } return parentsEmails; } @@ -188,4 +213,32 @@ export class UserRepo extends BaseRepo { async flush(): Promise { await this._em.flush(); } + + public async findUserBySchoolAndName(schoolId: EntityId, firstName: string, lastName: string): Promise { + const users: User[] = await this._em.find(User, { school: schoolId, firstName, lastName }); + + return users; + } + + public async findByExternalIds(externalIds: string[]): Promise { + const foundUsers = await this._em.find( + User, + { externalId: { $in: externalIds } }, + { fields: ['id', 'externalId'] } + ); + + const users = foundUsers.map(({ id }) => id); + + return users; + } + + public async updateAllUserByLastSyncedAt(userIds: string[]): Promise { + await this._em.nativeUpdate( + User, + { + id: { $in: userIds }, + }, + { lastSyncedAt: new Date() } + ); + } } diff --git a/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts b/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts index bc2cb2d268d..a762d3eeab2 100644 --- a/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts +++ b/apps/server/src/shared/repo/userloginmigration/user-login-migration.repo.integration.spec.ts @@ -2,11 +2,11 @@ import { createMock } from '@golevelup/ts-jest'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; -import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; import { UserLoginMigrationDO } from '@shared/domain/domainobject'; +import { SchoolEntity, SystemEntity } from '@shared/domain/entity'; import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity'; -import { cleanupCollections, schoolFactory, systemEntityFactory } from '@shared/testing'; +import { cleanupCollections, schoolEntityFactory, systemEntityFactory } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { userLoginMigrationFactory } from '../../testing/factory/user-login-migration.factory'; import { UserLoginMigrationRepo } from './user-login-migration.repo'; @@ -43,7 +43,7 @@ describe('UserLoginMigrationRepo', () => { describe('save', () => { describe('when saving a UserLoginMigrationDO', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId(); + const school: SchoolEntity = schoolEntityFactory.buildWithId(); const sourceSystem: SystemEntity = systemEntityFactory.buildWithId(); const targetSystem: SystemEntity = systemEntityFactory.buildWithId(); @@ -144,7 +144,7 @@ describe('UserLoginMigrationRepo', () => { describe('when searching for a UserLoginMigration by an unknown school id', () => { const setup = async () => { - const school: SchoolEntity = schoolFactory.buildWithId({ userLoginMigration: undefined }); + const school: SchoolEntity = schoolEntityFactory.buildWithId({ userLoginMigration: undefined }); await em.persistAndFlush(school); em.clear(); diff --git a/apps/server/src/shared/testing/dates-to-strings.ts b/apps/server/src/shared/testing/dates-to-strings.ts new file mode 100644 index 00000000000..96cc18d2c34 --- /dev/null +++ b/apps/server/src/shared/testing/dates-to-strings.ts @@ -0,0 +1,3 @@ +export type DatesToStrings = { + [k in keyof T]: T[k] extends Date ? string : DatesToStrings; +}; diff --git a/apps/server/src/shared/testing/factory/account-do.factory.ts b/apps/server/src/shared/testing/factory/account-do.factory.ts new file mode 100644 index 00000000000..6dae06a4052 --- /dev/null +++ b/apps/server/src/shared/testing/factory/account-do.factory.ts @@ -0,0 +1,13 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { Account, AccountProps } from '@modules/account'; +import { DomainObjectFactory } from './domainobject'; + +export const accountDoFactory = DomainObjectFactory.define(Account, ({ sequence, params }) => { + return { + ...params, + id: params.id || new ObjectId().toHexString(), + username: params.username || `Username-${sequence}`, + createdAt: new Date(), + updatedAt: new Date(), + }; +}); diff --git a/apps/server/src/shared/testing/factory/account-dto.factory.ts b/apps/server/src/shared/testing/factory/account-dto.factory.ts deleted file mode 100644 index 126d469e644..00000000000 --- a/apps/server/src/shared/testing/factory/account-dto.factory.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AccountDto } from '@modules/account/services/dto'; -import { ObjectId } from 'bson'; -import { defaultTestPasswordHash } from './account.factory'; -import { BaseFactory } from './base.factory'; - -export const accountDtoFactory = BaseFactory.define(AccountDto, ({ sequence }) => { - return { - id: new ObjectId().toHexString(), - createdAt: new Date(), - updatedAt: new Date(), - systemId: new ObjectId().toHexString(), - username: `Username-${sequence}`, - password: defaultTestPasswordHash, - activated: true, - userId: new ObjectId().toHexString(), - }; -}); diff --git a/apps/server/src/shared/testing/factory/account.factory.ts b/apps/server/src/shared/testing/factory/account.factory.ts index 1cdb3fdd26d..56d150bfe09 100644 --- a/apps/server/src/shared/testing/factory/account.factory.ts +++ b/apps/server/src/shared/testing/factory/account.factory.ts @@ -1,12 +1,15 @@ /* istanbul ignore file */ -import { Account, IdmAccountProperties, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DeepPartial } from 'fishery'; +import { AccountEntity, IdmAccountProperties } from '@modules/account/entity/account.entity'; import { BaseFactory } from './base.factory'; -class AccountFactory extends BaseFactory { +export const defaultTestPassword = 'DummyPasswd!1'; +export const defaultTestPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; +class AccountFactory extends BaseFactory { withSystemId(id: EntityId | ObjectId): this { const params: DeepPartial = { systemId: id }; @@ -22,12 +25,38 @@ class AccountFactory extends BaseFactory { return this.params(params); } + + withAllProperties(): this { + return this.params({ + userId: new ObjectId(), + username: 'username', + activated: true, + credentialHash: 'credentialHash', + expiresAt: new Date(), + lasttriedFailedLogin: new Date(), + password: defaultTestPassword, + systemId: new ObjectId(), + token: 'token', + }).afterBuild((acc) => { + return { + ...acc, + createdAt: new Date(), + updatedAt: new Date(), + }; + }); + } + + withoutSystemAndUserId(): this { + return this.params({ + username: 'username', + systemId: undefined, + userId: undefined, + }); + } } -export const defaultTestPassword = 'DummyPasswd!1'; -export const defaultTestPasswordHash = '$2a$10$/DsztV5o6P5piW2eWJsxw.4nHovmJGBA.QNwiTmuZ/uvUc40b.Uhu'; // !!! important username should not be contain a space !!! -export const accountFactory = AccountFactory.define(Account, ({ sequence }) => { +export const accountFactory = AccountFactory.define(AccountEntity, ({ sequence }) => { return { username: `account${sequence}`, password: defaultTestPasswordHash, diff --git a/apps/server/src/shared/testing/factory/base.factory.ts b/apps/server/src/shared/testing/factory/base.factory.ts index eadf61b5b1d..0c9153bb2da 100644 --- a/apps/server/src/shared/testing/factory/base.factory.ts +++ b/apps/server/src/shared/testing/factory/base.factory.ts @@ -1,5 +1,5 @@ +import { ObjectId } from '@mikro-orm/mongodb'; import { BuildOptions, DeepPartial, Factory, GeneratorFn, HookFn } from 'fishery'; -import { ObjectId } from 'mongodb'; /** * Entity factory based on thoughtbot/fishery diff --git a/apps/server/src/shared/testing/factory/board.factory.ts b/apps/server/src/shared/testing/factory/board.factory.ts index 38f1e63fe3a..63ff3f1ff05 100644 --- a/apps/server/src/shared/testing/factory/board.factory.ts +++ b/apps/server/src/shared/testing/factory/board.factory.ts @@ -1,8 +1,8 @@ -import { Board, BoardProps } from '@shared/domain/entity'; +import { LegacyBoard, BoardProps } from '@shared/domain/entity'; import { BaseFactory } from './base.factory'; import { courseFactory } from './course.factory'; -export const boardFactory = BaseFactory.define(Board, () => { +export const boardFactory = BaseFactory.define(LegacyBoard, () => { return { references: [], course: courseFactory.build(), diff --git a/apps/server/src/shared/testing/factory/boardelement.factory.ts b/apps/server/src/shared/testing/factory/boardelement.factory.ts index 62414b4e07b..5f373d2c02c 100644 --- a/apps/server/src/shared/testing/factory/boardelement.factory.ts +++ b/apps/server/src/shared/testing/factory/boardelement.factory.ts @@ -1,14 +1,13 @@ import { ColumnboardBoardElement, - ColumnBoardTarget, + ColumnBoardNode, LessonBoardElement, LessonEntity, Task, TaskBoardElement, } from '@shared/domain/entity'; -import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; import { BaseFactory } from './base.factory'; +import { columnBoardNodeFactory } from './boardnode'; import { lessonFactory } from './lesson.factory'; import { taskFactory } from './task.factory'; @@ -25,21 +24,10 @@ export const lessonBoardElementFactory = BaseFactory.define(ColumnBoardTarget, ({ sequence }) => { - return { - columnBoardId: new ObjectId().toHexString(), - title: `columnBoardTarget #${sequence}`, - published: false, - }; -}); - -export const columnboardBoardElementFactory = BaseFactory.define< +export const columnboardBoardElementFactory = BaseFactory.define( ColumnboardBoardElement, - { target: ColumnBoardTarget } ->(ColumnboardBoardElement, () => { - const target = columnBoardTargetFactory.build(); - return { target }; -}); + () => { + const target = columnBoardNodeFactory.build(); + return { target }; + } +); diff --git a/apps/server/src/shared/testing/factory/boardnode/column-board-node.factory.ts b/apps/server/src/shared/testing/factory/boardnode/column-board-node.factory.ts index d9ab7d64dec..6fed4bbbe77 100644 --- a/apps/server/src/shared/testing/factory/boardnode/column-board-node.factory.ts +++ b/apps/server/src/shared/testing/factory/boardnode/column-board-node.factory.ts @@ -1,7 +1,7 @@ /* istanbul ignore file */ import { BoardExternalReferenceType } from '@shared/domain/domainobject/board/types'; import { ColumnBoardNode, ColumnBoardNodeProps } from '@shared/domain/entity'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '../base.factory'; export const columnBoardNodeFactory = BaseFactory.define( @@ -13,6 +13,7 @@ export const columnBoardNodeFactory = BaseFactory.define(MediaBoardNode, () => { + return { + context: { + type: BoardExternalReferenceType.User, + id: new ObjectId().toHexString(), + }, + }; +}); diff --git a/apps/server/src/shared/testing/factory/boardnode/media-external-tool-element-node.factory.ts b/apps/server/src/shared/testing/factory/boardnode/media-external-tool-element-node.factory.ts new file mode 100644 index 00000000000..c4bb4d6a764 --- /dev/null +++ b/apps/server/src/shared/testing/factory/boardnode/media-external-tool-element-node.factory.ts @@ -0,0 +1,12 @@ +import { contextExternalToolEntityFactory } from '@modules/tool/context-external-tool/testing'; +import { MediaExternalToolElementNode, type MediaExternalToolElementNodeProps } from '@shared/domain/entity'; +import { BaseFactory } from '../base.factory'; + +export const mediaExternalToolElementNodeFactory = BaseFactory.define< + MediaExternalToolElementNode, + MediaExternalToolElementNodeProps +>(MediaExternalToolElementNode, () => { + return { + contextExternalTool: contextExternalToolEntityFactory.build(), + }; +}); diff --git a/apps/server/src/shared/testing/factory/boardnode/media-line-node.factory.ts b/apps/server/src/shared/testing/factory/boardnode/media-line-node.factory.ts new file mode 100644 index 00000000000..7118189e689 --- /dev/null +++ b/apps/server/src/shared/testing/factory/boardnode/media-line-node.factory.ts @@ -0,0 +1,11 @@ +import { MediaLineNode, type MediaLineNodeProps } from '@shared/domain/entity'; +import { BaseFactory } from '../base.factory'; + +export const mediaLineNodeFactory = BaseFactory.define( + MediaLineNode, + ({ sequence }) => { + return { + title: `Line ${sequence}`, + }; + } +); diff --git a/apps/server/src/shared/testing/factory/county.embeddable.factory.ts b/apps/server/src/shared/testing/factory/county.embeddable.factory.ts index c677cd365a9..58842c7f61b 100644 --- a/apps/server/src/shared/testing/factory/county.embeddable.factory.ts +++ b/apps/server/src/shared/testing/factory/county.embeddable.factory.ts @@ -1,5 +1,5 @@ import { CountyEmbeddable } from '@shared/domain/entity'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from './base.factory'; export const countyEmbeddableFactory = BaseFactory.define( diff --git a/apps/server/src/shared/testing/factory/course.factory.ts b/apps/server/src/shared/testing/factory/course.factory.ts index 6850160bd24..cd6bd1c81cb 100644 --- a/apps/server/src/shared/testing/factory/course.factory.ts +++ b/apps/server/src/shared/testing/factory/course.factory.ts @@ -1,9 +1,8 @@ -import { DeepPartial } from 'fishery'; - import { Course, CourseProperties } from '@shared/domain/entity'; +import { DeepPartial } from 'fishery'; import { BaseFactory } from './base.factory'; -import { schoolFactory } from './school.factory'; +import { schoolEntityFactory } from './school-entity.factory'; import { userFactory } from './user.factory'; const oneDay = 24 * 60 * 60 * 1000; @@ -43,6 +42,6 @@ export const courseFactory = CourseFactory.define(Course, ({ sequence }) => { name: `course #${sequence}`, description: `course #${sequence} description`, color: '#FFFFFF', - school: schoolFactory.build(), + school: schoolEntityFactory.build(), }; }); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/board-do-authorizable.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/board-do-authorizable.factory.ts index f774ba97bb4..7fcb192c277 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/board-do-authorizable.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/board-do-authorizable.factory.ts @@ -1,14 +1,19 @@ -import { BoardDoAuthorizable, BoardDoAuthorizableProps, UserRoleEnum } from '@shared/domain/domainobject/board'; -import { ObjectId } from 'bson'; +import { BoardDoAuthorizable, BoardDoAuthorizableProps } from '@shared/domain/domainobject/board'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DomainObjectFactory } from '../domain-object.factory'; +import { columnFactory } from './column.do.factory'; +import { columnBoardFactory } from './column-board.do.factory'; export const boardDoAuthorizableFactory = DomainObjectFactory.define( BoardDoAuthorizable, () => { + const boardDo = columnFactory.build(); + const rootDo = columnBoardFactory.build({ children: [boardDo] }); return { id: new ObjectId().toHexString(), users: [], - requiredUserRole: UserRoleEnum.STUDENT, + boardDo, + rootDo, }; } ); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/card.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/card.do.factory.ts index 0fbad6a0a13..ab475d4bf3f 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/card.do.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/card.do.factory.ts @@ -1,6 +1,6 @@ /* istanbul ignore file */ import { Card, CardProps } from '@shared/domain/domainobject'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '../../base.factory'; export const cardFactory = BaseFactory.define(Card, ({ sequence }) => { diff --git a/apps/server/src/shared/testing/factory/domainobject/board/column-board.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/column-board.do.factory.ts index 39733d6e471..fde1250ab09 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/column-board.do.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/column-board.do.factory.ts @@ -1,7 +1,7 @@ /* istanbul ignore file */ import { ColumnBoard, ColumnBoardProps } from '@shared/domain/domainobject'; import { BoardExternalReferenceType } from '@shared/domain/domainobject/board/types'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '../../base.factory'; export type IColumnBoardProperties = Readonly; @@ -12,16 +12,17 @@ class ColumnBoardFactory extends BaseFactory { return this.params(params); } } -export const columnBoardFactory = ColumnBoardFactory.define(ColumnBoard, ({ sequence }) => { +export const columnBoardFactory = ColumnBoardFactory.define(ColumnBoard, ({ sequence, params }) => { return { id: new ObjectId().toHexString(), title: `column board #${sequence}`, - children: [], + children: params?.children ?? [], createdAt: new Date(), updatedAt: new Date(), context: { type: BoardExternalReferenceType.Course, id: new ObjectId().toHexString(), }, + isVisible: true, }; }); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/column.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/column.do.factory.ts index ae129e115d9..fcb9597aacd 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/column.do.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/column.do.factory.ts @@ -1,6 +1,6 @@ /* istanbul ignore file */ import { Column, ColumnProps } from '@shared/domain/domainobject'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '../../base.factory'; export const columnFactory = BaseFactory.define(Column, ({ sequence }) => { diff --git a/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts index 526dbfe2869..282f457499e 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/drawing-element.do.factory.ts @@ -1,5 +1,5 @@ /* istanbul ignore file */ -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DrawingElement, DrawingElementProps } from '@shared/domain/domainobject/board/drawing-element.do'; import { BaseFactory } from '../../base.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/board/external-tool-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/external-tool-element.do.factory.ts index d159836d8ea..480b5a98706 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/external-tool-element.do.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/external-tool-element.do.factory.ts @@ -1,5 +1,5 @@ import { ExternalToolElement, ExternalToolElementProps } from '@shared/domain/domainobject'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '../../base.factory'; export const externalToolElementFactory = BaseFactory.define( diff --git a/apps/server/src/shared/testing/factory/domainobject/board/file-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/file-element.do.factory.ts index 17fa4e9e591..b8edde90b9b 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/file-element.do.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/file-element.do.factory.ts @@ -1,6 +1,6 @@ /* istanbul ignore file */ import { FileElement, FileElementProps } from '@shared/domain/domainobject'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '../../base.factory'; export const fileElementFactory = BaseFactory.define(FileElement, ({ sequence }) => { diff --git a/apps/server/src/shared/testing/factory/domainobject/board/index.ts b/apps/server/src/shared/testing/factory/domainobject/board/index.ts index cff2ebf8833..c309b01d66f 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/index.ts @@ -8,3 +8,7 @@ export * from './link-element.do.factory'; export * from './rich-text-element.do.factory'; export * from './submission-container-element.do.factory'; export * from './submission-item.do.factory'; +export { mediaBoardFactory } from './media-board.do.factory'; +export { mediaLineFactory } from './media-line.do.factory'; +export { mediaExternalToolElementFactory } from './media-external-tool-element.do.factory'; +export { boardDoAuthorizableFactory } from './board-do-authorizable.factory'; diff --git a/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts index e57270cfe75..31409d964d5 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/link-element.do.factory.ts @@ -1,6 +1,6 @@ /* istanbul ignore file */ import { LinkElement, LinkElementProps } from '@shared/domain/domainobject'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '../../base.factory'; export const linkElementFactory = BaseFactory.define(LinkElement, ({ sequence }) => { diff --git a/apps/server/src/shared/testing/factory/domainobject/board/media-board.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/media-board.do.factory.ts new file mode 100644 index 00000000000..7ab21c1a152 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/board/media-board.do.factory.ts @@ -0,0 +1,16 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { BoardExternalReferenceType, MediaBoard, type MediaBoardProps } from '@shared/domain/domainobject'; +import { BaseFactory } from '../../base.factory'; + +export const mediaBoardFactory = BaseFactory.define(MediaBoard, () => { + return { + id: new ObjectId().toHexString(), + children: [], + createdAt: new Date(), + updatedAt: new Date(), + context: { + type: BoardExternalReferenceType.User, + id: new ObjectId().toHexString(), + }, + }; +}); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/media-external-tool-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/media-external-tool-element.do.factory.ts new file mode 100644 index 00000000000..092b9021fc9 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/board/media-external-tool-element.do.factory.ts @@ -0,0 +1,16 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { MediaExternalToolElement, type MediaExternalToolElementProps } from '@shared/domain/domainobject'; +import { BaseFactory } from '../../base.factory'; + +export const mediaExternalToolElementFactory = BaseFactory.define< + MediaExternalToolElement, + MediaExternalToolElementProps +>(MediaExternalToolElement, () => { + return { + id: new ObjectId().toHexString(), + children: [], + createdAt: new Date(), + updatedAt: new Date(), + contextExternalToolId: new ObjectId().toHexString(), + }; +}); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/media-line.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/media-line.do.factory.ts new file mode 100644 index 00000000000..3e7790a20d8 --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/board/media-line.do.factory.ts @@ -0,0 +1,13 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { MediaLine, type MediaLineProps } from '@shared/domain/domainobject'; +import { BaseFactory } from '../../base.factory'; + +export const mediaLineFactory = BaseFactory.define(MediaLine, ({ sequence }) => { + return { + id: new ObjectId().toHexString(), + children: [], + createdAt: new Date(), + updatedAt: new Date(), + title: `Line ${sequence}`, + }; +}); diff --git a/apps/server/src/shared/testing/factory/domainobject/board/rich-text-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/rich-text-element.do.factory.ts index 058eabb2b42..802d179a52a 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/rich-text-element.do.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/rich-text-element.do.factory.ts @@ -1,7 +1,7 @@ /* istanbul ignore file */ import { RichTextElement, RichTextElementProps } from '@shared/domain/domainobject'; import { InputFormat } from '@shared/domain/types'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '../../base.factory'; export const richTextElementFactory = BaseFactory.define( diff --git a/apps/server/src/shared/testing/factory/domainobject/board/submission-container-element.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/submission-container-element.do.factory.ts index b42ff6aff7e..b4950ed4ccf 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/submission-container-element.do.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/submission-container-element.do.factory.ts @@ -1,6 +1,6 @@ /* istanbul ignore file */ import { SubmissionContainerElement, SubmissionContainerElementProps } from '@shared/domain/domainobject'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '../../base.factory'; export const submissionContainerElementFactory = BaseFactory.define< diff --git a/apps/server/src/shared/testing/factory/domainobject/board/submission-item.do.factory.ts b/apps/server/src/shared/testing/factory/domainobject/board/submission-item.do.factory.ts index ce72751a5b9..3ea6b6d9880 100644 --- a/apps/server/src/shared/testing/factory/domainobject/board/submission-item.do.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/board/submission-item.do.factory.ts @@ -1,6 +1,6 @@ /* istanbul ignore file */ import { SubmissionItem, SubmissionItemProps } from '@shared/domain/domainobject'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { BaseFactory } from '../../base.factory'; export const submissionItemFactory = BaseFactory.define( diff --git a/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts b/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts index 051b6926387..8a672648e43 100644 --- a/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/groups/group.factory.ts @@ -1,6 +1,6 @@ import { Group, GroupProps, GroupTypes } from '@modules/group/domain'; import { ExternalSource } from '@shared/domain/domainobject'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DomainObjectFactory } from '../domain-object.factory'; export const groupFactory = DomainObjectFactory.define(Group, ({ sequence }) => { diff --git a/apps/server/src/shared/testing/factory/domainobject/tool/external-tool-datasheet-template-data.factory.ts b/apps/server/src/shared/testing/factory/domainobject/tool/external-tool-datasheet-template-data.factory.ts new file mode 100644 index 00000000000..96f18c0751d --- /dev/null +++ b/apps/server/src/shared/testing/factory/domainobject/tool/external-tool-datasheet-template-data.factory.ts @@ -0,0 +1,67 @@ +import { CustomParameterLocation, LtiMessageType, LtiPrivacyPermission } from '@modules/tool/common/enum'; +import { + ExternalToolDatasheetTemplateData, + ExternalToolParameterDatasheetTemplateData, +} from '@modules/tool/external-tool/domain'; +import { DeepPartial, Factory } from 'fishery'; + +export const externalToolParameterDatasheetTemplateDataFactory = Factory.define< + ExternalToolParameterDatasheetTemplateData, + ExternalToolParameterDatasheetTemplateData +>(({ sequence }) => { + return { + name: `custom-parameter-${sequence}`, + properties: '', + type: 'Zeichenkette', + scope: 'Schule', + location: CustomParameterLocation.BODY, + }; +}); + +export class ExternalToolDatasheetTemplateDataFactory extends Factory { + asOauth2Tool(): this { + const params: DeepPartial = { + toolType: 'OAuth 2.0', + skipConsent: 'Zustimmung Ãŧberspringen: ja', + toolUrl: 'https://www.oauth2-baseUrl.com/', + }; + return this.params(params); + } + + asLti11Tool(): this { + const params: DeepPartial = { + toolType: 'LTI 1.1', + messageType: LtiMessageType.BASIC_LTI_LAUNCH_REQUEST, + privacy: LtiPrivacyPermission.PSEUDONYMOUS, + toolUrl: 'https://www.lti11-baseUrl.com/', + }; + return this.params(params); + } + + withParameters(number: number, customParam?: DeepPartial): this { + const params: DeepPartial = { + parameters: externalToolParameterDatasheetTemplateDataFactory.buildList(number, customParam), + }; + return this.params(params); + } + + withOptionalProperties(): this { + const params: DeepPartial = { + isDeactivated: 'Das Tool ist instanzweit deaktiviert', + restrictToContexts: 'Kurs, Kurs-Board', + }; + return this.params(params); + } +} +export const externalToolDatasheetTemplateDataFactory = ExternalToolDatasheetTemplateDataFactory.define( + ({ sequence }) => { + return { + createdAt: new Date().toLocaleDateString('de-DE'), + creatorName: `John Doe ${sequence}`, + instance: 'dBildungscloud', + toolName: `external-tool-${sequence}`, + toolUrl: 'https://www.basic-baseUrl.com/', + toolType: 'Basic', + }; + } +); diff --git a/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts b/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts index ab0ac21c76a..aa5959875d3 100644 --- a/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts +++ b/apps/server/src/shared/testing/factory/domainobject/tool/external-tool.factory.ts @@ -11,6 +11,7 @@ import { import { BasicToolConfig, ExternalTool, + ExternalToolMedium, ExternalToolProps, Lti11ToolConfig, Oauth2ToolConfig, @@ -56,7 +57,6 @@ export const lti11ToolConfigFactory = DoBaseFactory.define return this.params(params); } + + withMedium(externalToolMedium?: ExternalToolMedium): this { + const params: DeepPartial = { + medium: { + mediumId: 'mediumId', + publisher: 'publisher', + ...externalToolMedium, + }, + }; + + return this.params(params); + } } export const externalToolFactory = ExternalToolFactory.define(ExternalTool, ({ sequence }) => { diff --git a/apps/server/src/shared/testing/factory/domainobject/tool/index.ts b/apps/server/src/shared/testing/factory/domainobject/tool/index.ts index 2baa19dc715..f9b501d0ace 100644 --- a/apps/server/src/shared/testing/factory/domainobject/tool/index.ts +++ b/apps/server/src/shared/testing/factory/domainobject/tool/index.ts @@ -3,3 +3,8 @@ export * from './external-tool.factory'; export * from './school-external-tool.factory'; export * from './tool-configuration-status.factory'; export * from './school-external-tool-configuration-status.factory'; +export { + externalToolDatasheetTemplateDataFactory, + ExternalToolDatasheetTemplateDataFactory, + externalToolParameterDatasheetTemplateDataFactory, +} from './external-tool-datasheet-template-data.factory'; diff --git a/apps/server/src/shared/testing/factory/external-group-dto.factory.ts b/apps/server/src/shared/testing/factory/external-group-dto.factory.ts index d5eb865b89b..a6172ff6acf 100644 --- a/apps/server/src/shared/testing/factory/external-group-dto.factory.ts +++ b/apps/server/src/shared/testing/factory/external-group-dto.factory.ts @@ -1,7 +1,7 @@ import { GroupTypes } from '@modules/group'; import { ExternalGroupDto } from '@modules/provisioning/dto'; import { RoleName } from '@shared/domain/interface'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Factory } from 'fishery'; export const externalGroupDtoFactory = Factory.define(({ sequence }) => { diff --git a/apps/server/src/shared/testing/factory/external-school-dto.factory.ts b/apps/server/src/shared/testing/factory/external-school-dto.factory.ts index 4e9e3d1989f..e56e64d9243 100644 --- a/apps/server/src/shared/testing/factory/external-school-dto.factory.ts +++ b/apps/server/src/shared/testing/factory/external-school-dto.factory.ts @@ -1,5 +1,5 @@ import { ExternalSchoolDto } from '@modules/provisioning/dto'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Factory } from 'fishery'; export const externalSchoolDtoFactory = Factory.define(({ sequence }) => { diff --git a/apps/server/src/shared/testing/factory/filerecord.factory.ts b/apps/server/src/shared/testing/factory/filerecord.factory.ts index c05c1679ed2..e4b316ec493 100644 --- a/apps/server/src/shared/testing/factory/filerecord.factory.ts +++ b/apps/server/src/shared/testing/factory/filerecord.factory.ts @@ -1,6 +1,6 @@ import { FileRecordParentType } from '@infra/rabbitmq'; import { FileRecord, FileRecordProperties, FileRecordSecurityCheck } from '@modules/files-storage/entity'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DeepPartial } from 'fishery'; import { BaseFactory } from './base.factory'; diff --git a/apps/server/src/shared/testing/factory/group-entity.factory.ts b/apps/server/src/shared/testing/factory/group-entity.factory.ts index 4cc6d86a7f2..9019f145784 100644 --- a/apps/server/src/shared/testing/factory/group-entity.factory.ts +++ b/apps/server/src/shared/testing/factory/group-entity.factory.ts @@ -3,7 +3,7 @@ import { ExternalSourceEntity } from '@shared/domain/entity'; import { RoleName } from '@shared/domain/interface'; import { BaseFactory } from './base.factory'; import { roleFactory } from './role.factory'; -import { schoolFactory } from './school.factory'; +import { schoolEntityFactory } from './school-entity.factory'; import { systemEntityFactory } from './systemEntityFactory'; import { userFactory } from './user.factory'; @@ -25,7 +25,7 @@ export const groupEntityFactory = BaseFactory.define {} @@ -12,9 +12,9 @@ class H5PContentFactory extends BaseFactory {} export const h5pContentFactory = H5PContentFactory.define(H5PContent, ({ sequence }) => { return { parentType: H5PContentParentType.Lesson, - parentId: new ObjectID().toHexString(), - creatorId: new ObjectID().toHexString(), - schoolId: new ObjectID().toHexString(), + parentId: new ObjectId().toHexString(), + creatorId: new ObjectId().toHexString(), + schoolId: new ObjectId().toHexString(), content: { [`field${sequence}`]: sequence, dateField: new Date(sequence), diff --git a/apps/server/src/shared/testing/factory/h5p-temporary-file.factory.ts b/apps/server/src/shared/testing/factory/h5p-temporary-file.factory.ts deleted file mode 100644 index 4f2205b0490..00000000000 --- a/apps/server/src/shared/testing/factory/h5p-temporary-file.factory.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { H5pEditorTempFile, TemporaryFileProperties } from '@src/modules/h5p-editor/entity'; -import { DeepPartial } from 'fishery'; -import { BaseFactory } from './base.factory'; - -const oneDay = 24 * 60 * 60 * 1000; - -class H5PTemporaryFileFactory extends BaseFactory { - isExpired(): this { - const birthtime = new Date(Date.now() - oneDay * 2); // Created two days ago - const expiresAt = new Date(Date.now() - oneDay); // Expired yesterday - const params: DeepPartial = { expiresAt, birthtime }; - - return this.params(params); - } -} - -export const h5pTemporaryFileFactory = H5PTemporaryFileFactory.define(H5pEditorTempFile, ({ sequence }) => { - return { - filename: `File-${sequence}.txt`, - ownedByUserId: `user-${sequence}`, - birthtime: new Date(Date.now() - oneDay), // Yesterday - expiresAt: new Date(Date.now() + oneDay), // Tomorrow - size: sequence, - }; -}); diff --git a/apps/server/src/shared/testing/factory/import-user.factory.ts b/apps/server/src/shared/testing/factory/import-user.factory.ts index c76363aa6ac..eaaaeafe34e 100644 --- a/apps/server/src/shared/testing/factory/import-user.factory.ts +++ b/apps/server/src/shared/testing/factory/import-user.factory.ts @@ -3,23 +3,23 @@ import { RoleName } from '@shared/domain/interface'; import { DeepPartial } from 'fishery'; import { v4 as uuidv4 } from 'uuid'; import { BaseFactory } from './base.factory'; -import { schoolFactory } from './school.factory'; +import { schoolEntityFactory } from './school-entity.factory'; import { systemEntityFactory } from './systemEntityFactory'; class ImportUserFactory extends BaseFactory { matched(matchedBy: MatchCreator, user: User): this { const params: DeepPartial = { matchedBy, user }; + return this.params(params); } } export const importUserFactory = ImportUserFactory.define(ImportUser, ({ sequence }) => { return { - school: schoolFactory.build(), - system: systemEntityFactory.build(), + school: schoolEntityFactory.buildWithId(), + system: systemEntityFactory.buildWithId(), ldapDn: `uid=john${sequence},cn=schueler,cn=users,ou=1,dc=training,dc=ucs`, - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - externalId: uuidv4() as unknown as string, + externalId: uuidv4(), firstName: `John${sequence}`, lastName: `Doe${sequence}`, email: `user-${sequence}@example.com`, diff --git a/apps/server/src/shared/testing/factory/index.ts b/apps/server/src/shared/testing/factory/index.ts index 957d2e735a9..1d13ccbe062 100644 --- a/apps/server/src/shared/testing/factory/index.ts +++ b/apps/server/src/shared/testing/factory/index.ts @@ -1,30 +1,26 @@ -export * from './account-dto.factory'; +export * from './account-do.factory'; export * from './account.factory'; export * from './axios-response.factory'; export * from './base.factory'; export * from './board.factory'; export * from './boardelement.factory'; export * from './boardnode'; -export * from './context-external-tool-entity.factory'; export * from './course.factory'; export * from './coursegroup.factory'; export * from './domainobject'; export * from './external-group-dto.factory'; -export * from './external-tool-entity.factory'; export * from './external-tool-pseudonym.factory'; export * from './federal-state.factory'; export * from './filerecord.factory'; export * from './group-entity.factory'; export * from './h5p-content.factory'; -export * from './h5p-temporary-file.factory'; export * from './import-user.factory'; export * from './lesson.factory'; export * from './material.factory'; export * from './news.factory'; export * from './role-dto.factory'; export * from './role.factory'; -export * from './school-external-tool-entity.factory'; -export * from './school.factory'; +export * from './school-entity.factory'; export * from './schoolyear.factory'; export * from './share-token.do.factory'; export * from './storageprovider.factory'; @@ -40,7 +36,9 @@ export * from './user.factory'; export * from './legacy-file-entity-mock.factory'; export * from './jwt.test.factory'; export * from './axios-error.factory'; +export * from './tldraw-file-dto.factory'; export { externalSchoolDtoFactory } from './external-school-dto.factory'; export * from './context-external-tool-configuration-status-response.factory'; export * from './school-tool-configuration-status-response.factory'; export { schoolSystemOptionsEntityFactory } from './school-system-options-entity.factory'; +export { countyEmbeddableFactory } from './county.embeddable.factory'; diff --git a/apps/server/src/shared/testing/factory/news.factory.ts b/apps/server/src/shared/testing/factory/news.factory.ts index af7de567f40..de682bffcfe 100644 --- a/apps/server/src/shared/testing/factory/news.factory.ts +++ b/apps/server/src/shared/testing/factory/news.factory.ts @@ -1,7 +1,7 @@ import { CourseNews, NewsProperties, SchoolNews, TeamNews } from '@shared/domain/entity'; import { BaseFactory } from './base.factory'; import { courseFactory } from './course.factory'; -import { schoolFactory } from './school.factory'; +import { schoolEntityFactory } from './school-entity.factory'; import { teamFactory } from './team.factory'; import { userFactory } from './user.factory'; @@ -10,9 +10,9 @@ export const schoolNewsFactory = BaseFactory.define( title: `news ${sequence}`, content: `content of news ${sequence}`, displayAt: new Date(), - school: schoolFactory.build(), + school: schoolEntityFactory.build(), creator: userFactory.build(), - target: schoolFactory.build(), + target: schoolEntityFactory.build(), }; }); @@ -21,7 +21,7 @@ export const courseNewsFactory = BaseFactory.define( title: `news ${sequence}`, content: `content of news ${sequence}`, displayAt: new Date(), - school: schoolFactory.build(), + school: schoolEntityFactory.build(), creator: userFactory.build(), target: courseFactory.build(), }; @@ -32,7 +32,7 @@ export const teamNewsFactory = BaseFactory.define(Team title: `news ${sequence}`, content: `content of news ${sequence}`, displayAt: new Date(), - school: schoolFactory.build(), + school: schoolEntityFactory.build(), creator: userFactory.build(), target: teamFactory.build(), }; @@ -45,9 +45,9 @@ export const schoolUnpublishedNewsFactory = BaseFactory.define(SchoolEntity, ({ sequence }) => { +export const schoolEntityFactory = BaseFactory.define(SchoolEntity, ({ sequence }) => { return { name: `school #${sequence}`, currentYear: schoolYearFactory.build(), diff --git a/apps/server/src/shared/testing/factory/school-system-options-entity.factory.ts b/apps/server/src/shared/testing/factory/school-system-options-entity.factory.ts index 1cd9aea5ae0..8697d6f11b5 100644 --- a/apps/server/src/shared/testing/factory/school-system-options-entity.factory.ts +++ b/apps/server/src/shared/testing/factory/school-system-options-entity.factory.ts @@ -1,7 +1,7 @@ import { SchoolSystemOptionsEntity, SchoolSystemOptionsEntityProps } from '@modules/legacy-school/entity'; import { SystemProvisioningStrategy } from '../../domain/interface/system-provisioning.strategy'; import { BaseFactory } from './base.factory'; -import { schoolFactory } from './school.factory'; +import { schoolEntityFactory } from './school-entity.factory'; import { systemEntityFactory } from './systemEntityFactory'; export const schoolSystemOptionsEntityFactory = BaseFactory.define< @@ -9,7 +9,7 @@ export const schoolSystemOptionsEntityFactory = BaseFactory.define< SchoolSystemOptionsEntityProps >(SchoolSystemOptionsEntity, () => { return { - school: schoolFactory.buildWithId(), + school: schoolEntityFactory.buildWithId(), system: systemEntityFactory.buildWithId({ provisioningStrategy: SystemProvisioningStrategy.SANIS }), provisioningOptions: { groupProvisioningOtherEnabled: false, diff --git a/apps/server/src/shared/testing/factory/schoolyear.factory.ts b/apps/server/src/shared/testing/factory/schoolyear.factory.ts index 30b36d0f432..3c5e77d986a 100644 --- a/apps/server/src/shared/testing/factory/schoolyear.factory.ts +++ b/apps/server/src/shared/testing/factory/schoolyear.factory.ts @@ -15,12 +15,14 @@ class SchoolYearFactory extends BaseFactory { const now = new Date(); const startYearWithoutSequence = transientParams?.startYear ?? now.getFullYear(); + const sequenceStartingWithZero = sequence - 1; + let correction = 0; - let step = 1; - if (now.getMonth() < 7) { - step = 2; + if (now.getMonth() < 7 && !transientParams?.startYear) { + correction = 1; } - const startYear = startYearWithoutSequence + sequence - step; + + const startYear = startYearWithoutSequence + sequenceStartingWithZero - correction; const name = `${startYear}/${(startYear + 1).toString().slice(-2)}`; const startDate = new Date(`${startYear}-08-01`); diff --git a/apps/server/src/shared/testing/factory/share-token.do.factory.ts b/apps/server/src/shared/testing/factory/share-token.do.factory.ts index 95a8d67b379..9cf9d84ac63 100644 --- a/apps/server/src/shared/testing/factory/share-token.do.factory.ts +++ b/apps/server/src/shared/testing/factory/share-token.do.factory.ts @@ -1,7 +1,7 @@ /* istanbul ignore file */ import { ShareTokenDO, ShareTokenParentType } from '@modules/sharing/domainobject/share-token.do'; import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { Factory } from 'fishery'; class ShareTokenFactory extends Factory { diff --git a/apps/server/src/shared/testing/factory/submission.factory.ts b/apps/server/src/shared/testing/factory/submission.factory.ts index 7b4ee5f0455..e0ab13404bc 100644 --- a/apps/server/src/shared/testing/factory/submission.factory.ts +++ b/apps/server/src/shared/testing/factory/submission.factory.ts @@ -1,7 +1,7 @@ import { Submission, SubmissionProperties } from '@shared/domain/entity'; import { DeepPartial } from 'fishery'; import { BaseFactory } from './base.factory'; -import { schoolFactory } from './school.factory'; +import { schoolEntityFactory } from './school-entity.factory'; import { taskFactory } from './task.factory'; import { userFactory } from './user.factory'; @@ -34,7 +34,7 @@ class SubmissionFactory extends BaseFactory { export const submissionFactory = SubmissionFactory.define(Submission, ({ sequence }) => { return { - school: schoolFactory.build(), + school: schoolEntityFactory.build(), task: taskFactory.build(), student: userFactory.build(), comment: `submission comment #${sequence}`, diff --git a/apps/server/src/shared/testing/factory/systemEntityFactory.ts b/apps/server/src/shared/testing/factory/systemEntityFactory.ts index de6b026173d..47374ee7317 100644 --- a/apps/server/src/shared/testing/factory/systemEntityFactory.ts +++ b/apps/server/src/shared/testing/factory/systemEntityFactory.ts @@ -6,12 +6,14 @@ import { SystemEntityProps, } from '@shared/domain/entity'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; +import { SystemTypeEnum } from '@shared/domain/types'; import { DeepPartial } from 'fishery'; import { BaseFactory } from './base.factory'; export class SystemEntityFactory extends BaseFactory { withOauthConfig(): this { const params: DeepPartial = { + type: SystemTypeEnum.OAUTH, oauthConfig: new OauthConfigEntity({ clientId: '12345', clientSecret: 'mocksecret', @@ -33,6 +35,7 @@ export class SystemEntityFactory extends BaseFactory): this { const params: DeepPartial = { + type: SystemTypeEnum.LDAP, ldapConfig: new LdapConfigEntity({ url: 'ldaps:mock.de:389', active: true, @@ -45,6 +48,7 @@ export class SystemEntityFactory extends BaseFactory { } export const taskFactory = TaskFactory.define(Task, ({ sequence }) => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const creator = userFactory.build({ school }); // private is by default in constructor true, but in the most test cases we need private: false return { diff --git a/apps/server/src/shared/testing/factory/teamuser.factory.ts b/apps/server/src/shared/testing/factory/teamuser.factory.ts index b2aaaf5749a..ed067157c67 100644 --- a/apps/server/src/shared/testing/factory/teamuser.factory.ts +++ b/apps/server/src/shared/testing/factory/teamuser.factory.ts @@ -1,13 +1,13 @@ import { Role, TeamUserEntity } from '@shared/domain/entity'; import { BaseFactory } from '@shared/testing/factory/base.factory'; import { roleFactory } from '@shared/testing/factory/role.factory'; -import { schoolFactory } from '@shared/testing/factory/school.factory'; import { userFactory } from '@shared/testing/factory/user.factory'; import { DeepPartial } from 'fishery'; +import { schoolEntityFactory } from './school-entity.factory'; class TeamUserFactory extends BaseFactory { withRoleAndUserId(role: Role, userId: string): this { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const params: DeepPartial = { user: userFactory.buildWithId({ school, roles: [roleFactory.build({ roles: [role] })] }, userId), school, @@ -17,7 +17,7 @@ class TeamUserFactory extends BaseFactory { } withUserId(userId: string): this { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const params: DeepPartial = { user: userFactory.buildWithId({ school }, userId), school, @@ -28,7 +28,7 @@ class TeamUserFactory extends BaseFactory { export const teamUserFactory = TeamUserFactory.define(TeamUserEntity, () => { const role = roleFactory.buildWithId(); - const school = schoolFactory.buildWithId(); + const school = schoolEntityFactory.buildWithId(); const user = userFactory.buildWithId({ roles: [role] }); return new TeamUserEntity({ diff --git a/apps/server/src/shared/testing/factory/tldraw-file-dto.factory.ts b/apps/server/src/shared/testing/factory/tldraw-file-dto.factory.ts new file mode 100644 index 00000000000..82196972fa1 --- /dev/null +++ b/apps/server/src/shared/testing/factory/tldraw-file-dto.factory.ts @@ -0,0 +1,14 @@ +import { FileRecordParentType } from '@infra/rabbitmq'; +import { FileDto } from '@modules/files-storage-client'; +import { FileDomainObjectProps } from '@modules/files-storage-client/interfaces'; +import { BaseFactory } from './base.factory'; + +export const tldrawFileDtoFactory = BaseFactory.define(FileDto, ({ sequence }) => { + return { + id: `filerecordid-${sequence}`, + parentId: 'docname', + name: 'file', + parentType: FileRecordParentType.BoardNode, + createdAt: new Date(2020, 1, 1, 0, 0), + }; +}); diff --git a/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts b/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts index d5059777cef..ca24cb9fefe 100644 --- a/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts +++ b/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts @@ -1,12 +1,22 @@ import { WsSharedDocDo } from '@modules/tldraw/domain/ws-shared-doc.do'; import WebSocket from 'ws'; +import { WebSocketReadyStateEnum } from '@shared/testing'; export class TldrawWsFactory { public static createWsSharedDocDo(): WsSharedDocDo { - return { conns: new Map(), destroy: () => {} } as WsSharedDocDo; + return { + connections: new Map(), + getMap: () => new Map(), + transact: () => {}, + destroy: () => {}, + } as unknown as WsSharedDocDo; } - public static createWebsocket(readyState: number): WebSocket { - return { readyState, close: () => {} } as WebSocket; + public static createWebsocket(readyState: WebSocketReadyStateEnum): WebSocket { + return { + readyState, + close: () => {}, + send: () => {}, + } as unknown as WebSocket; } } diff --git a/apps/server/src/shared/testing/factory/user-and-account.test.factory.spec.ts b/apps/server/src/shared/testing/factory/user-and-account.test.factory.spec.ts index fffa48d540b..b831bb1c98a 100644 --- a/apps/server/src/shared/testing/factory/user-and-account.test.factory.spec.ts +++ b/apps/server/src/shared/testing/factory/user-and-account.test.factory.spec.ts @@ -1,7 +1,8 @@ -import { Account, User } from '@shared/domain/entity'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { User } from '@shared/domain/entity'; +import { AccountEntity } from '@modules/account/entity/account.entity'; import { setupEntities } from '../setup-entities'; -import { schoolFactory } from './school.factory'; +import { schoolEntityFactory } from './school-entity.factory'; import { UserAndAccountParams, UserAndAccountTestFactory } from './user-and-account.test.factory'; describe('user-and-account.test.factory', () => { @@ -10,7 +11,7 @@ describe('user-and-account.test.factory', () => { }); const createParams = () => { - const school = schoolFactory.build(); + const school = schoolEntityFactory.build(); const systemId = new ObjectId().toHexString(); const params: UserAndAccountParams = { @@ -39,7 +40,7 @@ describe('user-and-account.test.factory', () => { const result = UserAndAccountTestFactory.buildStudent(params, additionalPermissions); expect(result.studentUser).toBeInstanceOf(User); - expect(result.studentAccount).toBeInstanceOf(Account); + expect(result.studentAccount).toBeInstanceOf(AccountEntity); expect(result.studentUser.firstName).toEqual(params.firstName); expect(result.studentUser.lastName).toEqual(params.lastName); @@ -66,7 +67,7 @@ describe('user-and-account.test.factory', () => { const result = UserAndAccountTestFactory.buildTeacher(params, additionalPermissions); expect(result.teacherUser).toBeInstanceOf(User); - expect(result.teacherAccount).toBeInstanceOf(Account); + expect(result.teacherAccount).toBeInstanceOf(AccountEntity); expect(result.teacherUser.firstName).toEqual(params.firstName); expect(result.teacherUser.lastName).toEqual(params.lastName); @@ -93,7 +94,7 @@ describe('user-and-account.test.factory', () => { const result = UserAndAccountTestFactory.buildAdmin(params, additionalPermissions); expect(result.adminUser).toBeInstanceOf(User); - expect(result.adminAccount).toBeInstanceOf(Account); + expect(result.adminAccount).toBeInstanceOf(AccountEntity); expect(result.adminUser.firstName).toEqual(params.firstName); expect(result.adminUser.lastName).toEqual(params.lastName); diff --git a/apps/server/src/shared/testing/factory/user-and-account.test.factory.ts b/apps/server/src/shared/testing/factory/user-and-account.test.factory.ts index 2814dd2b5b5..9710025ab04 100644 --- a/apps/server/src/shared/testing/factory/user-and-account.test.factory.ts +++ b/apps/server/src/shared/testing/factory/user-and-account.test.factory.ts @@ -1,8 +1,9 @@ -import { Account, SchoolEntity, User } from '@shared/domain/entity'; +import { SchoolEntity, User } from '@shared/domain/entity'; import { Permission } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import _ from 'lodash'; +import { AccountEntity } from '@modules/account/entity/account.entity'; import { accountFactory } from './account.factory'; import { userFactory } from './user.factory'; @@ -27,7 +28,7 @@ export class UserAndAccountTestFactory { return userParams; } - private static buildAccount(user: User, params: UserAndAccountParams = {}): Account { + private static buildAccount(user: User, params: UserAndAccountParams = {}): AccountEntity { const accountParams = _.pick(params, 'username', 'systemId'); const account = accountFactory.withUser(user).build(accountParams); return account; @@ -37,7 +38,7 @@ export class UserAndAccountTestFactory { params: UserAndAccountParams = {}, additionalPermissions: Permission[] = [] ): { - studentAccount: Account; + studentAccount: AccountEntity; studentUser: User; } { const user = userFactory @@ -51,7 +52,7 @@ export class UserAndAccountTestFactory { public static buildTeacher( params: UserAndAccountParams = {}, additionalPermissions: Permission[] = [] - ): { teacherAccount: Account; teacherUser: User } { + ): { teacherAccount: AccountEntity; teacherUser: User } { const user = userFactory .asTeacher(additionalPermissions) .buildWithId(UserAndAccountTestFactory.getUserParams(params)); @@ -63,7 +64,7 @@ export class UserAndAccountTestFactory { public static buildAdmin( params: UserAndAccountParams = {}, additionalPermissions: Permission[] = [] - ): { adminAccount: Account; adminUser: User } { + ): { adminAccount: AccountEntity; adminUser: User } { const user = userFactory .asAdmin(additionalPermissions) .buildWithId(UserAndAccountTestFactory.getUserParams(params)); diff --git a/apps/server/src/shared/testing/factory/user-login-migration.factory.ts b/apps/server/src/shared/testing/factory/user-login-migration.factory.ts index a3feaedbd27..66e8ab94a57 100644 --- a/apps/server/src/shared/testing/factory/user-login-migration.factory.ts +++ b/apps/server/src/shared/testing/factory/user-login-migration.factory.ts @@ -1,13 +1,13 @@ import { IUserLoginMigration, UserLoginMigrationEntity } from '../../domain/entity/user-login-migration.entity'; import { BaseFactory } from './base.factory'; -import { schoolFactory } from './school.factory'; +import { schoolEntityFactory } from './school-entity.factory'; import { systemEntityFactory } from './systemEntityFactory'; export const userLoginMigrationFactory = BaseFactory.define( UserLoginMigrationEntity, () => { return { - school: schoolFactory.buildWithId(), + school: schoolEntityFactory.buildWithId(), startedAt: new Date('2023-04-28'), targetSystem: systemEntityFactory.buildWithId(), }; diff --git a/apps/server/src/shared/testing/factory/user.do.factory.ts b/apps/server/src/shared/testing/factory/user.do.factory.ts index 9d7f80b7725..57567d6cf1e 100644 --- a/apps/server/src/shared/testing/factory/user.do.factory.ts +++ b/apps/server/src/shared/testing/factory/user.do.factory.ts @@ -1,7 +1,7 @@ import { UserDO } from '@shared/domain/domainobject/user.do'; import { RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { DeepPartial } from 'fishery'; import { DoBaseFactory } from './domainobject'; diff --git a/apps/server/src/shared/testing/factory/user.factory.ts b/apps/server/src/shared/testing/factory/user.factory.ts index 99c3d45f126..43e579c8b7f 100644 --- a/apps/server/src/shared/testing/factory/user.factory.ts +++ b/apps/server/src/shared/testing/factory/user.factory.ts @@ -1,12 +1,48 @@ /* istanbul ignore file */ -import { Role, User, UserProperties } from '@shared/domain/entity'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { + ConsentEntity, + ParentConsentEntity, + Role, + User, + UserConsentEntity, + UserProperties, +} from '@shared/domain/entity'; import { Permission, RoleName } from '@shared/domain/interface'; import { DeepPartial } from 'fishery'; import _ from 'lodash'; import { adminPermissions, studentPermissions, teacherPermissions, userPermissions } from '../user-role-permissions'; import { BaseFactory } from './base.factory'; import { roleFactory } from './role.factory'; -import { schoolFactory } from './school.factory'; +import { schoolEntityFactory } from './school-entity.factory'; + +const userConsentFactory = BaseFactory.define(UserConsentEntity, () => { + return { + form: 'digital', + privacyConsent: true, + termsOfUseConsent: true, + dateOfPrivacyConsent: new Date('2017-01-01T00:06:37.148Z'), + dateOfTermsOfUseConsent: new Date('2017-01-01T00:06:37.148Z'), + }; +}); + +const parentConsentFactory = BaseFactory.define(ParentConsentEntity, () => { + return { + _id: new ObjectId(), + form: 'digital', + privacyConsent: true, + termsOfUseConsent: true, + dateOfPrivacyConsent: new Date('2017-01-01T00:06:37.148Z'), + dateOfTermsOfUseConsent: new Date('2017-01-01T00:06:37.148Z'), + }; +}); + +const consentFactory = BaseFactory.define(ConsentEntity, () => { + return { + userConsent: userConsentFactory.build(), + parentConsents: parentConsentFactory.buildList(1), + }; +}); class UserFactory extends BaseFactory { withRoleByName(name: RoleName): this { @@ -55,6 +91,7 @@ export const userFactory = UserFactory.define(User, ({ sequence }) => { lastName: `Doe ${sequence}`, email: `user-${sequence}@example.com`, roles: [], - school: schoolFactory.build(), + school: schoolEntityFactory.build(), + consent: consentFactory.build(), }; }); diff --git a/apps/server/src/shared/testing/index.ts b/apps/server/src/shared/testing/index.ts index afb9facad15..56f879e61e1 100644 --- a/apps/server/src/shared/testing/index.ts +++ b/apps/server/src/shared/testing/index.ts @@ -4,3 +4,5 @@ export * from './cleanup-collections'; export * from './map-user-to-current-user'; export * from './test-api-client'; export * from './test-xApiKey-client'; +export * from './web-socket-ready-state-enum'; +export { DatesToStrings } from './dates-to-strings'; diff --git a/apps/server/src/shared/testing/map-user-to-current-user.ts b/apps/server/src/shared/testing/map-user-to-current-user.ts index 42125189a2f..a05c042fe01 100644 --- a/apps/server/src/shared/testing/map-user-to-current-user.ts +++ b/apps/server/src/shared/testing/map-user-to-current-user.ts @@ -1,11 +1,12 @@ import { ObjectId } from '@mikro-orm/mongodb'; import { ICurrentUser } from '@modules/authentication'; -import { Account, User } from '@shared/domain/entity'; +import { User } from '@shared/domain/entity'; import { EntityId } from '@shared/domain/types'; +import { AccountEntity } from '@modules/account/entity/account.entity'; export const mapUserToCurrentUser = ( user: User, - account?: Account, + account?: AccountEntity, systemId?: EntityId, impersonated?: boolean ): ICurrentUser => { diff --git a/apps/server/src/shared/testing/test-api-client.spec.ts b/apps/server/src/shared/testing/test-api-client.spec.ts index 573439333ce..608ac2b5de5 100644 --- a/apps/server/src/shared/testing/test-api-client.spec.ts +++ b/apps/server/src/shared/testing/test-api-client.spec.ts @@ -11,7 +11,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { accountFactory } from './factory'; import { TestApiClient } from './test-api-client'; diff --git a/apps/server/src/shared/testing/test-api-client.ts b/apps/server/src/shared/testing/test-api-client.ts index e264d54ec10..fa697879bba 100644 --- a/apps/server/src/shared/testing/test-api-client.ts +++ b/apps/server/src/shared/testing/test-api-client.ts @@ -1,6 +1,6 @@ import { INestApplication } from '@nestjs/common'; -import { Account } from '@shared/domain/entity'; import supertest, { Response } from 'supertest'; +import { AccountEntity } from '@modules/account/entity/account.entity'; import { defaultTestPassword } from './factory/account.factory'; interface AuthenticationResponse { @@ -51,7 +51,7 @@ export class TestApiClient { return testRequestInstance; } - public put(subPath?: string, data = {}): supertest.Test { + public put(subPath?: string, data?: T): supertest.Test { const path = this.getPath(subPath); const testRequestInstance = supertest(this.app.getHttpServer()) .put(path) @@ -61,7 +61,7 @@ export class TestApiClient { return testRequestInstance; } - public patch(subPath?: string, data = {}): supertest.Test { + public patch(subPath?: string, data?: T): supertest.Test { const path = this.getPath(subPath); const testRequestInstance = supertest(this.app.getHttpServer()) .patch(path) @@ -71,7 +71,7 @@ export class TestApiClient { return testRequestInstance; } - public post(subPath?: string, data = {}): supertest.Test { + public post(subPath?: string, data?: T): supertest.Test { const path = this.getPath(subPath); const testRequestInstance = supertest(this.app.getHttpServer()) .post(path) @@ -81,7 +81,22 @@ export class TestApiClient { return testRequestInstance; } - public async login(account: Account): Promise { + public postWithAttachment( + subPath: string | undefined, + fieldName: string, + data: Buffer, + fileName: string + ): supertest.Test { + const path = this.getPath(subPath); + const testRequestInstance = supertest(this.app.getHttpServer()) + .post(path) + .set('authorization', this.formattedJwt) + .attach(fieldName, data, fileName); + + return testRequestInstance; + } + + public async login(account: AccountEntity): Promise { const path = testReqestConst.loginPath; const params: { username: string; password: string } = { username: account.username, diff --git a/apps/server/src/shared/testing/test-xApiKey-client.spec.ts b/apps/server/src/shared/testing/test-xApiKey-client.spec.ts index 83d9386b18d..1a87c66850a 100644 --- a/apps/server/src/shared/testing/test-xApiKey-client.spec.ts +++ b/apps/server/src/shared/testing/test-xApiKey-client.spec.ts @@ -1,6 +1,6 @@ import { Controller, Delete, ExecutionContext, Get, Headers, HttpStatus, INestApplication, Post } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import { ObjectId } from 'bson'; +import { ObjectId } from '@mikro-orm/mongodb'; import { AuthGuard } from '@nestjs/passport'; import { TestXApiKeyClient } from './test-xApiKey-client'; @@ -25,7 +25,7 @@ class TestController { describe(TestXApiKeyClient.name, () => { describe('when test request instance exists', () => { let app: INestApplication; - const API_KEY = '1ab2c3d4e5f61ab2c3d4e5f6'; + const API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4'; beforeAll(async () => { const moduleFixture = await Test.createTestingModule({ diff --git a/apps/server/src/shared/testing/test-xApiKey-client.ts b/apps/server/src/shared/testing/test-xApiKey-client.ts index 8be9e319bc7..05484bf438f 100644 --- a/apps/server/src/shared/testing/test-xApiKey-client.ts +++ b/apps/server/src/shared/testing/test-xApiKey-client.ts @@ -6,21 +6,30 @@ export class TestXApiKeyClient { private readonly baseRoute: string; - constructor(app: INestApplication, baseRoute: string) { + private readonly API_KEY: string; + + constructor(app: INestApplication, baseRoute: string, apikey?: string) { this.app = app; this.baseRoute = this.checkAndAddPrefix(baseRoute); + this.API_KEY = apikey || 'thisistheadminapitokeninthetestconfig'; } public get(subPath?: string): supertest.Test { const path = this.getPath(subPath); - const testRequestInstance = supertest(this.app.getHttpServer()).get(path).set('Accept', 'application/json'); + const testRequestInstance = supertest(this.app.getHttpServer()) + .get(path) + .set('X-API-KEY', this.API_KEY) + .set('Accept', 'application/json'); return testRequestInstance; } public delete(subPath?: string): supertest.Test { const path = this.getPath(subPath); - const testRequestInstance = supertest(this.app.getHttpServer()).delete(path).set('Accept', 'application/json'); + const testRequestInstance = supertest(this.app.getHttpServer()) + .delete(path) + .set('X-API-KEY', this.API_KEY) + .set('Accept', 'application/json'); return testRequestInstance; } @@ -29,6 +38,7 @@ export class TestXApiKeyClient { const path = this.getPath(subPath); const testRequestInstance = supertest(this.app.getHttpServer()) .post(path) + .set('X-API-KEY', this.API_KEY) .set('Accept', 'application/json') .send(data); diff --git a/apps/server/src/shared/testing/web-socket-ready-state-enum.ts b/apps/server/src/shared/testing/web-socket-ready-state-enum.ts new file mode 100644 index 00000000000..a847c38b619 --- /dev/null +++ b/apps/server/src/shared/testing/web-socket-ready-state-enum.ts @@ -0,0 +1,4 @@ +export enum WebSocketReadyStateEnum { + OPEN = 0, + CLOSED = 3, +} diff --git a/apps/server/src/test.ts b/apps/server/src/test.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/backup/setup/accounts.json b/backup/setup/accounts.json index e23c0869b9a..63a2e2ebeeb 100644 --- a/backup/setup/accounts.json +++ b/backup/setup/accounts.json @@ -1455,5 +1455,56 @@ "lasttriedFailedLogin": { "$date": "1970-01-01T00:00:00Z" } + }, + { + "_id": { + "$oid": "6613a9c117cb968747990b5e" + }, + "username": "admin.migration@schul-cloud.org", + "password": "$2a$10$wMuk7hpjULOEJrTW/CKtU.lIETKa.nEs8fncqLJ74SMeX.fzJXBla", + "updatedAt": { + "$date": "2017-01-01T00:06:37.148Z" + }, + "createdAt": { + "$date": "2017-01-01T00:06:37.148Z" + }, + "userId": { + "$oid": "6613a9965bd57f614a084ce7" + }, + "__v": 0 + }, + { + "_id": { + "$oid": "6613a9fb6bd71ff0cd00cb5f" + }, + "username": "lehrer.migration@schul-cloud.org", + "password": "$2a$10$wMuk7hpjULOEJrTW/CKtU.lIETKa.nEs8fncqLJ74SMeX.fzJXBla", + "updatedAt": { + "$date": "2017-01-01T00:06:37.148Z" + }, + "createdAt": { + "$date": "2017-01-01T00:06:37.148Z" + }, + "userId": { + "$oid": "6613a9d441b8c7a53a587dea" + }, + "__v": 0 + }, + { + "_id": { + "$oid": "6613aa3994b34e9db225eaa0" + }, + "username": "schueler.migration@schul-cloud.org", + "password": "$2a$10$wMuk7hpjULOEJrTW/CKtU.lIETKa.nEs8fncqLJ74SMeX.fzJXBla", + "updatedAt": { + "$date": "2017-01-01T00:06:37.148Z" + }, + "createdAt": { + "$date": "2017-01-01T00:06:37.148Z" + }, + "userId": { + "$oid": "6613aa2c7537afa17f3aade6" + }, + "__v": 0 } ] diff --git a/backup/setup/external-tools.json b/backup/setup/external-tools.json index dd6887def87..a503656bd01 100644 --- a/backup/setup/external-tools.json +++ b/backup/setup/external-tools.json @@ -4,10 +4,14 @@ "$oid": "644a4593d0a8301e6cf25d85" }, "createdAt": { - "$date": "2023-04-27T09:51:15.592Z" + "$date": { + "$numberLong": "1682589075592" + } }, "updatedAt": { - "$date": "2023-04-27T09:51:15.592Z" + "$date": { + "$numberLong": "1682589075592" + } }, "name": "TestTool", "url": "https://google.de/", @@ -110,10 +114,14 @@ "$oid": "644a4593d0a8301e6cf25d86" }, "createdAt": { - "$date": "2023-04-27T09:51:15.592Z" + "$date": { + "$numberLong": "1682589075592" + } }, "updatedAt": { - "$date": "2023-04-27T09:51:15.592Z" + "$date": { + "$numberLong": "1682589075592" + } }, "name": "CY Test Tool Board-Element Restriction", "url": "https://google.de/", @@ -132,24 +140,30 @@ "$oid": "647de247cf6a427b9d39e5b1" }, "createdAt": { - "$date": "2023-11-30T12:37:54.977Z" + "$date": { + "$numberLong": "1701347874977" + } }, "updatedAt": { - "$date": "2023-11-30T15:31:47.749Z" + "$date": { + "$numberLong": "1701358307749" + } }, - "name": "Cy Test Tool School Scope", + "name": "CY Test Tool School Scope", "config_type": "basic", "config_baseUrl": "http:google.com", - "parameters": [{ - "name": "searchparam", - "displayName": "searchparameter", - "description": "", - "scope": "school", - "location": "path", - "type": "string", - "isOptional": false, - "isProtected": false - }], + "parameters": [ + { + "name": "searchparam", + "displayName": "searchparameter", + "description": "", + "scope": "school", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + } + ], "isHidden": false, "openNewTab": false, "version": 2 @@ -159,24 +173,30 @@ "$oid": "647de247cf6a427b9d39e5c2" }, "createdAt": { - "$date": "2023-11-30T12:40:29.049Z" + "$date": { + "$numberLong": "1701348029049" + } }, "updatedAt": { - "$date": "2023-11-30T15:32:05.991Z" + "$date": { + "$numberLong": "1701358325991" + } }, "name": "CY Test Tool Context Scope", "config_type": "basic", - "config_baseUrl": "https:google.com", - "parameters": [{ - "name": "searchparam", - "displayName": "searchparameter", - "description": "", - "scope": "context", - "location": "path", - "type": "string", - "isOptional": false, - "isProtected": false - }], + "config_baseUrl": "https://google.com", + "parameters": [ + { + "name": "searchparam", + "displayName": "searchparameter", + "description": "", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + } + ], "isHidden": false, "openNewTab": false, "version": 2 @@ -186,33 +206,40 @@ "$oid": "647de247cf6a427b9d39e5c3" }, "createdAt": { - "$date": "2023-11-30T15:28:04.733Z" + "$date": { + "$numberLong": "1701358084733" + } }, "updatedAt": { - "$date": "2023-11-30T15:32:42.888Z" + "$date": { + "$numberLong": "1701358362888" + } }, "name": "CY Test Tool School and Context Scope", "config_type": "basic", - "config_baseUrl": "https:google.com", - "parameters": [{ - "name": "schoolParam", - "displayName": "cypress test school", - "description": "", - "scope": "school", - "location": "path", - "type": "string", - "isOptional": false, - "isProtected": false - }, { - "name": "contextparammm", - "displayName": "cypress test context", - "description": "", - "scope": "context", - "location": "path", - "type": "string", - "isOptional": false, - "isProtected": false - }], + "config_baseUrl": "https://google.com", + "parameters": [ + { + "name": "schoolParam", + "displayName": "cypress test school", + "description": "", + "scope": "school", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "contextparammm", + "displayName": "cypress test context", + "description": "", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + } + ], "isHidden": false, "openNewTab": false, "version": 2 @@ -222,33 +249,40 @@ "$oid": "647de247cf6a427b9d39e6c3" }, "createdAt": { - "$date": "2023-11-30T15:28:04.733Z" + "$date": { + "$numberLong": "1701358084733" + } }, "updatedAt": { - "$date": "2023-11-30T15:32:42.888Z" + "$date": { + "$numberLong": "1701358362888" + } }, "name": "CY Test Tool deactivated External Tool", "config_type": "basic", - "config_baseUrl": "https:google.com", - "parameters": [{ - "name": "schoolParam", - "displayName": "cypress test school", - "description": "", - "scope": "school", - "location": "path", - "type": "string", - "isOptional": false, - "isProtected": false - }, { - "name": "contextparammm", - "displayName": "cypress test context", - "description": "", - "scope": "context", - "location": "path", - "type": "string", - "isOptional": false, - "isProtected": false - }], + "config_baseUrl": "https://google.com", + "parameters": [ + { + "name": "schoolParam", + "displayName": "cypress test school", + "description": "", + "scope": "school", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "contextparammm", + "displayName": "cypress test context", + "description": "", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + } + ], "isHidden": false, "openNewTab": false, "version": 2, @@ -259,33 +293,40 @@ "$oid": "647de247cf6a427b9d39e7c3" }, "createdAt": { - "$date": "2023-11-30T15:28:04.733Z" + "$date": { + "$numberLong": "1701358084733" + } }, "updatedAt": { - "$date": "2023-11-30T15:32:42.888Z" + "$date": { + "$numberLong": "1701358362888" + } }, "name": "CY Test Tool deactivated School External Tool", "config_type": "basic", - "config_baseUrl": "https:google.com", - "parameters": [{ - "name": "schoolParam", - "displayName": "cypress test school", - "description": "", - "scope": "school", - "location": "path", - "type": "string", - "isOptional": false, - "isProtected": false - }, { - "name": "contextparammm", - "displayName": "cypress test context", - "description": "", - "scope": "context", - "location": "path", - "type": "string", - "isOptional": false, - "isProtected": false - }], + "config_baseUrl": "https://google.com", + "parameters": [ + { + "name": "schoolParam", + "displayName": "cypress test school", + "description": "", + "scope": "school", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "contextparammm", + "displayName": "cypress test context", + "description": "", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + } + ], "isHidden": false, "openNewTab": false, "version": 2, @@ -296,33 +337,40 @@ "$oid": "647de247cf6a427b9d39e8c3" }, "createdAt": { - "$date": "2023-11-30T15:28:04.733Z" + "$date": { + "$numberLong": "1701358084733" + } }, "updatedAt": { - "$date": "2023-11-30T15:32:42.888Z" + "$date": { + "$numberLong": "1701358362888" + } }, "name": "CY Test Tool active External Tool", "config_type": "basic", - "config_baseUrl": "https:google.com", - "parameters": [{ - "name": "schoolParam", - "displayName": "cypress test school", - "description": "", - "scope": "school", - "location": "path", - "type": "string", - "isOptional": false, - "isProtected": false - }, { - "name": "contextparammm", - "displayName": "cypress test context", - "description": "", - "scope": "context", - "location": "path", - "type": "string", - "isOptional": false, - "isProtected": false - }], + "config_baseUrl": "https://google.com", + "parameters": [ + { + "name": "schoolParam", + "displayName": "cypress test school", + "description": "", + "scope": "school", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "contextparammm", + "displayName": "cypress test context", + "description": "", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + } + ], "isHidden": false, "openNewTab": false, "version": 2, @@ -333,37 +381,603 @@ "$oid": "659bf6f049e52dedff83a8f1" }, "createdAt": { - "$date": "2023-11-30T15:28:04.733Z" + "$date": { + "$numberLong": "1701358084733" + } }, "updatedAt": { - "$date": "2023-11-30T15:32:42.888Z" + "$date": { + "$numberLong": "1701358362888" + } }, "name": "CY Test Tool Protected Parameter", "config_type": "basic", "config_baseUrl": "https://google.com/search", - "parameters": [{ - "name": "search", - "displayName": "Suchparameter", - "description": "Danch wird gesucht", - "scope": "context", - "location": "query", - "type": "string", - "isOptional": false, - "isProtected": false - }, { - "name": "protected", - "displayName": "geschÃŧtzter Parameter", - "description": "Dieser parameter wird nicht mitkopiert", - "scope": "context", - "location": "query", - "type": "boolean", - "isOptional": false, - "isProtected": true - }], + "parameters": [ + { + "name": "search", + "displayName": "Suchparameter", + "description": "Danch wird gesucht", + "scope": "context", + "location": "query", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "protected", + "displayName": "geschÃŧtzter Parameter", + "description": "Dieser parameter wird nicht mitkopiert", + "scope": "context", + "location": "query", + "type": "boolean", + "isOptional": false, + "isProtected": true + } + ], "isHidden": false, "openNewTab": false, "version": 1, "isDeactivated": false + }, + { + "_id": { + "$oid": "65f958bdd8b35469f14032b1" + }, + "config_type": "oauth2", + "name": "nextcloud", + "config_baseUrl": "https://nextcloud-nbc.dbildungscloud.dev/", + "config_clientId": "neWZs5MIKnAHUbbuO9TzeClZQF", + "config_skipConsent": true, + "createdAt": { + "$date": { + "$numberLong": "1710839997984" + } + }, + "isHidden": true, + "logoUrl": "", + "openNewTab": true, + "parameters": [], + "updatedAt": { + "$date": { + "$numberLong": "1710839997984" + } + }, + "url": "https://nextcloud-nbc.dbildungscloud.dev/", + "version": 1 + }, + { + "_id": { + "$oid": "65fc0fcde519d4a3b71193e0" + }, + "createdAt": { + "$date": { + "$numberLong": "1711017933720" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711018099651" + } + }, + "name": "Youtube Videoausschnitt", + "url": "https://www.youtube.com", + "logoUrl": "https://cdn-icons-png.flaticon.com/512/3128/3128307.png", + "logoBase64": "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAOxAAADsQBlSsOGwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAABaLSURBVHic7d1r0G5nXd/x7yqEEIKEg2JUhIDRYDirWCMgItIaVHCsh1qh0plai6OjlOEwbccR7QtTnIJ02hG0tk21OmE6TlELlggtimKDjDRGEZCDKIIFFORgEnD1xXoi27Ahz977vp/rvtf9+cw8s5PJm9+enZn/b19r/ddVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7I1pdAC2b64Lqnuc8nNBdaej/3xhdYdB0YBxbqo+dPTPH6r+snrvLT9TfWRUME6GArASc921enD1oOoLqvtWlxz9fNqwYMC++ovqrdXbjn59Y3V9df1Ufz4wFxuiAOyhuc6rvri6onpk9SXVvYeGAg7J26vXVq+ufr163VQ3j43EmVIA9sRcl1ZXHv08uo8f4QOM9uHqldVLq5dN9QeD83AMCsAOm5fj/G+pvrm6bHAcgON6Q3VNdc1UN4wOw+kpADtmrourp1RPri4fmwbgnN1QXV3956nePToMH6cA7IB5+XN4XPVd1de3POMHWJObqpdUL6qunWoenOfgKQADzcv63d+vnlE9cHAcgJPyxurfVy+clvVDBlAABjjay/+n1TNbjvwBDtE7q3+dIjCEAnCCjtb3/lH1A9XnDI4DsCv+qPrRFIETpQCckHl5tv+86vNGZwHYUW+u/vlULx4d5BAoAFs2L1/ne171VaOzAOyJl1dPs0K4XX9rdIC1muuOc/1gdV2GP8CZeFz123P9yFx3HB1mrZwAbMFcX1H9RMs3+QE4e2+ovnOqXxsdZG2cAGzQXOcd/a3/lRn+AJtw/+pVc/3Y7ObSjXICsCHz8j/pz1RfNDoLwEr9VvXtU/3+6CBr4ARgA+b6tpabsQx/gO354uq1c33r6CBroACcg7luP9ePVP+1unB0HoADcOfq5+Z64eyz6efEI4CzNNfdq59veeEPgJP3yurvTfVno4PsIwXgLMx1v+qXWp77AzDOm6vHT/Wm0UH2jUcAZ2iuL6tek+EPsAsurX51ri8dHWTfKABnYK5HV/+z+ozRWQD4a59ZvWKurx4dZJ8oAMc015XVS6tPG50FgE9wYfWLcz1xdJB94R2AY5jrCS2XU/gIBcBuu6n6pql+YXSQXacA3IajI6VfyPeoAfbFTdU3TMupLZ+EAvApzPXl1S+37J0CsD8+XF051atGB9lVCsAnMdfl1auru47OAsBZ+UD1yKmuHx1kFykApzHXZ1W/Ud1ndBYAzsnbqiumetfoILvGFsCtzEdvkmb4A6zBJdVL5rrT6CC7RgH4RP8hl/oArMnDq58cHWLXKACnmOvpuWUKYI2+ba7vHR1il3gH4MjRV/6urW4/OgsAW3Fz9ZhpecH74CkA1by86f/66t6jswCwVX9UPdgNgh4B3OLHM/wBDsG9qheNDrELDr4AzPWUPPcHOCTfNNeTRocY7aAfAcx1cfW71d1GZwHgRL2vunyqd48OMsqhnwD8uwx/gEN09+rHRocY6WBPAOb6xuq/jc4BwFBPONSbAw+yAMx1QfV7+dofwKF7R3X/abk86KAc6iOAZ2X4A1CfW33/6BAjHNwJwLysgLyh5Zv/APDB6rKp3jk6yEk6xBOAH87wB+Dj7lw9Z3SIk3ZQJwBzfX7L2p/P/QJwqo+1rAW+cXSQk3JoJwDPyfAH4BPdrvoXo0OcpIM5AZjr8ur6Dq/0AHA8B3UKcEjD8Okd1u8XgDNzu5ZZcRAO4gRgrntWb6/uODoLADvtxuqSqd41Osi2HcrfiL83wx+A23Z+9dTRIU7C6k8A5jqv+sOWi38A4La8u/rcqW4eHWSbDuEE4IkZ/gAc32dWXzc6xLYdQgH4ztEBANg7/3h0gG1b9SOAefne/1s6jKIDwOZ8rLrPVH88Osi2rH0wfmvr/z0CsHm3q75ldIhtWvtw/ObRAQDYW6suAKt9BDDX/ao3t+LfIwBbNVf3m+pto4Nsw5pPAJ6Y4Q/A2ZuqJ4wOsS1rLgCPHx0AgL33NaMDbMsq/4Y814XVe1u+6AQAZ+sj1adP9eHRQTZtrScAj8nwB+DcXVB9xegQ27DWAvDo0QEAWI1VzpS1FoBHjA4AwGqscqas7h2AeTn6f38eAQCwGTdWF03Lr6uxxhOAL8rwB2Bzzq8eOjrEpq2xADxsdAAAVuchowNs2hoLwINGBwBgdVY3W9ZYAB48OgAAq7O62bLGlwDfV91tdA4AVuU9U33G6BCbtKoCMC+D/32jcwCwShdN9YHRITZlbY8ALhkdAIDVumR0gE1aWwG47+gAAKzWJaMDbNLaCsDnjA4AwGp97ugAm7S2ArCqFzQA2CmfPjrAJq2tANxjdAAAVmtVM0YBAIDjWdWMWVsBuMvoAACs1kWjA2zS2grAHUYHAGC1VjVj1lYA3AIIwLasasasrQCsqp0BsFMUgB12u9EBAFit248OsElrKwAAwDEoAABwgBQAADhACgAAHCAFAAAOkAIAx/ND1QdHhwDYFAUAjueq6rLqRdVfDc4CcM4UADimqd451XdVX1a9ZnQegHOhAMAZmuq66sur76jePTgOwFlRAOAsTDVPdXV1afWc6sbBkQDOiAIA52CqD071g9WDq/8xOA7AsSkAsAFTvXGqr60eV/3e6DwAt0UBgA2a6trqIdX3Vx8YHAfgk1IAYMOmunmqH6vun7VBYEcpALAlU/3J0drg365+Y3QegFMpALBlU722ekTWBoEdogDACThlbfDzsjYI7AAFAE7QVB86Wht8UPVLg+MAB0wBgAGmetNUX5e1QWAQBQAGsjYIjKIAwGDWBoERFADYEdYGgZOkAMCOudXa4LsGxwFWSgGAHeS2QWDbFADYYdYGgW1RAGAP3Gpt8HdH5wH2nwIAe+RobfChWRsEzpECAHvG2iCwCQoA7Clrg8C5UABgz1kbBM6GAgArYG0QOFMKAKzIrdYGf3FwHGCHKQCwQkdrg1+ftUHgk1AAYMWsDQKfjAIAK2dtEDgdBQAOxClrg19a/froPMBYCgAcmKl+q3pk1gbhoCkAcICsDQIKABwwa4NwuBQAwNogHCAFAPhr1gbhcCgAwN9wytrgZVkbhNVSAIDTmupd1gZhvRQA4FM6ZW3wW6p3DI4DbIgCANymo7XBF1dfmLVBWAUFADi2U9YGH9hSCIA9pQAAZ2yqN0/LI4HHVTeMzgOcOQUAOGtHa4MPa1kbfP/gOMAZUACAc3LK2uDnVS/I2iDsBQUA2Iip3jvV92VtEPaCAgBslLVB2A8KALBx1gZh9ykAwNZYG4TdpQAAW3fK2uBXZ20QdoICAJyYqX4la4OwExQA4ESdZm3wY4MjwUFSAIAhbrU2+OrReeDQKADAUFO9rnpUyzsCfzg4DhwMBQAY7pS1wctb1gb/cnAkWD0FANgZp6wNPihrg7BVCgCwc6wNwvYpAMDOsjYI26MAADvN2iBshwIA7AVrg7BZCgCwV6wNwmYoAMDesTYI504BAPaWtUE4ewoAsPesDcKZUwCANbm+eu3oELAPFABg78113rxsCLyx+o7ReWAf3H50AIBzMS/H/s+vHjA6C+wTJwDAXprr8+e6pnp5hj+cMScAwF6Z68LqGdWzq/MHx4G9pQAAe2GuqXpydVV18eA4sPcUAGDnzfXwlvsArhidBdbCOwDAzprrs+d6YfWaDH/YKCcAwM6Z6w7VU6sfrj5tcBxYJQUA2ClzfX3LWt/9RmeBNVMAgJ0w12XV86orR2eBQ6AAAEPNdbfqWdXTWo7+gROgAABDzMtLyE+qnlvdc3AcODgKAHDi5vrKluf8DxkcBQ6WNUDgxMx1r7murl6R4Q9DOQEAtm6uO1XPPPq5YHAcIAUA2LKjtb5/W91ndBbg4zwCALZirofN9arqJRn+sHMUAGCj5rrHvHy3/7rqUaPzAKfnEQCwEXOdV3139ZzqosFxgNugAADnbK7Htvyt/wGjswDH4xEAcNbmunSua6prM/xhrzgBAM7YXBdWz6ieXZ0/OA5wFhQA4NjmmqonV1dVFw+OA5wDBQA4lrke3vKc/4rRWYBz5x0A4FOa67PnemH1mgx/WA0nAMBpzcvVvE+tfqi6y+A4wIYpAMAnOPp87/Or+43OAmyHAgD8tbkuq55XXTk6C7BdCgDQXHernlU9reXoH1g5BQAO2Ly8CPyk6rnVPQfHAU6QAgAHaq6vbHnO/5DBUYABrAHCgZnrXnNdXb0iwx8OlhMAOBBz3al65tHPBYPjAIMpAHAAjtb6XlBdMjgKsCMUAFixuR7W8vneR43OAuwW7wDACs11j3kZ/Ndl+AOn4QQAVmSu86rvrp5TXTQ4DrDDFABYibke2/K3/geMzgLsPo8AYM/Ndelc11TXZvgDx+QEAPbUXBdWz6ieXZ0/OA6wZxQA2DNzTdWTq6uqiwfHAfaUAgB7ZK4vadnnv2J0FmC/eQcA9sBcnz3XC6vfzPAHNsAJAOywebma96nVD1V3GRwHWBEFAHbU0ed7n1/db3QWYH0UANgxc11WPa+6cnQWYL0UANgRc92telb1tJajf4CtUQBgsHl5GfdJ1XOrew6OAxwIBQAGmuvRLZ/vfcjoLMBhsQYIA8x1r7murl6Z4Q8M4AQATtBcd6q+t/qX1Z0HxwEOmAIAJ+Rore8F1SWDowAoALBtcz2s5Tn/o0ZnAbiFdwBgS+a6+7wM/usy/IEd4wQANmyu86rvrn6wuuvYNACnpwDABs312JbP9z5wdBaAT8UjANiAuS6d65rq2gx/YA84AYBzMNeF1TOqZ1fnD44DcGwKAJyFuabqydVV1cWD4wCcMQUAztBcX9Kyz3/F6CwAZ8s7AHBMc33WXC+sfjPDH9hzTgDgeJ5V/bN8vhdYCQUAjucHRgcA2CSPAADgACkAAHCAFAAAOEAKAAAcIAUAAA7Q2grAx0YHAGC1Pjo6wCatrQDcNDoAAKt14+gAm7S2ArCqPxwAdsqqZowCAADHs6pT5rUVgA+MDgDAav356ACbtLYC8N7RAQBYrVXNGAUAAI5nVTNmbQXgPaMDALBaCsAO++PRAQBYrXeMDrBJaysAbx0dAIDVWtWMmUYH2KS5Lmplb2kCsDPuMtVfjA6xKas6AZjq/dWfjc4BwOq8Z03Dv1ZWAI7cMDoAAKvzO6MDbNoaC8D1owMAsDqrmy0KAADcttXNljUWgN8eHQCA1fm/owNs2qq2AKrmOr9lE+COo7MAsAofqe46uQxot03LjYCvG50DgNX4P2sb/rXCAnDk1aMDALAaq5wpay0A/3t0AABW41WjA2zD6t4BqJrrwpaLgbwHAMC5+Eh1j2n5dVVWeQIw1YeqXx2dA4C998o1Dv9aaQE48tLRAQDYey8bHWBbVvkIoGqu+1Z/0Ip/jwBs1Vzdd6q3jw6yDas9AZiWaxuvG50DgL3162sd/rXiAnDkxaMDALC3rhkdYJtWfTw+171bTgLWXnQA2KyPVfee6p2jg2zLqgfjVH9YvXx0DgD2zkvXPPxr5QXgyE+MDgDA3vnJ0QG2bdWPAKrmun3LScBnjc4CwF54V8vx/82jg2zT6k8ApvpoB9DkANiYH1/78K8DOAGomuue1duqCwZHAWC3faS6z1T/b3SQbVv9CUDVVH9a/czoHADsvP90CMO/DuQEoGqu+1e/U91udBYAdtJHq8unetPoICfhIE4AqqZ6Q/Vzo3MAsLN++lCGfx3QCUDVXJdWv9eyGQAAt7i5uv9Ubxkd5KQczAlA1VRvrq4enQOAnfNThzT868BOAKrm+pyWxwF3Hp0FgJ3wF9VlU/3J6CAn6aBOAKqm+uPqqtE5ANgZ/+rQhn8d4AlA1Vx3bHkX4JLBUQAY6y0tb/7fODrISTu4E4Cqqf6y+r7ROQAY7nsOcfjXgRaAqqleUr14dA4AhvmZqV46OsQoB/kI4BZzXVz9bnW30VkAOFHvbTn6/9PRQUY52BOAqmm58cmjAIDD8z2HPPzrwAtA1VT/pfrZ0TkAODFXT74Me9iPAG4x10XV66v7jM4CwFa9tXroVB8YHWS0gz8BqJrq/dW3dwD3PwMcsJuqf2D4LxSAI1O9unr66BwAbM33TfWa0SF2hUcAtzLXf6yeMjoHABv101M9eXSIXaIA3Mpcd6r+V/XwwVEA2IzXVF811UdGB9klCsBpzPXp1W+0XB8MwP56a3XFVO8eHWTXeAfgNKZ6T/WE6s9GZwHgrL2vutLwPz0F4JOYlsuCHl99cHQWAM7Yh6snTvX7o4PsKgXgUzh6W/SJLZcHAbAfbqq+capfGx1klykAt2GqV1Tf3PI/FAC77caW4f/Lo4PsOi8BHtNcj2m5QfDOo7MAcFofzvA/NgXgDMz1qOoXq7uMzgLA3/DBlmf+rxgdZF8oAGdoXr4P8AvVZ47OAkBVf1J93VSvGx1knygAZ2GuS6pfqi4fHAXg0N1Qfe1Ubx8dZN94CfAsTPW26pHVrwyOAnDIXl49wvA/OwrAWZqWjwT93eqqah4cB+CQzNULqscf3ebKWfAIYAPmZU3wp7IhALBtH6ieMtXPjw6y7xSADZnrvtVPV18+OgvASl1XfftUbxodZA08AtiQablw4tHVc6qPDY4DsCZ/1XLk/0jDf3OcAGzBvLwg+BPV/UdnAdhzN1TfOS03tLJBTgC24Oj70w+unt3yWUoAzszNLS9Zf7Hhvx1OALZsXr4V8Lzq74zOArAnXlo9zU1+26UAnJC5vrp6fvWA0VkAdtQbq6dPyyfX2TKPAE7IVNdWD6u+o3rL4DgAu+Qd1fdXDzb8T44TgAHmOr/6J9Uzq3sNjgMwyjuqH6l+cnLl+olTAAaa67zqG6pntFwyBHAIXl/9m+pnp+VlPwZQAHbAvPw5PLblVOCJ1R3GJgLYuBur/169aHKPyk5QAHbMXPes/uHRz4MGxwE4V6+vrq6unuo9o8PwcQrADpvrC6tvbblrwNXDwL64obqmumaqN4wOw+kpAHvi6K6Br6murB6Ti4eA3fHBlmP9l1UvO7oynR2nAOyhuW5fPbR6xNHPw6tLRmYCDspbWy7mefXRz+un+ujYSJwpBWAl5rqoemDLewNf0HJicMnRrxeNSwbsqT9v+Zv8W49+/f3q+up3puVKXvacAnAA5rpjdffqHkc/51d3OfrPF2brAA7RTdWHjv75/Uf//t5bfib3mAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDu+P8tpDnfCLeyhwAAAABJRU5ErkJggg==", + "config_type": "basic", + "config_baseUrl": "https://www.youtube-nocookie.com/embed/:video-id?rel=0&modestbranding=1&&controls=0&iv_load_policy=3&autohide=1&showinfo=0&theme=light&", + "parameters": [ + { + "name": "video-id", + "displayName": "Video ID", + "description": "ID des Videos", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "start", + "displayName": "Start", + "description": "Zeit in Sekunden", + "scope": "context", + "location": "query", + "type": "number", + "isOptional": true, + "isProtected": false + }, + { + "name": "end", + "displayName": "Ende", + "description": "Zeit in Sekunden", + "scope": "context", + "location": "query", + "type": "number", + "isOptional": true, + "isProtected": false + } + ], + "isHidden": false, + "isDeactivated": false, + "openNewTab": true, + "version": 2, + "restrictToContexts": [] + }, + { + "_id": { + "$oid": "65fc113ce519d4a3b71193e1" + }, + "createdAt": { + "$date": { + "$numberLong": "1711018300466" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711018300466" + } + }, + "name": "Invidious Videoausschnitt", + "url": "https://yt.cdaut.de/", + "logoUrl": "https://cdn-icons-png.flaticon.com/512/3128/3128307.png", + "logoBase64": "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAOxAAADsQBlSsOGwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAABaLSURBVHic7d1r0G5nXd/x7yqEEIKEg2JUhIDRYDirWCMgItIaVHCsh1qh0plai6OjlOEwbccR7QtTnIJ02hG0tk21OmE6TlELlggtimKDjDRGEZCDKIIFFORgEnD1xXoi27Ahz977vp/rvtf9+cw8s5PJm9+enZn/b19r/ddVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7I1pdAC2b64Lqnuc8nNBdaej/3xhdYdB0YBxbqo+dPTPH6r+snrvLT9TfWRUME6GArASc921enD1oOoLqvtWlxz9fNqwYMC++ovqrdXbjn59Y3V9df1Ufz4wFxuiAOyhuc6rvri6onpk9SXVvYeGAg7J26vXVq+ufr163VQ3j43EmVIA9sRcl1ZXHv08uo8f4QOM9uHqldVLq5dN9QeD83AMCsAOm5fj/G+pvrm6bHAcgON6Q3VNdc1UN4wOw+kpADtmrourp1RPri4fmwbgnN1QXV3956nePToMH6cA7IB5+XN4XPVd1de3POMHWJObqpdUL6qunWoenOfgKQADzcv63d+vnlE9cHAcgJPyxurfVy+clvVDBlAABjjay/+n1TNbjvwBDtE7q3+dIjCEAnCCjtb3/lH1A9XnDI4DsCv+qPrRFIETpQCckHl5tv+86vNGZwHYUW+u/vlULx4d5BAoAFs2L1/ne171VaOzAOyJl1dPs0K4XX9rdIC1muuOc/1gdV2GP8CZeFz123P9yFx3HB1mrZwAbMFcX1H9RMs3+QE4e2+ovnOqXxsdZG2cAGzQXOcd/a3/lRn+AJtw/+pVc/3Y7ObSjXICsCHz8j/pz1RfNDoLwEr9VvXtU/3+6CBr4ARgA+b6tpabsQx/gO354uq1c33r6CBroACcg7luP9ePVP+1unB0HoADcOfq5+Z64eyz6efEI4CzNNfdq59veeEPgJP3yurvTfVno4PsIwXgLMx1v+qXWp77AzDOm6vHT/Wm0UH2jUcAZ2iuL6tek+EPsAsurX51ri8dHWTfKABnYK5HV/+z+ozRWQD4a59ZvWKurx4dZJ8oAMc015XVS6tPG50FgE9wYfWLcz1xdJB94R2AY5jrCS2XU/gIBcBuu6n6pql+YXSQXacA3IajI6VfyPeoAfbFTdU3TMupLZ+EAvApzPXl1S+37J0CsD8+XF051atGB9lVCsAnMdfl1auru47OAsBZ+UD1yKmuHx1kFykApzHXZ1W/Ud1ndBYAzsnbqiumetfoILvGFsCtzEdvkmb4A6zBJdVL5rrT6CC7RgH4RP8hl/oArMnDq58cHWLXKACnmOvpuWUKYI2+ba7vHR1il3gH4MjRV/6urW4/OgsAW3Fz9ZhpecH74CkA1by86f/66t6jswCwVX9UPdgNgh4B3OLHM/wBDsG9qheNDrELDr4AzPWUPPcHOCTfNNeTRocY7aAfAcx1cfW71d1GZwHgRL2vunyqd48OMsqhnwD8uwx/gEN09+rHRocY6WBPAOb6xuq/jc4BwFBPONSbAw+yAMx1QfV7+dofwKF7R3X/abk86KAc6iOAZ2X4A1CfW33/6BAjHNwJwLysgLyh5Zv/APDB6rKp3jk6yEk6xBOAH87wB+Dj7lw9Z3SIk3ZQJwBzfX7L2p/P/QJwqo+1rAW+cXSQk3JoJwDPyfAH4BPdrvoXo0OcpIM5AZjr8ur6Dq/0AHA8B3UKcEjD8Okd1u8XgDNzu5ZZcRAO4gRgrntWb6/uODoLADvtxuqSqd41Osi2HcrfiL83wx+A23Z+9dTRIU7C6k8A5jqv+sOWi38A4La8u/rcqW4eHWSbDuEE4IkZ/gAc32dWXzc6xLYdQgH4ztEBANg7/3h0gG1b9SOAefne/1s6jKIDwOZ8rLrPVH88Osi2rH0wfmvr/z0CsHm3q75ldIhtWvtw/ObRAQDYW6suAKt9BDDX/ao3t+LfIwBbNVf3m+pto4Nsw5pPAJ6Y4Q/A2ZuqJ4wOsS1rLgCPHx0AgL33NaMDbMsq/4Y814XVe1u+6AQAZ+sj1adP9eHRQTZtrScAj8nwB+DcXVB9xegQ27DWAvDo0QEAWI1VzpS1FoBHjA4AwGqscqas7h2AeTn6f38eAQCwGTdWF03Lr6uxxhOAL8rwB2Bzzq8eOjrEpq2xADxsdAAAVuchowNs2hoLwINGBwBgdVY3W9ZYAB48OgAAq7O62bLGlwDfV91tdA4AVuU9U33G6BCbtKoCMC+D/32jcwCwShdN9YHRITZlbY8ALhkdAIDVumR0gE1aWwG47+gAAKzWJaMDbNLaCsDnjA4AwGp97ugAm7S2ArCqFzQA2CmfPjrAJq2tANxjdAAAVmtVM0YBAIDjWdWMWVsBuMvoAACs1kWjA2zS2grAHUYHAGC1VjVj1lYA3AIIwLasasasrQCsqp0BsFMUgB12u9EBAFit248OsElrKwAAwDEoAABwgBQAADhACgAAHCAFAAAOkAIAx/ND1QdHhwDYFAUAjueq6rLqRdVfDc4CcM4UADimqd451XdVX1a9ZnQegHOhAMAZmuq66sur76jePTgOwFlRAOAsTDVPdXV1afWc6sbBkQDOiAIA52CqD071g9WDq/8xOA7AsSkAsAFTvXGqr60eV/3e6DwAt0UBgA2a6trqIdX3Vx8YHAfgk1IAYMOmunmqH6vun7VBYEcpALAlU/3J0drg365+Y3QegFMpALBlU722ekTWBoEdogDACThlbfDzsjYI7AAFAE7QVB86Wht8UPVLg+MAB0wBgAGmetNUX5e1QWAQBQAGsjYIjKIAwGDWBoERFADYEdYGgZOkAMCOudXa4LsGxwFWSgGAHeS2QWDbFADYYdYGgW1RAGAP3Gpt8HdH5wH2nwIAe+RobfChWRsEzpECAHvG2iCwCQoA7Clrg8C5UABgz1kbBM6GAgArYG0QOFMKAKzIrdYGf3FwHGCHKQCwQkdrg1+ftUHgk1AAYMWsDQKfjAIAK2dtEDgdBQAOxClrg19a/froPMBYCgAcmKl+q3pk1gbhoCkAcICsDQIKABwwa4NwuBQAwNogHCAFAPhr1gbhcCgAwN9wytrgZVkbhNVSAIDTmupd1gZhvRQA4FM6ZW3wW6p3DI4DbIgCANymo7XBF1dfmLVBWAUFADi2U9YGH9hSCIA9pQAAZ2yqN0/LI4HHVTeMzgOcOQUAOGtHa4MPa1kbfP/gOMAZUACAc3LK2uDnVS/I2iDsBQUA2Iip3jvV92VtEPaCAgBslLVB2A8KALBx1gZh9ykAwNZYG4TdpQAAW3fK2uBXZ20QdoICAJyYqX4la4OwExQA4ESdZm3wY4MjwUFSAIAhbrU2+OrReeDQKADAUFO9rnpUyzsCfzg4DhwMBQAY7pS1wctb1gb/cnAkWD0FANgZp6wNPihrg7BVCgCwc6wNwvYpAMDOsjYI26MAADvN2iBshwIA7AVrg7BZCgCwV6wNwmYoAMDesTYI504BAPaWtUE4ewoAsPesDcKZUwCANbm+eu3oELAPFABg78113rxsCLyx+o7ReWAf3H50AIBzMS/H/s+vHjA6C+wTJwDAXprr8+e6pnp5hj+cMScAwF6Z68LqGdWzq/MHx4G9pQAAe2GuqXpydVV18eA4sPcUAGDnzfXwlvsArhidBdbCOwDAzprrs+d6YfWaDH/YKCcAwM6Z6w7VU6sfrj5tcBxYJQUA2ClzfX3LWt/9RmeBNVMAgJ0w12XV86orR2eBQ6AAAEPNdbfqWdXTWo7+gROgAABDzMtLyE+qnlvdc3AcODgKAHDi5vrKluf8DxkcBQ6WNUDgxMx1r7murl6R4Q9DOQEAtm6uO1XPPPq5YHAcIAUA2LKjtb5/W91ndBbg4zwCALZirofN9arqJRn+sHMUAGCj5rrHvHy3/7rqUaPzAKfnEQCwEXOdV3139ZzqosFxgNugAADnbK7Htvyt/wGjswDH4xEAcNbmunSua6prM/xhrzgBAM7YXBdWz6ieXZ0/OA5wFhQA4NjmmqonV1dVFw+OA5wDBQA4lrke3vKc/4rRWYBz5x0A4FOa67PnemH1mgx/WA0nAMBpzcvVvE+tfqi6y+A4wIYpAMAnOPp87/Or+43OAmyHAgD8tbkuq55XXTk6C7BdCgDQXHernlU9reXoH1g5BQAO2Ly8CPyk6rnVPQfHAU6QAgAHaq6vbHnO/5DBUYABrAHCgZnrXnNdXb0iwx8OlhMAOBBz3al65tHPBYPjAIMpAHAAjtb6XlBdMjgKsCMUAFixuR7W8vneR43OAuwW7wDACs11j3kZ/Ndl+AOn4QQAVmSu86rvrp5TXTQ4DrDDFABYibke2/K3/geMzgLsPo8AYM/Ndelc11TXZvgDx+QEAPbUXBdWz6ieXZ0/OA6wZxQA2DNzTdWTq6uqiwfHAfaUAgB7ZK4vadnnv2J0FmC/eQcA9sBcnz3XC6vfzPAHNsAJAOywebma96nVD1V3GRwHWBEFAHbU0ed7n1/db3QWYH0UANgxc11WPa+6cnQWYL0UANgRc92telb1tJajf4CtUQBgsHl5GfdJ1XOrew6OAxwIBQAGmuvRLZ/vfcjoLMBhsQYIA8x1r7murl6Z4Q8M4AQATtBcd6q+t/qX1Z0HxwEOmAIAJ+Rore8F1SWDowAoALBtcz2s5Tn/o0ZnAbiFdwBgS+a6+7wM/usy/IEd4wQANmyu86rvrn6wuuvYNACnpwDABs312JbP9z5wdBaAT8UjANiAuS6d65rq2gx/YA84AYBzMNeF1TOqZ1fnD44DcGwKAJyFuabqydVV1cWD4wCcMQUAztBcX9Kyz3/F6CwAZ8s7AHBMc33WXC+sfjPDH9hzTgDgeJ5V/bN8vhdYCQUAjucHRgcA2CSPAADgACkAAHCAFAAAOEAKAAAcIAUAAA7Q2grAx0YHAGC1Pjo6wCatrQDcNDoAAKt14+gAm7S2ArCqPxwAdsqqZowCAADHs6pT5rUVgA+MDgDAav356ACbtLYC8N7RAQBYrVXNGAUAAI5nVTNmbQXgPaMDALBaCsAO++PRAQBYrXeMDrBJaysAbx0dAIDVWtWMmUYH2KS5Lmplb2kCsDPuMtVfjA6xKas6AZjq/dWfjc4BwOq8Z03Dv1ZWAI7cMDoAAKvzO6MDbNoaC8D1owMAsDqrmy0KAADcttXNljUWgN8eHQCA1fm/owNs2qq2AKrmOr9lE+COo7MAsAofqe46uQxot03LjYCvG50DgNX4P2sb/rXCAnDk1aMDALAaq5wpay0A/3t0AABW41WjA2zD6t4BqJrrwpaLgbwHAMC5+Eh1j2n5dVVWeQIw1YeqXx2dA4C998o1Dv9aaQE48tLRAQDYey8bHWBbVvkIoGqu+1Z/0Ip/jwBs1Vzdd6q3jw6yDas9AZiWaxuvG50DgL3162sd/rXiAnDkxaMDALC3rhkdYJtWfTw+171bTgLWXnQA2KyPVfee6p2jg2zLqgfjVH9YvXx0DgD2zkvXPPxr5QXgyE+MDgDA3vnJ0QG2bdWPAKrmun3LScBnjc4CwF54V8vx/82jg2zT6k8ApvpoB9DkANiYH1/78K8DOAGomuue1duqCwZHAWC3faS6z1T/b3SQbVv9CUDVVH9a/czoHADsvP90CMO/DuQEoGqu+1e/U91udBYAdtJHq8unetPoICfhIE4AqqZ6Q/Vzo3MAsLN++lCGfx3QCUDVXJdWv9eyGQAAt7i5uv9Ubxkd5KQczAlA1VRvrq4enQOAnfNThzT868BOAKrm+pyWxwF3Hp0FgJ3wF9VlU/3J6CAn6aBOAKqm+uPqqtE5ANgZ/+rQhn8d4AlA1Vx3bHkX4JLBUQAY6y0tb/7fODrISTu4E4Cqqf6y+r7ROQAY7nsOcfjXgRaAqqleUr14dA4AhvmZqV46OsQoB/kI4BZzXVz9bnW30VkAOFHvbTn6/9PRQUY52BOAqmm58cmjAIDD8z2HPPzrwAtA1VT/pfrZ0TkAODFXT74Me9iPAG4x10XV66v7jM4CwFa9tXroVB8YHWS0gz8BqJrq/dW3dwD3PwMcsJuqf2D4LxSAI1O9unr66BwAbM33TfWa0SF2hUcAtzLXf6yeMjoHABv101M9eXSIXaIA3Mpcd6r+V/XwwVEA2IzXVF811UdGB9klCsBpzPXp1W+0XB8MwP56a3XFVO8eHWTXeAfgNKZ6T/WE6s9GZwHgrL2vutLwPz0F4JOYlsuCHl99cHQWAM7Yh6snTvX7o4PsKgXgUzh6W/SJLZcHAbAfbqq+capfGx1klykAt2GqV1Tf3PI/FAC77caW4f/Lo4PsOi8BHtNcj2m5QfDOo7MAcFofzvA/NgXgDMz1qOoXq7uMzgLA3/DBlmf+rxgdZF8oAGdoXr4P8AvVZ47OAkBVf1J93VSvGx1knygAZ2GuS6pfqi4fHAXg0N1Qfe1Ubx8dZN94CfAsTPW26pHVrwyOAnDIXl49wvA/OwrAWZqWjwT93eqqah4cB+CQzNULqscf3ebKWfAIYAPmZU3wp7IhALBtH6ieMtXPjw6y7xSADZnrvtVPV18+OgvASl1XfftUbxodZA08AtiQablw4tHVc6qPDY4DsCZ/1XLk/0jDf3OcAGzBvLwg+BPV/UdnAdhzN1TfOS03tLJBTgC24Oj70w+unt3yWUoAzszNLS9Zf7Hhvx1OALZsXr4V8Lzq74zOArAnXlo9zU1+26UAnJC5vrp6fvWA0VkAdtQbq6dPyyfX2TKPAE7IVNdWD6u+o3rL4DgAu+Qd1fdXDzb8T44TgAHmOr/6J9Uzq3sNjgMwyjuqH6l+cnLl+olTAAaa67zqG6pntFwyBHAIXl/9m+pnp+VlPwZQAHbAvPw5PLblVOCJ1R3GJgLYuBur/169aHKPyk5QAHbMXPes/uHRz4MGxwE4V6+vrq6unuo9o8PwcQrADpvrC6tvbblrwNXDwL64obqmumaqN4wOw+kpAHvi6K6Br6murB6Ti4eA3fHBlmP9l1UvO7oynR2nAOyhuW5fPbR6xNHPw6tLRmYCDspbWy7mefXRz+un+ujYSJwpBWAl5rqoemDLewNf0HJicMnRrxeNSwbsqT9v+Zv8W49+/f3q+up3puVKXvacAnAA5rpjdffqHkc/51d3OfrPF2brAA7RTdWHjv75/Uf//t5bfib3mAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDu+P8tpDnfCLeyhwAAAABJRU5ErkJggg==", + "config_type": "basic", + "config_baseUrl": "https://yt.cdaut.de/watch?thin_mode=true&iv_load_policy=3&continue=0&", + "parameters": [ + { + "name": "v", + "displayName": "Video ID", + "description": "ID des Videos", + "scope": "context", + "location": "query", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "start", + "displayName": "Start", + "description": "Zeit in Sekunden", + "scope": "context", + "location": "query", + "type": "number", + "isOptional": true, + "isProtected": false + }, + { + "name": "end", + "displayName": "Ende", + "description": "Zeit in Sekunden", + "scope": "context", + "location": "query", + "type": "number", + "isOptional": true, + "isProtected": false + } + ], + "isHidden": false, + "isDeactivated": false, + "openNewTab": true, + "version": 1, + "restrictToContexts": [] + }, + { + "_id": { + "$oid": "65fc11a5e519d4a3b71193e2" + }, + "createdAt": { + "$date": { + "$numberLong": "1711018405712" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711018405712" + } + }, + "name": "Classtime Session", + "url": "https://classtime.com/", + "logoUrl": "https://press.classtime.com/wp-content/uploads/2020/07/Classtime-monogram_800px-300x300.png", + "logoBase64": "iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAYAAAB5fY51AAAACXBIWXMAAA7EAAAOxAGVKw4bAAAgAElEQVR4nOx9ebxnRXHvt373zg7DMAzDMAw7AgLKYoKIoEgAwYUlCO55uOAzJiaRuBMVowaXuCS+4HM3MWqemASUKMENRQgiKGpAHBCRsA4wDAPMeu+v3h9nq66uXs75/X73zsCtz+d3b53u6qrqPtXVVafPQpiBGShh0ee5NzmBRdzHYhCWAFjC4KUEWsqEncFYBOJFAC0CsJCB+SDMR/F/NoBxAOMM9AAAhD7AEwBNANgEYB0I6wCsA7CWgTUgrAGwBsDdAK8C0SoA9xNwP41h9ewFWLPmNOpPw3DMwBYINN0KzMDUwXnM+MinMRd9bMPgpQDtD/BeDHoCCPuAsRsICxmYC2A+gHHPQghgzVjRsNHGqTfK1PEEF45tA4C1AG4H8S0g3ExMtzLxTQBW9YgeectLseE8mjHjxwvMnOnHOGz3CV7GwCEMHALgKQDvBdAKBpbWrocIjRsiMDU4wKWVNDQMAmI0VNKE+AfKJU825Db0BACrGHwHEd3KwHUgXE89XL/xJXTPQAM2A1s0zDisxxAsuoAX9Rn7AjgCxE8H40gmrNB0TMINWBagoygjQqIETYpHpYduE60XNBHd7wBwFYArQXQ1EVZufAmtMShnYCuEGYe1lcL2n+QeT2AxMw4B8dHMdBSAAwAsBmF2K2eSQ9PCmQBZaV+SJlaf6l9JswnAaiLcyOAfEegKEK4fG8fq9WfOXBfbGmHGYW1lsOgf+vsw04lgPh6gIxi8FPoaTuakHjQCyonEnLJhXA8TNKyOLR6uDgyAVoFwNcDf7vVw6cYX9W4xJMzAFgozDmsLh0V/318E0JEATmLwCQD2zY2QgPikTjmskaR1w4jURFnKaWU4tZUgugzAt4j4qk0v6s2kj1swzDisLQwW/z0DjEV9whEAn8FMJ4CwHNWtAhW0nbABmszUKkiTjMIyaAbdVcyqj9AUmwDUB+EuAJcBuJAIV4/Pwpr1p89MkS0JZs7GFgJLP8bjmwjHADgDwAkA7wGI3bbAjlmxZxbfVSuOAjt+5NND0BcX6CM7eyWNvZtXlHu7jjk0MT0NGg7oVtGY+pepdDWKRVmN3wbCZQRcOHseLn/0FJrADEw7zDisaYTl/7eP9RvoEIBfDOBlDCyvK1tFCIH6smwY17KkK+iiRyyKMncdDT5TEonZ9XeB6J8BfGXpfFx/1/Nnps10wczITwMs/jtejD6fyKBXADiSi5s0B7kOM7WpYZe0L8I3m0eX611ZY1REVdVdZs0mRhltUY2vY8JVBP48ES7dfGZvtaHRDIwQZhzWFMIOH+0f0Gc6G8ALAKyobpSsU7ZI6hSk0SlPgMa5kVPTkEobhVxTh0D66eiQk7IpPZOp5bBSY0cuIJ0UWw4LsJzaHQR8jYFPT76QbsQMTAnMOKwRw9KP8ezJPp7BzH/JRMcBPO4QjCKK6hAB5eoRqjfTui6R2CiirBya7v2fAPAdIvpwj/DDTWfQJkP6DAwJZhzWCOA8Zvyfj2AhAycz8CcADgPKmznbXkPJocm8lhWlGUXqaNAM6pCyeER0y66P0ATqN4HwU4D/gXr4+l/9Idae13M3dmdgcJhxWEOGHf+WF04CrybgbAb2T09IznYmKZpBnclUOJxBnUWOHt0cjpsqhncVq6QwkBoX17xuAvjTPdBnNp9JazEDQ4MZhzUkWPJhXszMZwF4GwNLnMqpiBBE2ahTx6GnfTk0LfXIusifoOnqXIXu9xPh/F4PX9h8+swF+mHAjMMaAM74KuPy/8Gifp/PAuj1APYCOjokUTZKhzQVad0w0suhpIYDjGEOTYv0+lbq8cfHiL5w8mm85mszqWJnmHFYHWHpR3ju5CSfBaY3gLCvrMu6FlXj7NVnO5wRXatKOgvtbHJ4dIluDLmteWQ7bT/1S+3exm6mrXi6NFgJ8EepR1+YPJ02GJrNQAJmHFZL2PFD/dnMdDKDP4AyogIwnNU9hyYzZWnlsDRdhkPKidQGTdmAwXYVhxFF1TTDGOeG5lYiesv4OH9946m9mV3FFjDjsDJhxUe4t3GCf4+Z3gPgWBDGW1/LUXTTtJtVl015pDZkh1TLH6FTG+oYufUTIHwP4HfMmUPXrn/+zOtucmDGYWXA0g/wUgbexeBXAjS3Kh/KxWWxS6hpRj2Zhh5FKZpR38aRw4NLNHSumvGx07rYm1PDqZ/YbUw8IwmiDQA+hx7e3f9DWmX0ZAYEzDisCCz9YH8+9+l1DLwN4MWjmLCxyZTDI1dOF6eWfIawLBvo3i2MfldxUKdW87D4ZJyrmn+cx2oQnU/gCyZP760ztJ0BzDgsE3Y+n3uTxIcz6MMonvVrIDXhhpGyKJotaldR02VEaoOmfbWOwx7nFjxGmBpq3a4i4r8c69E1m2e+FuTBjMNSsNP5PL8PvBvA61B8vmr4E1LQNKmETZNzC0LBZzAeo9pVzEn7TB4to5sUj6Au1CDmc4ggeM9a+mkdWu0qWjQk009aB8IFY2N418SpNBNtCZhxWCXs8gHuberziQB9FKhuUyiC+WIiVIE9IN6ZhPpdShGagkrR1HhJ0WXlzqHJqB/4WhYyoqQEj0E3GLJ5pJx6aEYMIZrtMM4rifCGOXPp0vXPmYm2gBmHBQBYej4vYeYPAfQiAHO7pBvJCTlg2pejx9CuZQX4DLXeoBnJ/V+CJtvpGXKj9ZaMHJq8+g0A/oV6eFP/NLofj3N43Dusnf6Gn8HAJxnYvy7MiSAsusRKHbsO0/nCsKIZJArLoRllpNZpg6EFj2FEqkEZZVmrFD5AE+BxEwH/u386/dCQ/LiBx63D2ulveD6Ac5n5jQBmc/ilbUUxlWldhIZVGqjfq+SllvX1kAY6TbhUlJEzUXS7YaQ9OY5/GJHYMKLZITntHB4DOLVNIPrbHuF9k6c9Pq9tPe4c1oFf7eP+m3EQgz4O4BhZNyU3MSZoRsajLBvq9TBLt1GkdV0ckiE3Wi+OB0r7yrIRX3e8nMCvf+J++O8bDnx8PZf4+OotgPtvplcy0/fBrrMCYMwkFzzb40R9Bs+RQUpuF70y2pAfNLbnkWhDFo8W/U2t0nW95smqvguPHAjIFcfHMOj7N66kV3bgvlXD4ybC2vl9vKjP/AEGvaYo6ZbWFatnl9Sv5B/YJWyd1nVJe0TZSKOMUURRQ45mO/e/LGt1cT1AMwweIHwKwFv4tMfH9xQfFw5r2Xt5XwY+C8ZROddZcoxp4IvHo5hsqmyQ9HMqJuSg17uGkl6n2gd0G4bjz7FDh4dBV+r/IyK8ik+jlQaXxxQ8ph3WGV/t44pf07EMfBHqE1pTfh1G0QyyI5brsFJyglEYEhfgMyd11OFYY5ii6eKwDJ6teYzCYZVlQ9xVvAuEl+NUfK+J+B978Jjt2c7v4dkMnMPFXeuzi1I3HXN2/URax6DygwqBHUMwkruKGTSxB58rykEm5FDTutSk7RIhJeqz9OiyeLRwSEEaas7klKbX8XHeRETv4h4+glMemx/DeEw6rOXv5W36fXyagTMB9KZjIpj1iqaTQ+rCo0uEUJa1Sg07TMhRv1m0lj8Vjr/LGCIRzer6gB5CRh+Er44Rzp48lR4xNNqq4TG3S7js3bys38fFAF6Eqn+BnRrnvBs7M4N6c3M3S8tP7QhZ0GJHLEg/Crkd6JO7ihl8Uu07ncfwfkiYf8cxjNphhnzVpgfGiyYZF+Oi/rIMjbYqeExFWDv/NT+ZGRcCvG+Vgo08rdO7ijrlNHi66Sfss9A2eukYZTlpTUc9Bt7EwODp40heyVweDxRltqAZ6iZGUbYSoDNwGv3CkLhVwmPCYS376z4IOIaZvgKgWVWm4hrCMFLHiG7D4DHU3a4OuuWM0ZYwzlmp4QgdUpRHlzEsyu4B+MUgXI5Tt/6EauvvAQBiegkYF0M6KyA7dYp6bZ2ydEi3vNSwS+owQMo2UMqSMUap1Dd3nFsdqzIZw4bArG+R5popfIp/CNqe3+6XKJYBuBigl3RrvmXBVh1hHfP9Plb+AOcw6HyAZ5spmH4GUKd4MNK61M2ixqtjKEUTTC0bGPStmbqNySNCkx0hZOjxzF2A9ZPANfca7QN8hvFmUUrJidTX6fEwxnkq0s92PDYB9DZsxx/BsVtvnLLVOqxd39sfn5zAOQx6H4BxYNoMwa43aKZ7V3GkqXFZtvMC4M2HAS/dD3jld4Fv3NaOx5R8yn6AMUzRDON6V6sxajeGEyCcC8JHcApNGNK3eNgqXe3e53FvcgLvYKb3gQtnlYLWaZ2GjJQl6f0TPFLPyJkpSQvdg/oNIzUm4GX7Ad85FXjVgcDcceCRzRm6pWCIqXEOT8JgqWOwvaLJ5mnQRM9DXP44GO8D8zvwn/2tcu5nTfYtCXZ/D4+vn8BHGfQ6gHsy7SJQcf5YpXXl1xQIxitiBE5QaV1bPCA3zr+BFpdTXBBZJmv1IvQWRHnosvL4kB2B9z4NOLp8loDLjngOi1XKlnPhydCvahrSi9rWW9BljMpjp39a6VR/9Rjl0AT0CByPg/FXWE+LcQm/Ac/buiKtrcphLX8Xz988gQ8x8FoAjbOqLl6gMqZQzEzC2EhUkZj0JIzRbwvJP0DDhtwaYxIhOznGVPk2M+0RnDhnMmlIObVM5yHn3nZzgHMOBV51ELBwtk/bD0WMEbny0l/2hMwBQw7H9IAxzupchZyKvRy5PFILQ9KxJ8Yg0b8eGK/DBHq4mN+EU7aed2ttNWHh7u/hcTA+xMyvA3Opt20yg6ZOqeNU6jSIXNMOh6G7gOyUJFJ/0h7At08D3nCY7ayiPDL6kyIZxs7rSMZZp/ARvq1Sw1j7bvU9AK8D40O4hLeawGWrUHTv87i3fjM+CuC14QhBpWMdUrZoWxHvO7uKhlwv9TPkNruKLnjqoGnmrO6Z6UWrCEGs/rDqAeyxEHjfkcBz9hR6xOQbenj8DT29tKhlVFU5tFi0mpuidaWp9U+dywjP7EhMp5/iMBGpvhYT1Mel/T/Hib0t/kMXW7zD2u1d/fENfX4HmF4HEmkgAA+vT4jMKVTqB5U26rSudiYBnmiSw2DqxwARCUM00kZPrkvmhfQKYilLTkrS9njBLOAVBwJ/cSiw4/ywXhJmj0Uq2+rRhUdGm1jqFBxH7Uwi9YBw6i37a0aAbVLhgBNTND0QXocNWI2L++/BKb0t+prWFp0SHnNeH5N9nMNMbwe4Vy0ltSuILu8ByGzinGMj7PdW/5QcIx1J2l5CV7O9Tkm6pBsi9SUAv78T8I1TgPceme+sAGCbWbZejn6G3M71lpwuaV6L+uA57JjmtZJhyUmNs61XD6C3g+kcfH+Iio8AtugI6+YJOocJ5X1W1bCrKCkSZQSHfgire+qibSc5RurUKS3KlONEAAbfZfOBtxwOvPwAYLzD0rbtLFXQdpyt1NCgifGonHbyXGmeZeMcGwqm6Km0LrM+BdGIOodv0XAc4PfhIZoA8JG01OmBLdJh7fqOPvpMLwFwPrSOVv7fwQH558uHaP6v6w2aKuOLTbioY0XaMZqpY8zpZcCcMeDUJwDvPAJYvg06vw9uuznpyZR0yhlO3ePfNnWKtCFRH70e1lWukNPJqUXamPxjCoDGAZyPi/ke9PjLeP6Wl4BtkQ6Liw9EfILBs2srSVzodhN2LjH1eXCFsyxnoPrkeMGWAKo+Hm7zr2hC/D2eJg0ljc0fIDEUyIz2Ijzk8f6LgfceBRy7G9DrOPkqWGalj4bcQaOoQSPi4P15ws7M+/OqwUfkHj5Bo3k6XpDS9xHC4h/CDbnNddUAfYHPBvAJTNJdAC43B3MaYYtzoSv+ip/MoK8AWEggYYfS2mQpiTKBl4ZAVTkrGibRWrVVNJqnpKGAXCTk+n0SoGZv7OHiygV6nNiliXkEArBoLvD2pwI/eBFw3O6DOysA2Hl+IzfKjlW97r9ub/RlsNscyrNYnyspWVCwX+7hARppMTE+FChvuEDYlCs31tbjzwE+Bf+FAL6Ci/jJ2MJgi3JYu76DlzHjQjCWJQ1uIAMtwDTyEchNTTgaUG7KIQVpGBgj4KQ9gW+dDrzp94t0cFiw8zaBig7nqtP5binXrG6hR9ApD0NX7fiHMYYhKIQsA3AhLt6yXgK4xTis3d7B23AfXwJ436ZUjLCzI8jiH4syhZf1JMvZpyGrraAnq1zgFJDb6MngoJ4BMKKMJHQw0JP3Ab74XOCJO6DztaoQ7LnQdZTRKCnT8cfapOpNEDZU6GrYWYk3zkLRsKJ37DKTJiI3m6a25ThNFk9mgLEvmL6Ei/uhpWfKYYtwWLu9g2f3J/FpBo51zI6NMFaUVHid3BkpW3EKA2ljIp3MTus4kVqCQEGeoieJSTzo3d1WlLV5EhgbkRXsMBdYMs/XIwTJ/rWNQjX/EI24uGWmfuJcBdOuUHql8OhljojcIL1BQ5rG0CEpt9kxOhZMn8bXOfY8w5TBtDusM77aR7+PNwI4M2Zw2Yu/YeTJayCc5u/xMOSmrrPkpBODBDnhCRmmuePhAQQmYJtZ/oX3nHFuVd/SqSfPYy4EbLXm3yVly5Qb7YMVzQZ4pOQIQWeij3M63fc4ZJh2h3X1z+k4MN5V6xIJtxtDsEJaNwWz0rdWqZ9O6wx6q9xLDwVup5YupEyiU5SlyKWxjtJhzRkD9lmkhEv5XSZ17mRrc2ylfsNI2SJpIMlyRUOZcoMpqsEzqHOMpjZT7gF4Ny7GcZhmmFaHteu5/X2Z8Y+ovxsIZO1y6DeYlWFxE24nUrwI7u36qTQwmvqxSj+zdhXhwDDSvqxdxZLm/vXAQxsyZHQAIuApS1VhhjPJcUixaDYrinLa+GmRmfrVDpeEU/NtlGGkXbXcRDpWSxA6BFJFht3W5GnIDaa3WlZx38lsAP+Ii+Q15qmHaXNYu57Li7hPnwU3X2QOGlqLSasXNV0/6OruHRuTpRWPHLCcWoSPt1iqYz0GKx/soFMmHLKjXZ5KWbzj1Li1jqpGJzd5aSBTj2jTlJwcxx+Qa0LBbDmAz+IiXhShHClMX4TF/AEAR5V4U1yFooGQvP7reaYm7YqmfrG0jkv5sbac4B9IFSVODo3bTSAeIQXBckiZMEqHdeiOwCxtZRnOJOeaYQpii4cbiSbSJYkLuwymdSUeTNm03EA6RimaFD6MXcWKT3N4FIAPYJpgyh3Wgef1setb+ZXM9Jqm1N3BoLrITgntcNtIwVjgAHLSuuSNoIJPiIYCch0a1W8NWalhKorKjDJueiCoxsCw7WwjLSyhtUNKOfbOUZWdOgV39Fi6knDKJjT1cY6kjWo+WLqZaZ0hN5paehICNPoZM8ZrcFH/lbihy8o6GEy5w1q7EQcB+EDrLXxFY05IqHoLWNUHQulUWJ+KYpI8huCQcnTI4fnf9wXeDjokOGq5LRdIpOgZaU2yPmVnifZWG9k2W78Ujy62nAMZ9p7FQ6+5oA/gFhw0iGpdYEod1u5v4/lg+jiAJXXqB8AKews0HpLDoWlStgqnuj5M45RXeEZa14T8mTwN3A3HM6GlweWkl799CHh4U3tVcuEZu8Tv9Wp1XTEF7B+mnVoiZbNwQdM4HDuNal6HZNCE+Fv0OWldgCfHaOqFISKXRdsCXwLmj+Pr3OKFQ4PDlDosZj4XwDFAOhzO2cFwaewUzNz1ozB9KFUMpYGxXUVO7U6yOwrW6t86wpC+MQRi1SUUtzasGuFbvZ+y1H2uUOuR41R0G3kca0so5lg8YrZTNmlhpNOxlriZWmq8Pnd+GjhQWsd56WcoNXbGxEkP6Rj0+VxMIUyZw9rtrfwMZnqjLCsMQRGqtCcZQWTQmBMlZcSc4TAy0g1PRhddU5DDM8J3sg/8/F6MDLadDRy9S6ByVOdB21GiTSoS9YIOdZxlhxEerdK+YdhIFx7W+gt6Iy7mZ2S0HgoMlB7nwm5v5SXMuALg/ZsIp1hXC0ct19gST9GI128UX3yuRpzQzAJCEwyHaepwWb8KhtQraATPIE1rPlX/xIDps0KGPSmaTl8rFmWvPhj44LOM+iHBb9YAP7kXWLMReGgTsHYTcN964O5HgTvL37rq5bwBPVkdSxjsA6vleRA2V5er1xh5NMIu2bBdkyb4FXDx+peWXx/PotGvlzH6G/5CuVHONX4TCEfjFLofI4aRO6y9z+Xe5gl8loGzQpK9CWscJ7/mm+ARnLSpSd22PkDT2uFMcf8P2hG4/CXDea1MF2AGHtwI3PIQ8OsHgZvXACsfAm59CPjdw8C6yZJONhryOGfZYYImNs4p3aPtM2iGMY9yxtApb9blL2AeXoVn00g/ZDHyF/hNTOBEAC+K0ch+1yCdes6xBTk0MR0Ej7o+oIfT3qBJvaQuKL+qZ2VsSoZc8CwauT5KGCNg+7nFm0Uf3QRsOyeixAiBCFg8Fzh8LnD4TkUZM7B+Ali9EVi5BrhuFXDd/cDP7wfuXQ9smkR6nK1xCulQ0ifHWTdU4xx6M2noHGga084yoBX/EGTYqiOs0fFFWM8XAvhmqukgMNL1dNe38Hxi/AzAvrG0LhhK59BQFUqH07Ear6hSKZtBwxTmWagi+Fs0JU/2dGss3TQScvHoKo10asQAxseA398ZeNbuwFOXF6+W2WEehv56mVHBo5uBXz0IXLMK+NHdwOV3AmvFV6ajUYQZQQw3dTJtPYfGertpW91I8LdSy5KGDbmShiOXYJoVWuFEKwE+FKf2RraFMzIT3etc7k1uxgcYqC+0t057FE1WOJ7gEdOjVboRqHdWsLbheIKGYax+mTx23gZ45m7A8XsBx+xWvGH0sQIbJ4Gr7gH+83+A791ZRGOb5SClxkifJ6tNyh4ETefLCy14BPW0dBuFvVdlel0m/luM01vwvNGkhiNzWHu8uX9kH/RtAPV9GoM6pBRNzknOkTM0Q+jAY7CLx009AdhxAXD8nsDznwA8Zeci9dtaoqiusG6icFgX3wZc9Fvgtw8Dm/sY2TiH6nMWT9NOUvXiOLs+oEvMaXv1ETkK1gE4HqfSVWbtgDAS8931zf35PdC3mfnIJl0qQ93ELoRJA7ihrhlui/RNpWlFcYPXH5UI0ATTOhL8LRpS/JXcisZNPwnyLHRy2gInAo7ZHTjjAODYPYAlU3pb35YFGyaA6+4D/v024Mu3FLuTFbhj2C5lq2mU7Q7/Moct19MzQDMVu4p1OTs0V4FwPE4Zfmo4Eoe1+5v5jcz4kDuQjcRQKA2kV6bsUFnTZK5cMd2y9NB8uqxcGTS6fpeFwB/uD/yvg4E9pu1Z+i0XHt0MXHQb8M83A1fdC0yUUVfqfE/37u2g6WWXywdt6p1ydmjehFPpbwPUnWHoDmuPN/FSBn7FwGIrggAGP8kmD0XTKXXModH1AZqhX0MwaHo94Ik7Aq86FHjeE4rrUo/1lG9Q2DwJXL0K+JufFf836istHSbsUJzWKBbgsmwY12ZDugXlAqvR4yfi5N4qs7YjDNW89z2Xexs34eMMfp3nclU4mdqFiIXkdigdSP063Cxat81N60jgZXl0V1HwrHdrWk6EQ5YBf/ZU4Li9gHn6C8szkISJPvDjVcAHfw58905GP2CL3m6gQeOlToBju1k3i0ZsnRI0rh35KZuTuqp56OnfZVeRAG/HsLD7CzBGr8fzh3cBfqgOa4839g9n0A8YPLeZ5IEJOUA47riojqF0Ds2gK1eKR3VxPERj9f/wXYC/OAL4g72m7ybPxxIwA5fcDpx3HfCrNWXhkKOsQS9z1PYe4RHkQ96S3nnOJO1dr8uEDQCeiVPpGkPzTjA0k9/7bf3ZE5vpGwCfULF2oyhfWqdQWpQNwxCy6gegGdSpgYoFbf8lwF88DTjpCcDcLfJ73Vs3PLgReMuPgX+5tbm+VUHKzip8kOthU3pttssCDCR3FQPe5DKM8fPx/N5Q3gcytIefJyfoZADHyrIqqG1A4azKucHZorfwFh+hqPFSDoXouUr4IjyH9gqaQBdL2HYOcN6zgEteCpz2xBlnNQpgZmw/B7jgKODDT2XMr8e4Sfy9V9BonI1ybd8aFzxH+goayT+HZ67c0Gt5nDeU8rHo08kYEgwlwtr9HJ4L4hsA2su7s9zYJdTHsZUplvbV9RGaQVeuVhFUDk1m/ewx4IVPAt5yFLB0i/mM5eMDvn0H8LLvAw9PuOUj2ejJySq6RPwdo/kUTdauoU4PgVtBOBCn0MCfOxk4wjrjqwwinAVgL6s+6RGNqCLZnhP1OfJTPDiDP5erbxsddH/VMaHY+fvKGcAHT5hxVtMBx68AvvgsYHv16dBB3rVf20ni/JtlObaIBI3m0UJuFn9N4E6MvcA4axjfNRzYYV17DRYBeEOonnPe5tjlJf0Cd9KumoedBuakisG0LpAGcobc8PcKm4kwewz48yOAb7wUOHqP0X2ReQZckDZa4SesAD72NMYsPcMjKVvWBx0SePAySotUsXaMXd6kmsJZfFwjlir66eEbcDEGvkNw4CnBzGcBvG/Ic7ufW7Cvlue8fdR746OKWynx1tDiJKpy8UZQstpyQ1O/WZQNGsQ/bBH/aEUB+y8BLnop8LZnAgsfQ8/5bQ1A4rKFxE/fk3DuoXWNbBHEm0+wyVxR2brOubw8MMK/LjHmg+Bj0jhcAnI5Plc9/lb+KN9K2uD7gtQrpjpATpQZhL3fyIsn+/gJiPcqdBLJq76tAYjuMsRyd7O9QZPiYdaLskHz/9Q1Bqt+Vg948SHA254BLH4cP0azpcLmPvCcS4ErxRtZR21nKRkpO3N45PBJ2WoOTWJulnArevz7OLm32qDOgoEirEnms1DkpxFg72+B+mEsRVI/86MVDp9A2iV29yiS1rVKFQO7kK12FRlYMIvx0ecC7z9hxllNJ1gpYYXP6gEffiqwcFYiXRK2W0TziZTNotH2HUgDKYMmqGcmTTK9zZXr7hjuhT6dhQGgc4S195t44eQk/wagJbU7Mu8ad0UM/OxSxPunIqAoTcrh//0AACAASURBVMbqFl11rNXP4FP178nLgI+fDOwf+DLyDGxZ8K5rgQ/9sjnu9LYHRROLxAaK5jNoct/E0CprCPFhp+5+jGFvPJ/WGlonoVOEdd55jP4kXg1giVOhtTcir5SH9OozeMq2ZNGQibYDdq9QWfU5/AnA8/cHvvKiGWe1NcHZTwR2mtccp76baR5bEKAJ2rKuz5UTa2/xGEB3j8YVtgSTeHXXHcNODusf12IhM87W5eZkbRn2eruKOThXcozUzEjHSOAhGs3TTP0CqaLJH4yxHvDHT2VccCpjyQLd1xmYLoilhBXsMp/x4r3rGvEvkfqNImXT6WeAJnqzqJ6HATyafuakilJWnR7y2fg6FqIDdL2GdTLA+4ciKPJKXLygSexgyHIVO5vfiKNGMoPg7sSR4FHxlJq6eLNnqGhS3xkUuN5VnDeLcP6JwDv/gDB7rKSZebXCFgGhXUKNv2q/asJEbFHlcqToJU1tZ15u2LgJc1cRgLeDZeaQLb9XmMlT8zf7qHEGmh1D2h/Mne5+b+2w9nkjzwbjT+qCjqEkq2MJzdZwhG/kOJq6CRqTh1woLNCpX0b/t50D/MMpwMsOxczrX7Zi2Gtb4Gk7NcfZp7JLesWJlG1YcpFODVunjjG59eSkP8El/dkRShNaO6z+JJ4B4LD8FoHwc8Cb2vxwO5DWGSlblEakjbFdRdZ8FE8u8e3nAZ97AeOk/Rg9KtKMKtWYSQm3DMhJCZkZRMBJK+DZLgm8+TeEtE7z7/hcYTStG1KKmnXTrLNjiMMwgdYfYG3tsJj5L8FwPWPAK8dCxaHcLOq09VOzzmldRjrppI0GTwJhp20IX3wRcPSeVKcXRC4+A9MPuSkhAByzHJgzZthlIK0rMBIX6e3UL5SyRVOtUm50Lon0M0bjzCVDbjS1zMWdz9xjNkB/iZbQasbsdQ4fwH38EkAv/6vIUmGXnzcugWOO1A+ydZyrR4qHVb9kAfDFFwEHL39spIF9BtZuBO5bB9z1CHDUiql7dOi+9cDfXgcsXwActhTYb3tgh7nT8+jS2k3Aof8G3L0OaVschq3q+ghNqj5FE5tnnowuPPyyPghPwil0o0FpQruXlTDOBrhXe2WpoPBTulHTW4Z8WyGBypcThmnqt9xJPgInZv/+Lw440ADevH80Qp/iyYRqGV00j/DpF3DprKhOLSx8S46yJvrA9fcAV94BXHkn8OsHCmc11gMuPBU4erep0WPHecBz9wROv6T4eOoO84CDlgBHLQdO2A04eMlgi4I8Dyl84Wxg120Yd69zbbHItJTtanvVtq7sOzUfCjs1+HSRa9CQDDBSPAM0xYzI6G/R0R6AsxF5FllD9mne6w28mBk/B3gFpFoUed1w6dVSXnfgF/1P88ol+7fdPOAzZwBH7WnotBXAA+uAH/0P8J+3At/+LbBavBBEjvNBOwL/cSawbevLpt3h324GXvf94lNe8lys2AY4cXfgeXsCT10GLBjxK6P/+ArgH28uD4Zoi0E7GwaPLvYeoGlVb9FUZYVDuwOEg3FK3uM6+REW84lgWhHJ8OwoixPHpqx2PIgDJ1Go0+Uz7hVNXW/xQBNpzhkDPnYy8PQ9bD22VHh0E3Dt3cD/uxH43m3AA+uLFBCA2V8QcMP9wKevB845fOr0PHWf4gvPb7rC/XjEHY8An7kB+PyvgF23Bf5wb+D1Bxdp4yhgibiB1LTFttFeys5a8mBDr07zroXcVjxI/qcVYD4RwJdzxGVdBXjKeX2A6RXGlBYX7ELAPs5ueeptjkXErMq9XJRdPOdmUY1zd5rxHvD24xjP3q/YTdK7gSF8OmHNBuCCa4Fnfwl4wdeAr95QXKPq+6fAA2bgo9cAN3d+jLU99Ah4+ROBv3m6bbiTDPx2LfDhnwEvv6w4zoHcXcIKFs9J2aLC9StoUvQWjZgz5nwQeD0fu7y2qZ7PAZrU62USutX/nB1DegW+kXeyshzWg2voEABHFsKke5IxpIw5XRdGXGf5ruIt8PAgVfk9IbW7572CpuWOofkKmhJ/7dOAs58a3g3cUnYJmYHb1gBv/y5w6KeAd14O3PSA8u8BqHtd0jy6Gfjz7xT/pwrGCDj7IODvngnMG1OVYvG84i7gO7ePRofxxGlLvegvNwBJgpaT6aCl9ea2aSPX4R+S4SpxJCb5kBzRefssjBcDPL+WXjuCJkcggbs0ro6Cp1uXM/gmDbs/Zr8sVp86VmVk0Jy0P+OcZxZ4FUG1/QH5UVlX/PY1jLd/l3H8F4FP/ZTx8MbqPFV9Acx7zxSNxH98F+P//pSLBXMK+/LyJzI++gzGgvGwbjevSfMB3IUjB5/oG4u2wr3bCNiloegir24jMGhybguy5PrzMkN/I0ghRe/hKbkuz/kAvRgZkHRYB72dxwF+mR4iL9LRoWSNVwqnIxrS5YqerLZ11BT4peoFDcXa6LLyeI/FhA89v3j0poqe2v4qGBX+6GbgI/8FHP9Fwqd/WqSCMWMzJ0sAZyb8/bXAT+8dnf4W3iPCi/cDLngWMFt+70xMhL22S/Mp+sCt8PWT2sarfza9PydqIrecFY3iaeKaZohyCapc0SQ/bJGrW1H+MlzMyWvqSYe17lEcA8byin/0bYUm7q58JHBv5Wb26DXuPdispLqm6JOR3yyPh1G4/Tzgk2cM/i6rUU3uyT5w8U3AMZ8nnH8FsHp9ef7qxSV8Lj2nJWgIcG7qfXgT4bXfKhzhVDotIsJp+wBfPhHYdZumfMlcwoePAp6zRx6ftnDHoygvc4ShGCNFE7G7EI9Y+pWy97o+IDfYXvHQcj0dAxDlL3kWRMvBOCZGDiR2Cfc+h8ETOANVmldLqVRROJO4zaGMQlIJvQjCAPi7LKqe3U6idnyRNjlynWAwVC/kjvWAtx8HHLRMLRwdIXW/Vlv8f9YC77mccMlKLr6zB3kuSnqqOlSNoTjP7NJrGlY0v1lDeNvljL87Hpg9Nty+pPDjd2Nc/gLgmnsIfWYcthRYviCvrfyfi9/xiDAU6fgqw63HtVlmHZqajKpp09BonjlBgSHXpCFRzoq+q9wYz/Zyz8B/8nfw7PDkjTosnuBFAJ1QS2EWzkQ6ChUKOzTkKUjOvVnGYFiGQBH6uk1TGruNIdzhgqaetrqN4H/ygcCZh6hz3BGGPYm/fhPhnd9n3PVwpa1yPCgmCxvluXjzcfOm/Gs3EQ5fznjFk6fGUUl8yVzGc/csdKue+6sdc6RtNf65N45umARueUgaRgJnIHnTpsaZhWHJOeTz5BCfFnLrs6nlav4Bns0SZtMQU+k3kv09Aet5EYDqG9weRFNCYjoC4D2cMsSvcVDdhQpY/K9Wb3bL1UXeggebbQn6VciGKgkgiPMfofGgpN99e+D85wGz9C5VRxhWivToZsI7vwucfTFw99p4Wgcg48MeLn1wcSnxSQbe+UPCD28fbr9GjbeB364F7hM305qpoTZLbWfqOJaEBG2VVb0FgsaSm0xRueEflZHiE9DLgD0AOiLWNOqwmPiMxiCls2n8qldf4o37shVsbS7Bk1yOmNq5s3bzUjuAOcdzxhjveQ5j2znddwRHsUt462rGy7/G+ORP5JgIvBortTh4uFpMSNMneD66mfHH/wn87qGp2zEcBAfaObif3Q+sm4g7+i54alexUDxBE1lgBtpVZKWDpiHFf6BdRZyBCAQd1hP+rL8ITCcUCshQDsJoq2PpTVhUyXbk4qx2/VjRWC/YUzQU2Llr4jy/bNBdwzMOITzrCd13BK1fBV3xK24DTvkS4crfNXFoTRP8VFGO8aSNzZoIdz8C/O9LCQ9vGqxfU4ED8NLDGP7N2+Hbew7Oqjw1hxQNtZFl4PVTgpbclG4pvKSnBE1yV7GAE3AxB79fGHRYfdCRAJb7NZlemyvMXrmbExFY6cuVm6xyGQFo9dg9jEVylKjX7ffaAXjLccUd18OErhOOGfjqfxNediFw7yNa2zycGOnUj5XTUvTWZYJr7gL+7NuE9ZsH6+NU4Lnw8Gbgsjvsuth9hDl2pttkQz3PIBySXS//dwGnbUJOqC9pHrwczEeGdAinhIyTCNxrxEiHgTLqqMpIeMymXN+5ZSoYdLSu1BANW2lei7SPrXpRVr2Ij8B463GM7ecNNxXsms5M9oFP/oTx5ksZGybEYDL8xSEnrdO4pknwZEPuJbcw3nkFMNmf/tRvGCnhhbdUd/X7UaiJaxq9MFjplcZTH2Edolxn4enwumQP79bfHkAnIQCRa1h8QsOIlbBaGzdUZYEDcFNJysP1zaKc0TaS9jFUXUbaJ39V/TP2Jpx0wHBTwa4p4SQDH7sKePd3CRvqx2IiUZRV3sUgIzRUKOeU9xn47C+A//NT8RzEFhBRdUkJN0ww/mllXSNbS0Z2uTVXDLyJkBQN2/SW3OAjcKk01pJr4MP4sEWG3BMQANNh7fOn/X3A2LdKBdzT6xs96XIHT9wsisAHUAVOkdVduhcLYnWetqwqyuOFc4F3nTj8VLCW32KSTfSB919O+OAPq4eUC0cSPwfpjwWYu7/s0zhtlQMLpZbvvRL4zPVUn7YtwVG1TQm/dTtw7X2qUNmLaWuaRtuYoiF1bEKiPouHRVOdn9z6AE9Sx6Z+cT32xcX9fSz2oQjrxIJtvS7CdSS6TDxL6KWKAQlK4daddIirEF/81DHpevWjSN2ZhzL2XTqaVLBNCsMMfOJqxid+bDj4LmlgSxpGWq61qzjJjPOuZHzhl+k+TjUOpJ3aI5uBD15fLgzaKRuRqrcwDCO9KvHaKebwHEBualexlVyNcwZ/phNhgOewDvxz7gF0fKFJpRA7whykrG9Ov/Q2kodYfwKf2PKeJRQ4R9/EkLkj2GFXcckCwmuOHE0q2DYl/OovgPf/gMo716v+N3jsveHNuMdpYruKzcodNtrGUly5GyaAt14O/Ouvpz+iapsSfu5XwA0PaLsWuJ4TCbwZR1GeSus0fS6e0i2V1kX0HFQHCtEX/4/HN9jzT17Bxj4WM+DdvEUGFvSY3rM1xUmIpn5cdSKwciN0Q6mlow0EIyRnn0bC2UcCuwQ3WYcDORPrG78ivPlbwGT1mE09DIZTgaCBS5N1v09qhddySfEP8NzcB17/beCfb9iy0sMY3Lga+OtrAfG+wORzhEjVI6Ne26nVPmLL5CGubmRUZYPun6Grxz+gKwXr6Qj0sVjz9VNCxiEALy1YVc5D4JVz0akf5M2i7E+mhKMwIdWm9v7il0gNWdPDqC/b7LCA8UeHjzYVzElbfnkP462XMjZOQOgv+y5wVuWpVDGHRvPUNJwvd+Mk4y2XMz51PdDfwlPCBzcS/vQKlLuwCUev7J2HsanhzaFMGs3TWNjqpoEAJCkXsSBlGP3lpYUvcsFwWHx0Y3x1Y60ehDQ0Rm2UqxSPBN4oWClM0LuEGmfzJXzNz0wN9S/zZtJXPo2w7dzRpoPJ6yebgNdfTFi9rizPMIzkxXFFH43SOuKxSGvDBHDuD4FP/IzqyGVLSwk394E3XcW45t66QrYQvRLlKq0jRe/hifSKEjRJ/k4Y48ut9Q/M3Sh/DpQrvPUraGQQxDgaCoyL7nSUa+Qun1Ze1VEmsLprnOI0/gvm4Onqhbrs1nnhKvz6nRcCL/29ANGQITSZNvcJb/oP4Kb7Ks0qHROpX2BFlfRJp5XgaTqkDLkV3mfgnVcA511BZQQzvU5LwmQfePt/ARfeErYTAP4cSdUbdhqz1a58pZ/SvkpDLT/CIyYnVe/xSIHL4yhd7Tisff+UFwF8QB0Z1Y6B0HhiVabwYjLJch9yOukc6xPtMcj4JVJFfXz6IUVKOBXpYCht+cK1jK/fAKEbhK56cFw8+QxgB56ahjJoPFzIZWZc8DPGn1wGPLxpy0gJN/UJ7/gx8MkbkJXWpXa7vIWh5e5e6mbR4MLDCZqE3CBNGx1yeQZpcAAu6jtXkB2HxcC+AC1uDJGaGi9RkNGN9ChqxzCwu9ecaLnOFDhx1YkAjX4OsSyLHqufeUNp2WbBHMILDxt9KhhLCW+4l/DhH1QfUogbTDHULQ0pwyBznkNM0lj6q0jr324GXvYNwqoq7Z2mlPDRzcAbrmD8wy8qv8tCV9vemVXqZNEnUj9rDkmcrHLNU8sVNFzhFk2Iv9At7xnAeF8oRI+SvznOWAzQvqKRcliMI1B8QjocxqVuFmXXHaVW2dCqTJSg0QOpwNSf3frQTsyznwjsvkOQ9dBBT6ZNE8AbLwHWbhTjGLjfp9UKr5xHMrWE4ZAyV12Hf8TpVhHED+8ATvh/hB/8T1k+xU7r1oeA518CfPHX7o6g1lwDWTTKrogS9ZYMTWPZKkfaC6WC9eyRBiHsD1DbosU36xjGGBWEs8Hs3LHgXsNiPL3xkITGcxMc5wFVFn2usOlUFDI6FW5YOTF2jon9suQxGC8/vLiNYqrSQZ2qfP5a4Od3Fbqw00eFs7qZM5UGGjSsafRi0mXBETyD+htyb3+Y8eKvA+dfzVizwR2TUeEbJ4Ev3kQ44SLgJ/fCXxgsvK0T77K7lyNX4xbPtnJzFqQuNN3lPl0U6Ivu8inpymlpHKW9SY8Sw+UaJPCMT2xRIJ1sBoHEcbef3lXcZ0fCYbtNXTqoV/3b1wAf/1E1RpFIthojJGgSRl6NglOujKfti/yk3Jp/xMjrvwysnwA++GPCc/8VuOhmQCYsw46urrsXOP0/gD/7AWPV+lpxoR/X/1p/pi6VOlk0nKAP6Fbh1FVuJj0F5FY0zQh3lGvwRPHWmBpqh7Xf63kZgBUFoTQ2xVsbWc3cN3qng8ZK3HyvMLy6k8Bh4G1vBE3RvOT3RvfMYAiqCdRn4P3fIzywztVMTmhZbuL18IgJ6rikjNWvJe7xj+kWkKvt6cb7Ca/4JnDsvwBfvYnwYPmWz0Ed1aZJ4LLfAWd+i3DcvwM/vLP5cGxNqW3K6I1Hw/BtTdtZgK/J32pv0VgQmBPBtqLen+/tIDZGnv6GHEPHFbiYl1UH9Tvd+5PyJq0qMqo4UuFMSNQxldeZqgvYXDgtIo8m1X8pKVmvgr16ZSK3KHpsAQPbzQOefYBaCKYIiAjX3M649NeA0+PyQw9FCTXKVeNcTvpmnEnMDEFD6nwqvPj4hy+3oiFm8bEEm2fzlnifRv6N6qbk/nwV44+/DSzbhnDSnoxn7wEcshNh+znF17aJ4u9tZwAPbSTcsobxzduAb95GuHlN+WGO2plRbSP1HjfL+koviH6gaatovHLl3Nkol9EsG3K1niG5Sf4h/QP91XId/jm6tdGh5En12lYifRwC4FJAOCyGuquUWRhS5YDkMpIbVpKroB4YaxWWna5W4cjApByeCYYTO3gFsOv2Sr0pgGqSfeSHhA2bWfStmbgcm/Qat754k0GT5M9Kt4Sz8ZwiUNwqkNJN950Zdz8CfO6XhM/9krH9PGDvRYQDdmDsvhBYsS1hm9mMOWPAZJ+wdhPj/vXALQ8VjurXDwJ3PRr66AYgjaEY58BHGfTHFBhIfuhB0TAXH7NgwVMGAqbcBE/Jp9Zf0wiexfVZV66micltnJZNUyyeLXjCH2elm+uwzjuP8aVVeIrrmX0/zc4RMledQhlv9a3wchJIiqCxm0YdcFpibKq+O1mrAgJwypOn3lkBABHhyt8CP7q11MRw2kSyn/YKWdEUNuA7eSp5Blfgkia0uFSJn2cHAm++kGL3xdRfrN4AyskWXtRWrwdWbwB+co+iqdWWJ17oRiH9Ufe3Wd1tiNkZ0PiIEA8S/5tIpamQWln1pg6ioZwVyX4Yi7bUIVTv6BDhYcqvz7WoT7VnPKXyI+MA8JX7MBfMezWNRUQFwF1FK8svy8o0sI4B6hWUHLHRCEh3WvqkGA01xObgBtv4xwvnAkftg2lJByf6wAVXWs5Y4OUkrp16IFJJRUheWgeXj7do1M6jGmclV0VdsUWp5q94xvqLUH91tJeQG+ZZ1UOVVTzg0pQLQ9DplzQ56VXI6Yfk5vKkDJqU3GBGlJH25oyhxxMxGt5r7Os0dxLY0AMA7mMbgFbUhE0LA5f1Dc6B8kphgrg0az0zyNVQq/IsPO+ndwTl7+AVhJ0WTu3uYPX773uAH/1WnKxqjGoggcWNjWQ5+zTN6AX4Z+wqmkYu+RtyPf01jb67O0cHc4lO4w7/OkxoCsit9PG2u2z14iDKtVzOmE/y7vwcnhGaptjW2XkWMrWbacgN+4OGZ+44E7Ci38c2QLlLyOClAJbKU+oAy1LDUGqHVNGT3xjqnqEcPGtXsdEktQtjQtnmWfsVX3OeamAGPnM1YfMkEuNI4jyEHZL+G3MMMafl2II634Pc5lDpb9pa1rOpieggxr+iYcB1igrYbQXDruSyGapvDYJPjj2nZMTqo/pnziOHh67LGEMPFE3Fn0FLASwF6ovutL8kYueCe10quJIoqyKkok19LcrZMSz+kbQ7yUocp64h+J10Ryb1qXsqm+iRPmZfTEs6eMca4Pu31JqVpS3wOnSuxpng7CpaNM1ZQnPuXP6pi/vJXUWwuBZl08TSOkd/o7+1/imeRt/dVLriAbcshdc2JIxapTnObpfFp8uuopKb2lVsem7TZO8qZvTX6ktqV9G5ZhrQoWjL+wO4sYwpeC8drbizWnuBqh5NfRCv2jX+2En9jNfFkKRnt639jKH7a/u9wl0XEfbecXrSwW/dRFhT37TYeOtgWpRzRzQCb/6UxpYwEq8tt+Dv9SDTCVg6RPob5J+KRFlrzmoVrf756Ymk4RRNvZbaPHPeLMo586xDypaSO5QbUEuelKDJ+bAFg/YCqhtHmZ4QNgYXsm8SFAo3wmWUVlkVuzT1emCXF6s7G4ag9VSgaOTi8LS93eOpAmbgy9cByZXcKJfu36Ph5gzGnFYq/Yw5JLJoDEfo2YqgISD5rKKpg6ChgFxLg7jT8oF0vbahRHsTNA/Ddj0ZmibRxuLhtWG3PmscAnJT9bUOCR7BtoWdPQFo7sNSX6go108zvC7qins9ypiuri8jmPo+DPI1lEGX7EBMY93Gq/RpYluydX0p9/A9MC3p4K/uBW6+vzqSY1Xg9V/rPIDAiVQolrI15zNCE5DbZlcxdW8WW+VD3FVkg6dvdMKZpnavkMBZ0WueOXxa7sQ1910NWa7mabWtXEVsDFv0JSy38FG9J72WewB2k+FXIVfOeIGzrK+O/VCPKjzwzCDBLm9WUFUe3VX0f+aOoLFDOG8W4cm7TE86eMkN5cc1jAgpx8BydtNSNIPiOa+gMY1c6mn1twrAQ2OSKbexotA4O4iJkyzX9s6c5EOJlI2sOaR5BuZZFG/x7F79NyW3y66i+pvVF48Guy36d+71NhEWAVhonWgbjAkTMKbGaVVElTKMalszmvoFyl3c76vUNNSPqm6HBcAeSwJEI4QNE8B3b5auRWqGekwbI7BpyBj3mibTEVKExtsxDNDE5Gbd5qDK5SpdL29ROxvE8QtQNmSCYWex+hyaYXx0IsYjqz6ugrDFsB4msBsmJPnbPBY+DCwaZ2AJgLmO8DrtM3Z6yqiJyjKqfWsVhVFN47YL9kXIVRVUcwvSeM8qahpLoKjfZykwZxxTnhLesQZYuUrqRQilbMEdMUHTjJLCuzxOk0PTEk/tKoaekcyVK/9aNOFdxWqM63gHDYiFgTo+oyfkpHbEojp0xWt7F058gJQte1dR06Tk1vM9tKuIuX3G4h6AxQDmu2FfLaE8NrxA9qsxqnbk46zKzR3A6lQG+KReMWPVi7KDlk9POvhfvyVsmtBrTgZuRFpUj5HdlgLlbfDWL/IzUl2K0GenrhG5VMltoyeQbcsEu7z61+rNnDlyWdHn0FhyBQ11lVtj6e8q1jwNGtY0hlx7nDEfjCU9MJaAMR42JH162xmc851BY5cwlhJK+tD3Cm0dXYiFwk/aJdJwhHDlbxs8mPJ0ndCaT+JG0GKyDebYmhsFRbm+6KtplJ6mDoLGdHg5TrEK3oLj3HAI2hE3NPLY4qHbyOOYnQKGrYrjlJ3X8iP2noJg/3T/LciQm+xDcJx5nIAl4+DiDnc3iGIndHZ3qirCqqzcMazbUEMjbmIM9qcOlQURufVeDz1dM9oE5O69I6Y8Hdw4AVx/Z6NHoWs5bnXoXDloQk7KxqnUqe0NllziNCBPtJPLOXI13kKufzNtVS/PA1S9KEeEJmdXseMrWZJyKUKfI7dlfwufYfe3WnYYbnlnuSXOwNIeCMualhK4PgHFVSpSJGzjXmgojKLmIf0siS7GaWqcdHnpHCPH3s2kICyaT1i8YOrTwdsfJDzwiNS16k38xFGgHMBQvlfo4cqQvE9/WXgOTQQnQ64VEQ7E3ymvGZZYi2foDJx1Ofs0pGk0HpHr8TdoKCC3sbO4XKpoMvrr4VpuZFcxSKPlVjwJy3pg7FQwCBmbgtYP5bL4XynAbnk9UNyKXjvZiNZm/U4LgW3mmqQjhZX3Aes2y5LhTNBkWscJp8hqQhtyPf6KxuSvaQbcVWwmpIogMmnqv8YanbKhiobEsQSq9XfbtDl2+CO/LiknRZ8DLdqExqhxnHE5pI7B2GkcwCKTGgQ3dGZVV9WTqBflDk2BSi5JqENH0QFpY3W977R8Gvt42cLp2SG84W5Dt1Lz+m8gdWq1Y9hld28Iu4qUSFHZKh/ybmbsOUpnnOsyCB6Cqj5PPk2ju0/TejctJ2VDQ0NMwh9T3SzIP7DwhN5NVtGk3k1W69Y2RY3IjdAv6qF2WDKUrLUoUXY6GQr7qKo0eZBrIIFUkTJoAFVe15Pg4ZZZx8u2m54dwlvuUzqpE1T/NSPelnjLR26AdOqX9b1CyyC9mjCN/pvk3xJ31zu5Yhl4F5oWN20GaRI46XLFM+dZxaHsZlq4phmCXHIdljyJ8uS6kH2dxesUoxlk9spz8cLtVYMQDo1CPZDlO28XbD5SuOU+XUKii5HJyhUW2VXkyg22PU+ChhK7ihZ/QwFmswAAIABJREFUTWPpJlsH5Dr8W/KUNM1SEB5DXVtDTROHnHp3kY/UGzTeK1osGZqvwSOlo6cbq/pI21j/Uu2h+5cYI2ZeNA7m4i53ko0r12WF7GVdHX6TqCfRjhQPQa7FqGOnVbINm/VO3wM8lm0X9XkjAQZw+4OWXmq8a6dcjXNZnvP6FOvNorFdxSHs7mka8xnAlruKsdQ4yLO1XDQLtCwrC2ouAzxn5zjTlruK3EWuxjvIHfauosmzxuKvoKlsmcALewDNLwiFY6mPa9/WKN1I93EG/O8VVjwaXynXP2g88OyhxkmWZzwzKH9U0izZZurTwQfXETZOiLS19Y2ODU0wiqJIhFFjabmtdy11hBRpW9NE+lv/DdAQ7PIsmsZkEbLl1qlQl1fQWHJVOcXoLZ5KT4q1RWRXMVNuzT8wPo7+miZDbmHLDIDm9wAUDivkIT2Q17JsI3NTlooJ23jODaWZu4SulnEgFJ/1mmq4+yFXB41ph9SMY9qBuectTZOzq+itui3kmvwNmphc0+kKuTnjE6ORS6FlTnLJtSopVi/4R2m0XHFM6tjS0+SfaJNFU9thQA+lQ4pHTE6qvoT546gdFsTZqZ4RVKG2IGrqSdSXEYOXKirh5HL0RjtqJQ0UPAKjqHkYPBfOw5SnhKvXGYV16GyNd4lrGnFTbtauYgueksZ7BlDxSX6vsOuuYmpMhpYqVvUQtA1OAmOLJidl4/iXhKL8hWNNPatoya0OCcYraNrwbyM3RdNFh6Iv83sAZpclcEHeLCpme05IV3vVyolVwiseBV6rmfMKmuiOIcFKA1NvHt1u3tSnhA+td/UIpk6Zr09pzgyJ0zAAz0DU7J5/wyATenaRm8RFJJqiD49z/ac8VrZcYyJ1MmhI0YfmRIiG9UcZUvMs8cygJYus8rYpauR1OiRpDJ4keXZ7Bc3sHjXvdRfl8kRrcI2gPolBQ5GRmY/XqR8rGubGSAJtwa4/toItX/8G5s6KVI4I1pafXHd1jU96CpSDHbfv04gJbZ9RKnmkJ33MaXn8VV+IW/JXOIHEi2kjDkmPYYReW0bQTripN08R4NtdwA5jtkrqvwW5PGIwklfQsEtjAqfrg/wbGB+H+Pqzy12cIie8ZsibRd03iwq8pHFe9EeGiAiYu4pwy7yUMCVHHM8aw5SnhJsmQzWEUe6IRW+k7JLWKTz1vUKTJ/L7G3x1TAu5VOnpyK14yPMAVV/wqEjMZ+gYIGqW15E83zeqZxWnS67GU3IZ4z0wen7YB1QJbx2m6XuzcnYImMXKKv0nNZ2S5R1eL2M9I+g9RxionzU29Snh5klfD9nTBiKGkaIxDDKHf5Qm9TgNqp7EaVoZsJBb88+JRCM0BapsGY0tp56z88rb0HCE/wA8vXJrLqZ4ej4gQ27OmBg8HZ/RTm6v1zBQJ1c4N/f02sYUNhTxZlHrIxQpnFM0SN9gF6gfH4s1Gg3YEV1kQnOFpbfoawo2aBA/T7nXgRwdNJ+A3CB9in8u3kIumfRw54Z1bEGKJlDfxVad9hFd/fnq62HSqPqQbrn1JI7bQkz/cQB9oIyySFCo0Ll5+2hFWNbXa18kPYwBNwte7ZuMEXE4aV27AAOTfYCm+OOps7STdEJnFmFx1S8DH4Cm/juM9NOQm/xe4YC7igT46aegyfleofsm3YoHarzWpmOa02gKoTtE/xI8h5FeaVzxNHcMR7GrGOiLsytq0BCZ/Ps9gCfKVnCBGwGmF3FDSdblFZ7zPGAdqQVo9K4iFM+aR/xYp4abJ6Y+JZw1ZunmnBQ/AlBvIgAUvaIxjafk2ZxJWy614RmgoRi9JbdlXyhXtyy5gLZlrzxGk3NzaZdn8TLkUoq+bcomdEu9WZQVfSu5Jc+o/qis1ZE70QNooqikwPNdCg0YSnjHkOv/4beG5uP+rqKrjTZFB9itD18AHx3MM3cmOzikwKRvxsB2WtFdxUy5OW0dGmUTyV1FoNv3CjV/zxbVOOs12qI0aDw7M9b6qB2m2lsyAu0dPdmtj+lZjHGYMcEOU6L8DR5aboDUBzbrJ8YBbPII67TPCNnrbpCJF6FkGdNJHqX0mpMeDc6r9/vODsq6jdHrCjZNIHBNaXSwqLq73usfIbrbhQav/6aeAYzs7jW7t77cJhy32+akk6kbQVO7iizbGjRZzypa+ntyKx4Q9RDtDHwYr09hgCieFjn0OTwz5eamqM6i0pYnMmhS/VU0BGzqAVjXcJTkYkbpj1AEwsSiVId3ci2gRrRR7r2CRuNcucSGPrkjqH+CfsPmqU8Jt58vdHR2RZvRr//qKCoSMUQnlyyvV664kUtNLLmeMesoKietsyaC4G/SGBqE+pu3qwgkUzYvmpdzJUCTStksuRq35llKN8VTzkyLZtBX0HAGTRDPGGdXf17XA2Nd3NhcSBlK5TY8ZcCiUywUZoemclmxXUWWPDWwa5oWWbWr+OCjoV6ODpYsKHVwNSr+ifMQm6zOWUil6BafTEfY2mkZOsTkxt4saupp8O/02mZPrg1pijSNU2+Ya6v6XKU0ucHXoUnVWzy4qQuqxKo+ICfGQ9Yx07pxgNf55GyEziw0J1FfsazaFHiRGpI/GipYS4KmZ7i7inVq4NMkzgMeXIcpTwmXbQf0COh7cqsxRD2GAOzzgIIm+JxgzqtgNJ6Sm6LvKHfgXcWEbvKvRdPUQ9RD1Vd6CZra5tSinaIJLAyxXcXGlrvJNfuo+RhyHZ6W/sPYVUzJLXmWi9+6HoHWNi0lSC/ATeO61AjpAPihYcWDHJwC5bmvl6m41MepXUGjfs2jU58SzhknLN2WmjHostul8Uik0nlX0cKNaCaaWmbIbSykndw2faEoTalFji0HUhjKoInxHMYraJI0CZ4U0G3UcrNpCnxtD8Ca4tg9oe4phVOn/8py3/tXQlkoIPCcHcPI62XMRcDXyoTV05ASAsDuixvcHMd6Ycjc7TLKc3YVpUs3aTjhkOrTEpZrfq9Q669pIv219IyNYdJe5ZFes0VdzI58bu3aJ/mzUS90JaBbWmcShavMaq1Hgj3JqRwSb8VOBbKmcVhuRdG8vshHcBxGyb5ZmUQ9N3jQ7TmRWkBB6yQZPLgirt+r5f5YHqv6ux9iME/9b7+lSs96nH0nzVVnU9f/BnD8HKLPkZuDRxacqtzTwVmUuJyQ7XhKGg72BWgV3SUctIln3pLSSm6HaNbDFU/i0fY3+XqcpFySDoudfwV7QlNIXr3gFsSbC/DS/xp4gKZZ/SNtQz/rOUPxu3P11KeERIT9lmldIPrj4nIUYjSdjZwV/w43gnoXvjPlepMlRm/hLeRSiKZ2hNVxhl2PaFdxULkUkFvhTZQ1oFyNC57NAuvTRL9FWEKCZk0PwN0VaQPkYrKdMpSmPmT08ZXb+ahEhKYZBHcFzQpDZYHoy90PARPTcPPoE3cCxoRSwdQvtTK3cEh1jeUYUk6LldMy5CqL8XBz1zKlv+pv1vcK2+pgUbKq4FCLQHsLNE+rfUCu5N9aB4OHB6zqI7oSDD0t/hFFg1XJcea7ewCv8k9QFVFVDoPghtPw63W5uZLZCtaUGSfV5xFP+6pjMspWP8JYv2nqU8K9ljAWzde6QugGr7yzUxd4Dk02bqRX+a+1riwzr+8puXoRi9GzRwPHQQ8azUYXBs1/GI81GYtcynFnbVK0jJCz0t4uqbFDT6t6AK0qnIXyFs0eJZpXITsELl7bgDSEol1zkisecq0ocA7RsE0vcWc3sOYROS7L7nuY8MjGqU8JFy8g7LkDqb4YJ0jhxXnKp6/xHMPQUZTGh5SypSZsSm4zYhGHkJrUCVtuFk9ty1WJsn1FU5ezomHBP8SndvwBnjlyDZ7mfI3RWH3JoYHSP4LHxpkkfUOzqgfgfgATROET6k4rYQRZRqxW96axg1OgvOlcYAU1dbSB1MH6TcDvHkg0GgEQAUfsWR6IyVS5X0HpTT5zstaG0mJXMUDTLC52W8cOUg4pB1fRTWpXMSvKiOC1neibo9V6bdoSKztjiyhhh6xoOvDo+jolybvVXOlab/VN9D/QdZMeoAkQ7u9R4bDWOf6hiqhqD1qyd7wh2Tircq2VdZxDg9BJZpg7hDnHzFh59/TsFD5jH5GmirSFnXH2nXS926VpNN5xd48DchGSm0r9UvQWniO3bX+DcoEuUSWFaFhR5DhTTTOKXcXp2s0cQK66y2BdD7i/R4TVADY070+vuYjGrBQSSFkfDnOp7kRzEqWPJ1R3usqAHxqPporNz0sP5U+nhkT49T3Ts1O4/zLCTgv1WhdJecyTqE56iiYVRXE6wmvFP0STMvIcPMeZZMkViLMgl3iAhqHKFe7Mp1SqaOCc0o0H+15hwy5MQxYNZ+jP8iJSeAyj+ntysYGA1b3ZC7AGwNqgEXpo21UnY0VU99wUqL0i+qmlISp0rDQGAzfeqc7rFMGiecDhe1RHGauuchiUoOnsGNhZWvJ0a7mrmLNjmPXwdCQiICBvVzHDZiwaZ6kJ1KdAL1deXcouE7on+SfYpyDYf1GXkhHjodqvnT8La3rXf4z6AG6vCZyIioUhCYehyvybRSvp0qgTHchxGppHrav4sdoRNOrl8e0PMB54ZOpTQoDxnIOkXrIv1Tgrh61oYvhAu4oZclNfQ2qdKho0yZtFc/Ck3GI0HFw7ejJoaqx7NJuMGEnxH8GuYqeFQeCEgA5q8cjqr8bZ4Xz7mtOoX74gmG+RJ7AO5epOCtyprzmLQxb/ZDvZNRKdIo+mOcl+W+8VNImbQ616mTY+tI7wm1XTkxY+a1/CgtkZJy4RzVj09chbdy8PA6+jiLgxx/i0vkCfw7/lbmYxRob9ohrFsihzB7AhUfQ5NCmeFp4jV+OKhhJyB30FTTaN1V+udMAtAFC90fxmfdK9SEgZferVII1XtVc7c4UWK2j+rqINjv6ajJv6yT7ws9uCbEYK82cDz3sSEFz9EpM+trKRrNU7wILGXP1E20F2FT2nYqz2yQglsTKnXi+Td5uDfygmij8XciFsnlHRmoejg8HT01HRJHcVLR0MHq3aKx6x+lT/SqKbgdJhEehWl5DBIs0r2EmHoctKvDQEcsptMBY05zhYDz14lSMUP31s/QTNdbdNz04hM+MPD2XMGrOdej2mqVSIVfmwdve43LWM0Ay6q8iJlI1DurWVq3Fnte8Yhdb2XrG1+UTTuqQzDct1eCb052HI1fgwbhbN7S/zrUAdYfFNpueohbESVhmSoEUAD+zuNSdR+tcGp0yahq/Ac45r/oQf/4Yw0Z+etPD3diM8YcfIyQKCEVL0RHeZgIZcOdoWTU5aF6MZGv9IfwmITGogeKNjzu7eAGlOcN4YcjlGnymXIvQckCtpzGcVBQ0F5Cbx+rpqjJ5uAiqHxVgFQDyi47gCBY0RmMbELo25wpUrYsGjwwrq4K54rWk0FOai/uENwLW3xghHB7PHgbOeFnc2ZvrNFZbeMQymdc5Fze6rn5O6Bp5VHGTVzdlVTEUiplPU5qPXbIt7gMaxM2Pt92hUfcpWvbTO0MNrn+qfqjd1iIxJkCbS/9Q42zJ5FQGrgNJhjY3TI0R0h9uCAFT3g1DDpXYuRRnpeoVnKRihCRpKPQiVk6scmfjlpIpl2Q9/PX1p4YkHMnbdHkIvCF0h9FQ0Go84fkrwCd5OMqxdxYT+3jXLlruKFKJJ4oX0+m/iVgkkaDRP3TZ7o8HimeHoh36PnJJrvoKGWvBPyLUXV7qDCI8ApcN6wXJsAOPWhrBCqsYCr2nkCVe44iFjskZBgcPAxc2lwV1FruI8EjzVLzM1vPqW4jPy05EWLpoHvPAp8gRB9Lcp94wh56HdzF2z5gx3MLZMuVH+Q9jNTDkcWy6E4ysLHPt1cQqUF3WROVFjLIoETVZalMefZLnuS0BulEbhZNG3TY0j9OTT3Pr2M3gDUDqs884jALiuaSMngg4TjRMf9ezliYikdc2NCokV1KIxJGrw++DDjXcCv7s/QTQiICK86sjqAxUkKxo0cyWP0ZgOoxxOZ1cx8qhEUR/mn73C16dR9JHiu4o0YARBCOwq2mgDbBXaNKSOg/U5cgy5ni0rmuTNphlyU3MlJaexkXC9ScMujcCvO69XXL3qicLr3ZYkOBDcEB3wbhZVqaJv1HEFPaceIvV4VJZf/gJpH8domLFpkvGdG6YvLdxmLnD2Ucoxd9ndy6HRPC08IJdjPDN2FaNyuboEEe6LuauY2kUVOHs8gazoblTP2Rl8Wu0qBuQ6jlsvDF1eQZMjV/cgs78Zcmvf1DisnnRYgJsGyjeH1gQO3jgcaQgND/dVewKv07rK8SmastPRHcP6ZtLmGHB/pI41DTHh0l8Q+jz1KWEFL/l9wh47iJOFAB446UkjHPZE0Dilo5xUX8y2goYsmtQuqpg4jZ2JJsqWm8nGQRoTz0zrKFDe/EvLGvgVNKxoLLmapi3PHDyjv72e4bBu+AzdQ0B54d0OCj1D0a8GUZRSaOpG0Cge3VVsJNpaa/3DcPM9wK/vThCNACqntWg+8KbjqTkpgQnXekKHVu8UjV4htQ6GY0jy55itlJwDcms8Ebm031WEoEkf12Os6y2I8IjRpOpbyzUg9oZTcz4p+qz5ZumhxyA2zoQ7Nr6Q7qmqeor0qqYFCcdAgqsqY1Ufeb0MwRikiMIW+FWlY6udmnscrDdoNmxifPP66UkJAYCZccITGUfvI/pV9TG1SyjSnNTNlhTiyYpnRG5qV7EVT4Mmuas4oFz3014Rp6zKvcUj59UoHdOraKSq0zqL56Bv+AzIjfI0Fo8Yz4xo/CpR4DmsK5smldMSeK2QgyRwEp2oeEj/7eIUoqnvGSKDZ+AX2CH00kPx+9drCZNTfBNpBUSE2ePAec8D5o6nI5VQ5JGKkBwazb/LrqIxEaITNkOuwx+I9jd7cim8iQCEzQ4xzaEMmqBci34YummcM/TMSTkDfWEYzyoKvP6rabguv1I00g6LriZgUyzYMw1drTruE+DG6mitmioCiKWQ7urui5KuLtwPpV4J964BvndjovGQQTutfZYCbzgW6EWcTTPZWjikFpPeWb01PSdWzszVPra6OruWIZrUriISeqoSDbUdKRuRx2Z9QGKQvwX1OMeZOUXs13kyDBpO1HtyVf9TO4Yi3PF4mDo2sAmEq2WB47Coh5UAVjciKscgu1U6l+CzhtTQOAarOhLpZE0eMRS3UPxSu4bWLqL6fe0axmR/elLCCj/rSMYRe4adutv3CM0IdxU5RsPDeQUNJ+gH31WE6UxrHAFc03j2PgSeGrd22TLkckquxhVP82ZRS7dAX1KLa6QvqwlYKQhdh3XjZ2gNgBub1lVj10dSw72pZ4E7NCRaKZxVuXWzqG4rdhULHtJHU13GuixWr3j8aCXhN/dOT0pYwfzZhPeeAiye34xm0/9qVOKG1IyRTaPPqMZT12GSEQzHI6QsHRIT3+SvJotHI8aZxV9Z4q2YkbSIQjRs02ueznwy5GptQ3xCNN58VfRmypaTooryrA9PRMaHLXrgxk0vpjWyQF/DAoAfmQFa51Unsbpr3LwwatM47ytS4ISakXoLNmwC/vGKQOUIIOS0nrCU8L6TgR4BA632iQkdo8nZVUzJbeU4DRpPB+20WvRX6+nYSQCCdYZdRdtn0idB8fH013I43j8Aea+PMfi2OragtjNfBhF+pMkNh8VXFF6XhPMgwHEe1equ6hVO1jAN0KlwZfhXpxSpXUNVf+kvGHeunr6UsMKf+yTg1U9n9GqLMpy6Mw4uTewjtM15ii8mpHgixDNAk0rrQnLb0HTfVSxGwHSmXaJZ2DSN08pYGFpe7zN10E58CK+CyerjgHKdcWb2wgbfYRFdD9AqBovQmZVytWY+7oV9VTuxDui0DoqGG1z+9fjI9THjmUHvF2nz4KOEf/3J9KWEFU4E/OVxVN7qUPXZxake0zCNiUcmIBTPQXYVUxGSRzOlu4o1cRRnBG4oFfYefDNnZnpFspxtGktuszDEaRz+Fs8EzpZchZNVXjvlfFkAVhGRezM7DIfVG8NqAFcHY0h9s2jK6EOrZubqXuOB1T2kptSkWd0yaAR87gfAg48mBAwBUk5r/mzg788kHLxLXdNq9cveVeyw6jr8QzpwxOHlOM7SzlJ9bEoiTsuUW5ZY9lFPNp+DBc7HrHVd1T5Qn+IfstNgveGXW/Pn/PocIATG2aPhq0tf5IDnsG74FPUB/jYxFWF0fdKl81Bl3s2iAi+bJDum6zlcpQmjN4umdgXLeit1XLue8fkfTG9KWOHbz2dc8GJgxSIxmC129ziDxuRp4dZuXYynhafkGosYJ2gKcw3LJVgftgBCDnqnBYT3PwvYdnaYJtfhNuICzrRDVOnJtXimdJvCZySd+Csll/Dt9S+kPhRYF91BwKUMBmemhLFQr1lZpX+mWmF21mdFU6+XJCIAQZPzOpnyR7H6SPr4lf8i3PvQ9KWEEt9tMfCZlxGWLWxGvoH4RKhGwaMXeP03x2h1W1b8O1yH8aJ11ZfUvWXFytx2AkI4vpohdtkG+NLJjNccAnzsD4A5Y4qGXXoPD6ROrZ4B5EB5B7kOz8RuJkl6g2cTJdo0qdTY42/hhEthgOmwbvxc7xYirCQARPr0xh1Q4wQa2ppS6lUekzrW9UloQUPqWAIF6h94BPjU99U5GzK0cVoHLgc+93LCzobTykn9slbIAH0y9UPCKWqnZcgdiH9Cf+lc/QW4oThgB+AbZwCH7lSUnbYf8O6jgDG7SQHCPoJk2g4j9TGa6JwBos8IJvkHeLYFc76n6pvjlZte0rvFamc6rJLlZVzdmOd4U9v7u89msfMvZ2WQH++KrhgWTw47nGHA164BfnXXcHkOAgctBz7/R8Dy7eBmPRVoa8yZTLo+YvQpY7TaZEPGIpbqQ45c19YIxf1OjOP3JPzr6Yzdt2MQUZ2av+Jgwh8fWgx24TS5aVvh3ODNLrqi0bim4QJng6dsSwG5FQ2l5GqcFX/Nkw3+OTQRuWSVF4HPZaHzFnFY+BYR9an0BO7qCljOwwz12FIofcIsGjJp2kFqdbOOH9kAfORbrUVlAwunnIMDjAOXA/90FrDHDsYCwEg7erXgJFMJiybA01x4pkBulI+iYfb1fPUhwGefy1g6v6GpYJwY73g68Jy9E3KFbqnUKTYmjm45/TVooumnpjdoYs8AArEgJSGXFb1L0wc4ONuCDouIrwJgxBWh1K8K9smnqWsitzIIGqs8zN/WLuaAzGOLo6D53g3Ad2+w2wwKbVJCie+3E3Dh2YSn7QlxHqrRiqdLZJVzhWXuKlr8Ux+2gHuWTRruoH/L3WtpZ4vnAv9wIuH9xxLmz4JzbVHis8YIF5xAOHIXYc3K/nPtOWXnSRp2Z1T+fBH8ueV8NG5RIgq3bUa5VR/vIiLnDQ0Sgg7rxs/21gB8WRUANelhxkrmXfBpt/qh3p2MtNUycoKtekIG2nCgrIQPXTI1tzm0gWULgc++HDjlYPcaizkcVv9T49bR8Sd5TpdcdXzwUsaFpxNeeECR+sk00MK3nc34xLMJ+y6uOqGyAStLCGQVxXQN8MnJPDrKlTTcVq7Fs4PcCmef5rKNL3Ufx5EQSwkB4MI6JSQqLsBbsz16g1rHTqZORt1JH4IOKRPkGiBh5T3AJ7/XjWcM2qaEGt92LvDRFwBvfTZjznhdE3X6HCj3Fo8In2acE/QWT1RjHKYhRe/hqd2ugFwAGOsxXnUIcNGZwME7tRvzFdsyPvccYNEcwEyLLDwwJk2xzad1WqdwDsit+ScCEE7IZUVv0TTRut8XxwaK4wsRgajDItDVAN0my6orSbFQM4Wn2tZ/s16X7CiXBlYtrTa6TBz/0xXANb/JkNMCuqaEEp81Brz2aMKXXwnssYMYpXLMarBuc4jQuGMcoA/QDHr3ejPuttycHUPrLvt9tge+fBrhA8cBC+c0qY2VBobwA5YQPv9cEvdo2bY60FzQ74AL8InNx1TKBqC8wO7Lrawoyt+bj+1x8fc2wH2djIaowxrvYQ2Ay4oAR6SEtQONRFnOpDdWP6ONuWKk7mcJOJcch9QlEtuwGXj3vwPrN+W3mUo4fHfg384GzjgMGB8bnJ8VaZr1gXEmdRyt7yI/Q0YFC2YBrzkM+NZLgD/Ys6zOSAND+DN2ZfzNMwmzxwIZQAovswpCZOdR4x1SNo9/l13FhFxvV9Hin95VvKxHCKaDABA16Xt/+m7seOh5G0D4IyclpEowhHJ+OVGcph5EivOB5kNV67Kz2qrJ+WdbfUYZkV13/8PA5kng6fsqmo5QTYJh4QvmAMftz9h/J8J1twMPb2ChqByzylhQjrGgqXjWxh6myeETk0tteSo+rHkqPgTCU1cAHz+J8UcHE+bNcsdtEDhwSSHqiuozxIYtkjr2IGC/bdrwEHh4wzEMXclAwzRv3fDyXvQb7MkzdtCreXyyz78DaLkyX39RDXSQYzT/v71zj7OrqvL8d92qBFJiCGkgqDwUQ6QSRGgcpVXABzgwfpT5tGam1dFgFeGpn7QIk1akgWkfbdsj3eOYHnl19KM0Ld0fFbt9gCIg4huQZ0xjEIxJCCHvFJWictf8ce+595x99uucc289krv4UNln799Z+3H3WXv9zt5nbzPdwOTYh4ExWUO0DjPdg7GVv78P/u8SeOsrLWWbQrL9Objmh3DTL2FX2is0O7wlLvhQGBifjlAfyGBK6HA9sPPnwiV/Au9a6B5cqg4SICz7AXzlkdYn0lgNaxLvMOKtex0GWn1GPDXAWAeGJkYNnbaBwVlOQCMGJLXkm8bkBpiGznX9fRy1670yjkeCpGHjfVfXDznxqnmq+vokTpJMNWZENK8jRt3IkTk/RFDs2oyzpLsehLrC/U82DNbsWRa9U0T2mwGnzoczjoX122DtVtiTdljTEjOimnFd8hiKXksqTgRe8kL4izfKcm5XAAAgAElEQVTAp94CJ704bKySl+plwqCcegQ8skn47ZYSjKGJEYx4yYaFgJ6IfMWHj9FZMt+wTv7hufeJc8EobWRYBof0BODHoAPJbe3xIFmVay+Q2iqQwmhEJf2Y1DyKp1NX8bJ8HuCfHANfOr/a+6JOU0JXeE8dfrxGWXG3cM+aZq1DI3NqYAhhfB5EaNQNehDukbmFURFedAAsPUl5z/HCHw2E2yS7ILeaPDMCi78BD9pOEJd2TdLXznQHxstqInSEqGMpTzvyOXLnISMIr9/9/vx2MqZEGayTzlNGxrkd9HSTEqbDXs1mY3ejIWPyNTGhjhSBOedU+NjZ0B9aJDKF5N41cP1P4O7HYTTlhFcx7JXSIzG2flITeOU8WHIiLF5EamlHnHRyYPjDDuHMW+APO90G2kn9JoLWRQwMMQNMaPAIlTNDLUW//0cHyRnr3h42R1EGC2BwqP4elK82smlTQrEVPlPBxrV15wfLCJnBmJV0UsV2TK5WXsuexYQeJpfB7e+DTy6Gd73GqPYUl/E6PP4MfPln8L1VsHFng+oCpQ1SC9OFdk7fL8BBs+C0lzUM1YkvglkzLPoC0glKaIZ/vh7e+y3h2dGCzEOSRzyPT2PUoZOUzhBGLfmm8WrJ14uxsCh1lC3BqLTa7b2j75ebiJDox2vRUH1uHfk16OENC56lhI2iuCsZTf0K/wBto+U0WCm7GGXUzPTmtc9Tmz0Lrh2G17zcBIVloiihL7x5l3Ln48It98NPfqc8X7cPMJmXypIPJ9My2sSbo3dmcCvhQfTVlNccIbxzEbzpZcrhB1areycpYVq+9Tic9z3YvcdICBn2CKMeOyNYhdGE+ntVT7xdDlkrNV41+j7JbdZnk0L+wOCQXqPw55aunK+cs4CBEnhG2CQ/l47SI7clnzIewkEvgH/+IMw/zHLvNJJnd8Htv4HbVsGv18GG7c2EAh0WLL9TExPzO6Qxhx4Af/wSOP3lcPp8eNELo6oRLd0aDFY+JFz6Q6g7WIVzG/IIWpcMDabOaOpnDgwxGGPwCM4YxmBE/250Se3DREohg7VwWBfWVX8tKv05Suiih6FKlmiIPAVtB6MpiwcTnW7BzD8M/vE8eMlBTHupK2x7Dn6zEe59Au5bC6s2wpbnGu+91PgJgMrtPGsGHLg/LDgEXnsEvO4oWHBwg/7VCvXWOOkGJUzCdVWuvAdW3N8Ityobon4donXgx4QoW45+xmAMneqZcAMdR+RVo0sk+ujiwl1gcKj+HZAzbZQw10Au7mpWLBVuVT70A7R0pg1jQ8p6SJ0yeie9tEEPD3qBpQwWmQqUMDY8Mib8fis8uUX5zUZhzWZYu1VZu014egfs3mN4EJYBCZSZ/cLBL2hs+XzUHOGlc+EVhyhHzxWOnAMDMyamXt2ihIncvxHO/lfY+byRIAVpn4mLGDxz3mwJHV2ZDGuF5buj58hZZhF9UthgLRzS0xX+HZhp8ZXaBQzkEpwaDTVC4AftmNEyMZEd5cSj4MalcOCApQx7qag2PK8tI7BzDMb2wNh4w1Ob2Q8z+xoe1NwBOGBmynGeZOmWQbzvaeHPvgmbnnPTusYgDIUpm0kbu0XrxKCWUYwnAiM6JsjbnjtHvk8BKTgBDP393P38OPcpnJxwgtYiUhddcy0wTYdNjBqNq2R/jAzeITbKEilpn7CwTm0sKv3Ql+Hv3xfvaU13EWkYpFkHTnZJ4qVblPCup4Tzv6c8+xxke5MjrAarUG12bwdrSb5DlBS1dOhP/zUxyWLUFrNRg/0oOYaU068O6ucP3yfC3RSUwiuHHrxWxoAvJI2VGJVMuIVW2gYnFUZbBi6DaYbtO4tq60dqYUx3Psa7NzGea3Fh0mmefH+0Gj78Vdg2EihSqh698PQPf/u3MPRt5Znm7x61M6cH0+6HFkyin3y8T2eq0EFMbgsaA5/+G5uvwBdGlkjhLQRKLnXUWwVWtd9ctXdkT8LJVfvRzoZjdkqM27bCLJqR4jAmXoNjM1qOPJzOVhNz1yp4/xfhme0uIC0a0QtPfDj9b9XwHoUvPiCc82/C1lEw+7PvWQg9L62wGPHmFjRqvzdav+d59JbfkW8a045nlcCtlJBSBuu/H8l24LrEOjeMZz7cFscIELDcIYyYqg1xGi3TqMWIQ4etyGb+D/4ePnA9PGn7ZKMnkyppSlglPPK8csVdwl/erexJmEGIVWgkxsdUmmHFHh+brxTN18OQXBhp5itw3f9cgmcId0uhZzYtC5fqbN2jvwU5uOyMYWhat4XxfDzZXotir5Va4opinOnNODWuXTpedgj8n/fBcYdnIVNlBnBfDHdilnDrbrjou3D7E5A7+ROi+wikHKSCOqouBLXqSGF85SpWDtkk6MtHPlArZbBKf/326HWyHfh0o0x5SpiEfbQu/deHaR902ozP7IhokcTQY2/jFoZ4jPU6dL8hTzwD714Btz2c+gSGNi3phSc+XJUG/mI9vPUm4bY10nxfbe/P3mfBeaan5VmI1W/RmSubgZFQ+SMOrfDXRRD4dFljBRUMFkCtxkpgjYsStuxGehQzKR5Jdd0YMfB5DE5j0tIfM5CWGWxD78yM9J2jsOwrcMNd8Lx355+eTISUpYHjdWXlr5V3f11YszVFzWyUrcB5hc6dP5v0KnRohXdnUfXoLxs2qaWXTrIGdGWV36uSwXrk+tpm4PPgnjGk5Ya7K6k2PhzNmSt4SJFelhjXLgkZrURGx+Cv/w0uuxl27WbSZ7V64WLhbaPKZd+Hy34AW0eTURpaAU2FkwgTY4Zj3uea+Jh8LeGqs4q58woNjOdgjs+PfKAW9c2gS2LZjFMWnqtztM7PFBYkyhJz0pLEBgVyD66qDaVb4pJ8xZWeilPj2pZPCONLt9V/wTz47Lvh+CMs5erJhEiRd153Pwkf+b6yZqvR2yUVBtw7i6QwRT9Bsy0WdWCcZbDpiczXrIt3Qankyrm6r6av3XlOzbtne0gq7+C08AzdCnqNjxImp481xG7d1RZvse4Zy52zABbReA/JpyN4n5luubbZytVPw5Jr4Sv39ijiZEgsDdw5Jnzmx8p7vq48saUIA7CH1UudHDpbs2we/ZrSH6CfrnxFLdTSZEVFdcI1Zw24zxuMlcoeFsBxS3X/8T36iCBH52YJlcYP33qC21SuYXmz8b4PMu2ziom193hRnfCQmnFqXBdKx/ASMxxSOOt4+Mv/qhxWccuUXrizs4S/XAeX/xDu29COC/WzECbUz7yYCB3OU9Vs6Q5cYcZjli1x0JA1CoueG5ZRyx2FpCMGC2BwSN8F/BPQn3YUgbY1weKoFGiEGEy3DVKrDB6MT0eoExz8Qrj8HfD2E6BvGu1gOp3FZdSeHVH+7mfCtb+isbbKpE6Qp0sF6FWIssVgXLQuTRV95ZyALWjGReTdI0PyL3RAOvZI1Pr1VuCOBotreFaJU5g4kO2KJWJQPJrtYZlJzITVjG9LyAJnnBpHulNHeDAOSnKOnqscm3bApTfDxV+G3240mqInHRcbDRzbo3zjMeVtNwn/75fuhaBRM242WuejgQYmNKsYylcD+XZ7VlFU7uhHS61qt0nHPCyAhUP11yjcBexvpYTpcCv7ZjhF/Rojk4GRdlgzeGnDUhJy2a3pqbiQpxbSEeOptTH2EXLOgHLeG4VzT4P+2tShUXtT2KSEDz4Nn/oR/PB3rXG3ej8q089i8onoZ7nxriAmVH/Aw3hkFNHTRoZrP7cgSklHDdbxF2jt+TE+r3BRRrHhRUbRw9CPgaWhohsylb/jB0ybz8L00la/op2Ndvnnz4NL/jOcsahxJH1POiuqylPbhGt+Av/8iDJubA8dc1pPGm9iomYMTUwq3PKDStK6DMasi4mx1EWbh92qgU/XxV4GXTFrBh96dknN+gFAGemowQIYHNZDUX0Mlbktg5Hm/5Z3Aa1waf6fEtMgGXGxxsR5fyquUwbJhUmn9/fBf3oZXHZWYwnEdDqhZ6qKKqzdrlz/K+Hmh5XNyVYwoYkgA+M7iyAWo5Z80xi16EwzD+eZCU2MesqW2wHVxKTLYGE8QH57GdHNUpPBXUOykQ5Kxw0WwOCQXgp81ksJlfAPENVRjCqEDFbzOsZgVdIRmU9r1HKMfuboPXMGvGVQufBNwvFHTD6lmq7hZ0eU634lrLw/2bMq/9skrZ6+tmFauFA/qqCj0uDYAR3FJsMERC8bObf2t5a7KklXDNai4fpAXeV24HVAzpEyw4Uayky3xMViOuFlOfUneafjO+ypnb4Qlp7W2JK553GFRRX+41m48T742sONXVGbKUTMdpHruAUxjX5SkLJFzhiqRadNT3Fa52gfD0bQe2t9nLFjqBbYCa64dMVgASwcrp+sKj9QGHBSQhs9jPAych3FVpMOjn7RRsvEdGJ0DGBm9sOrjoQPvB5OWQAv3N9y/z4uu8fhgfUNQ3X7401D1WrDpE+FD1zwTQQ1NAmhY9kt1CmDt1K/yPMKvZhytM5aF//hFzIiwlt2nis/pQtSeIvkWOkT+fm4sgLVS12U0DpjaKOKajRuBiOZn64laZtnu06JQHv22ZUeX/Vcnt77lexIpambXK5pytCPjSu/WCP84gl4+SHKn54kLH41HPLCyaddkx3eNgpff1T52kPC/etpL08w2rDVpyD1ACaY9g/V2Ao4+S3Sv13zVjTVj/IYteiMybfdj5pPi5FvFMbUST4sqilG0Nbpqq8VAyv6lI7NCprSNQ8LYHCpDrBH70dlge25yzyDoVIFPJGqK3dDXpYTY6Z7MNHpDkxs+ow+OOUY+NOT4LQFjUNe9xXZNQa/WAu3PgbfeBR2pU+rcbZzylvvxqEMHZ5VbJk/S77pHenKLhbNYGwDp5s+r6ZfT9zVBSqYSFcNFsDgUP2/gPyrQoOsxM4Ymo3l7SjNmE4YkxhMyKCGjI1DR+HPKQIGWQQOOxBePx/OPA5efRTMGcg127SXHbth1TPwrcfgu6vhD9thPJlIDxr+pE81Y21ev5DBBGmdgTcx5U44t2Acs4qFjqEvOatoKeco8M5d58m36aJ0vesef67Wnq9zg6LnNGxMAXqY+sEa3o2tcZMRwVGbSC8qxmA5dUQaRnceDqNc8Kj30Mg8bza89mjl1GOEU46BebOnDn0rGt65G370O+UHjwv3Pgm/26LUtUQbtvpZ9tfIxJUZ+Mr0xQI6uu2th8qRHxxl5UxhePNS6diaK5tMyFi7cFgPrqv+SJBjbY5UOhzyRApv4xrqjAUwnfDCQpiqW+yE2jCpW01g/qHwhvlw8tGw4FA4Yu7UXJiqCptG4LGnG3TvZ0/Bz37feJle2RtO0AVpXXBWsSSty2E8FFUt+RbZgqYQxkeNhVU10VN2LK11/dSCCTFYAIPDeiqqt6syM3rGEHI/mH3GMCtVlkKERq7Q/V4dzbiqo5833Yaxlc/wIF4wEw4+AI4/HF75EnjFPHj5IXDgLDhgv4n5EFu1cfjqjt2wfjs8+jQ8tB4e3ABrnoUto9mtpUt7O5n0pB8Vp06ho96rziq6WUVTv4eytfRb8k0wFlqXq4saOjPlF0HQMUXO2HW+FD5jsIxMmMECGBzSTwIfK/SNodFYef4v7VuaUvaTnEy6iSnoqVnTm3F5g1N89POtu7GOzObobepM6UlGzhl9cOhs5UUHCofPgZfMUV48pxGeN1s5aJYwZwBmFTxWfvR5ZfOIsGkXPLNT+f1W4YnN8OQWZf0O4Q/bYPOI38vwtk+MB9T63CT1a5i/cYWBrxM6KnnrETo6cXAFwqd2nl+73FLCrsjEGqxhHUD5d+CNgJUSJsGMhBq7jDHxYKp0go54YkXL4cCEPLFO7fC6Xz/MmQX7z2isC5vR1/i/rwbP74HdzSPrd++BbaONmTxThyufYoY/fx330IeMWiregrEZfSt1cmC6sgVNenDylK1xd16nbQCzYO6UPnnbjqXStVlBUybUYAEMDtWPA/mhwsFlF5TmR92mFH3gKoxcTkykQQoZk8IGpxMGycD4jEV0ug1TxoMwcZ0w2hlj4qBOGNTPgonfWaQZX2CxKDhmFVOY6MWinZ1V3CQib9pxvjzMBMqEf9BRO5KHgeW0KILiDKc7k7bDgjYOZcz7YtESutNpyUM3RhZJ0nlktjhxhC0YSWPUgtEsPkZn45+Ufk/ZxMCb+ToPO1Cj/KFy2sK2fEPhUPtYdCphTOF2TuFz+guULSZfjcCLD2Pm29xzR5DlR87RCTVWMAkeViKDQ/UvgpzXikg7TM1w7tnvhIdQ1AsL6CjtRUR6GYW8LAtmQrwsG6aqpxqjA5qLJau2c/hbvLIHRqTxmtZp4Lu5s6gv3zIzhiJy7Y4L5HxLS3ZdJu+T2RrLgXuUxJdq70rqPLTCGKkyXwnEinFPTodNZwzGIW0vyq7Dmd6ME+PaqT+Uv0OHtQ1TcWJcl8nfijHzsOUT0e5i/BuUnI7EWCW9T5r9LHlgG2ExMUY4uDOnqVMt+k2MFNDvyVfS5TfyTXYZjTnYoqnnHhGWxzZ3p2XSDNZj19e2CgwLus5HD0M/vM/ghB62iOT4Gys81NmbA2E14tVw6W34olTRwFj1F6BXasN3KtzKzt0+YNBnIyxlymbUVx35Zu6NwXjw1vP+CuTrfAWhKf02nW3MOoTh7edXP/2mrEwaJUxk4ZC+WeE7KDNNSpiEu7WzaKX0VJwa190oh1riossRoaMrp6jE0D6zfN2gn804d/2ViBmxMK0rswVNF+hn1ILS4rOKY9Q4a8cFcgeTKJO+i9KiM7kDuBJprAls/J+lh23Jh3ODQayEPKTUdeu3dOTjpXUV8u2ozonKx5FvecpWshwhPRlPpeG5B8/aw6BOJq2znN+XC8ccQhEK22hdgHLGnoGo1nypI1x55lwm1VjBFPCwABYN6cw6fEnRP2sY9iKHViQvGy2Ki3o3JibGQyiAcXsAxuhdYi1PCGMddU1MxfU+IUz0eh9POat4QPaX69mRKWfnTC/SjAulF9RRvg9Z9Fgw5ZbCyM01Ycm2C2Usf8fEypQwWACLhvSAOnwTeLPZV6M6UuAHTFQFDY5HTymjZ94fi+mGDlwdMj499FtEtaFDr1dHZP1D/SSHiTJqKUzM4AHVad1UWSwq3CGiZ2+/sLaTKSBTxmABLDy3fpjW5S5VXRC1BU1oexkg9kFwpqfiqnpZ3oetQD6hUbbQQ90Jo2ZgSrVzBw1/MaOepj9NQOA7u5idRUMLMoPfIdp0GnpKHWyRwqsl3zRGYXUNTtt2saTOvJ5cmfR3WGl59PraBhEWi8gGUHKzh60fJnG7suNpaJlDawBKixrpNgnojdUhxrXtIj2+F51FarSTH6MxM4ZlZhXN8ptli6lLZL6CPb79T8F8C7ahdbbOCAfb2VbOEKZMuHy+G0RYPJWMFUwxDyuRhcP1N6ryTZDZplNV2IMwMKHR3YfJjMUlvZuW/hhMTB6esjrTA5iYNqrqqaklrmg5fG0Uk0c73dLJJPFBbB69kntfZtETPIsgZlbRkm8Gb9MTeicYXiy6XWqcve0iuZMpJlPKw2qJcCfIhcBYcEGpx2OyxYW8LKeoz0My9Ht0FJKk4r5yxOopIILFWzWutWQbpvMIYUpdF9ap2GbZ1LYGMB1WxbpO0BcuOKtonbU0y+nBCM1Ptx2zio7FomMIF9bgzoiWnXCZkh5WIoNDegnwGUX7GwOHbcZQ2pcpKTu6e72okI6iHlQrLj9CekY/gqN3M1x4C5oCs4qh0TuECb3YzpTT4WX4ZgxD3k27nd1WtCNr08y4UHpBHc6+nIqz5mGmN67HBZZv+2Dtcxb0lJCp6WE1Zd6R+jngcoFxEWl6F43/GlLA3saM7mZa0dG8OVDFeFlZjNEbWzpcPS4QbuLFgxED0bLWtO8VT74S0N/CiB0jGHW0lN+mM11OXxly+m06M3GSsj7N1tGsJixhccTnSyhGntJCmPmamFxYPfpdZbDp1FQZkHGBy09cJFPWWEGhJ35y5JXD2j+ueoUiH0e1Zp8xbErR0S8wgpbysjqoo7inVlAH4TbqhI7Jnr31t1H6W0IIHrhgwXRlZ9EOn1cI3lnFOsInajX+astFtXGmsExpDwvgoRtkvH8mVwusEJF68i7AcRphRoIfNgc8Jgnc47T2MZ5YRofmE9WILzm7l9Fv6rTpMTDW2a5Q2UJ1SeUb+gbQWc6y+VrCUjRfIywmxsi31DeAEfm2Pc24dhYbRrWOyIr9+7h6qhsrmAYeViLHnav9e+r8PehFrWHfHP2hkJeV+Gjd8hBa+m3pqbhoD8Gho9teVle28UlhnL+DmU8oj0A53O3cKEHirxRebNnEFFqFb8F0ZLFo6L1kHrOir0+Wbb5YpryxgmngYSXy8PUy3idcBrJC0Xp2xjAlBbyqaA/J4zFZB8LmtViBHqnoEXZLh5h1MUSo5s1KBMapU42kwvVVYr6zy2xB04lv92zhMpiYfO2zmXVgRa0ml00XYwXTyGABPHyDjPTVWCZIix5GLRY1JeahDd1TQIfzgYx0+720LrS9TBNj6kxjxEMlkkDU9ikF803XJf23kM7IfEP61Yw36lt1C5q2OqOcnjax1sUTbvUTR79q90OtC7Kir49lWz44cfuxd0KmlcGChqc1Y4YuA/2EwHgyXvi8maBRS43U1vREvwXj9aIijVrMjJs3rEa8ZjGxM3q+vMQWH8jXWgYHpvXXplPDmFAdJV3HTBmS9PCMW27GsMCsolO/ePQb4dbforOKqbAg4yJ8Yr8ZOm1oYFp8nv6UllcO1/vHVS4BPqnQnwOYNROL/TAwhd+hmOmOfDPpHkx0ugPjTY/BBNJD9YMOtVGXdWTbOUWXpBFZ6aj3CZhVBJrvu+zl9HyHOA5cXqvxuc0fmvov2G0y7TysRB66oTY+70j+BlgukN/2wuLtBK1zyCOK8Zg8XpYz/0jqJBjxFoxTpy1c4lCGqvlKxXyjv1VUOyZH/UL5WsoWnFWMKZsn39CsogZ0ttonixkDlp9wvPzNdDVWMI09rLQMDul7gH9QmJ1JsHgIEvIipp2H4El3YAqlWzCTPasYXQ5PPnYvqxku+i1eie1lGu3sxxQ+r9DAp8qwHeHCrcvkJqa5TFsPKy0iepMIZwtkvyxvjXZNHJaH0YjoxMEWZr5OTJHrMuWIwXQjX5sO09P03JNzdKrkG7wneaiTH80S7sYMYNHvEI18rYdWpHRqEy+NXRfOFmHaGyvYSzysRBYO6/Gq3KKwIJNQYGSu6iGFvJcMppWeHyGdo7c5MndiTZA5MntG79BapEz5O7GzaMjLKOMBZdqn+Wt00HuL0ZHvA9nrUHqcDlktwuItfy4PspfIXmWwINkEkK8q8uZMQlGD040O28SUM2oFMGYeMWUL6Yipm4GrQg2j6x/TPsF8ujQwBGhd1OBRfmC4gxrv3bJsau1nVVX2CkqYlkevr22owdmC3gzUbRiflZbU/xkJ0Bzz2kdrrPotGGe+oXIU0R+r07i2UmdTaaDs1jKkHB5JXTvF87vEjcZuypbZMNKkgQHKlsRXPq8wkK/k8qUO3ExNz97bjBXshQYL4JEbaztrNVkioleQzCAW7fgBA9UJ17T9QKYtXTpjRzjyGzpJx2se453tiihb6Bs6V77BuoQwLYPkybeozgL5OtvQggktuG0nF8s3jU/NKo4JcoXAki3LpsYe7J2WvY4SpmXx15RHvsfpdeVLwIsL0x5LXJDSpOLUuLalC3SGOlkwhY9xd2AKpTvK4U33lC06vYKO0Pu+DMakeBEYlYa3pgbexNh0ktIZ2AF1ncKS0w/n+7f8t733sd57a5aSweH6AlW5AeEN6fgogxMwJuB/IKMfJhMTqSNkTDL32zAY5Q+lWzClDJJZvoqG35fuxzSMQEcXi0oqHInJLQQ1MOrIt5l+DyLDmy+R1ezlsldSQlMeu6G2mhpvR7k2HS/4LXabsrnFmpyKjBsRIuhJTLhbW7LY9BTIV0y8EbZS4wL5igsfozOynQttQWPRqQFM1LeK9u2Drq2JvH1fMFawj3hYaRkcrg8p8hng4CQutPVJ+tpMb2Eq0J5CXlLVdBuGDnhRFWhtpnxd8aIC6U161aJsNuoXua1zpS1oCC8WlXY5N4nI8mc/IjeatdmbZZ/wsNJSO0JuRHgT6J2tSI8X5bTophdl6rDpdGBiRo1QOcS4zqRHlC1UhmB6wBO13l/AE43+HQpLi1YRmtGTdHzMFjQWPYVnHq33cqeIvOmww3WfMlawD3pYiQyeqwOqejnIpcDMoAfQLQ8C/KO37fAIxwjvezEcPJQhsNhSUxhn2Sz5Fv7cpETZ0ngrxntoRdviVT31uhM6Av1oDOFvazX55DOXTK9tYTol+6zBSmRwqZ6qdf0iyLETMptVwfC1HjGHjkLUMgZTQkfwgY3AdOU8w1RcXkfbmEpVWhdx4lHJxaKrRDh/06Vyt6Vm+4zsc5TQlMeuk7sFOQVhJTCaSQzQHCvGc93qlyG9HuqoHh2C8Qz6dMZIDL2MuadMPo7rGGoYnW8L2KSEoW8GbeGI3UqDdM9/XuGoCCtr6Cn7urGCnsEC4LEbZFP/TIYFfSdgnW1pjXmeByHaXTWBZRYxhvAx4RIHH6gZ34l8jbDY4s2yVci3pd9RX0mFo/JNYXLtk8rXqdMRlsa3gO+cWWP4mctqm+hJjxKaMri0PqAqVytcBAwAYcpiYKIoSScoWwymaDqW+nWY1iXXOdtfidZF6rDeH6BsUJz6RcwquheCMoKwok/0yqcvq+2T76pc0jNYFjnufK2N1zlZVT8L8jqg+ruqMu9hHJhKRi3G6FHC4BQ0SDF55NoxlG5cxxntxFBoKlGb5U/FF10s2sT7MPldQwG4V4TL+vv56fpLxPot7L4sPYPlkWMvqA/oHrkI+CgwN50WfFhcLSu2Byk/kjtH72a40Ohd4nMT61H1VY+cMknUrpcAAAXeSURBVDEO76ZdgjDGfrBuwAPKlDMxMGSlE55oMaO9WYRPa40Vm3pelVN6BitCjj2vfijKlYoMAftD2GCBu0Mnj1ZnPIQS6c0478MU44kFMJ3YNdSHifFUWzhvO0cYXw+m0KyiqUdkFLhRhKs3LpeNZt17kpWewYqUV12gtdE9vBr4K+DNQH8papiK66jBKTq6RxqkytQxgKli+L06UnG+9MxGfgZly9E68xvDJibzHaKhR1N6yOiRcYQ7QK/Ybya/XHtJrUf/IqRnsArK4AX1mfU98g7gMwpH+4xJvBeVG3WboQK0zoHxvhgmsKDU9CAsXoaV+pmnDYdonYeytTCOU5FjqLF4ypk5jrf7tK+ZLmuA5dT01meW1/IHqPTEKT2DVVKOPV/3V+Uc0A8r4tySuWteVhnqaMHEPJDePAI6Woa7qhflacNW+Uq3s9/4Zox+JYyuFuEaaqzcuLyWXfPXkyjpGawKsvhrykM/YI6qngPyIYWjW4kFaE/HjJoDU+g9VAn6GUPrgjoqGqSYcth1JJ6Zg76lKF65LWgEhDXA52v9uvKUl8rWvXm/qm5Lr+U6JIPn1+fWkXNU+ShwcJGHOrQ5nJPWmd6Bgzp5N4ezUUsb9QtRNlv5ix5L5ShboRfbRelnyyB1452gbBLh04iufPqjtc3m7T0pLj2D1WE59kKdrXXOVXQpyLHptK4cytAJT60ZV8lTo/u0L6QjRD/dbRhhfM3Bw/FOrTl4rBLR62oi16//mGy31KonJaVnsLogV12l3LSe2QLvULgY+GNidoSwpafiqr7vquRB0KX3TAXpZed1ZD0tTYVb8YkCScyVDSNjCPcBX0C49cKPsf0qsTVIT6pIr0W7LK+4oD4TOFWVj4C8VaEWHOE7un1KVk9HzysssTd5aAuaqAWrHozTA3K2YcpMR3iilu2k64jcBvxvEe7ecLn0Zv26KD2DNYGy4AJdCCxVeBdweCvBHP2NuPR1VWpY1cvqhI6uHGVfwFPNY4obPkXWiui/gFy34Qp51JJbT7ogPYM1CfKKi+pztc6ZinwAeB0wUOSBnVBqWMLwdfvU5JCO0Lu6rI4UJQQyM4YpGti8cQThXuAfpcZ3139cei/SJ1h6BmsS5aSrlB1P6wkg71b4H8CLQwsdG6FIWud/MezEVD5yykb9QrQ3hLGVM1Q2D/3Mr3BPSd4grwP5igj/JIfywLoLeo/NZEmv5aeIHLdM+8ee542qLAbeCrx04nfebF93nBqG3gdZMBNCHcE1MPwO0dtAbtm/nzufuFzGLaXpyQRLz2BNMTnuImVMmaPCycBibRivF5NsttgNatil911T/aSd7ASD1kHWIXqbILco/FT62bru471HZCpJ79eY4nLMxToH1depyFkobwUWVD6UwUbruvCtom/GM7QQNFP+qqcxe+mnrkbkNoXvSI17110pW+nJlJWewZpmcszF9fmKnAl6BsjJCocCpb2oFibGy7Lgqm7015EX9B5Mvv66EZGfAreL6Hf/cHXtcXoybaRnsKapLFqmtd17mAt6AsgpwBsUFtLYaHBmV6ijA9N16ugpm1NHAzMGbAYeRbhH0R+JyAP71di85qrebp7TUXoGay+S+R/SOQoLQE8GeT2NJROHl/5WkcaMYWvW0qUnsM2Lj9ZFzyo68rXQz7XAvQg/FuGnCKvX/q8ezdtbpGew9nI5ZpkeVq9zAsIJwEkKR4MeDpKhkj7qqE1bMaVe4gsbaRinNcCvEB6oCQ889QnZYMm9J3uJ9AzWPiRXXaV8ZSv7a50DEA5V9FiQo4FjgPnAkSrMprEN9ADQD8QZJHIGJSvFDNI4jUWao8B24CmEx4H/AF0DsoqabhRk59AMRq+6qteN9xXp/dI9aclxf6G1XbuZgzKXxhY5B6N6KCKHAfOAOQhzFJ0DzAYZAAYQHQCZCfSD9ivS/F5S6yDjwHjzfdKIwgjoCCLbQbeCbAW2IjwNugFkI8ImYBPCZpStT/11731TTxry/wFW1vajNNuKgAAAAABJRU5ErkJggg==", + "config_type": "basic", + "config_baseUrl": "https://classtime.com/code/:code", + "parameters": [ + { + "name": "code", + "displayName": "Session Code", + "description": "", + "scope": "context", + "location": "path", + "type": "string", + "isOptional": false, + "isProtected": false + } + ], + "isHidden": false, + "isDeactivated": false, + "openNewTab": true, + "version": 1, + "restrictToContexts": [] + }, + { + "_id": { + "$oid": "65fc1285e519d4a3b71193e3" + }, + "createdAt": { + "$date": { + "$numberLong": "1711018629196" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711018629196" + } + }, + "name": "Lichtblick-Filmsequenz", + "logoUrl": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAkAAAAFkCAYAAADIT4SLAAAABmJLR0QA/wD/AP+gvaeTAABJrElEQVR42u2dCZgcRdmA4436e98IhISIB4pH2JlZDomIIl4oGECO7G7AiCiGKMfObIAVUBARAVGIyc5CAJEglwKiIAiEI+CBIDcYJJzZKwdJyLn/93X3hs1M9UxXd8/RM+/7PP2gsNPTU11d9XbVV1+NGQMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADCQS/+Lo/GOwWMnvoXaDQAA4C9AwxyNdyzLpt5B7QYAAECAECAAAABAgBAgAAAABIgDAQIAAECAOBAgAAAABIgDAQIAAECAOBAgAAAABIgDAQIAAECAOBAgAAAABIgDAQIAAECAOBAgAAAABIgDAQIAAECAOBAgAAAABIgDAYqJD07tedP4qfndgxxbHXDx2yix+mW7yfNeG/Rebt3e+95GL4+xHfndApbF1tSe5DKuvXeXIPd5y7bzt6G0ECAECDYijf8ntm7PDwc7eidRYnXcERw6+z2B72VH/puNX7fzK4OUxdj23hy1J9H3+ZlA97ktfxKlVQUBWvqbw4ZX/e2CQMey3ukNIx1DP/3K8ItXnza86s7Lhlf/60/Dq267eHj5RccOD57waQQIAQIEqCkFaKup+fHjOvIz5buulU74nnHtPfPlf188rq1nijxTm1FzEaCGEqAV158zHBSVoMTLz/E7D6+649Lh4XVrjb9x/dLFw8svPhYBQoAAAWoeAerufrWIz+nS8a4t8f1P61QdtRcBQoASKj9rF/6z/A/dsGH4xSt+jAAhQIAANYUASYd7YcCOea3GqFCDESAEKGHHqvm/Dfxbh9euGV5yxr4IEAIECFBDC5CU737BnyvneG7zabPeQC1GgBCgpMT8nPIlR2pseOmeqxEgBAgQoIYWII31sRQgvSffphYjQAhQQo4Xr/zJsC3rlw8MD3RlECAECBCghhSgbafNeqecd0MIAbqCWowAIUBJmf66/XfDYRg86XMIEAIECFBDCtD49t4Wa/lxjp5/U4sRIAQoIYdOZ4Vh6LS9ECAECBCghhQgTcwXToDyj1KLESAEKCHHypt67O1HVoMN/ugzCBACBAhQY44ATblg25ACdCu1GAFCgJKSbbnne9b+s+65xwiCRoAAAWpYAZL8P6+U875gL0C9p1CLESAEKCnHzNbhdYsXWgmQZopGgBAgQIAaVoAEOedplgK0buzBsz9MLUaAEKAEHUtnHyZLu9YFSwP05L+HB47bCQFCgAABamgB2uKQOW+Xcy8Mej80YzQ1GAFCgBJ4LP9tdnjDmlUlf+eax+8eHjz582SCRoAAAWp4AXJGgWRERzrdx8rKT3vvHN02gxqMACFASU2KKCu7Xlpw+fD6Zf0vxzuLFK159M7h5ZfkqpL7BwFCgOJmq7bzt9NtCkod+raPACFAJj44tedN0vF2yfc8XPC9L8m/v0GeqS/QCiFACFADHYPdk4YHT9y9tgHaCBACFE/jem25chFJ+iwChACV4/1TLniHCrXuDj/hiLNfR+uDACFADShAdbFCDQFCgOJpXP+DACFAgAAhQAgQAoQANVvjugwBQoAAAUKAECAECAFqGnTKIki5IEAIECBACBAChAAhQA2DiM1EBAgBAgQIAUKAECAEqKkESDrGvREgBAgQIAQIAUKAEKCmEiCRhBkIEAIECBAChAAhQAjQpCZrWM9EgBAgQIAQIAQIAUKAmk2ArkKAECBAgBAgBAgBQoCaTYD+hQAhQIAAIUAIkHkLjJ99bXjVLXMDHVouCBAClKCGdaiBBWg/BAgBQoAQIAQoym7ws6YF/q3rB59pMgEafsWEQ2dvIZ3Nrlu397RLQ/sD+d9ZeRhPFZk4QYNsx7b1HCKbIU4eO6WnVf7dWxtZgLaYcsH7x3bkd5Odrw+S3/5tOd+xUiYnanloJ+QEHUvHLP8+856D576xlnduwoEXvTlouSRUgPYKfOLJ816l2zfIZ/aQe3fo1m35Y+R//8itxz2dUn+P0BVzus3DdpPnvRYBahx0n7vx7b0t8kzur/dZn9lx7T0/lnpwstaDcW35afrM6p542tYhQAgQAtTEAjRuypyUNA4zpWP4kzx0S4PLxcbjKfezIkgiAtr51IsAjZ/as7NNwynn/poGEkvDc48cyy3LYZ0c98k5Thk7Zc4nqySDm+l3iZQeKI38r4Jeq3QM10gnMCuOY/yUuVtVQ4DGtfV8vtS55F7vIB3c8VION8rfr7C4by/J77hJpUI6zg80swDpRqdB7rnUn3PqpT8cd8icsVL/vytt0CVSNk+HaL8G5bhVftdZY9vmfKkaLzL1I0DDr3DEMOCzvlVH/iMIEAKUeAHSRkPfiuQheyJEg1HuGJCO6LcyQrRnJWTIRoDGHjz7w6XO5WROdkd2bpG/Xx9nOUjjdZuOpMX1uydOm/Ua6fgOkLL9iTT2V8r5H/Oka7iWhyZfrIYAyUjcjoWf36Yjv6U3IhdXPd6gMqQirJ1D8wlQT2fAclpVyz5wu8N/9X9SBofLddzu3LN46/RLKtE60q2jqo0sQPI7j7Yolz/U4plAgBCg2NjmkDkflbe3i+TBWlulDvIZfcPQjq4WAjShI/8u0zm009ZykL9ZXeHfv0G+Z46O1ET93dtOm/XOWstOLQVIZWd0PRZRubTC9fgumxFEBKjy6AuLJ7wDVarfK6SeXTBuas/HG02AnBHT4O3fkzpCzhQYApRIAfLiQ86sovgUHit12HzsQbPeV0UBWl84AqXTffLv/1r939+7IGq8VJML0Gq9lyq0MgKWj3u0rlQdEoE/fcIRZ78OAaqlAA2/Qkdk5Hv7a1TPdZTpWo0tagQB0hE0udePBPzta5zQBmKAEKAkCpAGAgd94Cp9aFyNHF06nVNpAZLv6Rv1mffKv/tdBYbLbY7bowTcNrkADbpTlVV78y+sS/fEOYqJAAVHFyJ4U9T1UN83SF24MGpdqLUAybkvDvyb2/LfH1NPIEAIUNC3Jm3QajjqU+rBvqHiI0Ad+Qedv5f8MTV8c9w0kFdGExCgcFNgtT96/1vpIGkEaFN0BaZ81wt1WB/69XlMogA5qyKTFveDACFAtvKjU0513KGcWYUpsP/JcXmd/e51Gr+CACVRgJwOaVGUVW8IkEXdaOvZ151+qcO64L1cJU2AdBWXxQrJ/9VN3A8ChAAFRmIlxrb3zI26EkKOO0U2znXy3bT3HKYNkubO0NVdmgdIjm/p0nc3mFhjXIIvPdYAvGoEQdtOz2ngq/xztrupqORBauvZx/nNsjxWV2DpyhNvufm9EVZgXY4AxS5Az+jKQ8354qYy6M2oaOqIjV6fs7xbhvK9of/nItaT+yu4OggBckdt2yKucNTVfA/qdJWbGkHyPkmqCG279HnW9kv+e4eugpK28jfuyr/84sB1IGL510KAdCGG124FivvRPG9j6hEECAEqWdHb8meEHZ3QvaQ0QZwGyVl/cXf3q73VVUdowGCJxvyhKgVBB3qTc1aptffuotdvNTwvAd06pRUmX9DoFU02o3pbHXDx2/wO7fRtkgqWOpfNESXVQUQB0g7rVOsVOt3dr3QSJGoupPASdCECVKFpL5GUCPLzD31JCZvgUKR5gpP0tD1/fomptw2aRiRxAtSW/3Vi434QIAQoUIfijMqEWuEgb8a9W8dZTzefNusN0ph8Qzua0XFIGgRdYwHSYfXzTXllQpW5NIZB9+B6uQx6fhh3u+ANbzduJujRU5vSQGv9iuF52UXO93DIeK6DEKCK1OGlIYT0nti3vRFRludkJycJYHt+yahYsFtiuM9VFSB9qbUoyz/WXdwPAoQAlUOG+z8WIq/NM9XoDHUVlpd068moohVWgFwJ6z036tubCSc/icSHWIzA3IQAWQvQKp3OiHtJuoqUl0U4xAhUvFvBNLMAeVM0D1jeg9XarqisVLKebjF53uu9xRS360tmkgRIR5stVk/WZ9wPAoQAlZt+Gtee/7tl43FvNZb2Fr5VxdBQfiKE/NwWNvjY4rq+ZnFNS+J+y2p0AZJOf2ol66XNViIvbynS83MEKLbffqpl+S8d1zbn01WvtLG0YVUSIKdf6Jmf+LgfBAgBKj2Ub5XSfNidsqn+RqYxiYaNAK1xOosK70826toWBO48Yx6JavgRIHkDr3THFmLV4GrNU4MARX+mbdJ1aNxdlIUUtW/DqiNA3nZHQdM8TE9E4SFACNAmHZ8TAOts6Be0oj8bLgg3iQLUu381r006ph8EXgnXkf8yAlRHAiToRpjeHms2EnQqAhTxezvy11ll527r+eqYBFMNAfIS4K5riLgfBAgB8q3ozuaYNsP2vV9JduNhJUCTqnltuuzaIoj2KASovgTIkdgpPZ+xFKChuHYQb0YB0v3WqpVDrFkEyNkyRl50g8b9aAxjYgoPAUKARnD3dMkvs2g8Lk5+41G/AuRNo6wKKEAnI0D1J0BeB/UHqxgzyRGFAIX+zddalPVToVJ0NJUA6Q4AgetvMuJ+ECAEyNiBtOWn2eSfqXQq/6YXILdxuy9gh34WAlSvAmQXaO9OISBA1qM/klXbKueP7Ac3pgGopADZxYP2HJm4wkOAEKCXK7vVyq/zG6PxqG8B0n3OAl5fDwJUnwLk3cfbbILtx0+b9RYEyLqMT7LZhiTKZsLNIEAaGB40FUqi4n4QIASokC3bzt/G6i01psR/CFDZxu2qgNc3DwGqXwHytmOoamxdswmQnOdRCwE6aUyDUAkBcsMheh4JOpWYqLgfBAgBMjTQMywaj8cSafsJFCB3b7RAw89XIkB1LECSJsJqM07ZggYBsirfD9kI5vgpF2yLAMXR7kjcT5JfhhEgBMgToJtqlbANASp5fb0IUPIFyOuobrbZhwoBsviutvwx1dp9vdEFSLbWOcSiLGckuvAQIARo4rRZrwm62sh5kGSDQQQIAUKA7BDRONFCgF6y3VC3uUeAgq/+kh3bz0aAzHipN5Y1dNwPAoQAbdIwT5nzSZvEYY2wdBQBQoBqIEBftMtR0/shBCjwb33OYvPgfRAgY1uzmcVGzMmN+0GAEKBNOg6L5e9u/E8jNR4IEAJUHbac2rO5ZSD0ZASoPBMOnb2FTbnKPn4TECDjNOKvmyLuBwFCgDad883/0iLXw5WN1XggQAhQVTurpRYjFT9EgIKMYPfsaSFAKyq923sSBUjqwN4WiTp/0DCFhwAhQNIZXG0xAvQzBAgBQoBCXqNFri3J7n06AhToew6zaL/uH9NghBGgCUec/bqtpubHy3Owq5Rfe+D9H6WvaJQVwAgQAjTyAN3bNFH/CBACVEMB0sDRam010zQCZLV/Ye+fm1iAdGPeuy329So8ntzikDlvb6jCQ4AQIKnY/fXecSBACFCDTIH1WHQ41yNAAaSyvWdus2WwDyNAUQ6Rp7UNE/eDACFABW+lawMPy7f1fB4BQoAQoJD3syP/U4v6dgsCFKhMA0/hx5FgsikFqMFSByBACJCDlwMo+MqUtjmfRoAQIAQodGf9I4vn7S4EKNAL3A3BV9b1/BgBCjUC1Cdt0XsRIASooQRowoEXvdlKgKbMSSFACBACFFqAshYrLv+NAAW47+098y1GgI5HgEIf1zdUADQChABN6Mi/y3IT1E8hQAgQAhTyGjvyR1VrO4zmWQWmgb2B73sWAYpwdOS/jQAhQE07AiQPQBoBQoAQoJD3U0YgLKZr5iNAgabAbrN4hk9oYgFa5S13LzwsclPllzdUIkkEqMmDoGW/IUsB2hUBQoAQoJCddXvvaRadzQ0IUKDf+ReLKbCfNKsA+SZCdPqA3gUWz84dYybPexUChAA1yjL4NRaVfw8ECAFCgELfz3MtrvFqBCjQ77zK4hn+BQJUjI7q6OiOhZx3IUAIUKMI0GKLNOgHIEAIEAIU8hrbey+yGHE9DwEK9DvPt1jOPRcB8h2dPNwmL5DsHN+CACFAyd8M1S49/1EIEAKEAIW8n3Y5a46P2DE2SSLE3hOrNa3YyAKkK7ysMpV35B/cYvK81yNACFDCR4B6rrQIzPw5AoQAIUBhR4CCL9mW0aJvIUCBRtW+ZTGq9gAC5M82B899t/zt800zpYgAIUBSkc+0aED+gAAhQAhQ6M7qucCjFVN6PoMABbjvkp3eov1a1TABvBUQIG+Uci+L8twgbdQXECAEKLlbYbT3TrWo8AsRIAQIAbJnu8N/9X9uhxHsGt8/5YJ3ROwYm0OAbO67jqxN7fkgAlSmT2jLz7Yo06cTu0kqAoQAjZsye3tL438rAoQAIUB2jJ/as7NFrMqiGDrGphAg77c+bTG1OBkBKs17Dp77Rrl/j1iU6UUIEAKUSAHycgGttOg89kKAECAEyPpeTq/mVHNTCZBFcLnEYf0KAQok7DtYpkjZDwFCgJInQO5DdH0z7gyMACFA1UI6oMss6tp0BMjqu4602GPtEQQosFjabN47tE1HfksECAFKnADJEOYRFhX9SRk1eiUChAAhQMGYOG3Wa7SDCFymUv4IUHA0kZ/VnoYHz/4wAhR4duBOi7L9S6I2TEWAECCn8zhkzli7jfGqLwMIEAKUVAGyXKn0VBydSDMJkPd7H7J4lk9BgIKxZdv528jnlwWPX+v5LgKEACVKgLwHycL0ey5BgBAgBCjwVMKl1e6cm0+Aek+wKONn5dgMAQpcf79tIfArErPSDgFCgEZV8g6LSr5OGpAPIUCNKUDagFnkq9kTAfJniykXvF++86VqT880mwBNOHT2FrpFQ0OOVNRYgLzv+YNF//APnfZFgBCgxAjQ5tNmvcEmTkHihn6PADWmAG05tWfzpK3+qFcBkufkHIsVSvNj7BibSoC8l7irbfLXTDjwojcjQAEFsyP/LptEnnL/uxEgBCgxAuQ1Wt1WwYRTer6OADWeAHkyXLUVS40qQDI9uJ183+rgoxJzvoQAhUc36LRJNin7rf0aAbJqj74QtHyd0biOfBoBQoASI0D6RiSVt99Cgl6Qh2JrBKixBMhrWIN23OcjQAbsV9D8I84VNM0oQCFGgTbI798bAbL6vvMsyvdxzYCOAPmw7oUnhlffe33dHKvumNfUAuQ1XEfarQjL37fVARe/DQFqOAF6KklpEepNgHR0weY5Gj81v3vM968pBUiWxH/UKoFfe/7FcVPmpBAgq9Hhhy1CJc5BgBLC+qV9TS9AulmgVNy7bCVo7EGz3ocANZAAdeSvsBCKPRCgl9HOxmoqub1nbgU6xkACNK4jP7ORBChM+cuxNKmpPaotQIpM7U60kEwdZfsiAoQAJUOAxmxcBv2SZW6gZ+N+izVem4w2yQN1uHznA7ryAwGq1AhQ4A5Mjwdqvay4HgRoi8nzXm+5kaQez2uAaQU6xqFavqHXUoC2mzzvtXIf7re8D9Kh9x5b6dFMXR0leaG+qkkDpR62JVGAvBHO4y3K9pmom/siQAhQ1QTIeYuy2yV+o+3rxniaWDHuRkPfIrx8KqtGBTEegwBVSoB6J1ne+4u14wn5Xe/VDlOWJu9TFQFqy38/9lGHjvxuUj8ftO10x7XN+XSFRgaeDHgNNzeaAHl16kNy7iUh2rC7K3FP3Lam9xdu3OTGVX83JlWAVBTl99xi8dJxBQKEACVGgJxOpb3n5yEaEGcFgO59pKta9K3Y/puHX6GjUDI8f6i3h5JfQ3YfAlQZAXKksy3fZ3XvO/J3yPD4ToFG8qbmx4ssf0vzi4zkb5F/XlgVAfICNKV+na5iHTZQ0xlpkCBaue4bwjwnugVNxUbE2vN/D3gd6ysxhVlrAfKmcfdwc5bZ3xt59haIkB+yzcFz3x3muzWVhAq9nOfcEjK6XvNEJVKAvGfYMkv0gQgQApQYAdJ4II1PCNeAvJwZVN8ytbORBuk74zvyX1aZ0HlkfdPyRnb2k/9+lPxzlhw3yd8PBu5EpvZ8HAGKX4AiCvD9ch/Pknv6Pbm/B8iQ/77j2vLT3DQLPZfI7/qvb0B19QSocMnuP1XAdBpEjq9p3Rw3Zfb246fM3UqH7x1hk7qmqR905NFLDLck7HdWKvZmVMfYY3E9q7Wjdn6bLF3WZ3P0EUYQ60GAPAlqCy9BniC25/8l7eBvdIGISo1O9etu6VJerbrNiYjsZG3bpF6cIf/7GovRNx0FOjqpAuSMfIokWpTlEn2eECAEKBkC5I3G2CRzq/YhD/bPEKDKCJCXxXhVNe9n2AYyigBV+dgQdeo2oIAcFtszFiLfV70IkFM3RMAtV4ZV87g3yQLkXcM8C+G7sW4200aAEKCgEiRvONmIb1IVOnpvQYAqI0DeKNBx1RXacMPkCRGgZTpaUI26s+20We+0X8jgO7U5I8kCpOiojXzX4nqsE3qvkixAbl3T/dUCZ4k+EgFCgBIkQN6cr2x8aZcOvaLHKnmT/km4GCMEyCYWyCrYMeIhwvXjMNep01SxdfiVOe6WTMUfqGr96chfEJMAnZV0AVK26chvKd93e/3UiZ4ro04J1YMAeaNsn7fIwv2SPAsfa0gBWtbzveFVd16WyGPlzb1Wv3XJz/cZfunuKwMdtueuRwHyOuW3Sif1K29uvBYNh8Yr9Max0gwBshpNuLfC93W9rpIJu5JsZMpOp0TlXAN1JD4Dupt2LYb9vfsWxwvLVY0gQA7O6iVnenCwhnXi5rhWmtWLALmjxRahEhJzF+VZr1sB4qj9UUkB2hj8NmXOJ3VD1GqJkK5I0qBcfYuLUTAQoKByccict8t3XVuh+3uXLCPfMbZrlVFBCTI+SBrZ62x2CI/5eF6+u6vWWdKlDmWsV/M1sgCNjAbJ6i4J0D/NZhVTxOMlN1amNxNz/E3dCJDmArPJvyTP6MkIEEciBWij9U/t+aBU5jPt5oCDp6nXxleXGlfibQEBChUL1hG00S0bDNyev97NEhvfHljGURBnJZCuPrPa5y7k6KRIovN9tU0MOZot287fRss6zGo+XcWnq0EbTYAKRrSPrtAI5zpNDaHpDiqVCLCeBMh5Me7If8piH8H1lcqDhQAhQNVFl8xrMrj23lOkQZlvswv2qEOHpW+VRuOneq4JR5z9ugo3flt7KxjKHrqzd7WL1Mt4Xfbaoi6ltUXvizTqB0sn9yebuBtphJfrKhDtEOJOlhm0jurSdmfprpty4Z+eZIft4DR78AKNkdFUDtqZjqljdIm7N0V4t5eeovD+LNIMxRr4rsvfIz5bXwv4bF1cL+XjZsDvPVbK4Y9BM2kXxSVKnZL6PUdXnumoaRXu6axg5dy7f9Veit38XoHaVY3jRIA4ki9Ahk7SSWbY3vsVefimy3GCVPhT3QdWM6JKThhZXeJknJaEZVG3tYDa3WftWJ1RD0mP73SwG++x3vPe6bryafyUC7atm+WvhukQnZrQLTKcfC7SCerwvJMHSX6LTpXo/3c7x55DdEWRbLo5oeYxDDFMWWjCPmeaTnavpzaPws10vLUu/NAcVrpprLZfGucidfyXXv042s1vJXmjZCQ8zEgZMAXG0YACBAAAgABxIEAAAAAIEAcCBAAAgABxIEAAAAAIEAcCBAAAgABxIEAAAAAIEAcCBAAAgABxIEAAAAAIEAIEAAAACBACBAAAAMgCAgQAAIAAcSBAAAAAjU7/zNRuHI13DHdPYmNDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ+rvSew3k0rMKj4XdkzajdCAJ9B2z0+amOjyYzexE6QAANCjD0ya+ZjCXOnogm/7rQFf63v5c+kL53x8J+nn5zEnSWQwXHd3pN1O6kAQGczt81FiHs6lDKB0AgEaUn+4xrxzMpm82NP6rh7KtuyJAgAABAECsqGCIPBxZ8SOXnu4rL52tXzA2/O4xHwECBAgAAGJudFNnl5CPWA+/a5DprmyJz62WD74CAQIECAAAGkqAvBEiv88tD/I7ECBAgAAAIFEC1NeV2qHE5y5HgAABAgCA6giQrMQayGXuju9ILyh1HbKM/RT5mw2jr6E/l3p8qDOzNQIECBAAAFRFgJbMTI2r9rWIxGQGcqkT3GvKHLpoRuvrLT6LAAECBAAAyROgiPKEAAECBAAACBACBAgQAAAgQAAIEAAAIEAACBAAAAKEAAEgQAAACBACVA7dx2zJzB236Z+Z2b0/m9l7oCs1eTCX2VP/nf432/M9373juwe70jsPZlNf1nPJqrg9hnItnxjunvTqSv2Gxd2T/m+os/WT+hukHPdxvzf9Oc3PJDuUv6ke7q9eo5MvqjP9eb2+/mzqa0NdLZ8e6tpxbEXvr2zSK+Xy4f6Zqd0kVcNeI/d3oKt1x77cxPchQMF5IZd+T39Xa2rkHmpd0zqn9fv5o7Z/Y6W/XzPKa33pm5mZNPL9Wof0/j7bPfEN9AYACFBNBEiWvXfL9z5RcDxaTQFSaSm+hszdy7onvnMTSZHGWjsl+e/XyPFiiSSOT0v5njzU/Ym3lhIoby+0HjkWlTjXMrmWedpJRi1rbewHujL7SZnlvd+5ocT3rpfjAfnuM/pm7vDB6Pc5PbWojLsy55vKxZHKXPpX8jePlLnG//Z3ZX5aeJ/CCm1/ruWzcn9/7ubCSq8pk+BzkcjYRX259GeCbNkStwCphGm+rKIyzabPiuvZ7M9ljjA8m08MHZv6eNnfdFzrx+RvZ+mzHCBZ6hODufQF8swcEJd4Oxst51JfkvL4rZTT4hLfrfd5vtS376ps0zMAIEDVu45s5peG61hbTQHSN0HTOUY6fh0NkPI6Wv7dkGUW7CcHjmvZrvBtdLAr9U35bw9ZnmudjA59O+wbuHaMco6lIbN5b5DP/25ZNvWO8CN1mR8YzrtwY7lMnvwq+Y4OlZoQ17dEpSnMdS3snrSZjLbNENH7X4Rs5/8KIgVxjwBJx/43w+dejKsjl3P921SnSwnf4LET3yLleWkZcS11vCgydI6+lIQWNx2xy6UftP1uR5SkDtI7ACBATS9ASzp3flt/Z0uLdI73R+gcn17a3fp2t2Fufb807jdFONeGwa7MVwK/BYu4aYLJMqNVNsez+mYfowDd45X/7tLx/Cfita2WurST5TXtJ597KqayWS0d6JSqClA21Wb6nI6kRB79ybZ+yCdT/El+n1me3eVd3qhdHOW5RqcbbUc4dVQu8nfLyKQ+O/QSAAhQswrQOhGffeWfK/2npqTzzKZf0Ost1aBqWTtTAl3p53z+ZpX3354p8X3ekflf0LggmU7Y3P0dvufr96Z75stxhychq8tN/Qwck94iDgFSGRSh+7E33WaUCmcUzb3GJ8qXTfqh4e7tXmsxwlFqFO5FrzzukuNWHeUJMIK2VuOEqiVAzpSs6Zq60tfGMPpzonGURMTIKNs6splN3xxAMFbJMRhghGiVzTOs08267U6pKV1vyvAWiQP6k9zb+0o9tzIldmHUqU0AQIASOwJUeE1y/F6DKFUsCjui/q7U10sMu+sIzEDBv1vgxFhk0x/R6Z+NHYn8bwmqToscXO0vVME7WRGmG0eP4DgxR/Ib9G3d2JGJXGmAqMZk+ImJ/LcrYxoBMp37MSmT4/s60xML38L1/zuBrNn0Db4dl9yHwNck3zPqs8v1d+k045LOlvF+nZ9OaXp1brnPNTxvG9gbJQhaynWOqa7qtGdEATKM5GTu9v0NMjJZou6fptvdFE7N6XSZBkI7U5/u7xj1/GSuCDzSecSer/Mk3vT9QyIzWZO065SuxJB93xOy4rqYzXyHngIAAWpuAepK/zlITIK+hXpvmaXiDB7X1TBlG3XpgEWQLvYbUbLo5DWu5lYVg9GiFaxcUrt5I11FU3EqaTEL0LM6dRNkBZ0TsOw71ZG6NOg1Le5snaD3Q8rme7bBt9J5b+U3giTXlquWAOm0n1kEM98P+0w6q+4szyn/7Y+G5+a5vmxqW6vvlrg7+exxNpLvBcybnrW/6bRz2e+UgHKfl5elcQTYAwAClEQBWidL0w+3GQoXaWkv0cn32Cy71bdW0yiMTh0Ffju2lJ7i8nWWxhs6l8y58QlQaraOBticz5vyWFYqsLrS5aPPinzfCsM1PGr3LEZbBu8jYgsijP6cbjOq5KxolED0WizjlxG7XXym0+6wGYnzpov7DS8bJ9NbACBAzShAx9n+nsVHt7zXZxQpVFyGaSWOThNVt66k/2EayYpFgLLpoyJ01JeYRqequZxZvu/MqM9RVAGS2JvOUqsYrYRQZN+Ju7Kov84qQ9P3F0wVV6j87zCN3AQZ+Skqx670NNMoVtSXCABIiAAtnpn+gMZaRDosEgHWswCFXXbus+rqknDnylxhijOpZl3xVpIVlY9fHJGNAOlUUujrEnkynbPSSRI3GTmQPEDG3yWpDqolQJ50rzV03j+y/T0a/2X7e7xpq+K2pMIiOpRt3dXnxeWHYc6nAfSGWL1h29WFAJBQAYrjsImBaEgBMq/2ujzk6MsFprw3Va4rBxjLuCDHUbUFKI7riiyH7jSl6TmYXi0B8urctabpQNuVTDKy92tTIHOp6SRv+qjq90GXqxu+d4XtdGqB7M82tAPH0GMAIEAIULARoKfjEiAva3NNBcjZwsA00iJv4LUUIC/hXfHUi6wgq1bZLJrR+nrbfDkVEaBs+hvm6wieS0dXAHppHYry4gQYOVlnCJr+aWUFyPiicUmkc+Yyh5pi9+gxABAgBKgJBchJVGgsn/QXazoC5LP0upoC5HX+Jnn5eTUFSK/DtOWDrpAKLrrO1izFz3OALNs+y9DX2iTutEFXl/nk7/luRKnKGM57Gz0GAAKEACFACFAdCpA3CnSW4TwDQZND+kwpPRMkCNjLp2NMQKhtTdS8RMW/1dmTbzjueB1NjWBK/kmPAdAMAuTsCt66Y5TDZgUGAoQAIUDxCJC3AWlxeWQzXy33WXdPNNNS9vTPApWDm4zwiRL5r17S/eR02lK/K7oApX9h/q12eYeKRpbcnEBFK8HoMQCaQIBYBt/cAqQdmW53oInoNAeSdoB+Wxw0mwDpSIiuMHOyUevu9lr3ZLfxehEgdyQm9c8wySElseXexjgvi01e9W8Dbhi8TMtNnq8vB93Wpfjep+f6vMDdK//t7yOHl0Zi0x3t3TinwVFHuS1OnqfHAECAEKAGECBdGaSZrd3A2dQJXmdym3fdgXfxblQBcvLaSOC3rv6R882S4y9ehu81gaeCayVAur2KYV8tTRxZus5m5hk+94Dt9w91tn5SE0HabLCrwdL6DFo+Y9dVagqfESAABAgBajABkg611ds24Pk4OoZGEqChzszWTr6j6LvS11SAlna3vt2Zbio+31S/z3ibqr4Y1/Jv3VbE2+R2hVXsYC71By2LgM/YnVUTINkDkB4DAAFCgBIoQDqlFfKNeYW3E/sdjSpAmjPGq4OrLctmjTPSoTuL15EAufUvdakhluevJcrxQFPw8kBXy5ZRnu3nu3d8t5es8mGbctVFFOWSqfoKkJS7iNSpcR26YW4cMUsAgAAhQFUWII3lKRPjoPlbHnE2s5TOQ3+zswmqdH4jSfQaNQjamQY0b4K5yQogmR66Ufc+098gz82XNNB2ZKd6L/i3vgTIvJzdV2jkv11jI0zhnnPZs8uty4PBpp0yc0olcdR7Ysw+LVmxad0BAAFqcgHq72xp8ZkO2aBbbGhnHmS7gkYUII2J0T3VzNs+pP+um9oG6UzrUYC8zUmfCjKltaRz57cZR7+y6Y5KPO+6ak7rjbfkfkWZ2JsjfetkLnOxz95yH6F1BwAEqIkFSFfX+HTwutT5czbX0IgC5FOuG+S5ONpm+4h6FCCvbH5skIP/FJVDNvMtU9B0lO0kgqLxSlrePs+Ls6mp/o1P+3WyeQPYzCRadwBAgJpYgHTzSvPohn1m3kYTIJ0KMq7oCpjzJgkCpJsam1b0iRRvv+l3p28ylMPvqtoGiGwZp+GcwOhMu/Ee+mz9wb5dAIAANbkA+SxrvivMNTSaADn5jYo/t7LcUvEkCZBXD28ttT+Xt4v8OkO9/3K126Nnuye+Qb77v4bpyLnGOinJVn0E72padwBAgJpagDR4t7BzSJ2MAOmquNRFhs/dEuYa6lqAJI7HcN6nR1ZY+eQM6hsJ8K428t3H2QRjy3+/x3D9y8OILAAgQAhQAwiQxrCYpnh05AMB8llBFHLap54FyMvvs6x4r75M2h1FkZV/xXtp/bJWbZLUkf0M5XBPiedsurFO5TLH0sIDAALUjALk1ymXWFXTXAJkyiGTuaLRBMiv/uhIoAbJG9MjyG7oNRMg04hVV/rPvuXmxg6Z9i97geXwAIAANaEAedew0tCZ/CZkXTmgsYKgjckLHwl3f5yA6roVICf/TvHeYLdrMLQhfuYxmxVwsbcF5v29ZpV8diVpos8KsltqNZUHAAhQTQXIb/lsEwnQQ4bPDdgsb9YcQfKZE/32wNKdvRM6AnSeT/6fnQOP/EgcTX82fbDGzJinYVJnBy6b41q2i7P+BagLq/TcBjE6Icq90JVnEUaqMvrcGxIi7ld2BC6bvs98D9I3aTbqsNe0LJt6h27rQW8BgADVpQDJKERn1M6sQQXoPJ9psD+XkyBndZArLCX3DNO37yQKkF+KADkW9s3c4YOlPuusVnJjVf5dei+w4JmU3X3IjOc4K45nTPZ/yxr23Xq8MAeSZsYOc34v8eIz3nnu0KnWoVzLJ8ptZ+F+1pmKm2qcyhK5DCIgnkAu8dtsVcRuRpCEn869kABqqddf87YTEVHMfIfeAgABqk8ByqX399lY8XHnLVd29dYYllKdYkMKkPtG7dc5vyD/PF2CYfdWURzqavl0f1fq67IqqEuE8mbD0mi/HeKXqQTpVJiWcX+u5bNJECBvZKvf5zet0izFOrqjSfV0A1knc7ETcOt0ii+aM2sbc9icq2XslI0cmnXZdxTDkI1Zs3hrvI4uSx85h2ZSthYgd8n4utLbT6RuD3sf+nLpz/icd8jdgy5zhrPiTGN8ulKTVXg0AaIz5eXWRb9rmmF5DctL/MblKv+6t5deh45eutvEZPbV75Gy/rX8zb+cLUM2/dwd9BYACFBdClBfbuL7jEPnAbLgNrIAeddxeQw7YT/jdS6BNrT0m3qst0zQsgLq+1HLxttmZLqzT1igHeLT3ygh8jcF+k6pq+Hqkc+mrRFXCHr17PT4d2BPXWobjySilzKlf4h4rNapMHoMAASo7gTIa4DPC9CQLW86AZJAcBnZ+WfYhl+nYEYaf5k2OizI5/zkox43Q9UNN0N3jF3pazV42TmP7D8VcDf5H5aog7uXGGl7+ZCNTsPVSR3p8N+BfVn3xHeGvQ+LZrS+Xpeelx7NCXxs0Hqno2JhrkXrq5yjN1BZlj7W6X5j5aZEAQABqqkAaQMsjeZvSzRmOkJ03vDkya9qJgEaVTZnOVM7wRr+IR3RWNLZMn70efRtXKYNTik52qZTDMekt0iKAHn17zsWHfdazTRs2m9KpxDL7HT+sK7IKiMph/pMsW2cptKd6MOUlU6d+Qdsp6+K41nW+CgvuPqW8lNuxbvVa16ioWzrrnFcy9CxqY97G64ut7yORbolSthyBoAEoMnQnDn5giNosGCMArSTDL9PG31oRxCq0ZPAS30T9aYkeqRD/pE07geVG8J2dgYvuAY9wk43SMd8YPFvstuAdOO1SYdQdF0++yOV4oVc+j06zeHtoq15cB7RZc+687kzVSar6ZwYkzJv3irI3lYSp2kZ69YKKhGFwlRUJse1fsxUxlHq21DXjmNN57QdzVjYPWkzDYz26s1tcjwgxxNOTIhKncSw6AhKuZWF3g7zB2m980aXztRYl/7OlpYgAcGKrlpyRMiV1h4nVkviZ8LWxSAvPaWm5kJ/lwTaO7FL2cxMCcK+ULfl8EYjtVwf0W1Z5Lhef6eTZsFHnKPijE5JDJe7eWrmCvnOBd41POFcjwSr633XOj0yogcAAAANhAqZQYD6w043AQAAANQ1OgUmQduLDaM/v6B0AAAAoCHxlp8XTX9prAylAwAAAI0pQBLrYhCgOykZAAAAaEi8vcY2GFZ/7U/pAAAAQGMKkHmT0UVsFgoAAACNKT/uru/rDZu1Hk3pAAAAQMPh7FifS/3NtL2JJi2khAAAAKDh0CSEcWY4BwAAAKg5ui+YZtgu/Pea2NDNfGzc6uGu4e5Jr6b0AAAAIJkClEs/6knNU3Jc424CmrnCmPDQPVawuScAAAAkFmcvP7tNPtdWYs8vAAAAgOoJUC6dtZCfleT8AQAQnj9q+zfqrumFx0BX646UTnVZnt3lXaZ7ocuXKR3wQ1d4DWQz3xK5eaiE+Gjyw7/0ZVPbUmLJRaY1Dy1qq3OZfSkZgBC8kEu/x9Rg6qqRJP6exd2T/m8gl5rhxEBk02fJKpcvJ+XaNYjV3HllDqWmQhD6cplP9Xdlvq8bm0rdmaUB0INdqcMGulq2pHQaQYCMkns9JQPQ5AI01JnZWq59YdHv6cqcjwABAAIEAA0pQD5p/t2jM/15BAgAECAAaDgBGrUUuPj35FKnIkAAgAABQCMK0FN+AjTYlfkxAgQACBAANKIA/d5PgPqyma8iQACAAAFAwwnQ4pnpD8i1LzH8nuuGx4x5BQIEAAgQADScACn9Xa3vl+s/U3e+lvw5V0mSuO8mZb+jehYgSSdwTEFuooN4egAQIKd96Mp8pSg/UXf6zdwxQIAg0QKkCRqLryl1O3cMAAFyrytzY1Hc5bETt+KOAQIEiRagvq7UDggQAALkh4y4P44AAQIEDSdAA13pfRAgAATIhLMVSy69GgECBAgaUIAyP2hGARru3u61g9nMTpJgc5oT1yD/1K0mqKmAAI1uH1q2NKYeQYAAAYLEC5DsqdZMArS0u/Xtsp/WT+V39hdvq5L+DTUVEKBR7VY2tQsCBAhQABZ2T9pM9+xaMnPHbfS7dfg0qWXXl5v4viWdLeNHfov+tloJkI5WLD665b0j17Okc+e3xdbAyWq6JAvQ8LSJr9H7o2kStHykYX6L/z1Nf0Z+37O+GcUjPhfPH7X9G0fukQaX10sZaX0ZuS5dSRklbYSzIfEx6S30XFontfwrfo+P2PN1y7onvlN/g97nOJ7HyNckZfh8947v1nLQ8ohjVVQUAVKxH91exXVfdEUoAgQIkI/wSAe+r7dP13/lWF/wXfr/n5G/uaI/m2rTDiJKgyPnmVd0ZNMdcTRm8ht2lvOdIcfdcs0rDeW2XhqDx+Sfl8nUyXeGunYcG4cAabkUddTH7LS5s8t3Nv07bwuQtYbPrnRWZ8gUlnZKNp20Bj5LYOMU3UJEvuMFw7n7jWVdcPTnMudWu04/2z3xDXrPpdwuMm6O6x59cvxFjh+O7JQ+kM18S/7/Ot/95NwtVaZYdVjS6cnnpmqdkONpwznXyPGklNPF/V2pr6vIRnuWC+9Barbp3g/lWj4h33uiHPPlWGq4ruXasUp93r/cS4q+CMizcbiUzR98fqOW6QI5jhvq/sRbY3n5mLnDB+V80+W43Kv/fvftCTku0fqg9aKS9U5f7DRdxEBX6k8yUvic8ZqcZ0lXTGW65W8ytqLpI0DXmNrd/q70XoO51Nlyb/7u3c/Cz22Q67lPjl+oNAZuCyWtSF82ta3WV3nGcnKeu3zK/pogbYTeS3piaCgBWjSj9fWSgyfrdTTDQQ9pRBfL9MP3w4wMuQJkOK9M4UQSH/cN50Gb3zHSwAxm0zdLo/jlKAI0+vOOhHWlry3XUZsaXs3VEaRxcxpG+9/qdzxVrbqsdU63PJHvHLS8xrVSpn82yLnWx5e0To50IEPZ1l2DjqjI358mx4uW17JQpSPUKI6ONBjOOdTZ+smCEa47La9pvo7kFH5f/8zMh1XcfOTb7xiQ43OhnsXJk1+lAurJVJi6OCTPwfFxj0ipTMo9u9pUfwK0d4+LpBwd9MXPJED6cjlaRt2XNGNC2NLPQC59epB2V37rlTG2D8MaZ0dPDA0jQN6y6UeiPRiZG23fFuMWIB3Clc/fGs+Dnrlb5SXcCFAmLY3O9nLcFPE61kpZfCOAAA0nTYCGjk19PKSkmo4HdGRI33JHv6HLqNubgkyrDHS2fqHUNFqgTkE6NZ3WiUOAdLRQp2LkheTC0HLblb5XBdOtI9u91ouRWhPmXCqVA9lUq9XIh5ZpNv2fmO7vPVomkUeFRaQ8yV0X+ZrkBUVk8giVPPsRoMwZWjc1Aawcq6Jch9aRAAJ0FQIECJABHRb1mR4afazyNi5dWaYT+Ls+2LUQIImDSNmOXgU4rgsjQDKFNce0zDTkMaD3vJEEaOC4lu2MAcsj9UimJb1Gu8cbdr+71KiFZg8Pey1S/78XoENcoeXiiECp+i+jCuU6xAACtMGTwydjkPjuZdnUO0KMIJnOdX/QUV6VlYDitsxnqscodFFicvSzmm0+zmdF60O52D2jAOmUm/9UbwjZzeyHAAECZNso5FJ7lOio/62dQ+GblzvKkprh+8YsMS7VFiCdMvCJi9jYqbtTLfJ7JbhRYwu0MdcgQ1diJOYplz7HG0kY1aGlDgglQMXHi9qR69Scfmak0dTr0LgjiQ36ZsmRq670Sf4CNOaVRSntXVktPM+ThX9nPCQ2pJL1VwOafeJOtCE/X0dx/D7n/bal5piV1B729T9zaImO+jaN5VKBKJxGcuJBfOqb1JmTo44AFY4KuNMu6Z/Iub+0uLN1gtZbnTpxpF+miLxpqmFj3FfxKMyQ1Ke81m0VUZ0q02Bk+buPOOVRYtRGp+OCl+0mcSYb5Pr/oc+glN/uIzFcm4zMyLWoyOrflejozw9T59y4xtTtZV7wevX51PiWkZFsfZlbMjM1TqcAtZx1enz0vdGppTBTYMbYHpXUbPooHXXW0T8dtdOXGw2878+1fFb++3kl2uoHS0p+NrN3QftwnbEP6UqfEqSN0KB7emJItAB5je+QadpFhna7yu3HpY2EF0RpGAkqH7sSlwB5b7hP+b25a+CxTaCqBka6nUrm/nJBmAEE6MmgwZxaFt40hek8/7Xr2E0xF/WxCsybgigKRu/Ppg8ONGLjCIhRoB622UNuoKt1R58pIZXVQ8sFu3p72N1jkrHB3A4fjShAI6Mu/9POq9zIi7MSzI17KlUXV2qMX7ngem8RxI1Rn0udHvKC8U+06TCd50BEyGdUbp2KkrV0uws6/MqlV2Uy6Lk09kfrqjO93ZWaHFGAVsnU4s/1pSxQ29TV8mm/UfjRcWPlp8QcCWIVGDSnADnikU3/1fQmIg1Xe+DzSMyDcVhZhqurJUDeyiFT4/JspZPglRCgNbrawjYmRKdPnKF+49t38Ea6XgVIR758ppFOtJIoWY3jEwA9JWi9VWEyBlDLCEXQ69CRGJ/Yud9HFiAZpbGZTnaEw7+jvVO/K+i5dIWRaWRMn/Wg59Brj7JCVH7/kT7TjOfYnMeb4jeOuujijSoI/0O+K64Cis8m53NH/AyjN6nvIUCAAAVqFNJ7+TyUp9tej7O80tCpBZknjipAXvD2BuOIwszUbpW+FyUEaHqEBnO68Zyd6c8nXYBkZPBA0zSNzZL/Ub/xMsOIyRXBPutM4Zpi2Kyn/7zpiaJR1CBBu74CJDFk1uWheWvMdXHBSDC0ZUd7n+Fci6pVV5yXAYOkqnAGPodMrZn2vBoJRK7SiKdJgOaHzdtU4j7PQoAAAQr0UDpBpUVxMmHf2MxDzOUbmKgC5Bfcp3PZVenQ/QQomzokdIMpq22i5rOpWwHKpS+IK67DvN9Zelm56SJvJZApfm1B2CSfpviSIDLlJ0BhRya8oOJQo1GG33Sp4VxLqltfUiebyse0xN88Opw+2C9mJkr+phgE6PqI53zGMDJ2FQIECFAZvIRqhsj+dGfoB9JdRmw9DRZFgLwYjLWmaYxSq6bqXYC8FTSRVjrVrQC5QaSFI4XfifNNuFy2Zv8pkcy+YX+XF7NSeM7LaiBAiwzP4bUhBfM3hmtbXWVh3j9KvIsGsvuM9E2r1m+okADdaWgz/4oAAQJU/uE5zjQfbhNjUoiTSr94Kmpl2bfxCAKkb9g+WZgvqloDXQEB8lZJmd5af5h0ATJ1BhrkG+Zc3khO8fRnmSBZja0xlO1glBEBcz3I3F8DAXrCNpVDCan7lSm2rZr1RaexfZ6FsokZ3QSDxkSHSyqdYbryAmQMUr8DAQIEqPwD+RfbZZRhG99yW0tEEiA3k2vkrQ/qTYA8mTSVyVGJHwFytx0p/F3fCCVAbiBz8f2XVWLW12DYmsDqWkSeDEuUVwVZvYUAlRoxNG/aqdOf5a/fyUA9HPe9rpMRoOsRIECALAXIS5q3vFRq9vAPZXEchAaIVkqA/PIQ2eyRgwBVfQRoQRyBx+5ITsuWpnLSlVm+owI+04ua1yfyb3P3krLqUBCgkAIUQJplavWX1dw0GgECqHMB8jLwmt6KTov8oGfTNxiSwn2pEgKkOYh8fsfSKLtiI0AV7gwk4Dku+dYcLMYg6BL3X/doM8eEpA6L+ttMq438kjoiQJUXIL9cRtL574kAIUDQhAKkG0P6iINmIZ61yeHsWj56F2DJHqqSM3LIUk7d+mLkMG3mVykB0n22/HaSrmoDjQBZSnLqEFP8TZgtDnw2eLysdKeeafffBbug/hftgi2dzqb1/46C+l+0p1O5XbMRoGI0samb3kKys/uM4gQTIGfH+eKy7WxpQYAQIGjGESDz0uGKHZUSoL6ZmUl+e5EhQPUrQM6WC4ZMtpoB2+Y8XkbcDYYM5AeWGaU8qpr1HwHyeW6ko9WRGGcfNs2ELPmbvASgSwNuRBpEgIzbg+hWIggQAgTNKEDu3kfV6gDW6JL7yowAZfb0+dxfq3kvEKAQZZZLnW1ahSjTHd8OJD+axsEQb+PkdpGVYSW/29kPrmr1f4nu6YQAjWQAd0bfrnFG/KLvxB5EgFaYPlvuniBAAA0qQJpzxSe9/D+Kh/wjHgFWaoQVIGPeITeX0c0IUH0LkBe/tcgnGPkKnaIwxfFo3idPYEx7Ia0PkinbZx8ybwo43vofJBN6owuQW5edjWOXhRCdtT6iG0mAgiZRRIAAGkyAdHdrn6mqo2vxW0IHQbtTIKZG818IUH0LkHP/JJGdz0a8I6kMdGPPW+S43Evb8GipzlL3wQpWLk5nPBzXUvyoNLIALelsGV9qZ/nRMWBeHNVcp2OW4Pa+zvRE3b7D9zkPJkB9YaYlESCARh0BkpicWm4dEZcAeTuCmxrThQhQ/QuQWxd3+Gg5sQlwrAg6deaOIknMiXHkMPg5EKDyeEkIn/WRW10td5yOkJXbeieiAD3o006mESAECJpQgPyXwadmJ0mAfDJPOx2ibqKIANW/AHlC8nWfbL3ljvW691G5ZeZF98tnGbyMjOYQoPgEyCfZ6no5Z7ZcnFaMAnS9z8veXggQAgRNKEDe9gGmGIp7kiRAXiPwX594pu0RoPoXIInvOKlAYh+WqayL/XfvduJI/iLlcLxOr4T5Ts1M7vMCcCkCFI8AyVTWzlG3colFgLLpn/lM95+MACFA0IQC5D08d5gCDiVL7psSJUBOnqLabnSIAIUc+cllugqu767R9U/3anI2u82mPzLUmdk6zrrpMzXzLAIUjwDJ35xnys8VZmQ2kgCZE2VqoP2NCBACBE0qQIbOx2t0U19PkgD5BXTLCMIfEKA6XgbvZvcdPe21WiWneh1SarZxh/FjUx9HgGIRoAfDPM9xC5AuvXdWkhV/fmWp7VIQIIAGFiBNBOYTP3NbkgRIGzFT9l3tXMNOkTS2AAVvICvbIWy6RYFualvV0aeZmd3NG2xmzkeAYhGgFYbNS4+stgB513JdrVe91qUAZVPHIEDQlALkPEBdqT/5zNN/LikC5L7JpC+oZVB3vQqQjIL9zdAJ3Vvruuu9la+LkgU6pjpnWiG0uj/b+iEEKLwA6TSXz+7tNRGgvmzmqz7t3KIw2680igBp/TL3IdWt/wA1ESBNNmccBcqmX9DYi6QIkK4C8hnm1jf6/ZpVgHSFlHGj0O4xr6xl3TUFyMqU7Lk1GIXa16djfLiasXDNMgKke3qFG61L7RZFgLR9kan9f/rEAs1rWgHKpaaE2boIoG4FSHNr2D1E5lgIaRjuXzwz/YFIYtO93WurIUDO78imf+G3M7z8xj1C/4YAy3Xrdwosc0Y9jPAVXZc5g/fT5fLBVES83Q1NjdnEo26XELT+N+QqsFz6McPnngpaJi+PUqT38s0ELQHOlqNI632eqZ+FfSnQOjTcPenVSRQgzcFUT+lQAIJXfhm69Rlm/o3NefRN1y9ZmGbp1ey6uhon6Pn0b70362uCPuBxCJDzvf4ZZ9fpcmvdfiH4G2Nrymn8pfGVOfG3JFKAZPTL5xz/0d22fX+/TGHIdhNfqdRIkQYa+9Tda5dnd3lXVZ+jY9Jb+Haw0mFLR36QVc4a3eLD3e3+VnnD/nXTjgBJLJVPeopzytUrff5UWIxTuBGW1JfYAkVl6k+alNOi3dzc21T3wf5s+uAkCpBm2dZ7aVwNnEt/puR3Sy45psqgZnjSsMyYFTeXOkCnsDTWQo9yS0+XzEyN0zfwEg1Nn3QSeXngO1QMNIBag4/1c852BjIUrXk15O/mawzFaPHQhqIaAuT8Dk2775N5dmRjSmmY52in5m6m2bKldrj6e/SBl6mZwzUXjE4BFnxuahIFyJNbv521n9IORK6x1Vlmnm3d1dmoUspH/v0zzoooubeVqLuuJPte10q3M0qfIx3gqeWPdFY+M13roDbIYZZZe1PBS/zrTeZ/KgL6XOn2DFrvnfov9U2fB7nW/XW0TbdyKIht6g8y4tGQI0DZ9Bf9yzN1+2BX6puaj0k7YT2cdAeyj5snKQsDJsJ8Wr9H9/UaaetKtjMq9hJsXyqxphsXmZox0NW640g7p6sTtY7o/Xc28XVftDbYiEw9CpB3jt/7rKJ9SZ9B555I+6DthK4Qdvfhy9zt/c2p9MRQu1Egnyynho64teybqzzk8rcPxL8bdmpGtQTI6fTdeKAnYv0NMk2SRAHyphBOCf27QwatBhp+N+8GH8fxvHbaKhVW9SaX+dSI+MV5aNbpZhQg75m+M4YyXKOrteSfTwb5+2XdE99Z8rpESP1yh0U41pb73noVIN0SpHBBgkXbfju9MNQMyeOwZ6BGWN62gpxPYzB02D7ktgSFx3LtfIMEk8YpQO5IkK4ykpGc6L9hgwYSlxvqrWcBGj5iz9d5IxNhOu9jKlV3nd+XTd9XIQly32Cz6U6ba9LRQPnsZTF9/2K9V0Gmzxp1KwxdTq1CGqEc7+jrSu3gjSh1BvmMjtAFam9E7uXvX4zhXj8k59onqSNAznm60j8K+dsX0AtDbUeBZEsAn3w+G+N4dBjT5pzO9JArEGtDPBQLdbftcm9ElRSglxvvls9qMGuI37BSt2MI0pjWuwB5HdFbpDP9o8WI131adpWuuxovU2ZKIvohjbv1dcl0oNe5bLD/zsz9OuppE9DdyLvB6zSzxkNZibcIu4xM7L1JG+Fu33N5mc8u0tgUi+diKy9j9aoQdWu+TAcdGHTKtZ4FyBuRPVpfGgL+9kH9e5vYOIDKdSSOsMiKn2z6r+7bviSZkyBEzZIcNPDXhEqMxr+4eXa0Yd90aas3TyyrPTJXaCyGCoPKjO33eAI0q+iIaQm7Tos5b5ASZOvFvhSOcA3qcK429jrPbbsE2olfMFy/rrIIe806cmMskwgruPpmZiZ5wakLCzr3Vd4o0ZkafBrmHoZqwHWbgk0DkJc69cyNwdoQkwSt0+mtcNfXsqW7c7zzMvCwoYNY6caDyH+XEQVJrvjhMN/jjTwV3Wtd/h2qM3PiNArOJYsZQnaMBxSfK1hw9yZ1T2LsvPiyhwriBIe96a3r9BktF4isKwg1ZYITJ6Z11s1lNktHYRZ2T9osrIhrWynnm+sENRff51VevfytjKYfFiZjuTfKUniPp0cUoOlFbY5kdw7/PGp9T5/iCOimZbDB25vvEo2ftFkUA9BQaOXXKaawjU09oG9tGwPEa5wTp2ZlIJKjv7/aS89Hyt/pxAoCWjXY0uY8eu3aaGveEi8Z5jqflUcXxHXtOjLn1hu75dxQIETyklFudWUt0VW2SW/n4qrr1FYAgNhGfpyRgE1GUoJON5Z8k3enrlYYJKifUgcAAIDayU8uc6ghTuek+OTKHNAZZSoYAAAAIDReXqL+Ajl5sVRSRlvceLhiAbJdFg8AAAAQCxJcOS3ulTCFaFI8RoAAAACgbtDVgpXeBNUnJcFySh8AAABqI0Bd6XsrvfGiBFj/wLSxKaUPAAAAtRGgXPqR4mR/qX/GdX4vb9VThkzoh1H6AAAAUBMkH89NPtma94l6bi/j8AJTZnLdbJPSBwAAgJogMnJcqX27NPmc7Tk1K6+I1U+cLV8Mm1RqBmJKHgAAAGqGLIPf3N3uwnfbihedrQ0kL5DmC5ItHb4i20vsvvGQbUr6s+mDnf3v3C09Hi21BYb+LaUOAAAANUc3ufTbsiLGY1HYfbQAAAAAKoK3ZcWiCohPv4wOHUXMDwAAANQlzm732dQhIi13aKxOBOkZ7M+lL9SRJXapBgAAgMSgu4Lrju6SE2iG/PNskZrfy0jODfLP2yR79N8lSPpvEhd0rcQFzdPEiRow3ZfNfHVxZ+sE3VmeEgQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACoEf8PC5vzecNZxDkAAAAASUVORK5CYII==", + "logoBase64": "iVBORw0KGgoAAAANSUhEUgAAAkAAAAFkCAYAAADIT4SLAAAABmJLR0QA/wD/AP+gvaeTAABJrElEQVR42u2dCZgcRdmA4436e98IhISIB4pH2JlZDomIIl4oGECO7G7AiCiGKMfObIAVUBARAVGIyc5CAJEglwKiIAiEI+CBIDcYJJzZKwdJyLn/93X3hs1M9UxXd8/RM+/7PP2gsNPTU11d9XbVV1+NGQMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADCQS/+Lo/GOwWMnvoXaDQAA4C9AwxyNdyzLpt5B7QYAAECAECAAAABAgBAgAAAABIgDAQIAAECAOBAgAAAABIgDAQIAAECAOBAgAAAABIgDAQIAAECAOBAgAAAABIgDAQIAAECAOBAgAAAABIgDAQIAAECAOBAgAAAABIgDAYqJD07tedP4qfndgxxbHXDx2yix+mW7yfNeG/Rebt3e+95GL4+xHfndApbF1tSe5DKuvXeXIPd5y7bzt6G0ECAECDYijf8ntm7PDwc7eidRYnXcERw6+z2B72VH/puNX7fzK4OUxdj23hy1J9H3+ZlA97ktfxKlVQUBWvqbw4ZX/e2CQMey3ukNIx1DP/3K8ItXnza86s7Lhlf/60/Dq267eHj5RccOD57waQQIAQIEqCkFaKup+fHjOvIz5buulU74nnHtPfPlf188rq1nijxTm1FzEaCGEqAV158zHBSVoMTLz/E7D6+649Lh4XVrjb9x/dLFw8svPhYBQoAAAWoeAerufrWIz+nS8a4t8f1P61QdtRcBQoASKj9rF/6z/A/dsGH4xSt+jAAhQIAANYUASYd7YcCOea3GqFCDESAEKGHHqvm/Dfxbh9euGV5yxr4IEAIECFBDC5CU737BnyvneG7zabPeQC1GgBCgpMT8nPIlR2pseOmeqxEgBAgQoIYWII31sRQgvSffphYjQAhQQo4Xr/zJsC3rlw8MD3RlECAECBCghhSgbafNeqecd0MIAbqCWowAIUBJmf66/XfDYRg86XMIEAIECFBDCtD49t4Wa/lxjp5/U4sRIAQoIYdOZ4Vh6LS9ECAECBCghhQgTcwXToDyj1KLESAEKCHHypt67O1HVoMN/ugzCBACBAhQY44ATblg25ACdCu1GAFCgJKSbbnne9b+s+65xwiCRoAAAWpYAZL8P6+U875gL0C9p1CLESAEKCnHzNbhdYsXWgmQZopGgBAgQIAaVoAEOedplgK0buzBsz9MLUaAEKAEHUtnHyZLu9YFSwP05L+HB47bCQFCgAABamgB2uKQOW+Xcy8Mej80YzQ1GAFCgBJ4LP9tdnjDmlUlf+eax+8eHjz582SCRoAAAWp4AXJGgWRERzrdx8rKT3vvHN02gxqMACFASU2KKCu7Xlpw+fD6Zf0vxzuLFK159M7h5ZfkqpL7BwFCgOJmq7bzt9NtCkod+raPACFAJj44tedN0vF2yfc8XPC9L8m/v0GeqS/QCiFACFADHYPdk4YHT9y9tgHaCBACFE/jem25chFJ+iwChACV4/1TLniHCrXuDj/hiLNfR+uDACFADShAdbFCDQFCgOJpXP+DACFAgAAhQAgQAoQANVvjugwBQoAAAUKAECAECAFqGnTKIki5IEAIECBACBAChAAhQA2DiM1EBAgBAgQIAUKAECAEqKkESDrGvREgBAgQIAQIAUKAEKCmEiCRhBkIEAIECBAChAAhQAjQpCZrWM9EgBAgQIAQIAQIAUKAmk2ArkKAECBAgBAgBAgBQoCaTYD+hQAhQIAAIUAIkHkLjJ99bXjVLXMDHVouCBAClKCGdaiBBWg/BAgBQoAQIAQoym7ws6YF/q3rB59pMgEafsWEQ2dvIZ3Nrlu397RLQ/sD+d9ZeRhPFZk4QYNsx7b1HCKbIU4eO6WnVf7dWxtZgLaYcsH7x3bkd5Odrw+S3/5tOd+xUiYnanloJ+QEHUvHLP8+856D576xlnduwoEXvTlouSRUgPYKfOLJ816l2zfIZ/aQe3fo1m35Y+R//8itxz2dUn+P0BVzus3DdpPnvRYBahx0n7vx7b0t8kzur/dZn9lx7T0/lnpwstaDcW35afrM6p542tYhQAgQAtTEAjRuypyUNA4zpWP4kzx0S4PLxcbjKfezIkgiAtr51IsAjZ/as7NNwynn/poGEkvDc48cyy3LYZ0c98k5Thk7Zc4nqySDm+l3iZQeKI38r4Jeq3QM10gnMCuOY/yUuVtVQ4DGtfV8vtS55F7vIB3c8VION8rfr7C4by/J77hJpUI6zg80swDpRqdB7rnUn3PqpT8cd8icsVL/vytt0CVSNk+HaL8G5bhVftdZY9vmfKkaLzL1I0DDr3DEMOCzvlVH/iMIEAKUeAHSRkPfiuQheyJEg1HuGJCO6LcyQrRnJWTIRoDGHjz7w6XO5WROdkd2bpG/Xx9nOUjjdZuOpMX1uydOm/Ua6fgOkLL9iTT2V8r5H/Oka7iWhyZfrIYAyUjcjoWf36Yjv6U3IhdXPd6gMqQirJ1D8wlQT2fAclpVyz5wu8N/9X9SBofLddzu3LN46/RLKtE60q2jqo0sQPI7j7Yolz/U4plAgBCg2NjmkDkflbe3i+TBWlulDvIZfcPQjq4WAjShI/8u0zm009ZykL9ZXeHfv0G+Z46O1ET93dtOm/XOWstOLQVIZWd0PRZRubTC9fgumxFEBKjy6AuLJ7wDVarfK6SeXTBuas/HG02AnBHT4O3fkzpCzhQYApRIAfLiQ86sovgUHit12HzsQbPeV0UBWl84AqXTffLv/1r939+7IGq8VJML0Gq9lyq0MgKWj3u0rlQdEoE/fcIRZ78OAaqlAA2/Qkdk5Hv7a1TPdZTpWo0tagQB0hE0udePBPzta5zQBmKAEKAkCpAGAgd94Cp9aFyNHF06nVNpAZLv6Rv1mffKv/tdBYbLbY7bowTcNrkADbpTlVV78y+sS/fEOYqJAAVHFyJ4U9T1UN83SF24MGpdqLUAybkvDvyb2/LfH1NPIEAIUNC3Jm3QajjqU+rBvqHiI0Ad+Qedv5f8MTV8c9w0kFdGExCgcFNgtT96/1vpIGkEaFN0BaZ81wt1WB/69XlMogA5qyKTFveDACFAtvKjU0513KGcWYUpsP/JcXmd/e51Gr+CACVRgJwOaVGUVW8IkEXdaOvZ151+qcO64L1cJU2AdBWXxQrJ/9VN3A8ChAAFRmIlxrb3zI26EkKOO0U2znXy3bT3HKYNkubO0NVdmgdIjm/p0nc3mFhjXIIvPdYAvGoEQdtOz2ngq/xztrupqORBauvZx/nNsjxWV2DpyhNvufm9EVZgXY4AxS5Az+jKQ8354qYy6M2oaOqIjV6fs7xbhvK9of/nItaT+yu4OggBckdt2yKucNTVfA/qdJWbGkHyPkmqCG279HnW9kv+e4eugpK28jfuyr/84sB1IGL510KAdCGG124FivvRPG9j6hEECAEqWdHb8meEHZ3QvaQ0QZwGyVl/cXf3q73VVUdowGCJxvyhKgVBB3qTc1aptffuotdvNTwvAd06pRUmX9DoFU02o3pbHXDx2/wO7fRtkgqWOpfNESXVQUQB0g7rVOsVOt3dr3QSJGoupPASdCECVKFpL5GUCPLzD31JCZvgUKR5gpP0tD1/fomptw2aRiRxAtSW/3Vi434QIAQoUIfijMqEWuEgb8a9W8dZTzefNusN0ph8Qzua0XFIGgRdYwHSYfXzTXllQpW5NIZB9+B6uQx6fhh3u+ANbzduJujRU5vSQGv9iuF52UXO93DIeK6DEKCK1OGlIYT0nti3vRFRludkJycJYHt+yahYsFtiuM9VFSB9qbUoyz/WXdwPAoQAlUOG+z8WIq/NM9XoDHUVlpd068moohVWgFwJ6z036tubCSc/icSHWIzA3IQAWQvQKp3OiHtJuoqUl0U4xAhUvFvBNLMAeVM0D1jeg9XarqisVLKebjF53uu9xRS360tmkgRIR5stVk/WZ9wPAoQAlZt+Gtee/7tl43FvNZb2Fr5VxdBQfiKE/NwWNvjY4rq+ZnFNS+J+y2p0AZJOf2ol66XNViIvbynS83MEKLbffqpl+S8d1zbn01WvtLG0YVUSIKdf6Jmf+LgfBAgBKj2Ub5XSfNidsqn+RqYxiYaNAK1xOosK70826toWBO48Yx6JavgRIHkDr3THFmLV4GrNU4MARX+mbdJ1aNxdlIUUtW/DqiNA3nZHQdM8TE9E4SFACNAmHZ8TAOts6Be0oj8bLgg3iQLUu381r006ph8EXgnXkf8yAlRHAiToRpjeHms2EnQqAhTxezvy11ll527r+eqYBFMNAfIS4K5riLgfBAgB8q3ozuaYNsP2vV9JduNhJUCTqnltuuzaIoj2KASovgTIkdgpPZ+xFKChuHYQb0YB0v3WqpVDrFkEyNkyRl50g8b9aAxjYgoPAUKARnD3dMkvs2g8Lk5+41G/AuRNo6wKKEAnI0D1J0BeB/UHqxgzyRGFAIX+zddalPVToVJ0NJUA6Q4AgetvMuJ+ECAEyNiBtOWn2eSfqXQq/6YXILdxuy9gh34WAlSvAmQXaO9OISBA1qM/klXbKueP7Ac3pgGopADZxYP2HJm4wkOAEKCXK7vVyq/zG6PxqG8B0n3OAl5fDwJUnwLk3cfbbILtx0+b9RYEyLqMT7LZhiTKZsLNIEAaGB40FUqi4n4QIASokC3bzt/G6i01psR/CFDZxu2qgNc3DwGqXwHytmOoamxdswmQnOdRCwE6aUyDUAkBcsMheh4JOpWYqLgfBAgBMjTQMywaj8cSafsJFCB3b7RAw89XIkB1LECSJsJqM07ZggYBsirfD9kI5vgpF2yLAMXR7kjcT5JfhhEgBMgToJtqlbANASp5fb0IUPIFyOuobrbZhwoBsviutvwx1dp9vdEFSLbWOcSiLGckuvAQIARo4rRZrwm62sh5kGSDQQQIAUKA7BDRONFCgF6y3VC3uUeAgq/+kh3bz0aAzHipN5Y1dNwPAoQAbdIwT5nzSZvEYY2wdBQBQoBqIEBftMtR0/shBCjwb33OYvPgfRAgY1uzmcVGzMmN+0GAEKBNOg6L5e9u/E8jNR4IEAJUHbac2rO5ZSD0ZASoPBMOnb2FTbnKPn4TECDjNOKvmyLuBwFCgDad883/0iLXw5WN1XggQAhQVTurpRYjFT9EgIKMYPfsaSFAKyq923sSBUjqwN4WiTp/0DCFhwAhQNIZXG0xAvQzBAgBQoBCXqNFri3J7n06AhToew6zaL/uH9NghBGgCUec/bqtpubHy3Owq5Rfe+D9H6WvaJQVwAgQAjTyAN3bNFH/CBACVEMB0sDRam010zQCZLV/Ye+fm1iAdGPeuy329So8ntzikDlvb6jCQ4AQIKnY/fXecSBACFCDTIH1WHQ41yNAAaSyvWdus2WwDyNAUQ6Rp7UNE/eDACFABW+lawMPy7f1fB4BQoAQoJD3syP/U4v6dgsCFKhMA0/hx5FgsikFqMFSByBACJCDlwMo+MqUtjmfRoAQIAQodGf9I4vn7S4EKNAL3A3BV9b1/BgBCjUC1Cdt0XsRIASooQRowoEXvdlKgKbMSSFACBACFFqAshYrLv+NAAW47+098y1GgI5HgEIf1zdUADQChABN6Mi/y3IT1E8hQAgQAhTyGjvyR1VrO4zmWQWmgb2B73sWAYpwdOS/jQAhQE07AiQPQBoBQoAQoJD3U0YgLKZr5iNAgabAbrN4hk9oYgFa5S13LzwsclPllzdUIkkEqMmDoGW/IUsB2hUBQoAQoJCddXvvaRadzQ0IUKDf+ReLKbCfNKsA+SZCdPqA3gUWz84dYybPexUChAA1yjL4NRaVfw8ECAFCgELfz3MtrvFqBCjQ77zK4hn+BQJUjI7q6OiOhZx3IUAIUKMI0GKLNOgHIEAIEAIU8hrbey+yGHE9DwEK9DvPt1jOPRcB8h2dPNwmL5DsHN+CACFAyd8M1S49/1EIEAKEAIW8n3Y5a46P2DE2SSLE3hOrNa3YyAKkK7ysMpV35B/cYvK81yNACFDCR4B6rrQIzPw5AoQAIUBhR4CCL9mW0aJvIUCBRtW+ZTGq9gAC5M82B899t/zt800zpYgAIUBSkc+0aED+gAAhQAhQ6M7qucCjFVN6PoMABbjvkp3eov1a1TABvBUQIG+Uci+L8twgbdQXECAEKLlbYbT3TrWo8AsRIAQIAbJnu8N/9X9uhxHsGt8/5YJ3ROwYm0OAbO67jqxN7fkgAlSmT2jLz7Yo06cTu0kqAoQAjZsye3tL438rAoQAIUB2jJ/as7NFrMqiGDrGphAg77c+bTG1OBkBKs17Dp77Rrl/j1iU6UUIEAKUSAHycgGttOg89kKAECAEyPpeTq/mVHNTCZBFcLnEYf0KAQok7DtYpkjZDwFCgJInQO5DdH0z7gyMACFA1UI6oMss6tp0BMjqu4602GPtEQQosFjabN47tE1HfksECAFKnADJEOYRFhX9SRk1eiUChAAhQMGYOG3Wa7SDCFymUv4IUHA0kZ/VnoYHz/4wAhR4duBOi7L9S6I2TEWAECCn8zhkzli7jfGqLwMIEAKUVAGyXKn0VBydSDMJkPd7H7J4lk9BgIKxZdv528jnlwWPX+v5LgKEACVKgLwHycL0ey5BgBAgBCjwVMKl1e6cm0+Aek+wKONn5dgMAQpcf79tIfArErPSDgFCgEZV8g6LSr5OGpAPIUCNKUDagFnkq9kTAfJniykXvF++86VqT880mwBNOHT2FrpFQ0OOVNRYgLzv+YNF//APnfZFgBCgxAjQ5tNmvcEmTkHihn6PADWmAG05tWfzpK3+qFcBkufkHIsVSvNj7BibSoC8l7irbfLXTDjwojcjQAEFsyP/LptEnnL/uxEgBCgxAuQ1Wt1WwYRTer6OADWeAHkyXLUVS40qQDI9uJ183+rgoxJzvoQAhUc36LRJNin7rf0aAbJqj74QtHyd0biOfBoBQoASI0D6RiSVt99Cgl6Qh2JrBKixBMhrWIN23OcjQAbsV9D8I84VNM0oQCFGgTbI798bAbL6vvMsyvdxzYCOAPmw7oUnhlffe33dHKvumNfUAuQ1XEfarQjL37fVARe/DQFqOAF6KklpEepNgHR0weY5Gj81v3vM968pBUiWxH/UKoFfe/7FcVPmpBAgq9Hhhy1CJc5BgBLC+qV9TS9AulmgVNy7bCVo7EGz3ocANZAAdeSvsBCKPRCgl9HOxmoqub1nbgU6xkACNK4jP7ORBChM+cuxNKmpPaotQIpM7U60kEwdZfsiAoQAJUOAxmxcBv2SZW6gZ+N+izVem4w2yQN1uHznA7ryAwGq1AhQ4A5Mjwdqvay4HgRoi8nzXm+5kaQez2uAaQU6xqFavqHXUoC2mzzvtXIf7re8D9Kh9x5b6dFMXR0leaG+qkkDpR62JVGAvBHO4y3K9pmom/siQAhQ1QTIeYuy2yV+o+3rxniaWDHuRkPfIrx8KqtGBTEegwBVSoB6J1ne+4u14wn5Xe/VDlOWJu9TFQFqy38/9lGHjvxuUj8ftO10x7XN+XSFRgaeDHgNNzeaAHl16kNy7iUh2rC7K3FP3Lam9xdu3OTGVX83JlWAVBTl99xi8dJxBQKEACVGgJxOpb3n5yEaEGcFgO59pKta9K3Y/puHX6GjUDI8f6i3h5JfQ3YfAlQZAXKksy3fZ3XvO/J3yPD4ToFG8qbmx4ssf0vzi4zkb5F/XlgVAfICNKV+na5iHTZQ0xlpkCBaue4bwjwnugVNxUbE2vN/D3gd6ysxhVlrAfKmcfdwc5bZ3xt59haIkB+yzcFz3x3muzWVhAq9nOfcEjK6XvNEJVKAvGfYMkv0gQgQApQYAdJ4II1PCNeAvJwZVN8ytbORBuk74zvyX1aZ0HlkfdPyRnb2k/9+lPxzlhw3yd8PBu5EpvZ8HAGKX4AiCvD9ch/Pknv6Pbm/B8iQ/77j2vLT3DQLPZfI7/qvb0B19QSocMnuP1XAdBpEjq9p3Rw3Zfb246fM3UqH7x1hk7qmqR905NFLDLck7HdWKvZmVMfYY3E9q7Wjdn6bLF3WZ3P0EUYQ60GAPAlqCy9BniC25/8l7eBvdIGISo1O9etu6VJerbrNiYjsZG3bpF6cIf/7GovRNx0FOjqpAuSMfIokWpTlEn2eECAEKBkC5I3G2CRzq/YhD/bPEKDKCJCXxXhVNe9n2AYyigBV+dgQdeo2oIAcFtszFiLfV70IkFM3RMAtV4ZV87g3yQLkXcM8C+G7sW4200aAEKCgEiRvONmIb1IVOnpvQYAqI0DeKNBx1RXacMPkCRGgZTpaUI26s+20We+0X8jgO7U5I8kCpOiojXzX4nqsE3qvkixAbl3T/dUCZ4k+EgFCgBIkQN6cr2x8aZcOvaLHKnmT/km4GCMEyCYWyCrYMeIhwvXjMNep01SxdfiVOe6WTMUfqGr96chfEJMAnZV0AVK26chvKd93e/3UiZ4ro04J1YMAeaNsn7fIwv2SPAsfa0gBWtbzveFVd16WyGPlzb1Wv3XJz/cZfunuKwMdtueuRwHyOuW3Sif1K29uvBYNh8Yr9Max0gwBshpNuLfC93W9rpIJu5JsZMpOp0TlXAN1JD4Dupt2LYb9vfsWxwvLVY0gQA7O6iVnenCwhnXi5rhWmtWLALmjxRahEhJzF+VZr1sB4qj9UUkB2hj8NmXOJ3VD1GqJkK5I0qBcfYuLUTAQoKByccict8t3XVuh+3uXLCPfMbZrlVFBCTI+SBrZ62x2CI/5eF6+u6vWWdKlDmWsV/M1sgCNjAbJ6i4J0D/NZhVTxOMlN1amNxNz/E3dCJDmArPJvyTP6MkIEEciBWij9U/t+aBU5jPt5oCDp6nXxleXGlfibQEBChUL1hG00S0bDNyev97NEhvfHljGURBnJZCuPrPa5y7k6KRIovN9tU0MOZot287fRss6zGo+XcWnq0EbTYAKRrSPrtAI5zpNDaHpDiqVCLCeBMh5Me7If8piH8H1lcqDhQAhQNVFl8xrMrj23lOkQZlvswv2qEOHpW+VRuOneq4JR5z9ugo3flt7KxjKHrqzd7WL1Mt4Xfbaoi6ltUXvizTqB0sn9yebuBtphJfrKhDtEOJOlhm0jurSdmfprpty4Z+eZIft4DR78AKNkdFUDtqZjqljdIm7N0V4t5eeovD+LNIMxRr4rsvfIz5bXwv4bF1cL+XjZsDvPVbK4Y9BM2kXxSVKnZL6PUdXnumoaRXu6axg5dy7f9Veit38XoHaVY3jRIA4ki9Ahk7SSWbY3vsVefimy3GCVPhT3QdWM6JKThhZXeJknJaEZVG3tYDa3WftWJ1RD0mP73SwG++x3vPe6bryafyUC7atm+WvhukQnZrQLTKcfC7SCerwvJMHSX6LTpXo/3c7x55DdEWRbLo5oeYxDDFMWWjCPmeaTnavpzaPws10vLUu/NAcVrpprLZfGucidfyXXv042s1vJXmjZCQ8zEgZMAXG0YACBAAAgABxIEAAAAAIEAcCBAAAgABxIEAAAAAIEAcCBAAAgABxIEAAAAAIEAcCBAAAgABxIEAAAAAIEAIEAAAACBACBAAAAMgCAgQAAIAAcSBAAAAAjU7/zNRuHI13DHdPYmNDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ+rvSew3k0rMKj4XdkzajdCAJ9B2z0+amOjyYzexE6QAANCjD0ya+ZjCXOnogm/7rQFf63v5c+kL53x8J+nn5zEnSWQwXHd3pN1O6kAQGczt81FiHs6lDKB0AgEaUn+4xrxzMpm82NP6rh7KtuyJAgAABAECsqGCIPBxZ8SOXnu4rL52tXzA2/O4xHwECBAgAAGJudFNnl5CPWA+/a5DprmyJz62WD74CAQIECAAAGkqAvBEiv88tD/I7ECBAgAAAIFEC1NeV2qHE5y5HgAABAgCA6giQrMQayGXuju9ILyh1HbKM/RT5mw2jr6E/l3p8qDOzNQIECBAAAFRFgJbMTI2r9rWIxGQGcqkT3GvKHLpoRuvrLT6LAAECBAAAyROgiPKEAAECBAAACBACBAgQAAAgQAAIEAAAIEAACBAAAAKEAAEgQAAACBACVA7dx2zJzB236Z+Z2b0/m9l7oCs1eTCX2VP/nf432/M9373juwe70jsPZlNf1nPJqrg9hnItnxjunvTqSv2Gxd2T/m+os/WT+hukHPdxvzf9Oc3PJDuUv6ke7q9eo5MvqjP9eb2+/mzqa0NdLZ8e6tpxbEXvr2zSK+Xy4f6Zqd0kVcNeI/d3oKt1x77cxPchQMF5IZd+T39Xa2rkHmpd0zqn9fv5o7Z/Y6W/XzPKa33pm5mZNPL9Wof0/j7bPfEN9AYACFBNBEiWvXfL9z5RcDxaTQFSaSm+hszdy7onvnMTSZHGWjsl+e/XyPFiiSSOT0v5njzU/Ym3lhIoby+0HjkWlTjXMrmWedpJRi1rbewHujL7SZnlvd+5ocT3rpfjAfnuM/pm7vDB6Pc5PbWojLsy55vKxZHKXPpX8jePlLnG//Z3ZX5aeJ/CCm1/ruWzcn9/7ubCSq8pk+BzkcjYRX259GeCbNkStwCphGm+rKIyzabPiuvZ7M9ljjA8m08MHZv6eNnfdFzrx+RvZ+mzHCBZ6hODufQF8swcEJd4Oxst51JfkvL4rZTT4hLfrfd5vtS376ps0zMAIEDVu45s5peG61hbTQHSN0HTOUY6fh0NkPI6Wv7dkGUW7CcHjmvZrvBtdLAr9U35bw9ZnmudjA59O+wbuHaMco6lIbN5b5DP/25ZNvWO8CN1mR8YzrtwY7lMnvwq+Y4OlZoQ17dEpSnMdS3snrSZjLbNENH7X4Rs5/8KIgVxjwBJx/43w+dejKsjl3P921SnSwnf4LET3yLleWkZcS11vCgydI6+lIQWNx2xy6UftP1uR5SkDtI7ACBATS9ASzp3flt/Z0uLdI73R+gcn17a3fp2t2Fufb807jdFONeGwa7MVwK/BYu4aYLJMqNVNsez+mYfowDd45X/7tLx/Cfita2WurST5TXtJ597KqayWS0d6JSqClA21Wb6nI6kRB79ybZ+yCdT/El+n1me3eVd3qhdHOW5RqcbbUc4dVQu8nfLyKQ+O/QSAAhQswrQOhGffeWfK/2npqTzzKZf0Ost1aBqWTtTAl3p53z+ZpX3354p8X3ekflf0LggmU7Y3P0dvufr96Z75stxhychq8tN/Qwck94iDgFSGRSh+7E33WaUCmcUzb3GJ8qXTfqh4e7tXmsxwlFqFO5FrzzukuNWHeUJMIK2VuOEqiVAzpSs6Zq60tfGMPpzonGURMTIKNs6splN3xxAMFbJMRhghGiVzTOs08267U6pKV1vyvAWiQP6k9zb+0o9tzIldmHUqU0AQIASOwJUeE1y/F6DKFUsCjui/q7U10sMu+sIzEDBv1vgxFhk0x/R6Z+NHYn8bwmqToscXO0vVME7WRGmG0eP4DgxR/Ib9G3d2JGJXGmAqMZk+ImJ/LcrYxoBMp37MSmT4/s60xML38L1/zuBrNn0Db4dl9yHwNck3zPqs8v1d+k045LOlvF+nZ9OaXp1brnPNTxvG9gbJQhaynWOqa7qtGdEATKM5GTu9v0NMjJZou6fptvdFE7N6XSZBkI7U5/u7xj1/GSuCDzSecSer/Mk3vT9QyIzWZO065SuxJB93xOy4rqYzXyHngIAAWpuAepK/zlITIK+hXpvmaXiDB7X1TBlG3XpgEWQLvYbUbLo5DWu5lYVg9GiFaxcUrt5I11FU3EqaTEL0LM6dRNkBZ0TsOw71ZG6NOg1Le5snaD3Q8rme7bBt9J5b+U3giTXlquWAOm0n1kEM98P+0w6q+4szyn/7Y+G5+a5vmxqW6vvlrg7+exxNpLvBcybnrW/6bRz2e+UgHKfl5elcQTYAwAClEQBWidL0w+3GQoXaWkv0cn32Cy71bdW0yiMTh0Ffju2lJ7i8nWWxhs6l8y58QlQaraOBticz5vyWFYqsLrS5aPPinzfCsM1PGr3LEZbBu8jYgsijP6cbjOq5KxolED0WizjlxG7XXym0+6wGYnzpov7DS8bJ9NbACBAzShAx9n+nsVHt7zXZxQpVFyGaSWOThNVt66k/2EayYpFgLLpoyJ01JeYRqequZxZvu/MqM9RVAGS2JvOUqsYrYRQZN+Ju7Kov84qQ9P3F0wVV6j87zCN3AQZ+Skqx670NNMoVtSXCABIiAAtnpn+gMZaRDosEgHWswCFXXbus+rqknDnylxhijOpZl3xVpIVlY9fHJGNAOlUUujrEnkynbPSSRI3GTmQPEDG3yWpDqolQJ50rzV03j+y/T0a/2X7e7xpq+K2pMIiOpRt3dXnxeWHYc6nAfSGWL1h29WFAJBQAYrjsImBaEgBMq/2ujzk6MsFprw3Va4rBxjLuCDHUbUFKI7riiyH7jSl6TmYXi0B8urctabpQNuVTDKy92tTIHOp6SRv+qjq90GXqxu+d4XtdGqB7M82tAPH0GMAIEAIULARoKfjEiAva3NNBcjZwsA00iJv4LUUIC/hXfHUi6wgq1bZLJrR+nrbfDkVEaBs+hvm6wieS0dXAHppHYry4gQYOVlnCJr+aWUFyPiicUmkc+Yyh5pi9+gxABAgBKgJBchJVGgsn/QXazoC5LP0upoC5HX+Jnn5eTUFSK/DtOWDrpAKLrrO1izFz3OALNs+y9DX2iTutEFXl/nk7/luRKnKGM57Gz0GAAKEACFACFAdCpA3CnSW4TwDQZND+kwpPRMkCNjLp2NMQKhtTdS8RMW/1dmTbzjueB1NjWBK/kmPAdAMAuTsCt66Y5TDZgUGAoQAIUDxCJC3AWlxeWQzXy33WXdPNNNS9vTPApWDm4zwiRL5r17S/eR02lK/K7oApX9h/q12eYeKRpbcnEBFK8HoMQCaQIBYBt/cAqQdmW53oInoNAeSdoB+Wxw0mwDpSIiuMHOyUevu9lr3ZLfxehEgdyQm9c8wySElseXexjgvi01e9W8Dbhi8TMtNnq8vB93Wpfjep+f6vMDdK//t7yOHl0Zi0x3t3TinwVFHuS1OnqfHAECAEKAGECBdGaSZrd3A2dQJXmdym3fdgXfxblQBcvLaSOC3rv6R882S4y9ehu81gaeCayVAur2KYV8tTRxZus5m5hk+94Dt9w91tn5SE0HabLCrwdL6DFo+Y9dVagqfESAABAgBajABkg611ds24Pk4OoZGEqChzszWTr6j6LvS11SAlna3vt2Zbio+31S/z3ibqr4Y1/Jv3VbE2+R2hVXsYC71By2LgM/YnVUTINkDkB4DAAFCgBIoQDqlFfKNeYW3E/sdjSpAmjPGq4OrLctmjTPSoTuL15EAufUvdakhluevJcrxQFPw8kBXy5ZRnu3nu3d8t5es8mGbctVFFOWSqfoKkJS7iNSpcR26YW4cMUsAgAAhQFUWII3lKRPjoPlbHnE2s5TOQ3+zswmqdH4jSfQaNQjamQY0b4K5yQogmR66Ufc+098gz82XNNB2ZKd6L/i3vgTIvJzdV2jkv11jI0zhnnPZs8uty4PBpp0yc0olcdR7Ysw+LVmxad0BAAFqcgHq72xp8ZkO2aBbbGhnHmS7gkYUII2J0T3VzNs+pP+um9oG6UzrUYC8zUmfCjKltaRz57cZR7+y6Y5KPO+6ak7rjbfkfkWZ2JsjfetkLnOxz95yH6F1BwAEqIkFSFfX+HTwutT5czbX0IgC5FOuG+S5ONpm+4h6FCCvbH5skIP/FJVDNvMtU9B0lO0kgqLxSlrePs+Ls6mp/o1P+3WyeQPYzCRadwBAgJpYgHTzSvPohn1m3kYTIJ0KMq7oCpjzJgkCpJsam1b0iRRvv+l3p28ylMPvqtoGiGwZp+GcwOhMu/Ee+mz9wb5dAIAANbkA+SxrvivMNTSaADn5jYo/t7LcUvEkCZBXD28ttT+Xt4v8OkO9/3K126Nnuye+Qb77v4bpyLnGOinJVn0E72padwBAgJpagDR4t7BzSJ2MAOmquNRFhs/dEuYa6lqAJI7HcN6nR1ZY+eQM6hsJ8K428t3H2QRjy3+/x3D9y8OILAAgQAhQAwiQxrCYpnh05AMB8llBFHLap54FyMvvs6x4r75M2h1FkZV/xXtp/bJWbZLUkf0M5XBPiedsurFO5TLH0sIDAALUjALk1ymXWFXTXAJkyiGTuaLRBMiv/uhIoAbJG9MjyG7oNRMg04hVV/rPvuXmxg6Z9i97geXwAIAANaEAedew0tCZ/CZkXTmgsYKgjckLHwl3f5yA6roVICf/TvHeYLdrMLQhfuYxmxVwsbcF5v29ZpV8diVpos8KsltqNZUHAAhQTQXIb/lsEwnQQ4bPDdgsb9YcQfKZE/32wNKdvRM6AnSeT/6fnQOP/EgcTX82fbDGzJinYVJnBy6b41q2i7P+BagLq/TcBjE6Icq90JVnEUaqMvrcGxIi7ld2BC6bvs98D9I3aTbqsNe0LJt6h27rQW8BgADVpQDJKERn1M6sQQXoPJ9psD+XkyBndZArLCX3DNO37yQKkF+KADkW9s3c4YOlPuusVnJjVf5dei+w4JmU3X3IjOc4K45nTPZ/yxr23Xq8MAeSZsYOc34v8eIz3nnu0KnWoVzLJ8ptZ+F+1pmKm2qcyhK5DCIgnkAu8dtsVcRuRpCEn869kABqqddf87YTEVHMfIfeAgABqk8ByqX399lY8XHnLVd29dYYllKdYkMKkPtG7dc5vyD/PF2CYfdWURzqavl0f1fq67IqqEuE8mbD0mi/HeKXqQTpVJiWcX+u5bNJECBvZKvf5zet0izFOrqjSfV0A1knc7ETcOt0ii+aM2sbc9icq2XslI0cmnXZdxTDkI1Zs3hrvI4uSx85h2ZSthYgd8n4utLbT6RuD3sf+nLpz/icd8jdgy5zhrPiTGN8ulKTVXg0AaIz5eXWRb9rmmF5DctL/MblKv+6t5deh45eutvEZPbV75Gy/rX8zb+cLUM2/dwd9BYACFBdClBfbuL7jEPnAbLgNrIAeddxeQw7YT/jdS6BNrT0m3qst0zQsgLq+1HLxttmZLqzT1igHeLT3ygh8jcF+k6pq+Hqkc+mrRFXCHr17PT4d2BPXWobjySilzKlf4h4rNapMHoMAASo7gTIa4DPC9CQLW86AZJAcBnZ+WfYhl+nYEYaf5k2OizI5/zkox43Q9UNN0N3jF3pazV42TmP7D8VcDf5H5aog7uXGGl7+ZCNTsPVSR3p8N+BfVn3xHeGvQ+LZrS+Xpeelx7NCXxs0Hqno2JhrkXrq5yjN1BZlj7W6X5j5aZEAQABqqkAaQMsjeZvSzRmOkJ03vDkya9qJgEaVTZnOVM7wRr+IR3RWNLZMn70efRtXKYNTik52qZTDMekt0iKAHn17zsWHfdazTRs2m9KpxDL7HT+sK7IKiMph/pMsW2cptKd6MOUlU6d+Qdsp6+K41nW+CgvuPqW8lNuxbvVa16ioWzrrnFcy9CxqY97G64ut7yORbolSthyBoAEoMnQnDn5giNosGCMArSTDL9PG31oRxCq0ZPAS30T9aYkeqRD/pE07geVG8J2dgYvuAY9wk43SMd8YPFvstuAdOO1SYdQdF0++yOV4oVc+j06zeHtoq15cB7RZc+687kzVSar6ZwYkzJv3irI3lYSp2kZ69YKKhGFwlRUJse1fsxUxlHq21DXjmNN57QdzVjYPWkzDYz26s1tcjwgxxNOTIhKncSw6AhKuZWF3g7zB2m980aXztRYl/7OlpYgAcGKrlpyRMiV1h4nVkviZ8LWxSAvPaWm5kJ/lwTaO7FL2cxMCcK+ULfl8EYjtVwf0W1Z5Lhef6eTZsFHnKPijE5JDJe7eWrmCvnOBd41POFcjwSr633XOj0yogcAAAANhAqZQYD6w043AQAAANQ1OgUmQduLDaM/v6B0AAAAoCHxlp8XTX9prAylAwAAAI0pQBLrYhCgOykZAAAAaEi8vcY2GFZ/7U/pAAAAQGMKkHmT0UVsFgoAAACNKT/uru/rDZu1Hk3pAAAAQMPh7FifS/3NtL2JJi2khAAAAKDh0CSEcWY4BwAAAKg5ui+YZtgu/Pea2NDNfGzc6uGu4e5Jr6b0AAAAIJkClEs/6knNU3Jc424CmrnCmPDQPVawuScAAAAkFmcvP7tNPtdWYs8vAAAAgOoJUC6dtZCfleT8AQAQnj9q+zfqrumFx0BX646UTnVZnt3lXaZ7ocuXKR3wQ1d4DWQz3xK5eaiE+Gjyw7/0ZVPbUmLJRaY1Dy1qq3OZfSkZgBC8kEu/x9Rg6qqRJP6exd2T/m8gl5rhxEBk02fJKpcvJ+XaNYjV3HllDqWmQhD6cplP9Xdlvq8bm0rdmaUB0INdqcMGulq2pHQaQYCMkns9JQPQ5AI01JnZWq59YdHv6cqcjwABAAIEAA0pQD5p/t2jM/15BAgAECAAaDgBGrUUuPj35FKnIkAAgAABQCMK0FN+AjTYlfkxAgQACBAANKIA/d5PgPqyma8iQACAAAFAwwnQ4pnpD8i1LzH8nuuGx4x5BQIEAAgQADScACn9Xa3vl+s/U3e+lvw5V0mSuO8mZb+jehYgSSdwTEFuooN4egAQIKd96Mp8pSg/UXf6zdwxQIAg0QKkCRqLryl1O3cMAAFyrytzY1Hc5bETt+KOAQIEiRagvq7UDggQAALkh4y4P44AAQIEDSdAA13pfRAgAATIhLMVSy69GgECBAgaUIAyP2hGARru3u61g9nMTpJgc5oT1yD/1K0mqKmAAI1uH1q2NKYeQYAAAYLEC5DsqdZMArS0u/Xtsp/WT+V39hdvq5L+DTUVEKBR7VY2tQsCBAhQABZ2T9pM9+xaMnPHbfS7dfg0qWXXl5v4viWdLeNHfov+tloJkI5WLD665b0j17Okc+e3xdbAyWq6JAvQ8LSJr9H7o2kStHykYX6L/z1Nf0Z+37O+GcUjPhfPH7X9G0fukQaX10sZaX0ZuS5dSRklbYSzIfEx6S30XFontfwrfo+P2PN1y7onvlN/g97nOJ7HyNckZfh8947v1nLQ8ohjVVQUAVKxH91exXVfdEUoAgQIkI/wSAe+r7dP13/lWF/wXfr/n5G/uaI/m2rTDiJKgyPnmVd0ZNMdcTRm8ht2lvOdIcfdcs0rDeW2XhqDx+Sfl8nUyXeGunYcG4cAabkUddTH7LS5s8t3Nv07bwuQtYbPrnRWZ8gUlnZKNp20Bj5LYOMU3UJEvuMFw7n7jWVdcPTnMudWu04/2z3xDXrPpdwuMm6O6x59cvxFjh+O7JQ+kM18S/7/Ot/95NwtVaZYdVjS6cnnpmqdkONpwznXyPGklNPF/V2pr6vIRnuWC+9Barbp3g/lWj4h33uiHPPlWGq4ruXasUp93r/cS4q+CMizcbiUzR98fqOW6QI5jhvq/sRbY3n5mLnDB+V80+W43Kv/fvftCTku0fqg9aKS9U5f7DRdxEBX6k8yUvic8ZqcZ0lXTGW65W8ytqLpI0DXmNrd/q70XoO51Nlyb/7u3c/Cz22Q67lPjl+oNAZuCyWtSF82ta3WV3nGcnKeu3zK/pogbYTeS3piaCgBWjSj9fWSgyfrdTTDQQ9pRBfL9MP3w4wMuQJkOK9M4UQSH/cN50Gb3zHSwAxm0zdLo/jlKAI0+vOOhHWlry3XUZsaXs3VEaRxcxpG+9/qdzxVrbqsdU63PJHvHLS8xrVSpn82yLnWx5e0To50IEPZ1l2DjqjI358mx4uW17JQpSPUKI6ONBjOOdTZ+smCEa47La9pvo7kFH5f/8zMh1XcfOTb7xiQ43OhnsXJk1+lAurJVJi6OCTPwfFxj0ipTMo9u9pUfwK0d4+LpBwd9MXPJED6cjlaRt2XNGNC2NLPQC59epB2V37rlTG2D8MaZ0dPDA0jQN6y6UeiPRiZG23fFuMWIB3Clc/fGs+Dnrlb5SXcCFAmLY3O9nLcFPE61kpZfCOAAA0nTYCGjk19PKSkmo4HdGRI33JHv6HLqNubgkyrDHS2fqHUNFqgTkE6NZ3WiUOAdLRQp2LkheTC0HLblb5XBdOtI9u91ouRWhPmXCqVA9lUq9XIh5ZpNv2fmO7vPVomkUeFRaQ8yV0X+ZrkBUVk8giVPPsRoMwZWjc1Aawcq6Jch9aRAAJ0FQIECJABHRb1mR4afazyNi5dWaYT+Ls+2LUQIImDSNmOXgU4rgsjQDKFNce0zDTkMaD3vJEEaOC4lu2MAcsj9UimJb1Gu8cbdr+71KiFZg8Pey1S/78XoENcoeXiiECp+i+jCuU6xAACtMGTwydjkPjuZdnUO0KMIJnOdX/QUV6VlYDitsxnqscodFFicvSzmm0+zmdF60O52D2jAOmUm/9UbwjZzeyHAAECZNso5FJ7lOio/62dQ+GblzvKkprh+8YsMS7VFiCdMvCJi9jYqbtTLfJ7JbhRYwu0MdcgQ1diJOYplz7HG0kY1aGlDgglQMXHi9qR69Scfmak0dTr0LgjiQ36ZsmRq670Sf4CNOaVRSntXVktPM+ThX9nPCQ2pJL1VwOafeJOtCE/X0dx/D7n/bal5piV1B729T9zaImO+jaN5VKBKJxGcuJBfOqb1JmTo44AFY4KuNMu6Z/Iub+0uLN1gtZbnTpxpF+miLxpqmFj3FfxKMyQ1Ke81m0VUZ0q02Bk+buPOOVRYtRGp+OCl+0mcSYb5Pr/oc+glN/uIzFcm4zMyLWoyOrflejozw9T59y4xtTtZV7wevX51PiWkZFsfZlbMjM1TqcAtZx1enz0vdGppTBTYMbYHpXUbPooHXXW0T8dtdOXGw2878+1fFb++3kl2uoHS0p+NrN3QftwnbEP6UqfEqSN0KB7emJItAB5je+QadpFhna7yu3HpY2EF0RpGAkqH7sSlwB5b7hP+b25a+CxTaCqBka6nUrm/nJBmAEE6MmgwZxaFt40hek8/7Xr2E0xF/WxCsybgigKRu/Ppg8ONGLjCIhRoB622UNuoKt1R58pIZXVQ8sFu3p72N1jkrHB3A4fjShAI6Mu/9POq9zIi7MSzI17KlUXV2qMX7ngem8RxI1Rn0udHvKC8U+06TCd50BEyGdUbp2KkrV0uws6/MqlV2Uy6Lk09kfrqjO93ZWaHFGAVsnU4s/1pSxQ29TV8mm/UfjRcWPlp8QcCWIVGDSnADnikU3/1fQmIg1Xe+DzSMyDcVhZhqurJUDeyiFT4/JspZPglRCgNbrawjYmRKdPnKF+49t38Ea6XgVIR758ppFOtJIoWY3jEwA9JWi9VWEyBlDLCEXQ69CRGJ/Yud9HFiAZpbGZTnaEw7+jvVO/K+i5dIWRaWRMn/Wg59Brj7JCVH7/kT7TjOfYnMeb4jeOuujijSoI/0O+K64Cis8m53NH/AyjN6nvIUCAAAVqFNJ7+TyUp9tej7O80tCpBZknjipAXvD2BuOIwszUbpW+FyUEaHqEBnO68Zyd6c8nXYBkZPBA0zSNzZL/Ub/xMsOIyRXBPutM4Zpi2Kyn/7zpiaJR1CBBu74CJDFk1uWheWvMdXHBSDC0ZUd7n+Fci6pVV5yXAYOkqnAGPodMrZn2vBoJRK7SiKdJgOaHzdtU4j7PQoAAAQr0UDpBpUVxMmHf2MxDzOUbmKgC5Bfcp3PZVenQ/QQomzokdIMpq22i5rOpWwHKpS+IK67DvN9Zelm56SJvJZApfm1B2CSfpviSIDLlJ0BhRya8oOJQo1GG33Sp4VxLqltfUiebyse0xN88Opw+2C9mJkr+phgE6PqI53zGMDJ2FQIECFAZvIRqhsj+dGfoB9JdRmw9DRZFgLwYjLWmaYxSq6bqXYC8FTSRVjrVrQC5QaSFI4XfifNNuFy2Zv8pkcy+YX+XF7NSeM7LaiBAiwzP4bUhBfM3hmtbXWVh3j9KvIsGsvuM9E2r1m+okADdaWgz/4oAAQJU/uE5zjQfbhNjUoiTSr94Kmpl2bfxCAKkb9g+WZgvqloDXQEB8lZJmd5af5h0ATJ1BhrkG+Zc3khO8fRnmSBZja0xlO1glBEBcz3I3F8DAXrCNpVDCan7lSm2rZr1RaexfZ6FsokZ3QSDxkSHSyqdYbryAmQMUr8DAQIEqPwD+RfbZZRhG99yW0tEEiA3k2vkrQ/qTYA8mTSVyVGJHwFytx0p/F3fCCVAbiBz8f2XVWLW12DYmsDqWkSeDEuUVwVZvYUAlRoxNG/aqdOf5a/fyUA9HPe9rpMRoOsRIECALAXIS5q3vFRq9vAPZXEchAaIVkqA/PIQ2eyRgwBVfQRoQRyBx+5ITsuWpnLSlVm+owI+04ua1yfyb3P3krLqUBCgkAIUQJplavWX1dw0GgECqHMB8jLwmt6KTov8oGfTNxiSwn2pEgKkOYh8fsfSKLtiI0AV7gwk4Dku+dYcLMYg6BL3X/doM8eEpA6L+ttMq438kjoiQJUXIL9cRtL574kAIUDQhAKkG0P6iINmIZ61yeHsWj56F2DJHqqSM3LIUk7d+mLkMG3mVykB0n22/HaSrmoDjQBZSnLqEFP8TZgtDnw2eLysdKeeafffBbug/hftgi2dzqb1/46C+l+0p1O5XbMRoGI0samb3kKys/uM4gQTIGfH+eKy7WxpQYAQIGjGESDz0uGKHZUSoL6ZmUl+e5EhQPUrQM6WC4ZMtpoB2+Y8XkbcDYYM5AeWGaU8qpr1HwHyeW6ko9WRGGcfNs2ELPmbvASgSwNuRBpEgIzbg+hWIggQAgTNKEDu3kfV6gDW6JL7yowAZfb0+dxfq3kvEKAQZZZLnW1ahSjTHd8OJD+axsEQb+PkdpGVYSW/29kPrmr1f4nu6YQAjWQAd0bfrnFG/KLvxB5EgFaYPlvuniBAAA0qQJpzxSe9/D+Kh/wjHgFWaoQVIGPeITeX0c0IUH0LkBe/tcgnGPkKnaIwxfFo3idPYEx7Ia0PkinbZx8ybwo43vofJBN6owuQW5edjWOXhRCdtT6iG0mAgiZRRIAAGkyAdHdrn6mqo2vxW0IHQbtTIKZG818IUH0LkHP/JJGdz0a8I6kMdGPPW+S43Evb8GipzlL3wQpWLk5nPBzXUvyoNLIALelsGV9qZ/nRMWBeHNVcp2OW4Pa+zvRE3b7D9zkPJkB9YaYlESCARh0BkpicWm4dEZcAeTuCmxrThQhQ/QuQWxd3+Gg5sQlwrAg6deaOIknMiXHkMPg5EKDyeEkIn/WRW10td5yOkJXbeieiAD3o006mESAECJpQgPyXwadmJ0mAfDJPOx2ibqKIANW/AHlC8nWfbL3ljvW691G5ZeZF98tnGbyMjOYQoPgEyCfZ6no5Z7ZcnFaMAnS9z8veXggQAgRNKEDe9gGmGIp7kiRAXiPwX594pu0RoPoXIInvOKlAYh+WqayL/XfvduJI/iLlcLxOr4T5Ts1M7vMCcCkCFI8AyVTWzlG3colFgLLpn/lM95+MACFA0IQC5D08d5gCDiVL7psSJUBOnqLabnSIAIUc+cllugqu767R9U/3anI2u82mPzLUmdk6zrrpMzXzLAIUjwDJ35xnys8VZmQ2kgCZE2VqoP2NCBACBE0qQIbOx2t0U19PkgD5BXTLCMIfEKA6XgbvZvcdPe21WiWneh1SarZxh/FjUx9HgGIRoAfDPM9xC5AuvXdWkhV/fmWp7VIQIIAGFiBNBOYTP3NbkgRIGzFT9l3tXMNOkTS2AAVvICvbIWy6RYFualvV0aeZmd3NG2xmzkeAYhGgFYbNS4+stgB513JdrVe91qUAZVPHIEDQlALkPEBdqT/5zNN/LikC5L7JpC+oZVB3vQqQjIL9zdAJ3Vvruuu9la+LkgU6pjpnWiG0uj/b+iEEKLwA6TSXz+7tNRGgvmzmqz7t3KIw2680igBp/TL3IdWt/wA1ESBNNmccBcqmX9DYi6QIkK4C8hnm1jf6/ZpVgHSFlHGj0O4xr6xl3TUFyMqU7Lk1GIXa16djfLiasXDNMgKke3qFG61L7RZFgLR9kan9f/rEAs1rWgHKpaaE2boIoG4FSHNr2D1E5lgIaRjuXzwz/YFIYtO93WurIUDO78imf+G3M7z8xj1C/4YAy3Xrdwosc0Y9jPAVXZc5g/fT5fLBVES83Q1NjdnEo26XELT+N+QqsFz6McPnngpaJi+PUqT38s0ELQHOlqNI632eqZ+FfSnQOjTcPenVSRQgzcFUT+lQAIJXfhm69Rlm/o3NefRN1y9ZmGbp1ey6uhon6Pn0b70362uCPuBxCJDzvf4ZZ9fpcmvdfiH4G2Nrymn8pfGVOfG3JFKAZPTL5xz/0d22fX+/TGHIdhNfqdRIkQYa+9Tda5dnd3lXVZ+jY9Jb+Haw0mFLR36QVc4a3eLD3e3+VnnD/nXTjgBJLJVPeopzytUrff5UWIxTuBGW1JfYAkVl6k+alNOi3dzc21T3wf5s+uAkCpBm2dZ7aVwNnEt/puR3Sy45psqgZnjSsMyYFTeXOkCnsDTWQo9yS0+XzEyN0zfwEg1Nn3QSeXngO1QMNIBag4/1c852BjIUrXk15O/mawzFaPHQhqIaAuT8Dk2775N5dmRjSmmY52in5m6m2bKldrj6e/SBl6mZwzUXjE4BFnxuahIFyJNbv521n9IORK6x1Vlmnm3d1dmoUspH/v0zzoooubeVqLuuJPte10q3M0qfIx3gqeWPdFY+M13roDbIYZZZe1PBS/zrTeZ/KgL6XOn2DFrvnfov9U2fB7nW/XW0TbdyKIht6g8y4tGQI0DZ9Bf9yzN1+2BX6puaj0k7YT2cdAeyj5snKQsDJsJ8Wr9H9/UaaetKtjMq9hJsXyqxphsXmZox0NW640g7p6sTtY7o/Xc28XVftDbYiEw9CpB3jt/7rKJ9SZ9B555I+6DthK4Qdvfhy9zt/c2p9MRQu1Egnyynho64teybqzzk8rcPxL8bdmpGtQTI6fTdeKAnYv0NMk2SRAHyphBOCf27QwatBhp+N+8GH8fxvHbaKhVW9SaX+dSI+MV5aNbpZhQg75m+M4YyXKOrteSfTwb5+2XdE99Z8rpESP1yh0U41pb73noVIN0SpHBBgkXbfju9MNQMyeOwZ6BGWN62gpxPYzB02D7ktgSFx3LtfIMEk8YpQO5IkK4ykpGc6L9hgwYSlxvqrWcBGj5iz9d5IxNhOu9jKlV3nd+XTd9XIQly32Cz6U6ba9LRQPnsZTF9/2K9V0Gmzxp1KwxdTq1CGqEc7+jrSu3gjSh1BvmMjtAFam9E7uXvX4zhXj8k59onqSNAznm60j8K+dsX0AtDbUeBZEsAn3w+G+N4dBjT5pzO9JArEGtDPBQLdbftcm9ElRSglxvvls9qMGuI37BSt2MI0pjWuwB5HdFbpDP9o8WI131adpWuuxovU2ZKIvohjbv1dcl0oNe5bLD/zsz9OuppE9DdyLvB6zSzxkNZibcIu4xM7L1JG+Fu33N5mc8u0tgUi+diKy9j9aoQdWu+TAcdGHTKtZ4FyBuRPVpfGgL+9kH9e5vYOIDKdSSOsMiKn2z6r+7bviSZkyBEzZIcNPDXhEqMxr+4eXa0Yd90aas3TyyrPTJXaCyGCoPKjO33eAI0q+iIaQm7Tos5b5ASZOvFvhSOcA3qcK429jrPbbsE2olfMFy/rrIIe806cmMskwgruPpmZiZ5wakLCzr3Vd4o0ZkafBrmHoZqwHWbgk0DkJc69cyNwdoQkwSt0+mtcNfXsqW7c7zzMvCwoYNY6caDyH+XEQVJrvjhMN/jjTwV3Wtd/h2qM3PiNArOJYsZQnaMBxSfK1hw9yZ1T2LsvPiyhwriBIe96a3r9BktF4isKwg1ZYITJ6Z11s1lNktHYRZ2T9osrIhrWynnm+sENRff51VevfytjKYfFiZjuTfKUniPp0cUoOlFbY5kdw7/PGp9T5/iCOimZbDB25vvEo2ftFkUA9BQaOXXKaawjU09oG9tGwPEa5wTp2ZlIJKjv7/aS89Hyt/pxAoCWjXY0uY8eu3aaGveEi8Z5jqflUcXxHXtOjLn1hu75dxQIETyklFudWUt0VW2SW/n4qrr1FYAgNhGfpyRgE1GUoJON5Z8k3enrlYYJKifUgcAAIDayU8uc6ghTuek+OTKHNAZZSoYAAAAIDReXqL+Ajl5sVRSRlvceLhiAbJdFg8AAAAQCxJcOS3ulTCFaFI8RoAAAACgbtDVgpXeBNUnJcFySh8AAABqI0Bd6XsrvfGiBFj/wLSxKaUPAAAAtRGgXPqR4mR/qX/GdX4vb9VThkzoh1H6AAAAUBMkH89NPtma94l6bi/j8AJTZnLdbJPSBwAAgJogMnJcqX27NPmc7Tk1K6+I1U+cLV8Mm1RqBmJKHgAAAGqGLIPf3N3uwnfbihedrQ0kL5DmC5ItHb4i20vsvvGQbUr6s+mDnf3v3C09Hi21BYb+LaUOAAAANUc3ufTbsiLGY1HYfbQAAAAAKoK3ZcWiCohPv4wOHUXMDwAAANQlzm732dQhIi13aKxOBOkZ7M+lL9SRJXapBgAAgMSgu4Lrju6SE2iG/PNskZrfy0jODfLP2yR79N8lSPpvEhd0rcQFzdPEiRow3ZfNfHVxZ+sE3VmeEgQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACoEf8PC5vzecNZxDkAAAAASUVORK5CYII=", + "config_type": "basic", + "config_baseUrl": "https://lichtblick.moin-schule.nwdl.eu/app/dist/", + "parameters": [ + { + "name": "id", + "displayName": "Filmsequenz", + "description": "ID der Filmsequenz, z. B. X1Y3Z3W", + "scope": "context", + "location": "query", + "type": "string", + "isOptional": false, + "isProtected": false + } + ], + "isHidden": false, + "isDeactivated": false, + "openNewTab": true, + "version": 1, + "restrictToContexts": [] + }, + { + "_id": { + "$oid": "65fc1488e519d4a3b71193e4" + }, + "createdAt": { + "$date": { + "$numberLong": "1711019144780" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711019144780" + } + }, + "name": "Product Test Onlinediagnose Grundschule - Deutsch", + "url": "https://onlinediagnose.westermann.de/", + "config_type": "lti11", + "config_baseUrl": "https://route-resolver.test.services.bildungslogin.de/api/v1/lti11/launch/7ce9a5aa-e603-4abc-9c45-7bc454cc093a", + "config_key": "https://route-resolver.test.services.bildungslogin.de/api/v1/lti11/launch/7ce9a5aa-e603-4abc-9c45-7bc454cc093a", + "config_lti_message_type": "basic-lti-launch-request", + "config_privacy_permission": "anonymous", + "config_launch_presentation_locale": "de-DE", + "parameters": [ + { + "name": "context_id", + "displayName": "Kontext", + "description": "", + "scope": "global", + "location": "body", + "type": "auto_contextid", + "isOptional": false, + "isProtected": false + }, + { + "name": "context_title", + "displayName": "Kontext", + "description": "", + "scope": "global", + "location": "body", + "type": "auto_contextname", + "isOptional": false, + "isProtected": false + }, + { + "name": "context_type", + "displayName": "Kontext", + "description": "", + "default": "Group", + "scope": "global", + "location": "body", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "custom_product_id", + "displayName": "Kontext", + "description": "", + "default": "urn:bilo:medium:WEB-507-08040", + "scope": "global", + "location": "body", + "type": "string", + "isOptional": false, + "isProtected": false + } + ], + "isHidden": false, + "isDeactivated": false, + "openNewTab": true, + "version": 1, + "restrictToContexts": [] + }, + { + "_id": { + "$oid": "65fc15b5e519d4a3b71193e5" + }, + "createdAt": { + "$date": { + "$numberLong": "1711019445098" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711019445098" + } + }, + "name": "Product Test Onlinediagnose Grundschule - Mathematik", + "url": "https://onlinediagnose.westermann.de/", + "config_type": "lti11", + "config_baseUrl": "https://route-resolver.test.services.bildungslogin.de/api/v1/lti11/launch/7ce9a5aa-e603-4abc-9c45-7bc454cc093a", + "config_key": "7ce9a5aa-e603-4abc-9c45-7bc454cc093a", + "config_lti_message_type": "basic-lti-launch-request", + "config_privacy_permission": "anonymous", + "config_launch_presentation_locale": "de-DE", + "parameters": [ + { + "name": "context_id", + "displayName": "Kontext", + "description": "", + "scope": "global", + "location": "body", + "type": "auto_contextid", + "isOptional": false, + "isProtected": false + }, + { + "name": "context_title", + "displayName": "Kontext", + "description": "", + "scope": "global", + "location": "body", + "type": "auto_contextname", + "isOptional": false, + "isProtected": false + }, + { + "name": "context_type", + "displayName": "Kontext", + "description": "", + "default": "Group", + "scope": "global", + "location": "body", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "custom_product_id", + "displayName": "Kontext", + "description": "", + "default": "urn:bilo:medium:WEB-507-08041", + "scope": "global", + "location": "body", + "type": "string", + "isOptional": false, + "isProtected": false + } + ], + "isHidden": false, + "isDeactivated": false, + "openNewTab": true, + "version": 1, + "restrictToContexts": [] + }, + { + "_id": { + "$oid": "65fd44ba09e6ffd0bae3b8d3" + }, + "createdAt": { + "$date": { + "$numberLong": "1711097018086" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711117950821" + } + }, + "name": "Moodle Fortbildung", + "url": "https://moodle-01.staging.dataport.dbildungsplattform.de/enrol/lti/cartridge.php/2/07aa5a7e070aa4ffac138a000d502638/cartridge.xml", + "config_type": "lti11", + "config_baseUrl": "https://moodle-01.staging.dataport.dbildungsplattform.de/enrol/lti/tool.php?id=2", + "config_key": "moodle", + "config_lti_message_type": "basic-lti-launch-request", + "config_privacy_permission": "e-mail", + "config_launch_presentation_locale": "de-DE", + "parameters": [], + "isHidden": false, + "isDeactivated": false, + "openNewTab": true, + "version": 3, + "restrictToContexts": [] + }, + { + "_id": { + "$oid": "65fd9736cb3d21d77bee50a6" + }, + "createdAt": { + "$date": { + "$numberLong": "1711118134160" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711358224688" + } + }, + "name": "OpenStreetMap", + "url": "https://www.openstreetmap.org/", + "logoUrl": "https://wiki.openstreetmap.org/w/images/thumb/5/57/Logo_simple_colors.svg/54px-Logo_simple_colors.svg.png?20140504131650", + "logoBase64": "iVBORw0KGgoAAAANSUhEUgAAADYAAAA2CAYAAACMRWrdAAAABmJLR0QA/wD/AP+gvaeTAAAQqElEQVRogdWae5RU1ZXGf+feW1X9roYGeQkNGJAFAspgjKCgRgSMb7AThaBjZmDN+MgME3Q5Bi0euvBFSEbXLB8ZQ5juQBM0AlFADEqkQ5IOtjTdGF42DXRHaKCqu7qqq+reu+ePW1VdVV3dNEnWZOZb6646Z599ztnfPa999i3F3xaTgNnAdOAyYBhgAK3An+LP74FPgB1A6G9jZu/gAv4RqAHkIp5zwGqg9KJ7rKhbed1/1z039q9gfHe4HThMN8Z7vV654oorpLS0VAzD6I5gGFgFFPXUkUrNlO9f/iBKvQWcQfERyFZs15Z5E548fyGLq6tfc8VK+k4FbZatKAnEch+5ddStkXhxLvAq8PepdaZPn86jjz7K9ddfT0lJCbqup7UZDoepqamhvLyct956i1AobSY2APcDv7kgsco638CYrTdlyC2gBsVOpdTW+8Y+tUcpJQB7jm0qVTozEZkJ6uuAFzhtmPrkr37lrhPx+v2BzcDXEg3Onj2bNWvWMHr06Au9rzSSb775Jk888QThcDghjgDfAn7RIzGA8toVnwETuu1BqQbd1GYO9Y6ZqpT8V0apLagZU4ff86t43gvsTrTncrnYvHkzt9xyC5qmARBo9XO+/UsC/gAhv4WlhXENbMOjFeIO96NPYX9KSkpwu90ABINB5s6dy/bt2xN9msAcnJeXRPrYA3P/+aZhwPXdEoNiNDnRJ6/vdkR7LK1E1I+mjrjnP1Pa/iXxkRo4cCA1NTVcffXVKKU403KaQy2/pTl8kKB1hnDAwgwrlMtCFXRgSgft/ggtzQFOnWkgShuFOX3Jzc3l/vvvR0TYvXs3gAbcCWwDmhOmaJlWi6htPZCK6zBrytCyI8CxFPFpzQw9nZJ/GrgRID8/n+rqakaMGIGIcOjQIQ4erqVD/CnqXSZPUqZ0ocU6yv4/fUig7TyaprFs2TIeeyz5XvOAjUBht8QGnzWrAH+mPKO/6378+fOF4oyII1K8/LVR81vj2dHAkwCaprF7926GDBmCbdvUHd5Hc3Nz1zZ12/m1OwmKGSdmWADEJMzBcx9z+mwTSilWr17NjBkzEuojgSXdErvxRp8J8mGPxMCdE43dBCQmeqjD1l5PKV+Fc17x7LPPMmnSJACONO/H33G6U8tWSSJ6bswRRXXE0sDWIOpyjIyXISC2cOTcH/D7/ei6zsaNG/F4PIkWFwOXZCUGoBQXnI4oZgViuTuBNkRtvXHE3YlRvgK4G8Dr9fLII48A0NR0iuYjAazzeY6NUYNoY19iTcVOczkxtPwoiMI8VUzsZDFiK7S8KCo36tSJuOJ1iqivr8c0TbxeLy+//HLCqnzg4W6J2Za9zXk/PUG+ceuoWyMi7BJ4J6XggUTi+eefp6CgANM0ORmsRyvs6Hy8Yecp7EhWNPoHMfq1o/KiqNwYet92jEuCnS0bVrKelddKY/NRAL7zne+Qm5ub0HoQUFmJzZ/oO6mUmgeyCWjvhtnQdZ+tuFwpNlummTp17wBQSnHnnXcC0Hz2OJanDb2wAzQBW6GUYBR1oHvD6a0qcfYMJV2sU7qg5UbRXBZKCc1nThCLxcjJyeGhhx5KqA0DvpqVGMCwovEfzxv/9NxogdVPHFfox+I4pUloGrPcLS0/nTaq7ExcNAhn4+DKK69kwIABAJyNNiJhN7EmL3YgF7vdg+XPI3rKi8Q6TxyzJQ/zTAF2mxs76MZsycc8XZAstyM65p+8mC0FWP48zDYXLX5nzS5YsCDVtOlZie354p0rNds8VdWw6ejljP/hyKJxuZfnXf5v88cvHYRYlyGyCNgK8vXJkxfFUqpOTCTuuusulFJEIh2E7fOYZ/MAhe4N4y49i5YXBVvD8sfXXMSFHcwBBNfgAK5L/ShNsENupCO+ibhsZ7qmTE9/yHnXY8emubiTjWzENGXfGl9gI1EsRGRhVNfNqoZNn4mwVde0LdcMu+uNdftfysuo+pVEYsyYMQCEIiEkpiOmMzJaQQQUaPlRx+iwY7QdckxRHgvltuJpEwm7sENu9JwY6DZafgSJdo5y2Hb2rPz8fFwuF7FYDGBo1hETxcwsYgP4O6V4xha7+jfH3z76leIRL1VKZar3UpxI9O/fH4BYrMPZvuNQifMq/iuJLd/W08tTdczMxdaZjIrjGCulGDx4cEJc1IXYb0++XYIwNRvhDIxA1DVlqsxKkSVJ5uTkAGCJle5TqGweRsoWLFnKM0Wqc8MWLEScfEFBcj3qXYiZMWaQxYfMCqW2Z0gSngctLS0AuA1Pp1cBSCxuZWIUNQFNkt5FmudhxdN66rtLh0vLQcVfVmtrsvu2LsQUMvtCfJK6trX9rS98OSmiU4lEY2MjAG4jx3FsXY5xdrsHUNjtjreu5TmHr5ZjOuVRw1mTMa1z00jxPDB1sDrfu8ty7puWZdHU1JQQn0sjJuLTUPSWWFtRSKo8bfoDKbL6RGLPnj0AFOQXYigPRkk7KLACuUQb+mKH3KDb6MXxNeKJoeV3gEDsVDGxU31AFFpeJEnMjhpETxYTa+68PBdpgwA4ceIElpUc2T+kEftt48SrcC6GvcHujWPrTRRL3zv8o4SzVk/86rBx40bC4TBKKYqNwaicGK7BgaS3oReHcQ8OoIzOaWr0D6H3C6Lld6Dld2D0C2Jc0ukfKN3G6BPC6BNKeiD9+pUAsG1bmhdYlT5i2Lf1khQIW0bValcJDPFHW6+OS23iHr9pmtTU1AAwyHsZYikkqjv7gChnWqXugIBEdJStQBdQCqUnwhxxYoaNyo9gmxrEdIrc/SgsdEbvlVdeSahFuhBDGN9rXsI2lLodQCy5J6Xo7URi8eLFWJZFQX4RJfZozDMFWIFc7KAHO35+pcI8U4h5Lh+7Na4Ty9gCLB2zuRi7LQe7w2Dk0MsB2LdvH3V1dQmtd8hcY9cOnzPXEGsQSj0AbATVSnbsnzpyznERZgGgHP8wju1AHcDevXvZu3cvACOGjSJvcAy9OJzZVhJ6vzZcl55PHtBdiJ/PRSyFlhdj2KiBFBYWIiIsWrQoVe1NyPDuyw8sX3C4/Y/3HfEf3Ddl+Jyy/JzgYKXkDpBXEQ4n9JTw/k8/812iFFcD7SD1ldWrvPFiG3gqoVtWVkYwGMTtdjNm0NXJWEc2aDlmfM1lv1gkvJRibzHDBziezdq1a6murk6obAU+hIyjr6J2eZmgNjit0KTgAzS1XWx927wJT57/9fFNIw1bZophf3DcfygiYo80dPs3ZeN80QwbFFBFPN4xe/Zs3n33XVwuF8H2IHUH6ojYQVyDA1kJxJq9SMRA79OO7u1INhlt6MvAgQMZNWoUmqbx6aefJi+xOGtrAnCoC7G3PvUVuw39NPHbbwpMYI+CX1o2m789cekfs1rUCQOoJH7hBHj44YdZs2YNhmEQiUQ4evwQAc/hrJVjzUVIxIXeJ4zuDcUb9DDEdVXSbTp69CiTJk1KPZT/BfhhItPFf6moXbFL4IYejA6alhrzwJXfP9VNuQdnA7k1s+Duu+9m7dq1FBY6MZfWYIBm/zEC9kks6bwkJIkVhygo9lDiKWVQv1IMw3GUd+zYwW233ZZweAF+BHw3ta+uccUDK/4VYXU3RrfYtjbz2xOf2tdNuQ6sA+7rppxBgwZRXl7OtGnTkpFf27Zpa28lEgsRM6NgGXhcOeTn55Gb23mBOHfuHE8++SSvv54aXuEd4JtA6vWpK7H1Nc+NtnSr61QTmnTFzd8av/RgQvT4fd4Z2ExH1PnTp2PlP/mo/VXgni51s2DSpEmsWrWKKVOmkJ+f362ebds0NjZSUVHBM888g2manRbBcmAZWXabrK52ee2KQ8CoFNEXls3XF0xc+gWAz4fWfrBoNajU4W9Zv6u9/sRpc1o3Nr4KjAfSypVSzJw5k9mzZ1NaWkpOTg62bXP27Fnq6+tZt24dJ0+ezGyrEWdNvZNZcAFiy38I6rG4winRuXHe2KWHARYuxFXk91YoxdwsVQObdrfXHms2r8uQLwN88fRtwEpSbtsXgVac9bSK7mMxQDdRKkRtiae+MDVrSiopb8D7s25IAXjnTMufMHKQsSdF9jSdpMA5ayYB1wFrcL6a9IQg8B7wTziBmqXESVVKpV7V8Pb3qho2VWZWyjpilXU+t2nrvzM1644F43yNAI/OxpNbWLxJlHzjAoYAtP/ik9D+w6diW4HneqHfByceOTBF1gGcAA7gHDdp+KTx55M1W70CXAOE0a2SKUPLkm5N9ussUFm1OrdsyuIwgO8GjNBAb7kIZb0wMoEz4s4Z8dK6L3ucMheLOKGncD5EHAW22cj7fdrtnePGlSUdhW6JJXCBNdUjRMm8l9a3VgBU1C0fZ0SM05f2HztQWfaEWMzckRK26xa7ZJfhPnHuKmXLDaDmKDgryPvo9rYpQ8uO7NrlM071166af8XTv0+tlzVKlUYq4F2vVO+28ExotkreCMVWD3foriVi2Q+CLDZcOlUNm44h7FSabD0fzduR8gUUgE8+f7dQazj3IRp7xJb3o6rtP24Y/mCkonbFJFBzymtX3NwEU7H5GNIvyN0SW7gQl9fv3YDqdIsuEmFRfJDMCccXTFzSXtXw9qwUnZEoFoqohcWucKiqYVOVKLVTEzZfO/yeg9eNubMN+OrampVDDM3+Bpr6h4oDK6ej1CWpHWX71pB1V/TdgOENeMv/AlIiwndf3BA4ClC577n+ohHce3LTpSDdfbzPA25WIqsEqa9q+PlqgPLaFVsMXY6j1GsI9xL/mpLWmaZ/kCnrQmzhQlzBAd4NwL1/JilQ8uhLlYE3Etmoy1oiFjttM2u8MitEaV+ur11xGc6510PUTB2bP+7f6zOlacR8PrSi1qI3VC/dom46Wv7i+tZXE7mK2uXzFdzh3Aiki2PcHXTLft9UzLqwpmSGAIEUYvfeix763PszJeqBbIq9gYLHX9zgfyaRrziw8j5B/URgR3X1ay7Qbu5lU03XjJhTi/QiFChdYptAnJjPh1aqF/34Is+pNCh4/IUNgRcT+Yra5WUisg7QEe19s3//a0B6/NNJp7Hs/EnDMo9C3XgBzYglnp3ZCjSfD639c+8bf+lIpZNaNkNQa3HWRkd7NPaxfRHTUMF7rqA+DWdD6UmzasHEJVkdAK39YPGjCA9lK+ylEc+mkTqw8nZB2wo4EWIluxZN9oWQ3qwXACxT8+zQejENpYdPyhqIr5cddkGc1PcT+Yq6FTeJyHrADbQKVGqirRTxaQi/B77sRbPV15fedt5Wqqcd9DSwTo+ZXZzfpG1LvumN0jXGcUEoUSteqPQn/9dRUbfiJrH5AchHgvqlW7M+ygzyiPi0vY3jrxVRdyJyFyrtzhfXYdmJYP06C46kiCPAr4GtiLVl3gTfscx6mTAQPkIx40KKGbSWv1DZufsB2KZ1aP5EX493LKV8NrAn/jy+99i7A2zdmobI7TjnVR8l2numYpYSjoO8i6gtwahVtWiy76L+q6ieuLfPMFuzfwcM6FUFpZa9sN7vu5hOeoP3Dr/nKTY6vnbt8P2/rqj1eA+PjwR8zov4s6AAFn+zcLSO9itgSI/Kor73QqX/5Z50/q9AA1i9oe2QbVu3KFFZ/guUgDzz/4UUZNzHltxfNApLfQgMTRFbKLXkxfX+H/zvmvaXIc25rKqNnLt+bN4mYDQKA8XnYD/84obWn/6N7Puz8T8tlNE97vgLKQAAAABJRU5ErkJggg==", + "config_type": "basic", + "config_baseUrl": "https://www.openstreetmap.org/", + "parameters": [ + { + "name": "mlat", + "displayName": "Geographische Breite", + "description": "Geben Sie die geographische Breite des Ortes ein", + "default": "52.40847", + "scope": "context", + "location": "query", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "mlon", + "displayName": "Geographische Länge", + "description": "Geben Sie die geographische Länge des Ortes ein", + "default": "9.80823", + "scope": "context", + "location": "query", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "zoom", + "displayName": "zoom", + "description": "Geben Sie den Zoom fÃŧr die Karte ein (Zahl zwischen 1 und 20)", + "default": "19", + "scope": "context", + "location": "query", + "type": "number", + "isOptional": false, + "isProtected": false + } + ], + "isHidden": false, + "isDeactivated": false, + "openNewTab": true, + "version": 2, + "restrictToContexts": [] + }, + { + "_id": { + "$oid": "65fad93bbe8ce15df1279d9b" + }, + "createdAt": { + "$date": { + "$numberLong": "1710938427057" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711358019585" + } + }, + "name": "OSM Route", + "url": "https://www.openstreetmap.org/", + "logoUrl": "https://wiki.openstreetmap.org/w/images/7/7e/Logo_by_hind_128x128.png?20100124154543", + "logoBase64": "iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAYdAAAGHQBd4HF4AAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAOdEVYdFRpdGxlAE9TTSBMb2dvM6v3AwAAAAt0RVh0QXV0aG9yAEhpbmTQ2CnUAAAAUnRFWHRDb3B5cmlnaHQAQ0MgQXR0cmlidXRpb24tU2hhcmVBbGlrZSBodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9saWNlbnNlcy9ieS1zYS8zLjAvXoNavAAATk9JREFUeNrtvWd0XNl5JSrJ7y3Psv3We8+2JPt5pLHGsuSxrZEltULnVnez2cw5gkQGCBA555xzzjnnnHMGCjknJjCTTbY6s1uxv7fPqaqLW1W3CgAJeSRN/9iLhcAigb3PF/b5zrlfIqIvfYHtYWJ8/K9mp6cvLczNxS4vLlatra4Orq+vL60sL99cWlx8vDA//+nM9PRvp6emPpybnb23uLCwjq9Nr66sdOH7i+bn5kKnJiYO/iH9TF+6kHH8MvBvwJf/VIjCz/IXwBvAEeCvn+W9QNj/WJyfT726vn4VZP56eHCQWpqaqLqiggrz8ykrPZ0yUlMpPTmZUhMTKSUhQQD7OA3ITkujkoICqquqoo7WVhofG3sP7zk8NzPjOT05+f/u4Of6r4AeYLibAngXIMWf9YAj8BzwZ39khL8JBAPDwK8UPxPD58AcEA8c3Y4gZqamTq0uL7dcWVt7ODsz83lHWxsV5OVxosUESyI+XgXJYsTFCUjF91aVldFQf/+v8O8tLy0s5E6Mjf2HBOEXgGzgmuhn2thNAVSJ3liMj4EOwAt4GfjzPzDC9wAhCsJ/rf7/Pxb6Nmn5ubQKYn52NgAr/b2xkRGqq66m7IwMSktK2jbZugiXRGysgDz8W11tbZ8PDPVshFX6tasRLoX/tlsCsFK+qXutMRnlnNL2D/4SGFSssreAv/pPJPwvRYSPSBHOYJR7kuwq9MinxYh8qi9Q7aIv1S4HUGyvHdmWXtQqiOSm2HtLKwtPWHgvyM3VTvouka2OJIaYGAG1lRVU311NriU2dDHzBLlUGVHqkDPVrwSL/98GuyWAf1W+aeu1UFr4MJU6N8Ipe9yN/JrMyTTvtLZf3G+BSSBGkWv/ZpcJZyILBUa1EW5ReI58Gk3Jp9mIovovUdKYlYCOG8HUuRBC60+y5Pg0i6beTaJqCCKmhwniAoVU+dLUwgSxFV+MfK5C/C6ubl1kayA6WkBDTTX+f5107YNSuvHLPA6HCgPlz5+3KwJQ/MIfsjdNHXGixY9SFUjjWMDrntuRlD/tScFtlnSp4Kyu0LoEpAJngX/YIeF7tyKc/duh7ZepZNabhu/H0eqTTCpedFMhnqHleiCt4WudC8H8TzmyOJgYVh6V05VrczQhk1FpYSEv4P6XkK1GuAaioigZfzbV1dDscjtd+biAEvsdd7UOUBJQrkwBjHB1LKph4G40FYOEoPgzZJlyQleeus6UChgB3xYR/lcKwsOAMeA32ggPAeHs3xq6H0srn2SoYO6DFEqRWauQX7fuS6ufZHJxDN2I5t+3Koggk5ZutdLi/CxVlJRQhoj4/+zVrY1whkQJsPdvb6uitsUk8e/om7slAAv2hgZZJ2nq/USa/yiFQ0oMSszdiqXBPW/S0LF9NAJBlC/4UFS3DdmWXdAliPuKtCFJuDkIZ1GmaMaLBu/F0PLH6Zv4RBUrQNtGsAr5Vate+NqmQEZvx9LorRgIIkOO68MkQ7jPy8r6g1jd2sjmiIyURFlhLgWVeSt/Z/q7JYDvKkloWA+iuQ8hAG1QiGMi05oGX3+dJtKtNMQhexSPXOtPcX125FhpwAsZ9t566cfoQrqI8PyzFATCC2c8qR8iWvo4TQvSVcAEMfwwmlLHN1d/6ZI7/16xQNj3tc0H0fIHubSOkN/V0UFZ6MmftVALCwggB1tbsjAzI4OLF+nMqVN0+OBB2vPmm/Taq6/S22+9RUcPH6ZzZ86QsYEBWV26RC729hQTErJjwjkiIlSQnZJCiZVR7HeYuysCUIiArU5KGnKAAJLVkKICJoThUwcQAd6g2Y0YSYGII8jkLxKpbi2QQhsukVelMeoJD+q9EyWZXsT1x+LHm1CKgX29+3YopY3bCuTnzDiieE0TxCFG13QSXbmyTA21tZSOIu9pVnciVqyPuzsZgcy39uyhF154gQ4eOkTnz+uRiYkpWVlbk7OLC/n5+1F8UgylZydRZEwYeXh4kI2tHZmbX6KLF/XpyJGjdATCsDQ1pWAfn20TniBGeDhHKv6P+VUZnw729PzlbgmghAnApdqQZj9I3sSHqmCCmFkO46t/xPq0VoFIYfb9JGqa9dtWilGvP6Z/kUCtNwIpY8pereizpuF3YiQjx8LtelpdWaTy4mJKZeF+h+Hc39ubzp4+Ta+8/DK9/MordBqvbe3sKC4hhmpaimh4toFqhq5QcfcNqh5/n+5/NgqM0YPPZPTglwzjdOvDEbrycIAWb/TQxFI7NXSUkV+gL8Rznvbv20fG+voUGRgoTbaIcCkkAjXlpVchgu/uhgDMmAD0Ea5l78bTzAdJAmZVkEwTZY5cAONZ1hrikMamOOomfXWmlxl8/xDCe8fNYKpZ96HCBRdKn7DVqPSVKF50lYwe87fqaW56mgqQ71NEq307uTvY15dOnThBzz//PJ05c5bc3T0oKz+N2vrLaeFmB935ZIgGpqtp4c4gNcz/lmpnfk2Nk3fo+rsDNLbYQL3jVTS60Ei3Qf6DzybooYBJfCzH6t1BGhhvQsSIpBPHj9N5iCsKQtBKeFiYVhRlZz8Y6Or6xrMK4NvK3Fy96kfTIgGoYyzMmAtgctBXUiDaIggTQ8/VMK3iaMEKT1Kr6rdCy40gjagx/6CUV/r5IH8nlTnL7edABCP+xImT5I+Pc4tQV9ztorufjnBsfDBAQ3O11D5cQU3TdyCAzzn6Vq5AFLW0dKeLbnwwSGMrTdQ7WUM3P8LfeyITiJdjiuPme2M0OtdCq7cHqa61mI5DdEYXLvBaQRvZ8UqEhqogIyFhbW15+b88tQAUIrjDBBDXb0fT6AYk8UEijSWYywvALi+tItEWPYZR7HWvh0oKZOr9BMm+Xhcar/mrRJC5x9kI+wtUAu8+WYp8iYqcFWFGCMUstx8+coS8kJ9zyuupY/461Q6MUM/8JC0+WqeF+/PU0NdK7VOILK1zVDX2HjUt/JZ6rtyhzqkqahgqppFbTeg8mmlkAxtGPQXUt1pPY7dbaflxn0D8w8+mOeav99DSRj9ez3Ks3xuhlMw4nh6cUFckaCFbAxAMQ3pCQtezCqCQCYBV7owMORI1IMu14QIYizDREMcmtIuidtJHq0B4nQAjJ3XcZlsCKJhzoRn8HR5F3k+ntSsgBj0+C/vb6buj8Ys7goLu5z//Obkh1CekF1LF0COqm/uc6oGinttU2HWDr/L62V9T3dSnIP1zKmxboOaZWRre6OCkN42VUv1wMY3ebOYCGL3VQo0jZdSzWEeyW20ku91G95+A/E9B/qczIHyGple76MqDEUEADz/F+8220drdUcouTCZjdBjayNaGnNTUqGcRADNs0LYd54XV5HsJNPn+JpSimJgL4v0/E8Gw8TFEhEs03uFBk3djuEB0RQ+G6vGtI4fs3TgqX/HYlggypux4JzCzPEyN2MRJQ8G3nb47ECv9zTfeoANo4aKio6ioVUY5zcuc+Mb533HSywbuU2HHFU56M8Pi59S5do9q+kuoZ7mGRm42cdKbx8upfrCYxmA0MTDSW8crqXu+jsZvd9DM/R66z8j/bEYgfHypg248YulhDpjH5+ZJttCF1DCFaDBGPaP1dNnSnKKRiiQJDw6mODXg85+X5eefe1oBfEtZB5Qv+dDEe/EckwISBEyshtKw6XEuAgH4ZY6GGdHUozit0YOhcz2YJh7HaxWIOHp03w1DNLDdUgQ90zXU2dJCGdh/39JoQavlir78pZdeovN6epSSmkST6wjts+uU17oM8j+H/z5Htz6epPErA9Q3N0ALD2/Q7P1bNHVvHKQ3IzUUQQC1fKUzwtuRAppGyvlKH7/dzklvG6+mvqVG/N0+uvfJFF/hDHLCYUUvd9H1R+OceDkWuABufzBDV+7LaOH6EG08nkZKcqEQLy9JwlUQFMQRGxT0S4jguzsWgEIEN5kAonttBAHoxPVwkjU402io0WZUMD+xKRaJCCJ7FEstSwGS4pCKHM3XA3SSnzPiQ1PjMspiBsk2TBZLGDgs31vb2FBpVT61DZXRjfcHaeTqIuW1LPKVPnHnHoo+GU1fbafZ6x0o5Mbp+gfDQnivHypB4VcvhPduFIaNw+U0fqudplA0Ljzsp/6ZJlp/OEwPQPp9RvyncwrMc8xc6aOrD2WceIYHT5gAevif1x9N0ezVIQhmme59tEhpOXHk5eioQbYk0E1E+fs/xJ9feRoBMO+e7Msv0vgv4jQB0pVQF8P4RgRPCUwEsmEfrdGDoVLmqSIOsUDU64+2W8E6BTAxNwiLtHBrRw1wQR//4osvkpe3DzV3V9Daw17qQdt2+5MxVO8yKmieosbZz6ht+de08dE0DaHXX73fzwWw8LBHCO+Nw2XUv9rIVzojfOZWN3XL6kA4Wj/k+LsfTfF8fuvDKZCvIB6rfBMLaAVH+Sp/+Nkix43HU6gLBjjpdz9coMnlAbr/8RK98+kKRLFCucUZFOnnJ0m4ErEi4GctfBoB6CvTQP+DSJKBdHWM68BYo4vcJPLT1xk5KmUe/Pu1CUQcPZpuaI8A1bIkGuju5nl/K0ctwNOTXgL5Ts4uMHKK4WJ2UM9ENS3f66E7aNXuPBmjThR2Ra0LVDPyADl9hmRLLZz8u08m0F72COG9eaSCBteaESk6aePDcZA8Q1Mo6vommmjlLvYcljqx8TSEz89zyFf9goCHny7SvY/nQXIfRDCCwm8cr/tp412WJlY4Vm5N0Nz6CF17MEML18bw8SQ1d5aqkB6rDtQLSoR6eX1+YP/+53YqgG8qBVA870lj78bS2C/kkGlAUxyyhzE0uBebRKf264wgHagD+u9EbJlimDiKFlwlyU+R2dDsnIzyMU2zlYXK3LY3IEwjY2Oqby2j2RsdPLyvv9PPV/9tLgAZ+vZpap58QNVDt6lh/DGNXn+HVh9fAclYsY8HOeEM/ctNNHYD4f5ON117X0b3EOLvfoLKfr2XFjcG6drjcbr3REn+ggjy1f7g0yWOO3jftbsTtHpngm6/t6AgfxWrfpX/eeOdOVq/O41aAF97sk6PPrtC+QXJPA2IyZaEvz9ZGBou70gAChGwbVwK77ai0Xdj5CLQBTWBDJ3cT4P79mwZPRoX/CQFMvaLeOq5F06tG4E09E4U5c85Sxd+s9XUhKo/BUWfLguV9dJss+bo0aNUUllAiyBteB6O3UQtyVZa6eaHY3D3xhWYoLVfrFLLzIdU1rlEnSu/oy6G1d/RwoNl6l9qotaxGmoZrabehSZ0Pr209M4QBDBHG79A0bjUDXOnE/kdwvpwVoX4+09A/JMlORDmGa69M03TawM0MtcO8aAA/FgGMQ7TzSeDQB/QD8exnfqnankUuv9kBgXlAsVEBGmQLYVQRL29e/a8vlMBsCFEssHWLhPAVlAXxNDFw3Kf4FGMIA6pCFI+5r6t9NJ4zU+D/NQRexqXDfOdMV1+OYMBKv030KGkZ8EivtmNX2YdrTzoR84fpfHVNnQ0bVj9Exx3n0zS2v1B6pT1UlX3PHWD+B6Gtd9Rx9w71CarxcrvBLqoY6KBBlfaaeZeP936aIYmVnoQ/sfQ20/Q8q1RmgKxjHSGe0/m5HXGJ920/hHqio/K4B8kUniuMdrKEBq+E00p1Zcpv8NRxRyrg2cSlH6RootMVRzU4m5MbLk4aRAeowRqhQSkinTURV7Ozrd3KgA9ZRpgbdjI42gFYmjkXTm0CuJRNA2+vYcGT+zTKhAlqiY9aQTfL51eNlNM2bK7pgUsK6JGjFmz0K/LMw9G+8TavfDICIT9bhqcaaDJtXaQIV/xGx+MUf9kA6IA8vwncPxu9dMw7Fn259BMO8L+NZq4/QvqXYcvMHydhlaxUu/1cdJHr3ZR12QT2sNBmtkYoJl1EI6Vvn4f4RxtX11vJjyCHBCWIWmO5bbbU2GPs/Bx341wCgTZU9j4YsZYEb4WmW9Mpf2uFF1oquKexhSbUUalGyXjZ2Zks3oglW0VxydQUXoGVWLEraaomKMiL59tTZ/diQD+QSmAgll3Ggb5SozoRAwNz/jz1T/kcm7LyNF5I4Ra1wO3TC+9DyKo7io2huZdBAGMTw5QNiZ6tvLMT2OjxdrGllqGq3m71jiAlbfaAmt2AKF+iK68N0J9U420hjB+86MJuvJ4BJFhHB+P0OAs0sNHk7T+7goNXP0Yhd84jWNXb+beACd9CiLpxIbO7L0hHgl6Zstp9cNaapmP5N1LfMUluJ7eWlvcpGpLOIs+gv8x+V4i+afo0RC2ypkgeq+H0RQ+17aMtq7QREU8LCoMsVG9PBhmuXkgukhOeGERVRcUasDfw+PRtgWgEMEVJoDQTksaehzFMayCaGlU2cu9gLRLkgJRjyBNK/47Si/FS25UMBZE/Z2dlILwpmuDxM/NjV7FgEZ+cR5aN3n1Xt9fRn0LiALYtGFgRRzL6cPr7cjnPWjpejnGrndR21g9X+mMcIYOWSMnfe7+EAQwTDPY0ctvTKbCyRByzDxF3oXnaQKrd+BOJMbmIim13opKBl21trcRWN1KP0QpiuBMfepaD1ERSisGWSMLjYUIMvYwlgtFhj+HbyRTPc4XiMmuyi/QQAEGYdD+ntiJADKYAKxKztPQoyjtUBPHULihPAJ0uEoKRF0UpaOu204v7HuypiGwmWa2BbrlBskxDF8ws6cLIZ5V7ozwNhmKt/lGTraS8Db076PXOnloV4Z3ttI7xhv5SmdgpHdONNPUzQEupIYVTBZheEbP63WKH7KEAE6QU/ZJasDm1BiMrrppb0qqsaDKMQ+t7S0jlRXCYnEEpF2gXqQCsTnGRBJZYCwIQvZOHPkln6eR+9E8chSXJFM1SK5EqNcKRIkL587d2IkAzirTADNiBh9FaoGqIAaNj8oFcCVYUiDq0aMK42C9d8O3kWJiqGrdC62fLY2O9lIG+n5dGyTMNWNDHEUVhcJKZ4R3zTRS+0Q9J56RPXW3j9pG62jyVq+w0hnhExt91AXCZ+8NC8hpjqe0Pk8+hMLSUHi7CRkGvsVfuxWcJru0Y5Q46kRuDb8g85QFOuVdTvYFixTY/phCu+6jq7qrAqPQInLOHxQ+Duu8TkaYRehE3SA2x5QCEEcQLpQNuVD6N+KprrSEKkAyR06uJLwxtfSzn/3sO9sVAN8XOBG5nxJ7bKgXbdnAO5FyPJJDQwxtzvI9AZOjkuKQQhfGu6pnvbZML+23gymZDX6Ox8uLP9be6dggOY5tXStrG/j4rcJKn7zdQ71zzdTQW0UjV9DP3+wh2XX5SlcSz8M7yJ5AUdc92QrzZ5S6r5VTzpQn2aYcJZfcU0Id4l50lqziD/PXwU2GXAyJI5cpvCefDOIWaa91PlkXfUSO1SSJC5HTdNKvR/jYNHWDDjpVkVv9L6npWq1gkDUvBlAEBCCOIInVFlQ24sYFMXQvitLSovkqL8/O0URWNkc6Fg0E4LBdAezlEQBDnMXTblQic6WiUReEPl+qx0ZR770wiCFCAQjifgQN6h3iAhjod5cWiJbo0bjit2WKyZ+XewE9snrKxxk9XTtibPeMDWeW1pTw0C5Dy8aqd0b6+EYPNQ/WUg/SSP98G7UM1tEgXDvZtV4e3uewJz93fwS5Hvm9OZlyp7wEwtmKv+j7Jtnjd8LCPXsd2WUqfN0i+iCZRewjz5JzdNYzhAwjmsir6WNyB6HOtb/VEIBt6ae0364EIuimC1EztM+2iMwy7vCv+ba8L5hhTYv+FF5gJHw8AAOtoNuRXCJPUmiuIYXnG5GNHwpOjL6VITUqUYqBGHXADxnZrgDYzD6Z5Z+hvnfCqV+Bvodh1LTuT6XjckHUQxB1ixCEj5589budEwlDJBC16CEWSPGIs06B9NwPE8Ju/2ArpWGwQ9dumAuGKcwxidspg1V7UzW0M8iu99LIGkSB3biJG/2ccEb8FBy88asD1DJXTLGtDpzoxFHV9jOi05ScEQVc80/z1+KvJQxbcvIds05QdJsZlcvct3RQ+26HU0GfE+V1O1DzSgRPFUqBdN4u5D5Iyyqs8FpLaod7WjnuQc1L/jT8AGNzSLN5EEJMvQVdTDxBjThSpkJ4ZiaVwCUVw9LU7LeIAv/XdgTADmzg2JUpSA/Xit4HYdQcqEdlGKMqPvQaNUx6UB0iRA8iRD/IV8eABGoXvKnlSoDW9NJ6K1D4Jfd3d1IS+l1dO2J6GOsKQ4pgFbu4iGOV+yQmcEZWu6lvtgMhvo0GF7s4+QsPxng9UDYfzv+dwHp9OuP6KvngrKFvpR5f/YxwJghW9LGVHlCnL+lQ8tSQcJgye223dE+rkVqiSkwoIF2P0hqtqAhRyab4YzLPvEux7fVUgT2T7A47Cs7SlzTJhu5HkXHQQToZdYDKK3J/o056MY6vc6ALYAjFJhgEcGIrH+CvlIc3UsccqBerXhPhHP0Zl+Qr//g+6lv044JoWvdDynChQhw1q1v2odolbwgiVBFFNEXBxFI85iIpDoaO2/LdwPyRQGqG9RsvRb5oR+x1TPdUNVYKpLOczlc5MLrWQz1TaPluQBh3Rmh4CdFguRerqQhzBw783/GrvkgG/ns4xKs7FIdO3bDyz7q9RqecXuZEiyMEy/+sGGSdARNAeJMJ9d2L0NrVtF8JoiAQWzXhASF4kU/yObIM8+Hk25X9kgrn+iEAd/JNPU9h+YYa0aPreghFFpvQfuuXuACyKhOvVOBwq5hwhiKkTCVycO4RM4+5Wwlgr7IDqL3qSz0gVQUP5egvlx8OGTz4FvWNe0oKREoQNQte1HUnZDOSQBj1Kz4aAum+G8qjScWsB1+JLSN5cLnStW5/Mvg4OdHBg4eob7JLIH3+/qiA/tlOTvjCAxktPhgn2UYXuSQYU6zicCkjlRVzboVnVATAEN1rTvp+b5JzzkkyDt5Lrnno/cvPk1fZeYroMEFU2M/BWkImABYpqhY8NFrcnluh1HEtmPwzL1BQ9kWqm0cKRV6vnEuhY06p5FAprxdSWzwoJMeA8nocKTTPUCWCtF8NIt+085Tfi27H9CdcALE1IXF1qAMY0YVKwCpXB6agHiMKfFmXAIT83/0gVAU9CvRO4/jWm2/wnb/eXlcNcfTqQCMEUYQVnzfkSFWznpz8/EFH6kS72bjmSxUzHlwsrM5ouRbAhdF2G47hQj1lQcHatj8ZLLHbd9kKMwLXhjjhLLTLIeMYnOumCQxaMPLb1vMpFW0lIzq01Vie40EkX+34WF0AcYMWKPrM+GvTcNUUkIDV71WK08rlemQOMkxwPwH73qgmM+pmhF8PppIRF466OW9qR+vmlBJJ3mWtFNjxDrnU/oYcqn5He61y0Tl8iFrgLndJh2GVM4GE5hmoRI++O1hct8L4BPdLpj/mAjDIOvVvHXV1HxTAIdUKHI45cuAASwP/rEsAPP97I4R13Q+hrgchID9ERQj9Tqf56u8ttpIUiCrCdAqECSK+zpyycI6/+aq/SgQR1xsDI3WUjp0/XdufhpiojUmMEQhXrnSGuTsyGprvhTj6qWwhUiDPKPgwOZekkW/rOAV2tlLsYJCkAJRIn7Qjt9TTFNdhgTMLdhpfZ4Xg5fhDPGVYxR2m2jlPpIIwoaUdglEU2nWHt3xm6bdVOoN9dsXkWLJOrTcLBP+DCSBETQBKxA/acQGgXX/MuOtubLzGIkA+iM7HeJwU2BkECOA1bfMAQv5PHrOXC0ANPd3yfn/A8DB13QuWFIgUenQIJG/QYcsI0tVZxzc/dG1/nsVsfVNng0D60sMJYJKWgZmNUWofr6PIhs2zB1H98SCiFHn3nkCCU82vybM6S6sAGJySTlDljDv1P4ygzCk7DQGwFMBesxTR9yBcpbWtu1LO/51DLjVkmnaTnGt+R/5tj5Eylsk82I0aVyNUPJBaRIyQPH1Jg8y1xpAL4GjI3kbGX29z81QxBJCHSClGLjwAJUwxAg8BnNMmACH/V13xps77wRrojTTgAuiptpEUiAa2IZCaJS+t4mi/G0ehPRPU3lpLCWwQQkF2HISQhD2ADLSFeVB8MareC2fP0sBEn0D68sMpWn5nilaw7946W0ZOaad5nuc5fSCCnKo/pbdtCjQMG8uch6TnfVarAFgKiGk35+S23w6iFAkBxA5YUGyfBS9kxV1Nw/Ui/m8c82ol2+wxfD2GC6MPZptHwmnujIod1No5LwqGANTNsf6HkfxENxPAfu/X3Rh/A62tnSUo/HIxFq+OHAVszc2ZAJy0CUDI/x33goBgDfRflk8Dd817SQqkE6QrsR1xtCO/F4+7aBVHyXIB/4W1t7ZTJvthEMYK09KpLCdHA+yk7ujckED6sgJNa9nI2QY8T1vEHOREeTTepEvZD7gZw/KvWAAWOe/QYadonQJgNQBb/R13g0GqH+XOOvL7CpQCCGkwpHoU0er+xwCGXPzbHglOYOlyIxdGJQ7NBuZc1HBQa+CUBufqa5hj5SvyY+JMAM8b/uhlxt9QZ2dZCQplRnQuDsZwYIEw5Cjghv0RCCBamwCE/N8OAWjgbhAN7t9DA4f3ahVIh6QoNMWhFEj5rBuOpftqFUj+nBvs0cc0NCDD3nYelYJoDgm3i52zG1/B/Nw7M8AsJ79yMZY7dlHdZrwyNwp6i0yjTOli9BzPucbJ1zRcOiYA9rWEEWedAuDDKTjE0rwRwDuXPpYSUMtElhtT4bCzVv+j5kqp4ATqBTdSRpczeaWepRqsdnVDrBqFclDuRQ2DLHbAlgvgBcPnPvzSl770dcbfaE9PSikTACMb9ZI6srGD6uvgwARQInUu4C+V+T9x1I7a7gbKcS9QEEAHqnS2+vvNj0kKpEMDWwskB4WMlEDY9xYtymcA4oa9aaBvmMpBfAmIFsCMDwWKgePHjtHMNczYPZqjlYczIF++ilkbySp19pq1fGbJxXQ+fJxHACmf3qbkE9KPXaDowUhJAfDWT+QEJmPlN27482K1Dr4H62BYJ9MvuKia/kfMwCo3fVgk8MjP5ja7lIPaei2Qi0ndIHOult8XtMfplQ4IgB8TnxgYCC+DALIZ2RiWYchSg7+zMxNAr5QAhPxfjp23VqUAxNiQD3z0GxyWFMhWkBJHJXpldYE0bvih2lY9EDLY28497hKF28VyfpEazp09Rwu3piGAGdxaEqlCmPI1W7knvYu49858eLvyX2368yVP6FRAH1/9h11qybMyXoN8bU4gcwv9Cy6QR/IZCinQp2pW/etwUVtvp5JpyiodcWugA/YFFFVlp+KgshY4tsacvFJwVU6BgYpAmD2uD8v5XPIR+te3vpOljACygYEcFgGUZGfCNeVA8ayEpzwFrEkJIFSZ/1vvBmhBIA2c3Mcvh2hDDmqTEokYW4ij+ZY/5Y85agikHX+38YafsAnE0NvVyslnZpAYhSh6lDA2NMTR8ClYuhGbDh3cOmbn8sKvx5wbPeaZ1/kqZwIQ78id9O2ic6GjuOrmMzJMWKUTrpiv69u8gYz9fbbpw9w+sQCYKIxD9sIHOM+vqgnIgYWceV6rg8oE0LDqS6YhAbwDYb2/XWIkJTdZyi12fG94sSFldqLQvoMo2WfPUwQzx5g4SlE0n447RK9efp7+9lt/7S1KAXXFMHuUZGewIlkNDvIi8IaUANgNXdjBwqTKHX8gQBLdcfKhj17n0/Dp/VXEoY6tBFIy60K1a946RZI5I78Uoqejia/6QrHTpQYr3MaR3uLPB0eEVg+5X0ki2861x166ONTvtcrj+ZiRwDoC+4pfC18/4dNKLgU+Gk4gM3rEAvCtukDm2Drn6YABk1SeKWeoCqP12trbBNyYEt/khLZTXoA6l10j98TT1H0vlKcRbxDei5WujBjB+fpUhJqEvQ7E9XgvGD1H+zx//h7IP68UwHBHx2AB2j4x4ens5LAIFrjpBAIYVb8fQMj/CSO21AwBSIEJo3XFi/r1DnIR9F06Su2wI1VEoiN6qIsjs992ywhStiYfCu3sruE+dwEULkDkcuUDrMcNKzLnUaRy3VMj/9smH4XN66xhvrCij3nwh5yrVb52PnyCzgUPUMxghk4nkFnH9hgIUX7sX6VPQYX62L9w1Op/hJca8Tohsm+d/1usE7kcas7NsGKZM4UWG2gIJq3DmgvAtkSPziYeZum6CuSfUwpgsK1tIQ8FoJLsFHb7CBsaxXBsFEbkwjEocwqnoSGAWnUBvKXM/6Wr7tR0208Ef2pSE0LrvAf16x8SDob2nztA3cF61FFrTa0Igdqih7pASudcdQqEoea6N/+ltvYV8y1O5nLlSQHtod0lC/JKNOCRow1IQYUuzv+Xog+QR2m0CsnMkWOGjFHiGh31aFb52sWYeUSBTnKr+4xKYVlnKEwfdQGwrWNnxbAISwdMKG7Zpymj21pre+ubdZ7KMGtRtlwp/Ht63uFUveBJWbjMMqrSVMVBTW234iLovBfC87+CLyuxAPqamm4lg/Ro3GkUjn2RMFT86ji0dy8TQKq6AIT83wjSlWjShVt+1F55mfrMj8r3BYRTwogMFseoM9sM5ou7RgRRppeGDV/KHbXfMnoULsrrgPrBTN7qKclWd7sY2CiYbcAZIXpkgTAWAcRDG864ak1FAI4VPA8bJV2ho+6NqgKInuVFIXtddTWLdyqsQLVLOEpBDQbC+ypnApW7ifGIFmxaKKPXRmt7659znkomXfFxGLnW/VJuDjknUd2qD2xxW4pAKyl2UJOaLTmKFtzF1+v9m0gAX+6srf0oytVVkvgwnIhmeAscQQC+6gLg+d+z0ZgabvmCfCn4aUAphmZ0De31UHuIHvWf2a9ybLzP+DBMHM20UjjtRJWrHloFwtAIkaUoLouoksVRJdpATjYsTXUwtysRTqGR/VEhguTh3gDmyCmJsk4+TebJm0WfY9XnPO971a9Scl8yWUYawR3cNIVYQagXOSUXwHq2UKT6ZmMnbgwbVitu/Lo65Uyg0v5lf7KdwYxBG60GWQhCfM6AC3yOJi4AVnsctEvBDGYgJp+dKLjwokrEiK42pWzc3hLZZ6Uk/xG75l8pgOHOzmO18Eki2MpXkC2FV3BOAgIwE98T+JfK61njhq25ALbCVgJpQd/aFW9I/RcOClGhM8NEJYKk4QeRSi/sa5VXPKlo2YWTz1C65oY7jKOoHtud2mzObLheWeh/9QyOYrWm4O8XUUxbGGXjqrgkWSp+tlqySOunY54tAsHWeRuoxF2o5TYi0nVfsgg7QLbFj4Svs5rAJPUGf12xniMUpr7Z56hw3Im/br2DQxkD1mQS/DafCWARh3kNbGQsd9JBq/+R1GZLxpFlKjOBZ7wyuTgarviSa8JJaoHBxD7uhNvomXoGW+PuEK2+UgBVnESFAMb7+qozMSQrJjuUASeilQjATinIZzgkFoCQ/4tX3aj+lo9WNHDsTCBtdVY0APeQtY5NSx6CSAomHVUEwVZ7AcJ9iuICyGQZroBZcMb7+W3WHnUY0ATRjOxsFDvqYAK4hFbQOl9OImvlxCGdrbIDDuV0wrsF1X0hOcafxf0CdkLd4Vdqia+XcRPoqEcT9+uVf7dsNVeoS3wggIJxR5VuJbDwArkknyTfIj0yC9tHbrhou/aaj1b/I2GwU2Um8LBjJqX2xwkCia0zJe+Ms3AWbcg/Vw8+gQnEFqSS/8UCGGpru5mAq+3EhKvjMo7JKQTwY7EA5PkfYazuprcIPnLoEMR2BdIZrc8jQQeiABNF3Q1vyhmzUxFK/sJmz589a0+1+B71uqO5pYCveEa0BmBzMvMjGAWQnm8dr6qNEtcFAt3qP6WQ7iuYcqqiuBZ7isNt6MUzLir1R4qsENbwVR762d8Vm0SlK3mCUFj3Ur3iqdKxNN/0p+BifYquNaGCCSfMMARo9z9wqse1/jPuBF6MnqRQ3FxeidwuFgiz3XMG7Sm+0ZzyRh34TETBvJtK/lcI4Htednb/jALw8yi4fALhuM1UHSfkswC/Us4FquR/j0YjqgXxYtTpxPYF0p5mJBcA/mSCyMfqL1t2EwRSc92L26m5cw5UedVDa3ppHkXrhwJQSXamBNLR+54wdqCA1mnYrW0QVineP1aIIrnY5vbNw01cCccppEyf6m/4CPVHRN8AJ5sVhfvtS1WiR9l6FqV2Y/Aj8wy5YqXHNZvL22KFKMpAjm3kEXLGgCaLBrVrXlrb29SJXv6eTGwm4THkmnSSoqpNqOWWpmiYUeaTeZaLI7zHUsj/X/7Kl38E8k8CZ+tKS0vL2cyfBOkccP9CMCz7sjz/d4qfFyDk/5hhK6rZ8OKovakO7x2KQyGQDR9qyzKhwbfe5J1CE6aAmCCSey01RMK+f6vU0notnErS0wSLM0OBdLheAiAA44vnKbUH18jd9FWJIJW4eMol+QQXQdUarsCHAELKDQRxBHctkknKde4N7LcrIpe6D/EAivswlpooD3+HkV++4MbJ9kYaSOm6LHQ07mmnKADE11/3odhGM/LKOivZ3jbfDuPR6HLeu+hAShEJA6gGrXNQ8UVM+JqqOKhZ2PBxTjhB3hAA+9ihUv7gi30eP28H8YeA/wf4u/7W1jtJOFQiJpyTLoIjUqMi/NuKBSDk/4JlZ0EAW2E7AmkE2T2W8pNCA2+/Sa1FZoI4MoZtdpxilOmlsjKNMhREM6SpAwZIBM7Fe6XpaUSQ2GYzCqsyFATBRGAfcwS7c17847g2C1jFyN8FemTgJx8ISZ2w4XWIPz6X3mctiCUHG2YeGaf566whW/KAAEpmXbkgmMXNIoF6imGCSJb189V/JniI7DIKBWGUL7qTU/xxRAF/LoJ4RBjP9DOU2ApvAwJowuf1FRdv//u+72aC+H/k5k9Ly5H2igoe/kPUSA9RrHyGczgqpxDAt8QC4PnfFNOu1RueW2I74qi74kGdAed45c8tY9PD1CBzEoRRdc0T1bH9U6eXhtE4vt2ZhoqXkZ0qBbSDTk5GGhEkpBzmTaeFSnpxTDhGRbPOXCAluI2EdR/qI2F5CzjylXoSV9k7C+ZY+Yo72cce5X5IYrsFeSI68DpFIRC//POUMcDEE0JpE50U3rtMnk0fbNrM3q2U3O0tiKMF7+kQcxRzfp5cKCzSsM+xeoIJIBd3IioX61f/+197QAD/N98A6u1dScfPq064Ol7FxVggf1n9kTENJ+GOGcMiLVzAkOYNDwU8VbAdcXCBXPekbocTcnfw6F5qKTSjmhuqAsnBHF3xkrPO6KErxTRgl6wwOYmTrESKEhgRZ2BuWCSiQFTNJbSUHkIE8c49S2n9qq2uW9pJysJqFncv2fg/mofuo7RJG2Hk+3L4AYxq4/+NtMaIrrnqRXbRR6galnNEjRFSwGkV95Sll+gmR/Jruy+55WyGEfB8jKKL/Q8WNdj+iNgcy8dovgOE5oELO84kHGI7gL/5yv/xFRMI4L80Fxcf68dj7CJhgEkSj7aPwQPDst///veZACLUBeDLFGWI1iIT9/El9FyibGZ39ltgJ04sCHVIC6Qj8Jx81RscpJplV8nokdht8czppawiTk48iE6WAhsXw9iYmyuGTUdtKXPEhrLRddhEY1y7w1wlorjgqSd5E/YqoiicceKRQflxPWoJO6SKUlxOrRRJ1boH2eL96lBERuBpK66pp1TMsQDkdIu0IRXSWe4P7V6jnLkq8kDEYLWI2ANxjDtGpfOuGFp1pUyYSCVY9eF4aBSLDHmIUoyr8zij+PXv/G0ABPAXM8PDN/nqF5GtRLAIx3DN/Q9+8AMmgJfUBfCKMqxkzztQxXV3qgTK1vEfgAET322uIohKkC+Giiiue/B+fwDnBGqgYm0RJAlzck+bXkoQntOG4A90e/EiMIkRrSBbHWwTJAabICkdm+nGB5do+hagQOxHWIdJkwFxWEUepLIVVf+jAA6iA+xecf3Bisf8KUdBFMUgxynxOH8djgjgkXlaJbXYxDth6vcWJ967+V2YWWkqAuE1BaIRK1QZ6axoNfd/m5K7LEG8K/dFmChYlGC1BvvYAHYz4+pf3vh2WU9Tk18LrsWNgNkTrEZ48OXLArxxL+K/fPe79KMf/ehdCODP1AXw58Bn/FIokMwEIIVSCCJDphAEG9TggnBQEUPtqPxyiC7b49ICAZiwcqcdtp1eilGYpg5exulciLDHgrLGbDGs4s7FkJ4YxElWRwKugI1D+I+BHxCJI9FejpZC5EjosiDvvLOCINKHrMnUby/vSvLx/8octaGKNQ+8VhUAQ3AFC+mmwsfxGAr1h+nDX+MsIJsUrt+QF6pVSAsnXKKEYdOCpVxBGA2obYqRSjyzzpB7ximekpiYsoZtyS39lIb3kSeDANLl6cWpWu4C7rN6fXpyYOD9RBg/YrKlcBLPJfj2t79NP/7xj720PTKmlz8sArtb5dfdtEC7ILIgiESs6iIolwmg2+ooyHdXQDVipI9aw91zVBFINWzeGgx+sNdFS06UMsAIt6eE7ktYoTYQjZukOMomfHgaUJIdBbK5D458KACbIGwLNDHHhYum6poHOSUfIx88nyi2zZTs44/CxrUSBMK8/XTY4aEYt7II3oe0YYttZXculsJZJ7KLPQLTxpAi6o3JLu4IlWBkjQkgpe8yFxaLAomdeERM6gkyiB7k5Ls3vIci04mysM2eK7OnVFjghSgmK9CSsggSiGcaxrWaw5U8Rrnj9hr+Ry4E4J5+mr8O75X7AN2dDb8rxp4Ia/VUCLe0VIG7kRF95zvfoe9973uPsPr/QpsAeB1gjO3MsmtuktAuDDlKFIIoeu1Fqn3zFUpBns+bc9CIJLGdZjzFcHHgfeu8TlL5Ky9SI57KkX/6DSqIPEMlq66S0UOq/oiJcZNveyrI1gYmkMJeby6CMtjdkdj08sVzB7NwMkiq/ihdcaXA8guCIJJgwOQhQiSA3DCII6RSn3In5KmldsObR5BqdDcJHZfwNQMKKDXDUe/bfB/BPreD1xTM2VTvaMrx/pEQU2DJBSrA5piU91GGa3HC8CQXJohcLJTyxmxqKy+Xh341wsUIAvbjnOQPf/hD+ulPf2qt66FRQh2QMWdPpddcBZRpQLc42rAVy6JAY9g5SoezF4t98WTsj6dh5edisicUo9kpQ5e5WLKt91MNbu7sOojjZXoHhN3DDs9TXBxSEUS9/ijH3kKMl6fO7U8lYjzxfni6WQQcTzuc2HFOxZW4CUcof9ZBo9aIbTfjK1z980wYrIg08HyDopuNeUrKnXLgqSUfdUMyIoFtzGGyw0aY8oxf5MCkZnuraHHDcLDDMfEYuaITYVGjFIWzukhikHZYauCW+mQWoe+naIhenewgCwsVuMgnf/j4F/B/6hKAUAeE9plT6VUXkO+iIgRtUBdIxRwmYVEEDmDjp2rYThBIyToe/cLO3uEpn0WraKU6rORzA6fexjUxjjzF1GA6uPe8fCu5w+OUZC0iRA+ROBIz3CiCRQAt25/iHbFoX3cUc6dRW7jgvdAmNpuQe/YpIa2UX3GjYOy2WUcf4iJRL1BZumC1gQ2+ziIDI4ytflanFGEvg6UWm5hD5JlnJlT+Ef3Tku1tBv6+G2oA1qbWolWOqDPiNYVSHOzzrO5ggnJLP0mtS7hat7+PEhDNpAhXAe5HOIynlykEoL+dR8bwOsCp9iKVQAC6sJVAGrPkM4Od1kc1BBLTYUplV3CY9KJ8xdehTxdHkEocrug9KxdBQ7mZUH+UaylOGWpwjVq6aBtU124Ys0kTIlCkXfERilKrSPT3i07847A6Q/IpOgdCMT4mEgBDAbogp5RjlD5iTS7pJygN1jn7fCXqiuQ+S/JCDRCOvx+LgtAzz1gQQHjfrGR7G1RxkX+vUhglEJN11CFsZXtwgbA0E4RLu1MRVWILnGkWF2OmoruRJB+Ei2GFE1IK8peBr2xHAL7yBzCfouKrzmpw4SjZJkrXnalHX05wA+62EYsjEcMZNfHy20Q67Y5JppcaRXToP/IWjyi6ao+a9QBamBmjfGwPayNcCvkpOHO3GMwjiB3SAPNA2OuyK278z2wcAGUCENceFVfdOdms9nBJO4EHOlsJ4mAFK/s6ex3XgX0AnOdXCiC0d16ypfXMOUMp/ZdVPsciT+G8ExcIc0yZMJoG02hyoJ/S0O6GoNBTJ1sFmPh1wVzki3LXj+H4dh8aJdQBqTO2VHTFWUCxEle1QVMg1a2W8jExENmYfBE9sDP/fFHPZap67SUawLHyShgz2qJIa/BZeSpwP6m1/qheC6D5mVEqwPYwSwFad8K0bJCkhwVR3TjmA7GqkwcsVVIM62xsIABt9YdcAJcFcST2XhLEEcsFcFEQQEjPkmQX44huhHVFYgGwuoM5pcqPR6fqqb+xkeIx0Cm10hnhgSJ4w/H7OW5HU5BfsJOnhgl1QHCvGRVeceIo0oCzBrSJo7renPrZIAizhQ/soQ6s+P5Db/GKvynugs70Uob+v/eU/AEUtTgcoS6QqjU8LGoa5GPlR6Dg24psbRskCTCKMvKCueklTiuZeFSdTewhrfWHMwTAilmlIIrw/y2Cvc1ex0AA3jCblAII6lqTdE9dM07y2kEsCh4BsO9Qe8WPZqb7qLGggKIhbiXZ6oSLEYAHUu7HncgK8seAP9/pI2N4HWBbeYky56MEEWhD0TYEUg7HrcPxuCAEFhXSHQ+IRKM9vdQ0yq+f6ToBUbbNkk/bTQrrG0MxWUtzUzIe9gXyt0m4FCLxHg31mbi9JERILZnYAbSGALTVH05pOD4/aCl8zFJHAjwRJg5W53gXnsXNYPLzBX7tG5IdjDfzIiAWZRRhhelluJJt03j8HcRdgjnHMPzftREeCIdPjNPyYQ+Gu8DfPc0zg3gdoJ/FDk58Tn4dq5SLqZeCdUcNFK477Uggxaswf9phcWLSJw6dRu6i/ZYRhEcRW/ldBJlu8hPCvrVPaHoczwTEbmA4Cr6dkq3LMy/BtHEXRs8r8dzjDEQEq5hDWmsPJwyAMgGIxcHIZH/GtJuSF8h1a5C7gN4tDyQ7GGaiueF4d9lVed2R1+pPE7gEs7+hgZKZw4diTxvZ6jDGVfgK8j8Fnnvah0a9qqwDbMs3+H/eo+kdysERsHwJEWgDF4cOgWTP22Js2nxb6SWwYpV69x6kvrf2UXjRBic/T538pyBbyjNnCMXfqc7JpDY8888l8ZzW+sMxFRPAA5YqoojDCaSyq7jZG2cEvQrOYOv3sfx32PieZPfCogYTQEimJY0OtdNwWxsv9ELx/9JJOEK9GJa4FOPHzz2nFMC5Z3lsnFAHWJVVCTkssAsrbt1BAo4q2IlAkscst4wg2UthPBKlelfS4MmTND8ySHmY/QtHdf9UhOvyzNVcNBZ66wqyqX+0ihpnEvgqFdcgCYhiOTN2KgLJX3SEU+fAhcEiiE+r/NJH17pPNSJIJY7DdUxl0/Q0bivt6qJs1sZqI15EdoAazBXkw+lj5Ic+04MjFSLoYwKwg93pWv+hIIL48QLKW3OQRP6aw5YCURdBeJuh7giC1R+AJ3awf9u/+kOaH5Qg/2nJliBcm6vG3rcQtUZXXRUN4IbRzskcnGfwQHo4yFtasSiicVxcz+Pn5Ij0YIlLm2zyp+Vn/nABFO9aFkPwlJJSmpnso4neHmrEnf5JKEJVVrza6lYnPMDEhMMfOI+noChWPUOhrn5/JwLw434ADkAmTGYKAnCpe0KZS/Ci1+wF5KnAYUcCiekzoawFW0mBFGNDKA15MKpgjYKq36Np2TTlYQg0HMXedkP505Cty1VjCMbrVOw2NhUVUldjDQ30NtDwKIDLp7vGcF6hLIKKWiLwvMAiqm3rpcbGPohmlkYGR2liqJsGMLhRioHWGNi4ISy/b7G6lWSrwxcbPIexv68k/8DlPSxt//CZHh4tVQckTMJ06BkTRBDQNUM5qyB/1V5FCNqgSyAZczY4PGGmKg68bmn0pol0XIicCtsTZs14YhS1RQRSBMjf7dWti2xdRouyMGMfhyESsU2ZZKzkHMwjMoKLUKNksssr2e4k61DY/wnfu93VLQn09wzueJzsntde48S/9MoLdNzrgHJMzHG3BCDUAf7dxpQJe9Kt4X1BBIlTyRCBnQ5sXyCJo5c2BbKKW8JKg2lKSX4yIx+PrYsLo7EY3EQW5EWRVrtDthumY7dF+BZ991aV+bOSrQ5L3H+kONZFb+x7BRtQl3H24YJSAI27IgBxHWBfrUdZK7YUK8sRBODR9ACfc6BskK2OnG1hUyAhLfpyMeDjzmI8/CAjUZL80ahAGg33o05/bPpcfrbVfWr/fv4LPIEhCU/8UnW5ajsi/GnJ1kG4vwJO587RfvmBTo7D596kzEm5ixqEzkMhgPeBr+yWAHgdYIg6IGvFhovAp33ziFX4YBP/nBLZSqyKsbVAgloxfdtbSU3ZBdrJj5STPxLqQ8NBHtTu5chFsJPcrYSS/BdefJ5MPI/Sa6+/THoYlfYFCU9L9m6tbiXZ/sjvSngi3J96+216XkH8iy+/QDZhpzG+7ySYZSlTNuKTQj/cLQEIdUDspAVlQgTJc4G4yUJ55/3vKHYiXSEOGxUxqENdHBE4oOnXsYii8hPczvEx1SQVKciPpQlOfoQm+SFy8gcD3GjA15la3W0p3HJnuVtJ/vMvPI/dPn1eh6Tjl2fqAyG8+jKZ4HYxRt5/9uoWE66EL1KUAYT50uaGDl2wPUhpYzYa+y/MRFPOCT5NHaBNAEId4NtpSBnL1hxhgw2iCdfPKai3j1Jx1TsTiBTUBRIjy1P5+/UZVZz8qbS4Lcj3FMjv93akPg8cNXeypPBL5tvK3Zvkv4iTwK6UtWSrUqDG91vS2cv76XVsolzAL94VK+/3vbqlYH/mDJ1GanoFQzJK4g+deYMicN+wrv0X+yq9p64DdD0vgNcBtlXnKR3kpytEENA9pjbf/jvy7ZhHREjB120BkL+sKYaMZXtyFYrJzzEzUConP3Ub5Purkt/rZk09zpbUaGcmF4GOvC0m3yC0B7eDhuCYuJlkURrSYEyHkF/Z97NK2wCPnXHH1urvhXCscgYX3G18Af3869ggE/X0tOfgq+SO28QL1rfefwnEHYhPWwfoEoBQB6QvWcmxLEf4cAUGR36lcdDBreEx+XVOIzL0IlrUUdRoPsVN4vk2M2H4uF4gvxkPMFIhP0FOvgzkj0VpId+LkW8nkN/tiHuALutT2fkjFGJqIpm7T6uR71TzG6QjR4ofMddZoEZ1mdM550B6de8x/vffxlzdRdyrcxlVuOuFC5zcna5uRrYfRrRYQXcJ6eY8Nm72Kto5JV594yVem4TjAqiCte3vvyRNWT91HaBLAEIdED1uTmkQgBgpeIhj2GA1uTc+1PpQJE2wsF8tkD+ZEqOFfG/VsO/lICff1YqT32mNAclzB6nq0CtUdRBPBju2h0JMjHWSzzdlWq/zOiS4VX/LAtVXUfSaJeKwqPdxen3v5gp9AU9HYRHiGEau9CEMMxBqhgsqTZXAxoyJAuzr7PvexDOM8KAGFcKVvbye3QEKKDfExpv9lg6qFJh/8rR1gC4B/BN7w6O4Ds2++CQFdVyguAkzSl26LCCNAzdvTsTzlc9W2NbkJyjIl1j5YbrJ77TGWb+zB6j60KsC+VUHX6aqAy9T0dE3KcTYiOdtdfLZcKZdyTT5N8LYar5I7sWnKKjlIgXjdVSfMaUvWGl0L76iridrxRmRA9NFuD7PHIdSj1lG097TOGr9+n4NQnXhZUxMH7mwh4zcD2M0/SyFYho4c852h/b6pkAysakW02dKYa2GdCr8bdKDBQ3O4nZLAAaCIzh7iRKmL1HEoBEuWNAnv6bzFNR+gWLHVQWRCjEk4dh03FQYRcvSKWyogoL6OmAojVJrXhonf1Ib+YqVPyRBfqeNmPhXFcRvkl914CWq2v8S5SMSiMk/4VyESx6WyLOhnuJw20iGojYJbNYTCtQkdDkJ45d4VGCiYIJIm7NSaXuzVpxEAsEodl8/hfT3UPwkHh6JOf5gEOmerScHLoj0wG0e7oB1zAnyzL9Afjh+zm4OYyt8Z+6pvYZ7Gt1rAv/EgO+lpE1dhoDwMy3ailPA8d0SQB57Q/Oi05S8aMmRIkLCtDmucNen8AFD8oUgAiGIGJkZvgYhKKEQRVdJAE2n74B8PxdOfqeNMdXjwqnqw69tQf6LHJX7XiCLV5/nrd553yZ+AVRAl0zRxdhwpM3j3uExM62dS+LkJYobMcXdQR382jh2d2AqZhi20+Kq+x/KVLNTg0yJtFlcctlrzA2zSLiyiTILiMBaQxzh2FpXkP858De7JYAN9qbOjXqUvGAhx6ISlhpImMGBCAgiYhDFTvM5PAZFLohONfInFCaP9pXvwsN9o95hOfEC+RLEq5HPEPfm82Qa6CSs3qixfKGDYYjoNaKECXPhY7E4xN2LT/uK/BIpHO2KGriEEzlILy0XcEOXESLE5W35HyEQQNaSzbYNstSZyxTZY8xTUzQ2y2KHzLgIBHFosdjdGoVLoxZ2ywf4b8qQEthvREkgXxdUBaIQxKwFwr6visMnST4cviGRydNmcYFqjr6uIP9V7eTv1yS/8u3nqWDPT+ko+mmlABKmwoXuhcG/SU9oa8XIUINP+7LwHulLjoJAWIQI7zHELeImgiBSIQgp/yNu1JwSJyy0CiRl2pIieoxQlyCdDpvivYwRbax2bLGb45ZSBV+JuyUAIf/HzuCHWLikAd2iQKVe4i84fNshn636epbnRat+S/L3qZLPkPjyD+il778gFJ4pCw4q3Ytf43nhtXp7K4dcEL4dC4IA0nBzWYaESBiYIEI6DCh6CMZRs6YgIvCx8nXStAWeEWzIaxBGOCOfrfqnSS9KpM1bP3X+1yUAIf8nzF/SQKISC1Kw4ORPqZE/riRfYfIMK8lHvm81P0fVwqp/bctiT77qFeS/vUl+5d6fkc3//Gd65bk35MOYXWMqRWoyhBk1bCx0L7rg2zErCCAFZ/XVBaItesgFgVPEQyacaIecIzzqMIEw8lNmLLXWH1Lu6VYWe+igqTj//+1uCYDnf6cGPYqfNxeQIAmxOETkp8VpJ19h8vS4XKa60/uo+sjPd0i+6qpnxDOkvvoDeu5rf0sHT2CaqKePF6RihPYYYGdTvXPRBBOIX+ekIIAk3PuvLhDp6KEpEJ/6c1qjh7b6YycCcWm8qBTA4m7NAwj536/PgOLm8JgVJURi0MSlbZPP3L0W4zNy4jXI316xp05+xqs/pFf+v6/Tz779DZzFs9coUlnn4tNwVqWTkWNTIOLuxa9rfHMGYtZb0v/QBSaO1IXLvF3WFT3SdYpja4GYFZ5WCiBptwQg5P/oKVMugK0QPwdbtlgZ9tXJDxGR703dThZUe/ItVeKfotiTIv8n3/w6eZee1FqgekMAUh1MigR82jdrgCScCdRsb7VHD6UIYsfwSJkZC60C0VZ/bFccSbOW4vx/YrcEwPO/WdEpip01pdg5KZhxyAWgJH9zkmciQUx+gJx8hPwWE+lV/zTFnhT5XqUntBaoiXMY30KLup0OhsGj+ab8GYLY80jRIhL1FKMuEP/m8/LP7zB6bFcgQf0mz5T/tQmA53/HhnMUM2sCmKogVgwIoL3Yb5P8FE3yR0A+a+/qzx/SEvKfrthTJ9+z5ITO4jSo+wI8AeNtdDByuNS/r9jgeqTT/0jWKg5L8m08p1Uc24kgW4nDueHCM+V/qXMBQv736UUlCwGIEaMCU93ko9UbCfenHrR3tSfeekbyFcS//TMF+T8VyP8xJ//4lgWqZ93pLbqXTUTKEoTw79W2tCP/QwkWcfwQAXQJRFf02I5AnjX/SwlAyP/hE0YUNWPMEc1hIgcXw3bIx8MkrAxA+uu7WuyJyX/uv36DbFKSKXrSU2eBysTABKC7gwFgs4YN52BaafMpouFjKTvwPzYROWpM8VNmWgWyVfTYSiDxqC2eNf9LCSBXkf9/F6kgXxMm1MbIV7N3xeSzYq/J4MQm8Ud2p9irfEtM/jdxyfKY4lm/vyXfrgEUra4SBSr+nMUWcc8FrQKJGg9F1d+PAxxPVHYwvdoWeWu7Pf9DVSCs4Eyc37mDul2BBMA1FOX/r+6WAG7waWD0rpHTRhQ5ow5jTv60ZLUvd/iGAt2pHnv1Gqv+8LMVe2Lyf/yNv6fLibko0H6pQhjbjvbpHKKQ4TSKmnTnBSoTgX+XHkWMGQmiiEX1HDYaD9H0Idf/QnL72rN1Bd9rtU3/Q1McXvWnn8JB1SYOTYE41gtjYEtPS776/QDfVIYUL6yWiGlchQYRbMKYWnWQz0a32S5e3em3tef77ZD/tjT56aKc71Z0lNch0bBRA/oqybnuY0kSWWRgBJuldZBn2xywQB4taxrCUcIZg6q+Xb0UMR60A/9DUxxxMzge3nR2B+7pzgViWnBKKYDk3RKAvjKkhExgvxkC2ISRbvLR5/d52u9CsfeCRrEnJ/8HnPyf/v3XKNRrr0ZxGj2Nlqu3FgR+JEmsYfyK1kEVJgav9mkKHs6AqCxF7e12/A9pgUSMon6aMNYRPcx3aK+riiN2ylyc/0/ulgB4/jcpPPnLsCkDUiJ8amvye1zwnAFM5Ogmf+fFnpj8n339q5SPE7B9B96k+AEDyQI1Cj57yEgs+fdVg9QJcm++hrz+CHv6N1VId228Rz4I/yEjifh7Flpa3K38D+3wqGMeyvYiyHbEoS4Qv15DsQC+ulsC4Pnfrh6jSiA+lAvAEOT7CqPbUuR32puKKv3dK/bE5P/kH75G3gkHqCJA/uyBWpfDWgpUVTBx+HWdp3AZVuS0NXAZn7+0RXtrsg3/Q7dA3CGAp40e2xGIQ935Xcn/4nsChfzv0X2eQif1AQNqKfLdnOFTs3dHYfLI27zdL/Y4+a/8QMj5zoVHeB0SLTOkLtjITAQp2GlTL1DFUIrApea4ToGotrfS2IlAoqeMcSHEKa3iiH3G9MKwW/lfLAAh/wfJLlAII79YjfyEbZC/C8WeJvmHFQWpHEUxx7kAimJPqBWpRvLORQmFMJxrjmkIJGqb2I5A1IURNmrAsfMIsj1xRE6aiMP/qd0SAM//xgUnPwye0Af5PiLyYyTIN/y9FHsVb/1ElfyCw6hBDDcBAaTUyp9HUG9zQChSIzQgF0QYiln3plOa4tDS4u6GQNzqTnKvZCfRYycC8enRFwvga7slAJ7/bWvP/FJ15cdoOHwdGM3ekvynKPbUyXcqOETiYlQoSsf1qX/vG9SL+4XDJ1XFoQ6f7rMUItPXKpCtosfTCESZcn4f6YXBvu6ckvzlZyVf+bwAnv8vZpyghjyfHZK/O8WeOvmO+QfldciUEgYqaDaW3x8Yiw0eKZEo4Vh1TKdApKPH0whELgwmSNf6k7uWXqQEYrKZ/1N2SwD6jPyKFK/P2fSuQL6awyeQf3h3K/2KPWLyv0YOID8E5GsDE0atg/xxtIl1ZyUFwshnXoZdxRGdAlFPL9sTiJFWgYSM4LDJ0IXfW3oJGzfa1fyvFEBOWbIH6SKfzefv5h6+NvLt8w5S8ORFDYSoAOcBfQ9zAaRhN0wpjGCkBq9ePbKHB29ecor0Uo/SkeC9ZFp0kmwhFI9uPQoa1xcEshVUxLFNgTjXHuO+ic7oscP6I2zckLy6L5IdQr9J/sldzf9cAJFJFg+Fs3oK8sXTu92Ol3ZxD38z5MvJ/w8R+QcoaOKCCCBfHQpBFEQepV5cRx+F61rtG89wwi9mHhf/cuRI1/wcE4Rd3Rne7gaNX9QaQXYkEIUYHKuPPVUEEYsjFIR7IrXZQbQmBSc1f6Zd6v8FAYwnRV1VXfnhAvm97ja6p3UPSJ/O2arYUyffDuQHgnQlgiQQiPbUo/cc2TWcISv8EgzSjmv75bwL1AA2gDFQANzS8r2KCKEQhOyiwgPRXn9oE0QIoosTIsBO00uIzIA8sVllW6uTcIZVIA04s1urnwsAJ3SfaJAPh2/Ax4lqjr+59R7+Dos9FfK/8TWyzdtPAeN6AgIVCBjD+ToF4WYlJ6VXuCbh3wO+rGXS+VuA0bYEUb8pCPX6Q5tAAob1yG/g/JaRI2hMn9x3Tvjf7RbhmhEgPvy+6vRuAA3hoAbf2NnlYk+dfJu8feQ/fh7Enyd/2Xly79kW4Y+3Q/hW2JYgMhUpQ4sgxHCoOYrCU1MggQrCbWrPbEX4CpAKnAa+/vsiXEMAmNypF8/wDQd7qRzI3K1iT5186xw8n7fnLNk2nN4O4dXPSvgOBJEP3NQlCBYh3CGIQO6ayotT+8rDXAgBYxfJrfP8dghfZq3cfzbhGgIY9HP9xkCo72/5ACdO6zTqHdmVgU1xyK/Y82NKf1lO/o/+4at0LmjPdgi3Bv7990X4NgTxj4DhdgRhXY3ZvJT9zEndDuGndjOH74oTGHn6SPQgxrhajE4+8+kc9WJPlfyv0X6bl/4gCX8WQbDn+Z6OOyT+mdiY1hLbrGH79X9IhGvdDrb8yX+0lh989dkGNt/STv4P/+6rvzrq/BrLcw//GAh/CkGICf/qH8vPIbz4wVf/5iuG//rtjjJG+C4Ue2Ly8d4fAy/9MRL9pw6VD5gIDP71nzrKGOHPUOx9Qf4fqQCUItD/H//UWbbvxR0Uez8ViK94k5H//S/I/2MVgCCCf/nvnWVvv7CjYu8L8v9EBKAUwcV/+VZn6d7nt1XsfUH+n5gAlCK48F2I4K2f6cz3X5D/JyqATRH8Y1fJnp9+Qf7/jgJQiuDUt7/Zzo5mCeS/ych/jqKf/3eQ/7UvyP9TFoBCBH/2wt9/Ldvgu/9I7j/4LjniMqZz3/4mI57hvS/I/xMXgEgIbwNtwPvABlAM/P0Xv8w/Tvz/VqoD+jC7JVsAAAAASUVORK5CYII=", + "config_type": "basic", + "config_baseUrl": "https://wiki.openstreetmap.org/w/images/c/c8/Public-images-osm_logo.png", + "parameters": [ + { + "name": "from", + "displayName": "Start", + "description": "", + "scope": "context", + "location": "query", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "to", + "displayName": "Ziel", + "description": "", + "scope": "context", + "location": "query", + "type": "string", + "isOptional": false, + "isProtected": false + } + ], + "isHidden": false, + "isDeactivated": false, + "openNewTab": false, + "version": 3, + "restrictToContexts": [] + }, + { + "_id": { + "$oid": "65fd9dabcb3d21d77bee50ae" + }, + "createdAt": { + "$date": { + "$numberLong": "1711119787052" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711119787052" + } + }, + "name": "Übersetzer", + "url": "https://translate.google.com/", + "logoUrl": "https://cdn1.iconfinder.com/data/icons/google-s-logo/150/Google_Icons-09-512.png", + "logoBase64": "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAApVklEQVR42uzBgQAAAACAoP2pF6kCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGZHjl2qCsA4gP6+izW9F1hITfagIXRqira26P9fosb0wRNErw6KD+/9nJ0d9Mk54wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJ6m/v/6EeBVeb8398dM80FXPsxVy8q8qK5lV+9XatGdN0lSyV6ql3mkpu5cP67cpHusZOzUVaXGuaaxhmGsni8u9283SbYBdkad/TwOsDMW22HxZRimVbpWXbVK+nOSw0o+pesglbd5Bp3eJFlXap3kJOl1zTmduv/d9/3fJGOAF6POf38P8OK82971t7lynPRRqo7S+VqVw+yoTm8q+ZN+YN9uWqIKwzCO/+9ztMLeFkFR5Kq2tdAzWSaJMxotKoQ20aYW0VfoI/QRWpe4aNHGoGUR5aYmSAhyEb2ecaRAXBQV45wrXLSMUMfRyesH/8/wvN0Ps0TMouJNkm6bAb5gZm0XXwcHMbMNtftXT+NEFNEP6ltOEUcDgi1A4hNQjYQXkqqNpUbVtwVm6y/ysxlm1la90dCQkmQwpCHBsYhIMQAECuktwXQUPGoWyWMgx8w8A2DWYXY1unpOAqPLBdGPrYjEO4LpBD1rLiUPvSEwW7sQZtZiUR8Z6FNSXADOScp8wm8dgZBeEjGlIqaAGcxsxeL98DBmtmY7tqffRxRcBM5DHMbaQugD4gHB1PyingANzOyfotrv20izVeo+tDdGBZcF4xGxB9toi0L3k2Di4OnqU6DAzPwEYNYCUR/NzhRFXAl0iYh92OYkPgtNpimTwGvMzL8AzFahl2ZcDbgGHME6i/SqCN1V2n0HWMDMCHkGwOxvuvL023gQ1yHGIkiwDqcfwL2mdBt4jpmZ2R/1sVP7a+XSzbxc+lirlOT+z/JKVp2rZDfqY8d3YmZmW9dcJSvlldJErVz6uVkXLbceZQt5Obs1Xx44gJnZb/buPUauqg7g+KE7pWgRlKcCGjEUsEgLzDmzLZas+zvTUgjPhIoiQVCDsaTFV1JFyVaiSJCAtYj44GHDS0IDUpgFqynWhkgpIo9IqVag8zuzfUAL7c6W0u6ue5eWECyUdru759z9fpLvv006m/x+987cB4aOUC5OCGLnxbmcaMASt0m9m9PTkQYAkE9LisXhNbHnB2+finYh0aCk3m5WsbdXpXGMAQDkQ3dTU0HFXdjT8lgXEMWRetcVxM6rNpfGGgBAmrpbzDCV0pQg9vlYFw7FWe+BgLd3V8uNowwAIA3dxuxRleI5wbulsS4YSqY3gtgbXjy5+DEDAIhX+Fzxs0Hso5EuE0o0FVfv6cqXJ5d49DMAxES9O1LFzY11gVBOElfLLiTNvmUyAIDBE04rfjB4N5P7+GkgU3GLa946AwAYeCr2dJ7cR4OViutU7+aEpuIBBgDQ/2pSGh3ELYh1MdDQSsWtDt59yQAA+u9BPkHcDL7up0h7sNpUOswAAHYf9W68evtspIOf6M3EvZq9bIiLBAGgj7K3tql3s1VcZ7RDn+gdqXcVvg0AgF1ULZfGBbHLYh3yRDv6NoBrAwBgJ5/d3/tbv3dvRDvcid5n6t2cVU2j9zYAgHcXJhWPVu+eiHWYE+1iS3WiO84AAP5f1ZcuUO82RDrAifqWuNeD2Eu5QBAA3nahXxB7a7SDm2g3puLuW14u7msAYChbUS4eq+Kei3VYE/VTS7OfuwwADEXq7bkqrj3SAU3Ur6m364OUzjIAMFR0T5nSoN5epd51xTqciQYi9a5Lvb2K6wIA5N6KSeP2U28fjnUgEw1O9u7sWhgDAHmU/eYZvPtPnAOYaHBTb5eslMaDDQDkSSgXJ6h3a2IdvkQxpGL/q832KAMAeaDefiWI2xTr0CWKKrGvaLM90QBAqrILm1TsT6MdtESRpuLaq83FUw0ApKb3ef7e3hLrgCWKPfV2szbbiwwApGLZ5CNGqLh7Yh2sRCmkYreE5uIXDQCk4IWmsR9WbxfGOlSJUqh3+ZfdeQYAUhCaigcEsU/GOlSJUki93dzTuQYAUtA2cfxBQdzTsQ5VohRSsVtqYs83AJCC7OElKu6ZWIcqUQqx/AEkZYWMOzR4tzTWoUqUQix/AEmpTT7uQBX7r1iHKlEKqbjOqi9dYAAgBcvLxX3VuydiHapEKaTeddW8vdgAQAqyt5apuL/FOlSJUihb/lUpft0AQApWjBv3gSBuQaxDlSiFVFxnkNJXDQCkoLvFDFNxc2MdqkQppN51adl9wwBAKlTsL2IdqkQp1Lv8vZ1qACAV6t0PYx2qRCnUu/zFXmIAIBXq3Zd76op1sBLFHmf+AJITysUJQdymWAcrUextPXieZgAgFW1NjZ9UcatjHaxEsbd1+U83AJCKVU2j9+blPkR9TNwMAwCpyG73C979MdqhSpRCZfc9AwApCWKviHaoEiWQivuOAYCUVJuLp6q4zlgHK1H0ib3MAEBKQrn4CfVuTbSDlSjyVNwPDACk5IWmpr1U7OOxDlba2WxH8O7f6u3C4N396t0cFTtLvb1KxbYEcTO2m3cz1bvrgnc3q7i5PT0SxD6f/Xtx/j9jyn7fAEBqgtgb4hyq9O7ZjVsP2m7Olk9ViudUm0tjV0wat5/pBy9NmPCRqjSOUSlNUe8uD97dGbz9p3q7Oc7PhzN/AHhPwRfPjHWw0tveHuftU0HcL2tiz2+baD/T3dRUMBHI3hCp3o0P3k1XsbcFbzXWz7E/yg6GDACk5qXy8Yfwu3+sWVVxv9Zme8bycnFfkxBttkdlb7xTcfeot+vj/Hz7noptMQCQmt77/cXNj3W4DrW23n3xWO/X61I8oduYPUwOLJt8xIia2FN6D2a8bYv189+FZhoASJE22+9GOliHVCr28exseaU0HmxyrnvKlIbQXJr05kWJrj3Wv8kOE3uFAYAUtTW7Y4K416MdsHlP7CsqdlZ2UZ0ZorLHTau4C3taHO3fabvZHxsASFF28Vh6Qzf91Luunv6k3p6bfS1u8JYVUiwFsb+P/aBUxV1pACBVQexlsQ7YPKbiOoPYednv+gbvqW3i+IOCdzN7Whfhmf81BgBSxVf/A5i4TT3dVC03jjLYKdmzDFRsSzQHAuKuNgCQqq1v+Xss2oWZm+zG4N312aOVDfp8IBC8+1n2mXLmDwC7SMVeEufCzEfqXZeKvb3aVDrMYLfSSfbjQeyt2Wc8wGf+1xoASFl2i1mcv6vmJHH/CFI6yaBf1crODtS3WOrdzw0ApE69vSPa5ZlwKnZVEPe17OcVgwHR+ywB76YFca/24991Vl4exARgCFNvy7Eu0FRTcZ3q3XWpPaI3T7KfBVTsQ/1w5j+b5Q8geb33/Hv7bKyLNMXUuxdrZdtkEAWV0pTg7drddOb/G5Y/gFwI3k2PdZGmmHr7uzUnnvghg6i0TSwd3vdrA+yvWP4AciF7f3sQ+3KsyzSlVNzqIKWzDKKVfdsVvJup4jp35cCO6zgA5IZ6NzvWhZpY92dPqDNIgnp3WvBuHV/7AxiSalIard5ujnShJtHWC/0uZzmkR5vtUcG7pZz5Axhy1Nt7Y12sKaTerg/izjZIVnatRhA77z1+87+F5Q8gV7I3q6l3XbEu1+gT+3yt3Phpg+RlzwxQ725k+QMYEoK4+dEu18hTsa3ZxZMGuRLEzXjbUxvvyg4MDADkSVVKzbEu1wS6nsWQX+rt1CD2Vv7GAHIpnFxsjXS5Rp2Ku9IAAJCijQ82SPvcvV5pO2PsklgXbYyp2BYDAECqOiqFhzpaC931yvAtq6aOekTFdca6dGN5fW8Qe6kBACBVGyrDx9Qrha7sAGBba68+aHEoW14BvJ1U7BZtthcZAABSVq8U7siW/jvbcNtIrU0+YVmsi3jQHvAj9gsGAICUbXx4xOH1SmFztvC3V/3+PTeuPO+YhbEu5AFP7DcNAACp66g0XJ8t+h31csthi4K3HdEu5oHpRwYAgNS9Nt/sX68U2rMF/3567bf7PBfKxRWRLud+Tb270QAAkAf1SsMV2WLfmeo9twrWTh9atwqquPt4AAwAIBe67zZ79iz01Tt7ALDtVsHVF49aMBTeGaDeLlw2+YgRBgCAPOhoHfb5bJn3pXXXHLg4eLs21uW9G5Z/20vl4w8xAADkRb1SmJ8t8b62/s6RoXbKCc/GusT70BtBSicZAADyovfWv9ZCZ18W/ztvFWzL362C0wwAAHlSrzT8ZGeX/FC6VVC9vcMAAJAn3QtMoaNSCNnC7o823LTPc9WUbxUU93TbxDEjDQAAeVJvbTizz4t+x7cKrq0l+VZB2xEmFY82AADkTUel8EC2pPu7+oOFrjXTP5XUWwXVu28bAADypj7PHFpvLWzJFvRAtW7WAY9VvV0T69J/K7GP8rAfAEAu1SuFb2VLeaDb8IeRIUR8q6CKq1fLjaMMAAB5VG9tWJQt5MGofd7w11deePRfIz37v9QAAJBH7Q+Yj267938wW9ty6CIVV4/o7H9Rd4sZZgAAyKN6pTBtwBb9jm8VXKrlYjWGp/2pd0caAADyqt7a8Ei2fGOp/d4R62tnH/v3Qb7n/1oDAEBebfizObgPV/8PwK2Cdssg3PO/Vr3b3wAAkFf1SmFqtnBj7dXZ+z2pZbuaZ/0DALAb1VsLf4l1+W9rw10ja7VTjn9mgJb/0iXF4vD/sXc3L1FFYRzHH7vHJLKFLoIKahNRgYI11qYCdYyoNjG0K3qhXasgaDkSVMs20tqCiKKgxXSPEGRuDFqZZGHionKcEWxmcubcoAknhBZRODhvlzP3fj/w+xt+z7nc5xwBACCocgnpMK4q2lr8/60Knt/3yocb/04LAABBZkadmK2FX+ZVwYmGrQr2944JAABB52nnnq1FXy7L97fMzR8/MFf303+0d0AAAAg6o50ZW0t+XauCse43dbz0560AABB03kvZaWu5V7IquHR91/j8QKRYh8//ZwQAgKD74bZetrXYK01uuHNyPhpZrOH0/5ErfwEAoWBc9dDWQq8m+SebUwuneqaq/PP/ggAAEHSlkrR4WqVsLfNqYxKtxcUre15X+On/y/uz+zcKAABBl3/R2mVridcjmZs7VlcFC+scAG4IAABhYLS6amt51yv5B+0zycGDs+U//UeKn6M92wUAgDAwWo3YWtz1TOF5Wz4V65pY+/QfSQgAAGFhtDNta2k3alVw9X3/fweAhYFITAAACIPSmLQbrX7ZWtiNyvfhzslkNJL+6/S/9OnE7jYBACAMvIQ6ZmtJNzrLjzelkyd7pv6s/t0VAADCwrjqmq0F7dOrgj/TF/eOf+0/3C0AAISF0eqRreXsV4x2pgUAgDAxrjNrazH7FeM6twQAgLDIJaTDuGrF1mL2K2ZUHRI0VN9Q5lv/ULZESK3pi2dzAqA2nlZHbS1l3+KqZKkkLQIGANI0GYzntwoAXgCsJUarEQEDAGmqROOZIwKgekY7d2wtZt/ibjgnYAAgTZW+oewlAVA9z1XPrC1mH2JctVLQsk3AAECaK/HsbQFQPU8772wtZ5/W/z4IhAGANF3imacCQH6zdzetcZVhGMev65xJk2CLVltKbRARFxbEglLE4iYq4k6XCq512Y+Q+gncuXHtxpWghG5ciosKvqFQC76niC+NtY2ZNDPnLuk3mFmE+5n7/4PnAwSGOf/cz3OemUuEvLM5up314Xwoa7N/XxABwGptrV/a/loA5rOzqbW0D+ZDWgeHIAURAKzW1vql7R0peHsHmMfu5X4964P5sNb4kyNnBREArBYXrwICc08Alt7K+mA+pAOAt2JDnSACgNXk2th+WkiLL9fMPDyi0uJbSYMANCkUa0JaBEBiDp1SYZY4RAS0zDojpEUApOaTKizkbwSgXWECIDECIDNH6QlAF3FVAFrGFkBiBEBiIZU+QRv99JoANMucAUiNAMgsXDYAIjRe/VxbAtAuswWQGQGQVHyoVVtHVZXjR94AAFoXJ4S0CICkxsdqvwEg+XcBaFqEjnMbYF4EQFKDR2XH//dEXBeAptnuX9n455gwOwKgrk5xvyqz/hCA5u2NuuPCHAiAsqbSskozAQAsgGFiAiApAiApD1pRYY7hXwFoXic9KMyOAKjLitITgLAJAGABRCcmAEkRAElZfekA6KSbAtC8biAAsiIAkhqKTwCG8I4ANC8cq8IcCIDKSp8BiMH7AtA8y0eE2REAdbn4WwDRiwAAFkAoCICkCICkwlF6ArDEBABYCJYIgKRGQla1r8/siv/9wIIItgDSYgKQlOU9FbavIE6BBWC2ANIiALKySgeAp1oSgAXABCArAiCpCI1VmEf81wAsgojarzRnRgAkFcW3ADrFfQKwADwRZkcA1NVpWjoAhkEPCEDzrLgjzI4AKK10AHTuav8cMrAgQiIAkiIAkgq59BmAIYIJALAALBMASREASUXxCYAcpwSgeeHgUq+kCICkOvuWSvPDAtC+4FrvrAiApKz+T1UWOi0AzevYAkiLAEhqZdirHQCONQFoXvAWQFoEQF7/hbSrsvwYn0+gfeHad5pkxhdsZqG/VJSl5d3nxRQAaJxD28LsCIDa7Ci9DeA7/eMC0LShIwCyIgBSc+kAGOwnBKBpvXVDmAMBUFpE8QmA4ikBaNrgIACSIgByKx0Ass4JQNOWJwNbAEkRAJnZWyoswk/yGQVaFpPL7zxU/FKzvPhyTSyGuKbCbB3de/YI5wCAVoW2JYUwOwKgtr4flQ6AAxPHBQFoFfv/iREAia0s7/0cxe/RtobnBKBR/ltIiwDIbSLHT6qNAABaZf0ipEUAJOdw6W0Ay2d3PtYZAWhORPwmpEUAZOfaBwEPeNS9IADNsUUAJEYAJBfFJwAHQt2LAtAe61chLQIguc7xg4pz6OXY4LMKtCYIgNT4Uk1u6Kffqzrr9P/nR+cFoClL044tgMQIgPyuSyp9I+A9XbwqAC25zT0AuREALQh9oeqs1wSgGSHG/9kRAC1w7QCI0P5X+yc/feaDN04IQBsirgqpEQANGBRXVNfWuzvn3rt488LbHsZvCkATbH8npEYANGDi6ZWIej+oMZU/e/3GS19+NH70ouSlu+zde5BU1Z0H8N8wLSiurFkirlUas2pM4pqEcG8Pb3puD+pQQGDOoUWJpa6pyD5iYiXrkjVWbo/DQ3zsOmtiFiogfc4M4mCUCPSA0YgxEVGJQdCggg9ACANzb+Mww0OYDqfyUIzIPLrv/XX391P1/ccqqyiaOt++fe75nSz1+QZBPheD72azZdOR3IQoO59KWDab/T0BQO+1p8vf7GiOZEsh7elI59blZywepmu22Fpmj08NTgNAQXBcPxlP+tlSTcz1BxOwhl8ACkZZSWwDZInaHj342R9NzVw+6Qj1uZD+RtkNBFAAyohsKlFZynYeGtCGdwAAcqGjOXIL1yf2XGV/unzz9CVjtHnSP2GUyFjzJvQnAObiSX8X16fz/Mcr+QmmALn8AjCca3HnIrtX9l82Rk98wZR8F4KXAYG1KjdzAc9iDiaO6z9GwB62AArEaaceeYGytI+KTDZLR357eKAa742LdVBfu4s/L/47ATCWLcs6VNKymGAKkEsd6cgyrk/wPcyOW5cO/amtxVHzZN+dWFpWEgBT8aTfyPXpPJDUZhIEALnTno7cxLTIu51Muu9vxi+uftqUeY+ixGoCYClbFnf9nWzLOYA4dd75BAC5c3BF3y9yLfTuHPHbvOLMpUO1eNcUeW8STcmSfcsa+Iq7mQquxRxIXH8nAUBeXgbczrXcT5b25si++Y98YaGtxWFT4L2NpeXDBMBM3PVnsy3nAOIkvUcJAHLvWIku4lrwJ3nyf/nrS6rSprhzFiU7o4vkPxMAI47rv8q1nIOI43rfJwDIvY50n2u4lvyJsm356StGNUx6w5R27iNSBMBEzG29lGsxB3gEME4AkHv7m+kcs4/Otew/8pP/weU//6z5yX+/Kev8RBwd0piwCICBuOvdybWYA/r5/2i1u3cAAUB+dDSXr+Va+h9M9Yvs+M+m4Q+Zks57lPwlAYQs0ZQtd5L+u1zLOZC4/iYCgPzpWBX5HtfiN/FW9nt2XMO435pyDipWSk4igBBVua3VbIs5uPyYACCfXwDoPI7bAObPtP6xgUujqqbVlHLA2XJRfXU/AghJPOmvZFrKwaXWryEAyPs2wPPcjvjV/+xLSz6Y6hd8LC2+SwAhiLmZi8z+N9tiDubt/yOj5mQ+RQCQ93kA/8Wl/N9LRzZeufjyZ0wJhxzfarz60wQQsHjSr+dazIHF9Z4jAMi/A6v7/ROHbYCtK85YNUpN2tmTwsaxQCgGVbP3DYwn/Ta2xRxc6ggAgtGRLl8f6hG/Zec/ZCtxyBQvq6SkIICAxJP+HKaFHGgqa70YAYARyDbAf4dyxK85sv1bS0Y+YcqWZ0TL4AWJswggz2Kuf6bj+hmupRzg8J/91fVZvIQLEJQD6X4XBr0NsCd92tNVDeM38yz+D0XJRgLIM8f17uBaygFnJQFAoMzdAE8GdcRv7fJByyt0zX62pf+RWLpmCgHkSczde66T9NuZFnLAyUwnAAiUuRvgqryX/8pIa93DQ5ZxLfoTRok9X1Y1gwggP3v/jTzLOPjxv6NntpxDABCsbBP1PVbSLfkq/0y67ya5+IqX2Jb8yfN4oilRTgA5VFXbOjzuep1cSzng/f9fEQCEoz1dfnc+yv+15Wc+OUJN8pgWe5djaZkkgByJudlIPOm9xLWQg4/3HQKAcBxs7vt5s0efw/3+A6lHLn7MTPXjWurdipKdUV2DEaWQE/FkZgbPIg4hrtc51vU+QwAQnvbm8qdzUf5tzadsn/7gmOfYlnnP41uLJl9IAL0w9vZ9n8OLf8eN/32eACBU5mXAa3pb/jtX9P91XE/YxrTAcxCxwZo3oT8B9Pinf38t1zIOJ5kZBADhyj5Fp3akI609nOp3ZNWyc9NRLQ7zLO6cXhjUQFkqI4Buclw/ybOEw/v531yCRAAQOvMy4Owe7Pe3/qCpYg3Xws5LlJxDAN0w1vVGxZPe+2zLOIQ4Sf8ZAgAe2lbToPbmSEdXy99f2e+VyYuveJ1tUePqYGAgPqvtbMf1d3At4hCP/91AAMCGuSDoJ10p/1eW/8MzI/Wkgpnql5eTAUpcTwAn2/d3/TVcSzjM2f8j5u45gwCAjwMr+l3Qno68/0lH/Ob/7BLGF/kEGXHYbpDjCOAEnKR/H9cSDvkLwCICAHbM/QBLPq7830tH3rruQWcDzzIOLe1WgxhJAB8Rd/2buRYwg+N/YwgA+Nm/6pSvfHQw0LYVp6+NN0xsYVrCYcfHlwD4sMpab5KZcc+1gEON620hyuIkDQBXHenIqr8e8fv5ub+o0DXFMdUvf2m3tLycoORVuX5l3PU72BZw+E//txEA8HVgVbmzP31K64ymYS8wLVx+UeKQnZKCoGRV1u4bGnf997iWL4PyPxhzd/8jAQBv4xur02zLlmksJd+3GsQ0gpITd/0hcdfzuJYvXv4DgC6zGoTDtWh5Rxy19JTpBCUj7mYqnKTXyrV4uSTm+oMJAAqDuQ+fZ8nyj6XlvNhTsQhBUYvX+k486bdxLV0+8Z4kACgc0ZS0zdAbriVbAFn1pcZpnyIoSo7rXeskvUM8C5dZar2JBACFxVLyEablWiARb1ToxCUERSRbZi73MRfasC1cXi//ve662T4EAIXFahRfsEvgpr+8RomMpWvGExS8y+7adbqT9B/kWrY84/0HAUBhsrWoZ1uuBRNx9Fh+mGhKlBMUpCp338WO67/Ms2TZZtcE993+BACFyexjW1ru5VmshRbxXDQ1+WKCglKVzEzFGf/ux4xEJgAobJauuYlnoRZeLCU6bC2+Q1nCSFTmqt29A+JJbx7bguUc19+Jp3+AImCOtNlavsq1VAsySq6IPpDAZDSm4rVvOU7Sf4dtwTJPpet9mwCgOES1rGJbpoUaJfZYWl5NwEY0NXmgpcVCW03ZOerONeu5FizruP7OYfdsO40AoHhYWjSwLdMCjqXk0xWq5isE4clSmaWmXGtr0fLh8c7Df/Q/a+JJj2fRsg3e/AcoOkMXTzzbVsLjWqSFHTNGWKrBCxJnEQRqaKP4nKXlL0702UQX3riusnbHPp5ly27m/7bq+mw/AoDiYynxbzwLtDhiTlyYv2McGcw/82XLVvIec5vjST+X1JVvj5n90hauxcslZkIiAUCRct0+tpJruRZoscTScmNUyyvJdTFFLR9HW5WYZSvR1s3PpX3kvYvXcS3fsOMkvfWY+gdQ5MyEQEuLA1zLs5hiKbnVVuJGXC7Ue5c0Jf7OUnKGraXfm88kuuDmtfHa3Ye5FnEocb3Osa43igCg+JmFlGtpFmPMF4Fog/zmsRLrS9At9oOTzrOVnJnLgVbRRdM2V858bTfbQg4+jQQApcHsUdtKrONamMUaS8ltlhbft3TNOQQneau/5jJby0ctJY7k6bPwRt+d/h3TQg4urt/h1HnnEwCUDnPTHbYCwok5omYrsWxIg5yAFwaPP8dvJi1aSmwOaKBT57D7Zz0bT+4t4dsBPZcAoPRYDeJ7XEuyVGJpscPWss5uFBdQCbIar/602R6xlVhtvhiF8RlEF35jY+Xtb+/nWdD5i5mWiJG/AKXKnArQ8nGu5VhSUbLTVuJFS8vkkMaEVcz3DZiZFFZK/qulxBPHcoTHrzJX7hoz99mtXMs6T8f+vkYAULrMYmwp+Qe2xViyES2WlspSUxIjfvq1M6iA/enMvphoa3GH+ZJjBidx3ZoZft/9G0pheqCT9DUBAJjF2TyB8ixCxLyrcSxrzNvwdoMcZzUl/p6YsubdeIqtai61GsQ0W8v7LSVeKbR/WxULbnohXruziI8Kensuc9sGEQCAYWt5H9cFGTk+fy7ULbaSS20tb7VScpKZ7xDkMcPYA9edasbwRnVNjaXFDywll5gBSLYWh9n+vXUj0dTVb8dmbyzKo4JOshWXWAHABy6qr+6Ho4GFHbOfbmYOmF8LLCW0reVcS8lvm4tyzK88Q7Qcbd4v+Gpq8hfNS4d/iSly89+txkSFnRJjh6TEFWbrwVZTrjL/v63kHFuLlJm5b2mxqWTulFCyfdT/PryJa5H3MCsJAODjh66IPWwXZAQJIUPn3faSk2wp+KOCTtLfd3ld63kEAPBxolpWmSdJrosxgoSR6KJrt1bWvV7YRwVd/3oCAPgk5iga14UYQUKLSvij73riLbYF/4nxHiIAgK6MYrW1aGK7ECNIiC9gDr//rlfjyVamRf+xN/1trXb3DiAAgK7evmZrsYHtQowgIaZi4Tc3V9a9VQBHBb33q2pbhxMAQHd8tUGcb2u5m+sijCChJjV195g56/bwLP6/vvh3CwEA9PC+gJG2lgfZLsIIEvL0wBH/N+8NpqN+f+m62T4EANBTtpbXFNo0NwQJMkPn3/J7p3YXp6OC2+Oz2s4mAIDeMvfYc118EYRFUtN2xGZteI/Bvv+BuJupIACAXLGUvJft4osgHKKmtI+857FtIe/7/wsBAOT8+mAll7JdfBGESYb+f+1rTu2eMI78zSUAgHxdAmNr+STXhRdBuCT6wA1bK+u2BHhU0FudaMqWEwBAvljzJvS3lfgV14UXQVhND5z71O4Anvxfi7n+mQQAkG/mTnpbiRfZLrwIwml64I/r33GSXp5m/HstVe6+iwkAICiDFyTOspR4he3CiyCMUrHgptcr67YfzfELf+2Y9AcAoRi6eOLZlpYbuS66CMIqi67aM/qO59ty9OR/uLI2cwUBAITly6pmkK3Ey2wXXQThlUMj7lU7e7nnf7QqmZlKAAAhOX47QMvfMV1wEYTh9MBbtzi1u3t6t//NBADARTQ1eaCt5HquCy6CcEt00XXvxma+erib5X87AQBwcfw1wvJxrgsugnCcHjj67pUtXdz3v5MAALi6qL66HyYGIkj3Muwns7d/0vRAx/XuIAAA7hJNiXJLiflcF1sE4ZiKhdPfic188yjKHwAKW5bKbCVncl1sEYRlUlP90XN/0/ahn/1/SAAAhSiqxPW2EofYLrgIwiyWEkdG1jfucFzvNgIAKGRWgxhpa9HCdcFFEE6xtHjTXnLDpQQAUAxsnfi8rcUbXBddBGERJdaZ4VoEAFBMKvTXB9hKLGO7+CJIuOW/zNy2SQAARSlLZZaSM2wtjrJdiBEk4JhTM7GnYhECACh2thLyWNq4LsgIEkzEYUuJbxEAQCmp0IlLLC028VyYESTvL/vtGqLlaAIAKEWxB6471dainusijSB5iRIvWqnEZwgAoNTZSkhbyz+ydzchVpVhAMffir6joFpUiyiKyiBp5nmvWhmVFEg0zZznuScKoxBK3NSmhS4ihRKk78EIrIWe57kG3pZSSVC0iIiMWljZIsg+KYpctEjQrHvGmwQyYc6M98zM/wf/zSxmce9wnjv3vOd99zX2gk00TUmYL+mWZyYAwJETBa8St4+aeuEmmlKuv2dvr0wAgKPVK6HFbQ27B9JcSkJ31R9wEwDgv+UoWtltT1Mv6ETHlNuhHDp+bbc8LQEAjk19n1TcXhDXg429wBNNkoR+P+zFsgQAOD7itljCdjf1Qk90dFot6ZbnJwDA1MjmVace3kHQ9jfzgk/Uy+0HqWw0AQCm11A1tiCHvdPYAUDzNP2zf6//nAQAmDmtsHvE7btmDgOaT0noZ62qvCEBAE6M+uQ0CVvPbQEaSK6/idsaVvgDwIDI1rErcmg3ux1q7LCgOVP/qZSXW9XYBQkAMHg5ipaEvtfUwUFzILd3h7bawgQAaB5xHev1eWOHCM26xPXT7DqSAAANt27dyeLtkt0EaUq57RFvP1D/PSUAwCzS/yAgrl82dshQ83Lbm11X1WdTJADA7FV2y1Oyt++V0E8aO3Ro4E3cOvL2ynrjqQQAmFukKpeK6w6eGqAjub0/cY//r3RSAgDMbRLFcHbdKqF/NHYw0Yw+ztdre46ilQAA849su+9CCV2b3fY2dVjRNOb2Y6+nFm8ZvSwBAFCvE5DKRsV1h7gdaOwAo+Pcq992tqIoWNgHAJiURHGxuK3h6YHZnn4jrhsWeXl5AgDg/5CO3iShL4nbT80ccvTv+u/Tpvp9Y1EfAGB69hSoyqU5dDyH/dzUAThP2ydhnl1HeIQPADBj6hPgxIs7ctgmFg8OcJe+0Geko7cx9AEAA9HqlNfn0Cck9AMWEM5Y+3u9LW6P1idAJgAAmmSh3392rvT2HLoxu37MhkPHl7gdqF+//us4sihWnJsAAJgthl67+5LD5xHYixK6i28IJsn1lxy2U1wfHw67+crx5acnAADmivobAgm7VTr6mIR2ctgX4nqwsYN55lbqv9HryVYUhVTlpQkAgPlm4raB242tjj2c3Z6TsLd6fT2bbx/0P9R8Ja5v5tDnpbLVw14sq78RSQAAYHKy+a6zpFNel11HJIpHstuzvV7PoR+K27eDPMdAwn7ttXviP3nXVyVsvUT7Ian0zqFqbEH9pEQCAAAzQ7rleTnKqydOOaxstOXtFVLZ6v5Ohhty6LiEbZ7I9ZUc2v0ncd1e//xIbk/n0I11Erq2/j31McotL5YPR7FEtuk1rS3lRbdsefCMBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgL/bgwMBAAAAAEH+1oNcAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAnARPEwwIqV5O9AAAAABJRU5ErkJggg==", + "config_type": "basic", + "config_baseUrl": "https://translate.google.com/", + "parameters": [ + { + "name": "op", + "displayName": "Operation", + "description": "Operation der Anwendung", + "default": "op", + "scope": "global", + "location": "query", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "sl", + "displayName": "Quell-Sprache", + "description": "geben Sie die Quell-Sprache ein", + "default": "de", + "scope": "global", + "location": "query", + "type": "string", + "isOptional": false, + "isProtected": false + }, + { + "name": "tl", + "displayName": "Ziel-Sprache", + "description": "Geben Sie die Ziel-Sprache ein", + "default": "en", + "scope": "global", + "location": "query", + "type": "string", + "isOptional": false, + "isProtected": false + } + ], + "isHidden": false, + "isDeactivated": false, + "openNewTab": true, + "version": 1, + "restrictToContexts": [] } - ] diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 1f0458a975f..fa332adf663 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -1,409 +1,65 @@ [ { "_id": { - "$oid": "6287989eafaf6045788f578e" + "$oid": "65d5f62f2dfd4122af9607e9" }, - "state": "up", - "name": "add-nextcloud-permission-to-users", - "createdAt": { - "$date": "2022-05-20T13:32:06.402Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "62b2d03a7e84883ed4042ffe" - }, - "state": "up", - "name": "setLtiToolsIsHiddenDefaultBehaviour", - "createdAt": { - "$date": "2022-06-22T08:01:25.296Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "62ceaead46c704f3c0a9f921" - }, - "state": "up", - "name": "RefactorOauthConfig", - "createdAt": { - "$date": "2022-07-11T13:57:06.225Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "62da74248a724b0a60157dd3" - }, - "state": "up", - "name": "allow_experts_join_meeting", - "createdAt": { - "$date": "2022-07-22T09:48:38.838Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "62e25ac2b11660acd431c703" - }, - "state": "up", - "name": "conferencePermissionForTeacherAndStudent", - "createdAt": { - "$date": "2022-07-28T09:23:48.792Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "632c5fafc744915f90d1c73b" - }, - "state": "up", - "name": "add_oauth_client_permissions", - "createdAt": { - "$date": "2022-09-22T13:01:17.867Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "6411b6f983c6759284e07dc8" - }, - "state": "up", - "name": "sanis-rebranding", - "createdAt": { - "$date": "2023-03-15T12:15:53.385Z" - }, - "__v": 0 + "name": "Migration20240108145519", + "created_at": { + "$date": "2024-02-21T13:10:07.253Z" + } }, { "_id": { - "$oid": "6411b99e207ae5149cca93a9" - }, - "state": "up", - "name": "add-tool-admin-permission", - "createdAt": { - "$date": "2022-11-21T15:44:50.170Z" + "$oid": "65d5f62f2dfd4122af9607ea" }, - "__v": 0 + "name": "Migration20240115103302", + "created_at": { + "$date": "2024-02-21T13:10:07.273Z" + } }, { "_id": { - "$oid": "6411b99e207ae5149cca93aa" - }, - "state": "up", - "name": "add-submitted-and-graded-keys-to-submission", - "createdAt": { - "$date": "2022-11-28T09:39:29.773Z" + "$oid": "65d5fcc609ea95ffabb997fd" }, - "__v": 0 + "name": "Migration20240221131029", + "created_at": { + "$date": "2024-02-21T13:38:14.058Z" + } }, { "_id": { - "$oid": "6411b99e207ae5149cca93ab" + "$oid": "65eecc9abaf077b6f9fdb878" }, - "state": "up", - "name": "add-school-tool-admin-permission", - "createdAt": { - "$date": "2023-01-04T14:41:52.212Z" - }, - "__v": 0 + "name": "Migration20240304123509", + "created_at": { + "$date": "2024-03-11T09:19:22.532Z" + } }, { "_id": { - "$oid": "6411b99e207ae5149cca93ac" - }, - "state": "up", - "name": "add-task-card-permission", - "createdAt": { - "$date": "2023-01-12T18:24:50.878Z" + "$oid": "65fad61d12d7267c5d7a520c" }, - "__v": 0 + "name": "Migration20240320122229", + "created_at": { + "$date": "2024-03-20T12:27:09.614Z" + } }, { "_id": { - "$oid": "6411b99e207ae5149cca93ad" + "$oid": "65faff9f8009e32be883536d" }, - "state": "up", - "name": "AddPermissionCreateToolEtherpadForStudent", - "createdAt": { - "$date": "2023-02-14T08:58:00.462Z" - }, - "__v": 0 + "name": "Migration20240315140224", + "created_at": { + "$date": "2024-03-20T15:24:15.250Z" + } }, { "_id": { - "$oid": "6411b99e207ae5149cca93ae" - }, - "state": "up", - "name": "EditHomeworkCreateAndEditPermissions", - "createdAt": { - "$date": "2023-03-01T10:40:42.101Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "641484462942e36e9ce79c58" - }, - "state": "up", - "name": "oauth-provisioning-school-feature", - "createdAt": { - "$date": "2023-03-17T08:48:14.877Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "6426df110243fda1e06675fe" - }, - "state": "up", - "name": "RefactorSystemSubConfig", - "createdAt": { - "$date": "2023-03-15T14:04:48.654Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "6426df110243fda1e0667600" - }, - "state": "up", - "name": "change-ctl-paramter-location-from-token-to-body", - "createdAt": { - "$date": "2023-03-31T13:24:33.452Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "642acfb9f493260e5422c6f6" - }, - "state": "up", - "name": "remove-systems-permission-of-admins", - "createdAt": { - "$date": "2023-04-03T13:08:09.416Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "642ad0122937ff80780808c6" - }, - "state": "up", - "name": "add-system-view-permission-for-admins", - "createdAt": { - "$date": "2023-04-03T13:09:38.055Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "642c1905ec49d9a2cce1939d" - }, - "state": "up", - "name": "remove-system-view-permission-from-user", - "createdAt": { - "$date": "2023-04-04T12:25:50.707Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "644a5cc76018bf037415806a" - }, - "state": "up", - "name": "move-school-migration-attributes-to-entity", - "createdAt": { - "$date": "2023-04-27T11:30:15.605Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "6458ecb692a2c4444867d973" - }, - "state": "up", - "name": "remove-leave-team-permission-from-user", - "createdAt": { - "$date": "2023-05-05T07:26:34.051Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "645cd5c0925829be40c28eb2" - }, - "state": "up", - "name": "add-context-tool-admin-permission-to-teachers", - "createdAt": { - "$date": "2023-04-28T11:00:42.924Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "6475c08fb0c19a8a6ceed37c" - }, - "state": "up", - "name": "add-context-tool-user-to-user", - "createdAt": { - "$date": "2023-05-16T11:09:04.602Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "649edddd0a10f0671489a4c3" - }, - "state": "up", - "name": "add-user-login-migration-admin-permission-to-admin", - "createdAt": { - "$date": "2023-05-16T08:02:20.719Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "64cb7789b3f7ad8d586809c0" - }, - "state": "up", - "name": "ctl_tools_add_lti_locale", - "createdAt": { - "$date": "2023-08-03T09:46:49.653Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "64f1e7ce5f01dac16032e59d" - }, - "state": "up", - "name": "add-join-meeting-permission-to-admin", - "createdAt": { - "$date": "2023-09-01T13:28:44.835Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "64f5c9d76edbcaa5cc4431d2" - }, - "state": "up", - "name": "add-join-meeting-permission-to-teacher", - "createdAt": { - "$date": "2023-09-01T12:31:41.993Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "64f5c9d76edbcaa5cc4431d3" - }, - "state": "up", - "name": "add-start-meeting-permission-to-admin", - "createdAt": { - "$date": "2023-09-01T13:14:13.453Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "652686eb35521c3d90686845" - }, - "state": "up", - "name": "remove-moin-schule-logout-endpoint", - "createdAt": { - "$date": "2023-10-11T10:40:18.782Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "652ea0196ddf74176cb57561" - }, - "state": "up", - "name": "add-group-view-and-list-permission", - "createdAt": { - "$date": "2023-10-17T14:38:44.886Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "653a645338f94b0ea8e3173d" - }, - "state": "up", - "name": "add-group-full-admin-permission", - "createdAt": { - "$date": "2023-10-26T13:06:27.322Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "654cc2326b83f786c4227b21" - }, - "state": "up", - "name": "tool-and-user-login-migration-renamings", - "createdAt": { - "$date": "2023-11-09T11:27:46.062Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "656f15a29ac13a4b78e2e31f" - }, - "state": "up", - "name": "system-permissions-update", - "createdAt": { - "$date": "2023-12-05T12:20:50.147Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "6576dbdb91b90e92b8ce9b9c" - }, - "state": "up", - "name": "add-school-system-view-and-edit", - "createdAt": { - "$date": "2023-12-11T09:52:27.346Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "6585950b4903600babed0d79" - }, - "state": "up", - "name": "school-default-props", - "createdAt": { - "$date": "2023-12-22T13:54:19.864Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "6585979f8eef141a0615da6f" - }, - "state": "up", - "name": "add-protected-field-to-custom-paramters", - "createdAt": { - "$date": "2023-12-20T11:44:04.729Z" - }, - "__v": 0 - }, - { - "_id": { - "$oid": "65969f4adbb61670101696fb" - }, - "state": "up", - "name": "remove-undefined-parameters-from-external-tool", - "createdAt": { - "$date": "2024-01-04T12:06:34.725Z" + "$oid": "6602deeb0611e07702de5cb8" }, - "__v": 0 + "name": "Migration20240326072506", + "created_at": { + "$date": "2024-03-26T14:42:51.024Z" + } } ] diff --git a/backup/setup/roles.json b/backup/setup/roles.json index 774cecef365..a966c647620 100644 --- a/backup/setup/roles.json +++ b/backup/setup/roles.json @@ -137,7 +137,8 @@ "GROUP_LIST", "GROUP_FULL_ADMIN", "SCHOOL_SYSTEM_EDIT", - "SCHOOL_SYSTEM_VIEW" + "SCHOOL_SYSTEM_VIEW", + "USER_CHANGE_OWN_NAME" ], "__v": 2 }, @@ -195,7 +196,10 @@ "TOOL_EDIT", "YEARS_EDIT", "GROUP_LIST", - "GROUP_FULL_ADMIN" + "GROUP_FULL_ADMIN", + "USER_CHANGE_OWN_NAME", + "ACCOUNT_VIEW", + "ACCOUNT_DELETE" ], "__v": 2 }, @@ -253,7 +257,8 @@ "HOMEWORK_EDIT", "CONTEXT_TOOL_ADMIN", "JOIN_MEETING", - "GROUP_LIST" + "GROUP_LIST", + "USER_CHANGE_OWN_NAME" ], "__v": 2 }, diff --git a/backup/setup/school-external-tools.json b/backup/setup/school-external-tools.json index 1fe5b985d2f..722bb9d7c50 100644 --- a/backup/setup/school-external-tools.json +++ b/backup/setup/school-external-tools.json @@ -4,10 +4,14 @@ "$oid": "644a46e5d0a8301e6cf25d86" }, "createdAt": { - "$date": "2023-04-27T09:56:53.401Z" + "$date": { + "$numberLong": "1682589413401" + } }, "updatedAt": { - "$date": "2023-04-27T09:56:53.401Z" + "$date": { + "$numberLong": "1682589413401" + } }, "tool": { "$oid": "644a4593d0a8301e6cf25d85" @@ -97,10 +101,14 @@ "$oid": "647de374cf6a427b9d39e5bc" }, "createdAt": { - "$date": "2023-11-30T15:23:31.370Z" + "$date": { + "$numberLong": "1701357811370" + } }, "updatedAt": { - "$date": "2023-11-30T15:23:31.370Z" + "$date": { + "$numberLong": "1701357811370" + } }, "tool": { "$oid": "647de247cf6a427b9d39e5b1" @@ -108,10 +116,12 @@ "school": { "$oid": "5fa2c5ccb229544f2c69666c" }, - "schoolParameters": [{ - "name": "search", - "value": "xxx" - }], + "schoolParameters": [ + { + "name": "search", + "value": "xxx" + } + ], "toolVersion": 1 }, { @@ -119,10 +129,14 @@ "$oid": "647de374cf6a427b9d39e5bd" }, "createdAt": { - "$date": "2023-11-30T15:23:45.423Z" + "$date": { + "$numberLong": "1701357825423" + } }, "updatedAt": { - "$date": "2023-11-30T15:23:45.423Z" + "$date": { + "$numberLong": "1701357825423" + } }, "tool": { "$oid": "647de247cf6a427b9d39e5c2" @@ -138,10 +152,14 @@ "$oid": "647de374cf6a427b9d39e5be" }, "createdAt": { - "$date": "2023-11-30T15:29:00.061Z" + "$date": { + "$numberLong": "1701358140061" + } }, "updatedAt": { - "$date": "2023-11-30T15:29:00.061Z" + "$date": { + "$numberLong": "1701358140061" + } }, "tool": { "$oid": "647de247cf6a427b9d39e5c3" @@ -149,10 +167,12 @@ "school": { "$oid": "5fa2c5ccb229544f2c69666c" }, - "schoolParameters": [{ - "name": "schoolParan", - "value": "test" - }], + "schoolParameters": [ + { + "name": "schoolParan", + "value": "test" + } + ], "toolVersion": 1 }, { @@ -160,10 +180,14 @@ "$oid": "647de374cf6a427b9d39e6be" }, "createdAt": { - "$date": "2023-11-30T15:29:00.061Z" + "$date": { + "$numberLong": "1701358140061" + } }, "updatedAt": { - "$date": "2023-11-30T15:29:00.061Z" + "$date": { + "$numberLong": "1701358140061" + } }, "tool": { "$oid": "647de247cf6a427b9d39e6c3" @@ -171,10 +195,12 @@ "school": { "$oid": "5fa2c5ccb229544f2c69666c" }, - "schoolParameters": [{ - "name": "schoolParan", - "value": "test" - }], + "schoolParameters": [ + { + "name": "schoolParan", + "value": "test" + } + ], "toolVersion": 1, "status": { "isDeactivated": false, @@ -186,10 +212,14 @@ "$oid": "647de374cf6a427b9d39e7be" }, "createdAt": { - "$date": "2023-11-30T15:29:00.061Z" + "$date": { + "$numberLong": "1701358140061" + } }, "updatedAt": { - "$date": "2023-11-30T15:29:00.061Z" + "$date": { + "$numberLong": "1701358140061" + } }, "tool": { "$oid": "647de247cf6a427b9d39e7c3" @@ -197,10 +227,12 @@ "school": { "$oid": "5fa2c5ccb229544f2c69666c" }, - "schoolParameters": [{ - "name": "schoolParan", - "value": "test" - }], + "schoolParameters": [ + { + "name": "schoolParan", + "value": "test" + } + ], "toolVersion": 1, "status": { "isDeactivated": true, @@ -212,10 +244,14 @@ "$oid": "659bf73f49e52dedff83a8f2" }, "createdAt": { - "$date": "2023-11-30T15:29:00.061Z" + "$date": { + "$numberLong": "1701358140061" + } }, "updatedAt": { - "$date": "2023-11-30T15:29:00.061Z" + "$date": { + "$numberLong": "1701358140061" + } }, "tool": { "$oid": "659bf6f049e52dedff83a8f1" @@ -229,5 +265,113 @@ "isDeactivated": false, "isOutdatedOnScopeSchool": false } + }, + { + "_id": { + "$oid": "65fd74c4d1c1ddf3bb2b05de" + }, + "createdAt": { + "$date": { + "$numberLong": "1711109316850" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711109316850" + } + }, + "tool": { + "$oid": "65fd44ba09e6ffd0bae3b8d3" + }, + "school": { + "$oid": "5f2987e020834114b8efd6f8" + }, + "schoolParameters": [], + "toolVersion": 3, + "status": { + "isOutdatedOnScopeSchool": false, + "isDeactivated": false + } + }, + { + "_id": { + "$oid": "65fd9882cb3d21d77bee50a7" + }, + "createdAt": { + "$date": { + "$numberLong": "1711118466387" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711118466387" + } + }, + "tool": { + "$oid": "65fd9736cb3d21d77bee50a6" + }, + "school": { + "$oid": "5f2987e020834114b8efd6f8" + }, + "schoolParameters": [], + "toolVersion": 2, + "status": { + "isOutdatedOnScopeSchool": false, + "isDeactivated": false + } + }, + { + "_id": { + "$oid": "65fd9bfdcb3d21d77bee50ac" + }, + "createdAt": { + "$date": { + "$numberLong": "1711119357634" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711119357634" + } + }, + "tool": { + "$oid": "65fad93bbe8ce15df1279d9b" + }, + "school": { + "$oid": "5f2987e020834114b8efd6f8" + }, + "schoolParameters": [], + "toolVersion": 1, + "status": { + "isOutdatedOnScopeSchool": false, + "isDeactivated": false + } + }, + { + "_id": { + "$oid": "65fd9dd2cb3d21d77bee50af" + }, + "createdAt": { + "$date": { + "$numberLong": "1711119826020" + } + }, + "updatedAt": { + "$date": { + "$numberLong": "1711119826020" + } + }, + "tool": { + "$oid": "65fd9dabcb3d21d77bee50ae" + }, + "school": { + "$oid": "5f2987e020834114b8efd6f8" + }, + "schoolParameters": [], + "toolVersion": 1, + "status": { + "isOutdatedOnScopeSchool": false, + "isDeactivated": false + } } ] diff --git a/backup/setup/schools.json b/backup/setup/schools.json index a6382fa5381..4681123d100 100644 --- a/backup/setup/schools.json +++ b/backup/setup/schools.json @@ -427,5 +427,46 @@ "STUDENT_LIST": true } } + }, + { + "_id": { + "$oid": "6613a91e0f48785dcfe9e0b3" + }, + "name": "Migrations-Test-Schule", + "fileStorageType": "awsS3", + "federalState": { + "$oid": "0000b186816abba584714c58" + }, + "county": { + "antaresKey": "NI", + "_id": { + "$oid": "5fa55eb53f472a2d986c8812" + }, + "countyId": 3256, + "name": "Nienburg/Weser" + }, + "systems": [], + "updatedAt": { + "$date": "2020-07-27T08:21:14.719Z" + }, + "createdAt": { + "$date": "2017-01-01T00:06:37.148Z" + }, + "__v": 0, + "currentYear": { + "$oid": "5ebd6dc14a431f75ec9a3e79" + }, + "purpose": "demo", + "features": [ + "rocketChat", + "videoconference" + ], + "enableStudentTeamCreation": false, + "permissions": { + "teacher": { + "STUDENT_LIST": true + } + }, + "officialSchoolNumber": "01337" } ] diff --git a/backup/setup/users.json b/backup/setup/users.json index daf68a36137..0721735c657 100644 --- a/backup/setup/users.json +++ b/backup/setup/users.json @@ -526,10 +526,10 @@ "org" ], "firstNameSearchValues": [ - "V", - "Ve", - "Ver", - "era" + "V", + "Ve", + "Ver", + "era" ], "lastNameSearchValues": [ "V", @@ -6339,5 +6339,253 @@ "preferences": { "firstLogin": true } + }, + { + "_id": { + "$oid": "6613a9965bd57f614a084ce7" + }, + "__v": 0, + "firstName": "Admin", + "lastName": "Migration", + "email": "admin.migration@schul-cloud.org", + "updatedAt": { + "$date": "2020-10-21T15:47:29.456Z" + }, + "birthday": { + "$date": "1977-01-01T11:25:43.556Z" + }, + "createdAt": { + "$date": "2017-01-01T00:06:37.148Z" + }, + "preferences": { + "firstLogin": true + }, + "schoolId": { + "$oid": "6613a91e0f48785dcfe9e0b3" + }, + "roles": [ + { + "$oid": "0000d186816abba584714c96" + } + ], + "emailSearchValues": [ + "a", + "ad", + "adm", + "dmi", + "min", + "in.", + "n.m", + ".mi", + "mig", + "igr", + "gra", + "rat", + "ati", + "tio", + "ion", + "on@", + "n@s", + "@sc", + "sch", + "chu", + "hul", + "ul-", + "l-c", + "-cl", + "clo", + "lou", + "oud", + "ud.", + "d.o", + ".or", + "org" + ], + "firstNameSearchValues": [ + "A", + "Ad", + "Adm", + "dmi", + "min" + ], + "lastNameSearchValues": [ + "M", + "Mi", + "Mig", + "igr", + "gra", + "rat", + "ati", + "tio", + "ion" + ] + }, + { + "_id": { + "$oid": "6613a9d441b8c7a53a587dea" + }, + "__v": 0, + "firstName": "Lehrer", + "lastName": "Migration", + "email": "lehrer.migration@schul-cloud.org", + "updatedAt": { + "$date": "2020-10-21T15:47:29.456Z" + }, + "birthday": { + "$date": "1977-01-01T11:25:43.556Z" + }, + "createdAt": { + "$date": "2017-01-01T00:06:37.148Z" + }, + "preferences": { + "firstLogin": true + }, + "schoolId": { + "$oid": "6613a91e0f48785dcfe9e0b3" + }, + "roles": [ + { + "$oid": "0000d186816abba584714c98" + } + ], + "emailSearchValues": [ + "l", + "le", + "leh", + "ehr", + "hre", + "rer", + "er.", + "r.m", + ".mi", + "mig", + "igr", + "gra", + "rat", + "ati", + "tio", + "ion", + "on@", + "n@s", + "@sc", + "sch", + "chu", + "hul", + "ul-", + "l-c", + "-cl", + "clo", + "lou", + "oud", + "ud.", + "d.o", + ".or", + "org" + ], + "firstNameSearchValues": [ + "L", + "Le", + "Leh", + "ehr", + "hre", + "rer" + ], + "lastNameSearchValues": [ + "M", + "Mi", + "Mig", + "igr", + "gra", + "rat", + "ati", + "tio", + "ion" + ] + }, + { + "_id": { + "$oid": "6613aa2c7537afa17f3aade6" + }, + "__v": 0, + "firstName": "Schueler", + "lastName": "Migration", + "email": "schueler.migration@schul-cloud.org", + "updatedAt": { + "$date": "2020-10-21T15:47:29.456Z" + }, + "birthday": { + "$date": "1977-01-01T11:25:43.556Z" + }, + "createdAt": { + "$date": "2017-01-01T00:06:37.148Z" + }, + "preferences": { + "firstLogin": true + }, + "schoolId": { + "$oid": "6613a91e0f48785dcfe9e0b3" + }, + "roles": [ + { + "$oid": "0000d186816abba584714c99" + } + ], + "emailSearchValues": [ + "s", + "sc", + "sch", + "chu", + "hue", + "uel", + "ele", + "ler", + "er.", + "r.m", + ".mi", + "mig", + "igr", + "gra", + "rat", + "ati", + "tio", + "ion", + "on@", + "n@s", + "@sc", + "sch", + "chu", + "hul", + "ul-", + "l-c", + "-cl", + "clo", + "lou", + "oud", + "ud.", + "d.o", + ".or", + "org" + ], + "firstNameSearchValues": [ + "S", + "Sc", + "Sch", + "chu", + "hue", + "uel", + "ele", + "ler" + ], + "lastNameSearchValues": [ + "M", + "Mi", + "Mig", + "igr", + "gra", + "rat", + "ati", + "tio", + "ion" + ] } ] diff --git a/config/README.md b/config/README.md index 69081ee6460..b2f6db5b686 100644 --- a/config/README.md +++ b/config/README.md @@ -293,12 +293,11 @@ The solution is the only existing way how environments should be passed to the n Please be careful! Secrets should be never exposed! They are readable in browser and request response. - + - - + + -> The public config endpoint should move to v3/ stack in future. ## Desired changes in future diff --git a/config/default.schema.json b/config/default.schema.json index 7c2ac799a0c..5b6177ad980 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -180,7 +180,7 @@ "default": "", "description": "Add custom domain to the list of blocked domains (comma separated list)." }, - "BLOCKLIST_OF_EMAIL_DOMAINS":{ + "BLOCKLIST_OF_EMAIL_DOMAINS": { "type": "string", "default": "", "description": "Add custom domain to the list of blocked domains (comma separated list)." @@ -315,16 +315,7 @@ "H5P_EDITOR": { "type": "object", "description": "Properties of the H5P server microservice and library management job", - "required": [ - "S3_ENDPOINT", - "S3_REGION", - "S3_BUCKET_CONTENT", - "S3_BUCKET_LIBRARIES", - "S3_ACCESS_KEY_ID_RW", - "S3_SECRET_ACCESS_KEY_RW", - "S3_ACCESS_KEY_ID_R", - "S3_SECRET_ACCESS_KEY_R" - ], + "required": ["S3_ENDPOINT", "S3_REGION", "S3_BUCKET_CONTENT", "S3_BUCKET_LIBRARIES"], "default": {}, "properties": { "S3_ENDPOINT": { @@ -338,25 +329,25 @@ "default": "eu-central-2", "description": "Region name of bucket" }, - "S3_ACCESS_KEY_ID_RW": { + "S3_ACCESS_KEY_ID": { "type": "string", "default": "", - "description": "Access Key to S3_BUCKET_CONTENT with R/W permissions" + "description": "Access Key to S3_BUCKET_CONTENT with RW and listing buckets permissions" }, - "S3_SECRET_ACCESS_KEY_RW": { + "S3_SECRET_ACCESS_KEY": { "type": "string", "default": "", - "description": "Secret key to S3_BUCKET_CONTENT with R/W permissions" + "description": "Secret key to to S3_BUCKET_CONTENT with RW and listing buckets permissions" }, - "S3_ACCESS_KEY_ID_R": { + "LIBRARIES_S3_ACCESS_KEY_ID": { "type": "string", "default": "", - "description": "Access Key to S3_BUCKET_LIBRARIES with R permissions" + "description": "Access Key to S3_BUCKET_LIBRARIES (at least read and listing buckets permissions for editor, aditionally write permission for library management)" }, - "S3_SECRET_ACCESS_KEY_R": { + "LIBRARIES_S3_SECRET_ACCESS_KEY": { "type": "string", "default": "", - "description": "Secret key to S3_BUCKET_LIBRARIES with R permissions" + "description": "Secret key to S3_BUCKET_LIBRARIES (at least read and listing buckets permissions for editor, aditionally write permission for library management)" }, "S3_BUCKET_CONTENT": { "type": "string", @@ -371,8 +362,8 @@ "INCOMING_REQUEST_TIMEOUT": { "type": "integer", "minimum": 0, - "default": 10000, - "description": "Timeout for incoming requests to the h5p editor in milliseconds." + "default": 600000, + "description": "Timeout for incoming requests (including file uploads) to the h5p editor in milliseconds ." }, "LIBRARY_LIST_PATH": { "type": "string", @@ -524,6 +515,11 @@ "default": "amqp://guest:guest@localhost:5672", "description": "The URI of the RabbitMQ server." }, + "LEGACY_RABBITMQ_GLOBAL_PREFETCH_COUNT": { + "type": "number", + "default": 25, + "description": "deprecated from the start, PLEASE GET YOUR OWN CONFIG, prefetch count is applied per channel globally for all legacy rabbitmq channels (nest based code is not affected)" + }, "HOST": { "type": "string", "format": "uri", @@ -641,7 +637,7 @@ }, "ANTIVIRUS_ROUTING_KEY": { "type": "string", - "default": "scan_file", + "default": "scan_file_v2", "description": "rabbitmq routing key" }, "DOCUMENT_BASE_DIR": { @@ -1034,6 +1030,11 @@ "description": "Nest Log level for api. The http flag is for request logging. The http flag do only work by api methods with added 'request logging interceptor'.", "enum": ["emerg", "alert", "crit", "error", "warning", "notice", "info", "debug"] }, + "EXIT_ON_ERROR": { + "type": "boolean", + "default": true, + "description": "By default, the application is terminated after an uncaughtException has been logged. If this is not the desired behavior, set exitOnError to false." + }, "SYSTEM_LOG_LEVEL": { "type": "string", "default": "requestError", @@ -1139,11 +1140,6 @@ "default": false, "description": "Toggle for course sharing feature." }, - "FEATURE_COURSE_SHARE_NEW": { - "type": "boolean", - "default": false, - "description": "Toggle for the new course sharing feature." - }, "FEATURE_LESSON_SHARE": { "type": "boolean", "default": false, @@ -1154,6 +1150,11 @@ "default": false, "description": "Toggle for the task sharing feature." }, + "FEATURE_COLUMN_BOARD_SHARE": { + "type": "boolean", + "default": false, + "description": "Toggle for the column board sharing feature." + }, "FEATURE_USER_MIGRATION_ENABLED": { "type": "boolean", "default": false, @@ -1169,11 +1170,28 @@ "default": false, "description": "Toggle for copy course feature." }, - "FEATURE_IMSCC_COURSE_EXPORT_ENABLED": { + "FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED": { "type": "boolean", "default": false, "description": "Toggle for the IMSCC course download feature." }, + "GEOGEBRA_BASE_URL": { + "type": "string", + "format": "uri", + "default": "https://www.geogebra.org", + "pattern": ".*(?:],[:]" + }, + "MODIFICATION_THRESHOLD_MS": { + "type": "number", + "default": 60000, + "description": "threshold in milliseconds to get deletionRequest to delete" } + }, - "default": { - "ENABLED": true, - "PORT": 4030, - "ALLOWED_API_KEYS": "" - } + "default": {} }, "FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED": { "type": "boolean", @@ -1401,6 +1424,20 @@ "default": false, "description": "Enables the copying of ctl tools when copying a course." }, + "CTL_TOOLS_RELOAD_TIME_MS": { + "type": "number", + "default": 299000, + "description": "Sets the time before launch request data is called again from server" + }, + "FEATURE_SHOW_MIGRATION_WIZARD": { + "type": "boolean", + "default": false, + "description": "Enables the migration wizard on the school administration page in the migration section." + }, + "MIGRATION_WIZARD_DOCUMENTATION_LINK": { + "type": "string", + "description": "The documentation page that gets rendered in the migration wizard tool." + }, "TSP_SCHOOL_SYNCER": { "type": "object", "description": "TSP School Syncer properties", @@ -1435,10 +1472,44 @@ "API_KEY": "" } }, + "FEATURE_TLDRAW_ENABLED": { + "type": "boolean", + "default": true, + "description": "Enables tldraw feature" + }, + "TLDRAW_ADMIN_API_CLIENT": { + "type": "object", + "description": "Configuration of the Tldraw's Admin API client.", + "properties": { + "BASE_URL": { + "type": "string", + "description": "Base URL of the Tldraw's Admin API." + }, + "API_KEY": { + "type": "string", + "description": "API key for accessing the Tldraw's Admin API." + } + }, + "default": { + "BASE_URL": "http://localhost:3349", + "API_KEY": "" + } + }, "TLDRAW": { "type": "object", - "description": "Tldraw managing variables.", - "required": ["PING_TIMEOUT", "SOCKET_PORT","GC_ENABLED", "DB_COLLECTION_NAME", "DB_FLUSH_SIZE", "DB_MULTIPLE_COLLECTIONS"], + "description": "Configuration of tldraw related settings", + "required": [ + "PING_TIMEOUT", + "FINALIZE_DELAY", + "SOCKET_PORT", + "GC_ENABLED", + "DB_COMPRESS_THRESHOLD", + "MAX_DOCUMENT_SIZE", + "ASSETS_ENABLED", + "ASSETS_SYNC_ENABLED", + "ASSETS_MAX_SIZE", + "ASSETS_ALLOWED_MIME_TYPES_LIST" + ], "properties": { "SOCKET_PORT": { "type": "number", @@ -1446,32 +1517,53 @@ }, "PING_TIMEOUT": { "type": "number", - "description": "Max time for waiting between calls for tldraw" + "description": "Websocket ping timeout in ms" + }, + "FINALIZE_DELAY": { + "type": "number", + "description": "Delay in milliseconds before checking if can finalize a tldraw board" }, "GC_ENABLED": { "type": "boolean", "description": "If tldraw garbage collector should be enabled" }, - "DB_COLLECTION_NAME": { - "type": "string", - "description": "Collection name in which tldraw drawing are stored" - }, - "DB_FLUSH_SIZE": { + "DB_COMPRESS_THRESHOLD": { "type": "integer", - "description": "DB collection flushing size" + "description": "Mongo documents with same docName compress threshold size" + }, + "MAX_DOCUMENT_SIZE": { + "type": "number", + "description": "Maximum size of a single tldraw document in mongo" + }, + "ASSETS_ENABLED": { + "type": "boolean", + "description": "Enables uploading assets to tldraw board" }, - "DB_MULTIPLE_COLLECTIONS": { + "ASSETS_SYNC_ENABLED": { "type": "boolean", - "description": "DB collection allowing multiple collections for drawing" + "description": "Enables synchronization of tldraw board assets with file storage" + }, + "ASSETS_MAX_SIZE": { + "type": "integer", + "description": "Maximum asset size in bytes" + }, + "ASSETS_ALLOWED_MIME_TYPES_LIST": { + "type": "string", + "description": "List with allowed assets MIME types, comma separated, empty if all MIME types supported by tldraw should be allowed", + "examples": ["image/gif,image/jpeg,video/webm"] } }, "default": { "SOCKET_PORT": 3345, - "PING_TIMEOUT": 10000, + "PING_TIMEOUT": 30000, + "FINALIZE_DELAY": 5000, "GC_ENABLED": true, - "DB_COLLECTION_NAME": "drawings", - "DB_FLUSH_SIZE": 400, - "DB_MULTIPLE_COLLECTIONS": false + "DB_COMPRESS_THRESHOLD": 400, + "MAX_DOCUMENT_SIZE": 15000000, + "ASSETS_ENABLED": true, + "ASSETS_SYNC_ENABLED": false, + "ASSETS_MAX_SIZE": 10485760, + "ASSETS_ALLOWED_MIME_TYPES_LIST": "image/png,image/jpeg,image/gif,image/svg+xml" } }, "TLDRAW_DB_URL": { @@ -1479,82 +1571,70 @@ "default": "mongodb://127.0.0.1:27017/tldraw", "description": "DB connection url" }, - "FEATURE_TLDRAW_ENABLED": { - "type": "boolean", - "default": true, - "description": "Tldraw feature enabled" - }, "TLDRAW_URI": { "type": "string", "default": "http://localhost:3349", "description": "Address for tldraw management app" - } - }, - "required": [], - "allOf": [ - { - "$ref": "#/definitions/FEATURE_ES_MERLIN_ENABLED", - "$ref": "#/definitions/ANTIVIRUS" - } - ], - "definitions": { - "FEATURE_USER_MIGRATION_ENABLED": { - "if": { - "properties": { - "FEATURE_USER_MIGRATION_ENABLED": { - "const": true - } - } - }, - "then": { - "required": ["FEATURE_USER_MIGRATION_SYSTEM_ID"] - } }, - "ANTIVIRUS": { - "if": { - "properties": { - "ENABLE_FILE_SECURITY_CHECK": { - "const": true - } + "SCHULCONNEX_CLIENT": { + "type": "object", + "description": "Configuration of the schulcloud's schulconnex client.", + "properties": { + "API_URL": { + "type": "string", + "description": "Base URL of the schulconnex API (from dof)", + "examples": ["https://api-dienste.stage.niedersachsen-login.schule/v1/"] + }, + "TOKEN_ENDPOINT": { + "type": "string", + "description": "Token endpoint of the schulconnex API (from dof)", + "examples": ["https://api-dienste.stage.niedersachsen-login.schule/v1/oauth2/token"] + }, + "CLIENT_ID": { + "type": "string", + "description": "Client ID for accessing the schulconnex API (from server vault)" + }, + "CLIENT_SECRET": { + "type": "string", + "description": "Client secret for accessing the schulconnex API (from server vault)" + }, + "PERSONEN_INFO_TIMEOUT_IN_MS": { + "type": "integer", + "description": "Timeout in milliseconds for fetching personen info from schulconnex", + "default": 120000 } }, - "then": { - "required": [ - "FILE_SECURITY_CHECK_SERVICE_URI", - "FILE_SECURITY_SERVICE_USERNAME", - "FILE_SECURITY_SERVICE_PASSWORD", - "API_HOST" - ] + "default": { + "API_URL": "", + "TOKEN_ENDPOINT": "", + "CLIENT_ID": "", + "CLIENT_SECRET": "" } }, - "LERNSTORE_ENABLED": { - "if": { - "properties": { - "FEATURE_LERNSTORE_ENABLED": { - "const": true - } - } - }, - "then": { - "required": ["ES_USER", "ES_PASSWORD"] - } + "HOSTNAME": { + "type": "string", + "description": "Hostname. Should not be usually defined as it's expected to be taken from the process env." }, - "FEATURE_ES_MERLIN_ENABLED": { - "if": { - "properties": { - "FEATURE_ES_MERLIN_ENABLED": { - "const": true - } - } - }, - "then": { - "required": [ - "SECRET_ES_MERLIN_USERNAME", - "SECRET_ES_MERLIN_PW", - "ES_MERLIN_AUTH_URL", - "SECRET_ES_MERLIN_COUNTIES_CREDENTIALS" - ] - } + "HEALTH_CHECKS_EXCLUDE_MONGODB": { + "type": "boolean", + "default": false, + "description": "Toggle that, when enabled, excludes MongoDB from the health checks." + }, + "FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enables the synchronization of courses with linked groups during provisioning." + }, + "SYNCHRONIZATION_CHUNK": { + "type": "number", + "default": 10000, + "description": "Size of chunk for synchronization" + }, + "FEATURE_MEDIA_SHELF_ENABLED": { + "type": "boolean", + "default": false, + "description": "Enables the media shelf feature" } - } + }, + "required": [] } diff --git a/config/development.json b/config/development.json index eb106993b10..b5478d63f10 100644 --- a/config/development.json +++ b/config/development.json @@ -69,12 +69,22 @@ "SECRET": "devSecret" }, "HYDRA_URI": "http://localhost:9001", - "FEATURE_IMSCC_COURSE_EXPORT_ENABLED": true, + "FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED": true, + "FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED": true, "NEST_LOG_LEVEL": "debug", "SESSION_SECRET": "dev-session-secret", "FEATURE_COURSE_SHARE": true, + "FEATURE_LESSON_SHARE": true, + "FEATURE_TASK_SHARE": true, + "FEATURE_COLUMN_BOARD_SHARE": true, "FEATURE_COLUMN_BOARD_ENABLED": true, "FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED": true, "FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED": true, - "FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED": true + "FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED": true, + "SCHULCONNEX_CLIENT": { + "API_URL": "http://localhost:8888/v1/", + "TOKEN_ENDPOINT": "http://localhost:8888/realms/SANIS/protocol/openid-connect/token", + "CLIENT_ID": "schulcloud", + "CLIENT_SECRET": "secret" + } } diff --git a/config/globals.js b/config/globals.js index 633878d1b3f..6747c023d3c 100644 --- a/config/globals.js +++ b/config/globals.js @@ -24,14 +24,12 @@ switch (NODE_ENV) { } let defaultDbUrl = null; -let defaultTldrawDbUrl = null; switch (NODE_ENV) { case ENVIRONMENTS.TEST: defaultDbUrl = 'mongodb://127.0.0.1:27017/schulcloud-test'; break; default: defaultDbUrl = 'mongodb://127.0.0.1:27017/schulcloud'; - defaultTldrawDbUrl = 'mongodb://127.0.0.1:27017/tldraw'; } const globals = { @@ -89,15 +87,6 @@ const globals = { ROCKET_CHAT_ADMIN_USER: process.env.ROCKET_CHAT_ADMIN_ID, ROCKET_CHAT_ADMIN_PASSWORD: process.env.ROCKET_CHAT_ADMIN_ID, - // etherpad - ETHERPAD_API_KEY: process.env.ETHERPAD_API_KEY, - ETHERPAD_API_PATH: process.env.ETHERPAD_API_PATH, - ETHERPAD_URI: process.env.ETHERPAD_URI, - ETHERPAD_OLD_PAD_URI: process.env.ETHERPAD_OLD_PAD_URI, - ETHERPAD_OLD_PAD_DOMAIN: process.env.ETHERPAD_OLD_PAD_DOMAIN, - ETHERPAD_COOKIE__EXPIRES_SECONDS: process.env.ETHERPAD_COOKIE__EXPIRES_SECONDS, - ETHERPAD_ETHERPAD_COOKIE_RELEASE_THRESHOLD: process.env.ETHERPAD_COOKIE_RELEASE_THRESHOLD, - // nextcloud NEXTCLOUD_BASE_URL: process.env.NEXTCLOUD_BASE_URL, NEXTCLOUD_ADMIN_USER: process.env.NEXTCLOUD_ADMIN_USER, @@ -106,9 +95,6 @@ const globals = { // calendar CALENDAR_URI: process.env.CALENDAR_URI, - - // tldraw - TLDRAW_DB_URL: process.env.TLDRAW_DB_URL || defaultTldrawDbUrl, }; // validation ///////////////////////////////////////////////// diff --git a/config/h5p-libraries.yaml b/config/h5p-libraries.yaml index fb4bfd27138..ecced062da7 100644 --- a/config/h5p-libraries.yaml +++ b/config/h5p-libraries.yaml @@ -1 +1,2 @@ -h5p_libraries: ['H5P.Accordion','H5P.MultiChoice'] +# Libraries can be configured with ansible (ConfigMap is mounted here) +h5p_libraries: [] \ No newline at end of file diff --git a/config/migrate.js b/config/migrate.js deleted file mode 100644 index ba3db8dd566..00000000000 --- a/config/migrate.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * this file is used to customize 'npm run migration' - * see https://www.npmjs.com/package/migrate-mongoosesetting-options-automatically - * for further details - */ - -const { getConnectionOptions } = require('../src/utils/database'); - -const options = getConnectionOptions(); -let { url } = options; -if (options.username) { - url = url.replace(/^mongodb:\/\//i, ''); - url = `mongodb://${options.username}:${options.password}@${url}`; -} - -module.exports = { - d: url, // database connection url - t: './migrations/template.js', // use custom migration template -}; diff --git a/config/test.json b/config/test.json index 08b9d373609..ca846fc8a8d 100644 --- a/config/test.json +++ b/config/test.json @@ -43,7 +43,8 @@ "NEST_LOG_LEVEL": "error", "CALENDAR_URI": "https://schul.tech:3000", "HYDRA_URI": "http://hydra:9000", - "FEATURE_IMSCC_COURSE_EXPORT_ENABLED": true, + "FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED": true, + "FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED": true, "SESSION": { "SAME_SITE": "lax", "SECURE": false, @@ -68,9 +69,29 @@ "TLDRAW": { "SOCKET_PORT": 3346, "PING_TIMEOUT": 1, + "FINALIZE_DELAY": 1, "GC_ENABLED": true, - "DB_COLLECTION_NAME": "drawings", - "DB_FLUSH_SIZE": 400, - "DB_MULTIPLE_COLLECTIONS": false + "DB_COMPRESS_THRESHOLD": 400, + "MAX_DOCUMENT_SIZE": 15000000, + "ASSETS_ENABLED": true, + "ASSETS_SYNC_ENABLED": true, + "ASSETS_MAX_SIZE": 25000000, + "ASSETS_ALLOWED_MIME_TYPES_LIST": "" + }, + "SCHULCONNEX_CLIENT": { + "API_URL": "http://localhost:8888/v1/", + "TOKEN_ENDPOINT": "http://localhost:8888/realms/SANIS/protocol/openid-connect/token", + "CLIENT_ID": "schulcloud", + "CLIENT_SECRET": "secret" + }, + "ADMIN_API": { + "ALLOWED_API_KEYS": "onlyusedintests:thisistheadminapitokeninthetestconfig,someotherkey", + "MODIFICATION_THRESHOLD_MS": 60000 + }, + "EXIT_ON_ERROR": false, + "TEACHER_STUDENT_VISIBILITY": { + "IS_CONFIGURABLE": false, + "IS_ENABLED_BY_DEFAULT": true, + "IS_VISIBLE": true } } diff --git a/jest.config.ts b/jest.config.ts index 0a3acc84f5b..4f30624cba0 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -29,6 +29,7 @@ let config: Config.InitialOptions = { '^@infra/(.*)$': '/apps/server/src/infra/$1', }, maxWorkers: 2, // limited for not taking all workers within of a single github action + workerIdleMemoryLimit: '1.5GB', // without this, jest can lead to big memory leaks and out of memory errors }; if (!process.env.RUN_WITHOUT_JEST_COVERAGE) { diff --git a/migrations/1653053526402-add-nextcloud-permission-to-users.js b/migrations/1653053526402-add-nextcloud-permission-to-users.js deleted file mode 100644 index 9c4a9b03d62..00000000000 --- a/migrations/1653053526402-add-nextcloud-permission-to-users.js +++ /dev/null @@ -1,75 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { info, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'roles200523', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Migration does not add the NEXTCLOUD_USER permission for this instance.'); - return; - } - - await connect(); - - await Roles.updateMany( - {}, - { - $pull: { - permissions: { - $in: ['NEXTCLOUD_USER'], - }, - }, - } - ).exec(); - - await Roles.updateOne( - { name: 'user' }, - { - $addToSet: { - permissions: { - $each: ['NEXTCLOUD_USER'], - }, - }, - } - ).exec(); - await close(); - }, - down: async function down() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Migration does not add the NEXTCLOUD_USER permission for this instance.'); - return; - } - - await connect(); - await Roles.updateOne( - { name: 'user' }, - { - $pull: { - permissions: { - $in: ['NEXTCLOUD_USER'], - }, - }, - } - ).exec(); - - await close(); - }, -}; diff --git a/migrations/1655884885296-setLtiToolsIsHiddenDefaultBehaviour.js b/migrations/1655884885296-setLtiToolsIsHiddenDefaultBehaviour.js deleted file mode 100644 index df9b5869906..00000000000 --- a/migrations/1655884885296-setLtiToolsIsHiddenDefaultBehaviour.js +++ /dev/null @@ -1,43 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const LtiTool = mongoose.model( - 'ltiToolHiddenBehaviour', - new mongoose.Schema( - { - isHidden: { type: Boolean, default: false }, - }, - { - timestamps: true, - } - ), - 'ltitools' -); - -module.exports = { - up: async function up() { - await connect(); - - await LtiTool.updateMany( - { - $or: [{ isHidden: { $exists: false } }, { isHidden: null }], - }, - { - $set: { isHidden: false }, - } - ).exec(); - - await close(); - }, - - down: async function down() { - await connect(); - // //////////////////////////////////////////////////// - // Nothing here. - // //////////////////////////////////////////////////// - await close(); - }, -}; diff --git a/migrations/1657547826225-RefactorOauthConfig.js b/migrations/1657547826225-RefactorOauthConfig.js deleted file mode 100644 index 976af1a790e..00000000000 --- a/migrations/1657547826225-RefactorOauthConfig.js +++ /dev/null @@ -1,81 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); -const { connect, close } = require('../src/utils/database'); - -const System = mongoose.model( - 'systemSchemaOauthRefactor', - new mongoose.Schema( - { - oauthConfig: { - type: { - clientId: { type: String, required: true }, - clientSecret: { type: String, required: true }, - grantType: { type: String, required: true }, - redirectUri: { type: String, required: true }, - scope: { type: String, required: true }, - responseType: { type: String, required: true }, - authEndpoint: { type: String, required: true }, - provider: { type: String, required: true }, - logoutEndpoint: { type: String, required: false }, - issuer: { type: String, required: true }, - jwksEndpoint: { type: String, required: true }, - }, - required: false, - }, - }, - { - timestamps: true, - } - ), - 'systems' -); - -// This migration renames a field and removes an unnecessary one - -module.exports = { - up: async function up() { - await connect(); - // Rename field codeRedirectUri -> redirectUri - await System.updateMany( - { oauthConfig: { $ne: null } }, - { $rename: { 'oauthConfig.codeRedirectUri': 'oauthConfig.redirectUri' } } - ); - alert(`Renamed codeRedirectUri to redirectUri`); - // Drop field tokenRedirectUri - await System.updateMany({ oauthConfig: { $ne: null } }, { $unset: { 'oauthConfig.tokenRedirectUri': 1 } }); - alert(`Dropped field tokenRedirectUri`); - await close(); - }, - - down: async function down() { - await connect(); - // Create tokenRedirectUri and copy value - const systems = await System.find({ oauthConfig: { $ne: null } }) - .lean() - .exec(); - alert(`Reading systems`); - const responses = systems.map((system) => - System.findOneAndUpdate( - { - _id: system._id, - }, - { - $set: { 'oauthConfig.tokenRedirectUri': `${system.oauthConfig.redirectUri}/token` }, - } - ) - .lean() - .exec() - ); - await Promise.all(responses); - alert(`Added field tokenRedirectUri`); - // Rename field redirectUri -> codeRedirectUri - await System.updateMany( - { oauthConfig: { $ne: null } }, - { $rename: { 'oauthConfig.redirectUri': 'oauthConfig.codeRedirectUri' } } - ); - alert(`Renamed redirectUri to codeRedirectUri`); - alert(`...finished!`); - await close(); - }, -}; diff --git a/migrations/1658483318838-allow_experts_join_meeting.js b/migrations/1658483318838-allow_experts_join_meeting.js deleted file mode 100644 index 86df5f1754a..00000000000 --- a/migrations/1658483318838-allow_experts_join_meeting.js +++ /dev/null @@ -1,63 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error, info } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'roles200722', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Migration does not add the JOIN_MEETING permission to role teamexpert for this instance.'); - return; - } - await connect(); - await Roles.updateOne( - { name: 'teamexpert' }, - { - $addToSet: { - permissions: { - $each: ['JOIN_MEETING'], - }, - }, - } - ).exec(); - alert(`Permisson JOIN_MEETING added to role teamexpert`); - await close(); - }, - - down: async function down() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Migration does not remove the JOIN_MEETING permission from role teamexpert for this instance.'); - return; - } - await connect(); - await Roles.updateOne( - { name: 'teamexpert' }, - { - $pull: { - permissions: { - $in: ['JOIN_MEETING'], - }, - }, - } - ).exec(); - alert(`Permisson JOIN_MEETING removed from role teamexpert`); - await close(); - }, -}; diff --git a/migrations/1659000228792-conferencePermissionForTeacherAndStudent.js b/migrations/1659000228792-conferencePermissionForTeacherAndStudent.js deleted file mode 100644 index 6a64c582185..00000000000 --- a/migrations/1659000228792-conferencePermissionForTeacherAndStudent.js +++ /dev/null @@ -1,81 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const Roles = mongoose.model( - 'roles280722', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -// How to use more than one schema per collection on mongodb -// https://stackoverflow.com/questions/14453864/use-more-than-one-schema-per-collection-on-mongodb - -module.exports = { - up: async function up() { - await connect(); - - await Roles.updateOne( - { name: 'teacher' }, - { - $addToSet: { - permissions: { - $each: ['START_MEETING'], - }, - }, - } - ).exec(); - alert(`Permission START_MEETING added to role teacher`); - await Roles.updateOne( - { name: 'student' }, - { - $addToSet: { - permissions: { - $each: ['JOIN_MEETING'], - }, - }, - } - ).exec(); - alert(`Permission JOIN_MEETING added to role student`); - await close(); - }, - - down: async function down() { - await connect(); - await Roles.updateOne( - { name: 'teacher' }, - { - $pull: { - permissions: { - $in: ['START_MEETING'], - }, - }, - } - ).exec(); - alert(`Permisson START_MEETING removed from role teacher`); - await Roles.updateOne( - { name: 'student' }, - { - $pull: { - permissions: { - $in: ['JOIN_MEETING'], - }, - }, - } - ).exec(); - alert(`Permission JOIN_MEETING removed from role student`); - await close(); - }, -}; diff --git a/migrations/1663851677867-add_oauth_client_permissions.js b/migrations/1663851677867-add_oauth_client_permissions.js deleted file mode 100644 index de13e0eb35a..00000000000 --- a/migrations/1663851677867-add_oauth_client_permissions.js +++ /dev/null @@ -1,55 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'role_220920221502', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - await connect(); - - await Roles.updateOne( - { name: 'superhero' }, - { - $addToSet: { - permissions: { - $each: ['OAUTH_CLIENT_EDIT', 'OAUTH_CLIENT_VIEW'], - }, - }, - } - ).exec(); - - await close(); - }, - - down: async function down() { - await connect(); - - await Roles.updateOne( - { name: 'superhero' }, - { - $pull: { - permissions: { - $in: ['OAUTH_CLIENT_EDIT', 'OAUTH_CLIENT_VIEW'], - }, - }, - } - ).exec(); - - await close(); - }, -}; diff --git a/migrations/1669045490170-add-tool-admin-permission.js b/migrations/1669045490170-add-tool-admin-permission.js deleted file mode 100644 index 3c5e699c00f..00000000000 --- a/migrations/1669045490170-add-tool-admin-permission.js +++ /dev/null @@ -1,55 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'role_211020221500', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - await connect(); - - await Roles.updateOne( - { name: 'superhero' }, - { - $addToSet: { - permissions: { - $each: ['TOOL_ADMIN'], - }, - }, - } - ).exec(); - - await close(); - }, - - down: async function down() { - await connect(); - - await Roles.updateOne( - { name: 'superhero' }, - { - $pull: { - permissions: { - $in: ['TOOL_ADMIN'], - }, - }, - } - ).exec(); - - await close(); - }, -}; diff --git a/migrations/1669628369773-add-submitted-and-graded-keys-to-submission.js b/migrations/1669628369773-add-submitted-and-graded-keys-to-submission.js deleted file mode 100644 index 460d54fc18d..00000000000 --- a/migrations/1669628369773-add-submitted-and-graded-keys-to-submission.js +++ /dev/null @@ -1,61 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { Schema } = mongoose; - -const { connect, close } = require('../src/utils/database'); - -const Submission = mongoose.model( - 'submission221128', - new mongoose.Schema({ - grade: { type: Number, min: 0, max: 100 }, - gradeComment: { type: String }, - gradeFileIds: [{ type: Schema.Types.ObjectId, ref: 'file' }], - submitted: { type: Boolean, default: false }, - graded: { type: Boolean, default: false }, - }), - 'submissions' -); - -module.exports = { - up: async function up() { - await connect(); - const resultStepOne = await Submission.updateMany( - {}, - { - submitted: true, - graded: false, - } - ).exec(); - - alert(`Create of key [submitted: true, graded: false] is completely: ${JSON.stringify(resultStepOne)}`); - - const resultStepTwo = await Submission.updateMany( - { - $or: [ - { - $and: [{ gradeComment: { $exists: true } }, { gradeComment: { $ne: '' } }], - }, - { - $and: [{ grade: { $exists: true } }, { grade: { $gte: 0 } }], - }, - { - $and: [{ gradeFileIds: { $exists: true } }, { gradeFileIds: { $not: { $size: 0 } } }], - }, - ], - }, - { - graded: true, - } - ).exec(); - - alert(`Update of key [graded: true] is completely: ${JSON.stringify(resultStepTwo)}`); - - await close(); - }, - - down: async function down() { - alert(`Is nothing to rollback`); - }, -}; diff --git a/migrations/1672843312212-add-school-tool-admin-permission.js b/migrations/1672843312212-add-school-tool-admin-permission.js deleted file mode 100644 index 54d8c5f0f06..00000000000 --- a/migrations/1672843312212-add-school-tool-admin-permission.js +++ /dev/null @@ -1,57 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const Roles = mongoose.model( - 'role_202304011542', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $addToSet: { - permissions: { - $each: ['SCHOOL_TOOL_ADMIN'], - }, - }, - } - ).exec(); - - await close(); - }, - - down: async function down() { - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $pull: { - permissions: { - $in: ['SCHOOL_TOOL_ADMIN'], - }, - }, - } - ).exec(); - - await close(); - }, -}; diff --git a/migrations/1673547890878-add-task-card-permission.js b/migrations/1673547890878-add-task-card-permission.js deleted file mode 100644 index dd3267a3bb7..00000000000 --- a/migrations/1673547890878-add-task-card-permission.js +++ /dev/null @@ -1,55 +0,0 @@ -const mongoose = require('mongoose'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const Roles = mongoose.model( - 'role_202304011542', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - await connect(); - - await Roles.updateOne( - { name: 'teacher' }, - { - $addToSet: { - permissions: { - $each: ['TASK_CARD_VIEW', 'TASK_CARD_EDIT'], - }, - }, - } - ).exec(); - - await close(); - }, - - down: async function down() { - await connect(); - - await Roles.updateOne( - { name: 'teacher' }, - { - $pull: { - permissions: { - $in: ['TASK_CARD_VIEW', 'TASK_CARD_EDIT'], - }, - }, - } - ).exec(); - - await close(); - }, -}; diff --git a/migrations/1676365080462-AddPermissionCreateToolEtherpadForStudent.js b/migrations/1676365080462-AddPermissionCreateToolEtherpadForStudent.js deleted file mode 100644 index 5b09fabe447..00000000000 --- a/migrations/1676365080462-AddPermissionCreateToolEtherpadForStudent.js +++ /dev/null @@ -1,57 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const Roles = mongoose.model( - 'role_202304011544', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - await connect(); - - await Roles.updateOne( - { name: 'student' }, - { - $addToSet: { - permissions: { - $each: ['TOOL_CREATE_ETHERPAD'], - }, - }, - } - ).exec(); - - await close(); - }, - - down: async function down() { - await connect(); - - await Roles.updateOne( - { name: 'student' }, - { - $pull: { - permissions: { - $in: ['TOOL_CREATE_ETHERPAD'], - }, - }, - } - ).exec(); - - await close(); - }, -}; \ No newline at end of file diff --git a/migrations/1677667242101-EditHomeworkCreateAndEditPermissions.js b/migrations/1677667242101-EditHomeworkCreateAndEditPermissions.js deleted file mode 100644 index 0879e1ce10a..00000000000 --- a/migrations/1677667242101-EditHomeworkCreateAndEditPermissions.js +++ /dev/null @@ -1,62 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const Roles = mongoose.model( - 'role_202304011545', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - await connect(); - - await Roles.updateOne( - { - name: 'teacher', - }, - { - $addToSet: { - permissions: { - $each: ['HOMEWORK_CREATE', 'HOMEWORK_EDIT'], - }, - }, - } - ).exec(); - alert(`Permission HOMEWORK_CREATE and HOMEWORK_EDIT added to role teacher`); - - await Roles.updateMany( - { - name: { - $in: ['user', 'courseStudent'], - }, - }, - { - $pull: { - permissions: { - $in: ['HOMEWORK_CREATE', 'HOMEWORK_EDIT'], - }, - }, - } - ).exec(); - - alert(`Permission HOMEWORK_CREATE and HOMEWORK_EDIT removed from role user and courseStudent`); - }, - - down: async function down() { - alert(`Is nothing to rollback`); - }, -}; diff --git a/migrations/1678882553385-sanis-rebranding.js b/migrations/1678882553385-sanis-rebranding.js deleted file mode 100644 index bd26b3b7fbc..00000000000 --- a/migrations/1678882553385-sanis-rebranding.js +++ /dev/null @@ -1,68 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const Systems = mongoose.model( - 'system202303151316', - new mongoose.Schema( - { - displayName: { type: String }, - }, - { - timestamps: true, - } - ), - 'systems' -); - -// How to use more than one schema per collection on mongodb -// https://stackoverflow.com/questions/14453864/use-more-than-one-schema-per-collection-on-mongodb - -// TODO npm run migration-persist and remove this line -// TODO update seed data and remove this line - -module.exports = { - up: async function up() { - await connect(); - // //////////////////////////////////////////////////// - // Make changes to the database here. - // - Only use models declared in the migration. - // - Make sure your migration is idempotent. It is not guaranteed to run only once! - // - Avoid any unnecessary references, including environment variables. If you have to run the migration on a single instance, use SC_THEME. - - await Systems.findOneAndUpdate( - { - displayName: 'SANIS', - }, - { - displayName: 'moin.schule', - } - ) - .lean() - .exec(); - // //////////////////////////////////////////////////// - await close(); - }, - - down: async function down() { - await connect(); - // //////////////////////////////////////////////////// - // Implement the necessary steps to roll back the migration here. - await Systems.findOneAndUpdate( - { - displayName: 'moin.schule', - }, - { - displayName: 'SANIS', - } - ) - .lean() - .exec(); - // //////////////////////////////////////////////////// - await close(); - }, -}; diff --git a/migrations/1678889088654-RefactorSystemSubConfig.js b/migrations/1678889088654-RefactorSystemSubConfig.js deleted file mode 100644 index 64ce3c9a1cd..00000000000 --- a/migrations/1678889088654-RefactorSystemSubConfig.js +++ /dev/null @@ -1,64 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); -const { connect, close } = require('../src/utils/database'); - -const System = mongoose.model( - 'systemSchemaIdpHintRefactor', - new mongoose.Schema( - { - oauthConfig: { - type: { - alias: { type: String, required: false }, - }, - required: false, - }, - oidcConfig: { - type: { - alias: { type: String, required: false }, - }, - required: false, - }, - }, - { - timestamps: true, - } - ), - 'systems' -); - -// This migration renames a field and removes an unnecessary one - -module.exports = { - up: async function up() { - await connect(); - await System.updateMany( - { oidcConfig: { $ne: null }, 'oidcConfig.alias': { $exists: true } }, - { $rename: { 'oidcConfig.alias': 'oidcConfig.idpHint' } } - ); - alert(`Renamed oidcConfig alias to idpHint`); - await System.updateMany( - { oauthConfig: { $ne: null }, 'oauthConfig.alias': { $exists: true } }, - { $rename: { 'oauthConfig.alias': 'oauthConfig.idpHint' } } - ); - alert(`Renamed oauthConfig alias to idpHint`); - alert(`...finished!`); - await close(); - }, - - down: async function down() { - await connect(); - await System.updateMany( - { oidcConfig: { $ne: null }, 'oidcConfig.idpHint': { $exists: true } }, - { $rename: { 'oidcConfig.idpHint': 'oidcConfig.alias' } } - ); - alert(`Renamed oidcConfig idpHint to alias`); - await System.updateMany( - { oauthConfig: { $ne: null }, 'oauthConfig.idpHint': { $exists: true } }, - { $rename: { 'oauthConfig.idpHint': 'oauthConfig.alias' } } - ); - alert(`Renamed oauthConfig idpHint to alias`); - alert(`...finished!`); - await close(); - }, -}; diff --git a/migrations/1679042894877-oauth-provisioning-school-feature.js b/migrations/1679042894877-oauth-provisioning-school-feature.js deleted file mode 100644 index f9982fa6213..00000000000 --- a/migrations/1679042894877-oauth-provisioning-school-feature.js +++ /dev/null @@ -1,69 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const School = mongoose.model( - 'school202303170948', - new mongoose.Schema( - { - features: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'schools' -); - -// How to use more than one schema per collection on mongodb -// https://stackoverflow.com/questions/14453864/use-more-than-one-schema-per-collection-on-mongodb - -module.exports = { - up: async function up() { - await connect(); - // //////////////////////////////////////////////////// - // Make changes to the database here. - // - Only use models declared in the migration. - // - Make sure your migration is idempotent. It is not guaranteed to run only once! - // - Avoid any unnecessary references, including environment variables. If you have to run the migration on a single instance, use SC_THEME. - - await School.updateMany( - {}, - { - $addToSet: { - features: { - $each: ['oauthProvisioningEnabled'], - }, - }, - } - ) - .lean() - .exec(); - // //////////////////////////////////////////////////// - await close(); - }, - - down: async function down() { - await connect(); - // //////////////////////////////////////////////////// - // Implement the necessary steps to roll back the migration here. - await School.updateMany( - {}, - { - $pull: { - features: { - $in: ['oauthProvisioningEnabled'], - }, - }, - } - ) - .lean() - .exec(); - // //////////////////////////////////////////////////// - await close(); - }, -}; diff --git a/migrations/1680269073452-change-ctl-paramter-location-from-token-to-body.js b/migrations/1680269073452-change-ctl-paramter-location-from-token-to-body.js deleted file mode 100644 index 653dd42e79f..00000000000 --- a/migrations/1680269073452-change-ctl-paramter-location-from-token-to-body.js +++ /dev/null @@ -1,102 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const { Schema } = mongoose; - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const ExternalTool = mongoose.model( - 'external_tools202303311526', - new mongoose.Schema( - { - parameters: [ - new Schema( - { - name: { type: String, required: true }, - displayName: { type: String, required: true }, - location: { type: String, required: true }, - }, - { _id: false, timestamps: false } - ), - ], - }, - { - timestamps: true, - } - ), - 'external_tools' -); - -// How to use more than one schema per collection on mongodb -// https://stackoverflow.com/questions/14453864/use-more-than-one-schema-per-collection-on-mongodb - -module.exports = { - up: async function up() { - await connect(); - // //////////////////////////////////////////////////// - // Make changes to the database here. - // - Only use models declared in the migration. - // - Make sure your migration is idempotent. It is not guaranteed to run only once! - // - Avoid any unnecessary references, including environment variables. If you have to run the migration on a single instance, use SC_THEME. - - await ExternalTool.updateMany( - { - 'parameters.location': 'token', - }, - { - 'parameters.$[element].location': 'body', - }, - { arrayFilters: [{ 'element.location': 'token' }] } - ) - .lean() - .exec(); - - await ExternalTool.updateMany({}, [ - { - $set: { - parameters: { - $map: { - input: '$parameters', - in: { $mergeObjects: ['$$this', { displayName: '$$this.name' }] }, - }, - }, - }, - }, - ]) - .lean() - .exec(); - // //////////////////////////////////////////////////// - await close(); - }, - - down: async function down() { - await connect(); - // //////////////////////////////////////////////////// - // Implement the necessary steps to roll back the migration here. - await ExternalTool.updateMany( - { - 'parameters.location': 'body', - }, - { - 'parameters.$[element].location': 'token', - }, - { arrayFilters: [{ 'element.location': 'body' }] } - ) - .lean() - .exec(); - - await ExternalTool.updateMany( - {}, - { - $unset: { 'parameters.$[].displayName': '' }, - } - ) - .lean() - .exec(); - // //////////////////////////////////////////////////// - await close(); - }, -}; diff --git a/migrations/1680527289416-remove-systems-permission-of-admins.js b/migrations/1680527289416-remove-systems-permission-of-admins.js deleted file mode 100644 index 6599a9f0c6e..00000000000 --- a/migrations/1680527289416-remove-systems-permission-of-admins.js +++ /dev/null @@ -1,94 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error, info } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'roles200725', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info( - 'Migration does not remove the SYSTEM_CREATE and SYSTEM_EDIT permission from role administrator for this instance.' - ); - return; - } - await connect(); - await Roles.updateOne( - { name: 'administrator' }, - { - $pull: { - permissions: { - $in: ['SYSTEM_CREATE'], - }, - }, - } - ).exec(); - alert(`Permission SYSTEM_CREATE removed from role administrator`); - - await Roles.updateOne( - { name: 'administrator' }, - { - $pull: { - permissions: { - $in: ['SYSTEM_EDIT'], - }, - }, - } - ).exec(); - alert(`Permission SYSTEM_EDIT removed from role administrator`); - - await close(); - }, - - down: async function down() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info( - 'Migration does not add the SYSTEM_CREATE and SYSTEM_EDIT permission to role administrator for this instance.' - ); - return; - } - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $addToSet: { - permissions: { - $each: ['SYSTEM_CREATE'], - }, - }, - } - ).exec(); - alert(`Permission SYSTEM_CREATE added to role administrator`); - - await Roles.updateOne( - { name: 'administrator' }, - { - $addToSet: { - permissions: { - $each: ['SYSTEM_EDIT'], - }, - }, - } - ).exec(); - alert(`Permission SYSTEM_EDIT added to role administrator`); - - await close(); - }, -}; diff --git a/migrations/1680527378055-add-system-view-permission-for-admins.js b/migrations/1680527378055-add-system-view-permission-for-admins.js deleted file mode 100644 index 82720107e62..00000000000 --- a/migrations/1680527378055-add-system-view-permission-for-admins.js +++ /dev/null @@ -1,55 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'roles200726', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $addToSet: { - permissions: { - $each: ['SYSTEM_VIEW'], - }, - }, - } - ).exec(); - alert(`Permission SYSTEM_VIEW added to role administrator`); - await close(); - }, - - down: async function down() { - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $pull: { - permissions: { - $in: ['SYSTEM_VIEW'], - }, - }, - } - ).exec(); - alert(`Permission SYSTEM_VIEW removed from role administrator`); - await close(); - }, -}; diff --git a/migrations/1680611150707-remove-system-view-permission-from-user.js b/migrations/1680611150707-remove-system-view-permission-from-user.js deleted file mode 100644 index 8f8013bc654..00000000000 --- a/migrations/1680611150707-remove-system-view-permission-from-user.js +++ /dev/null @@ -1,57 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'roles200735', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - await connect(); - - await Roles.updateOne( - { name: 'user' }, - { - $pull: { - permissions: { - $in: ['SYSTEM_VIEW'], - }, - }, - } - ).exec(); - alert(`Permission SYSTEM_VIEW removed from role user`); - - await close(); - }, - - down: async function down() { - await connect(); - - await Roles.updateOne( - { name: 'user' }, - { - $addToSet: { - permissions: { - $each: ['SYSTEM_VIEW'], - }, - }, - } - ).exec(); - alert(`Permission SYSTEM_VIEW added to role user`); - - await close(); - }, -}; diff --git a/migrations/1682595015605-move-school-migration-attributes-to-entity.js b/migrations/1682595015605-move-school-migration-attributes-to-entity.js deleted file mode 100644 index 0f44d967b0c..00000000000 --- a/migrations/1682595015605-move-school-migration-attributes-to-entity.js +++ /dev/null @@ -1,165 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { Schema } = mongoose; - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const System = mongoose.model( - 'system1682595015605', - new mongoose.Schema( - { - _id: { type: Schema.Types.ObjectId, required: true }, - alias: { type: String, required: true }, - }, - { - timestamps: true, - } - ), - 'systems' -); - -const School = mongoose.model( - 'school1682595015605', - new mongoose.Schema( - { - _id: { type: Schema.Types.ObjectId, required: true }, - oauthMigrationStart: { type: Date }, - oauthMigrationPossible: { type: Date }, - oauthMigrationMandatory: { type: Date }, - oauthMigrationFinished: { type: Date }, - oauthMigrationFinalFinish: { type: Date }, - systems: [{ type: Schema.Types.ObjectId, ref: 'systems' }], - }, - { - timestamps: true, - } - ), - 'schools' -); - -const UserLoginMigration = mongoose.model( - 'user_login_migration1682595015605', - new mongoose.Schema( - { - school: { type: Schema.Types.ObjectId, ref: 'schools', required: true }, - sourceSystem: { type: Schema.Types.ObjectId, ref: 'systems' }, - targetSystem: { type: Schema.Types.ObjectId, ref: 'systems', required: true }, - mandatorySince: { type: Date }, - startedAt: { type: Date, required: true }, - closedAt: { type: Date }, - finishedAt: { type: Date }, - }, - { - timestamps: true, - } - ), - 'user_login_migrations' -); - -// How to use more than one schema per collection on mongodb -// https://stackoverflow.com/questions/14453864/use-more-than-one-schema-per-collection-on-mongodb -module.exports = { - up: async function up() { - await connect(); - - // Find all schools that have a migration - const schools = await School.find({ - oauthMigrationStart: { $exists: true }, - }) - .lean() - .exec(); - - if ((schools || []).length === 0) { - alert('No school with user login migration found. Nothing to migrate.'); - return; - } - - alert(`Found ${schools.length} school(s) for update.`); - - // Find migration target system - const targetSystem = await System.findOne({ - alias: 'SANIS', - }) - .lean() - .exec(); - - if (!targetSystem) { - error(`Cannot find SANIS system, but ${schools.length} school(s) have a migration and need to be migrated.`); - return; - } - - // Map old attributes to new documents - const userLoginMigrations = schools.map((school) => { - // Find schools source system - const schoolSystems = (school.systems || []).filter((systemId) => systemId !== targetSystem._id); - const sourceSystem = schoolSystems.length >= 1 ? schoolSystems[0] : undefined; - - return { - school: school._id, - sourceSystem, - targetSystem: targetSystem._id, - mandatorySince: school.oauthMigrationMandatory, - startedAt: school.oauthMigrationStart, - closedAt: school.oauthMigrationFinished, - finishedAt: school.oauthMigrationFinalFinish, - }; - }); - - alert(userLoginMigrations); - - // Save new documents - await UserLoginMigration.insertMany(userLoginMigrations); - - alert(`Created ${userLoginMigrations.length} UserLoginMigration(s)`); - - // Remove old attributes - await School.updateMany( - {}, - { - $unset: { - oauthMigrationStart: '', - oauthMigrationPossible: '', - oauthMigrationMandatory: '', - oauthMigrationFinished: '', - oauthMigrationFinalFinish: '', - }, - } - ); - - await close(); - }, - - down: async function down() { - await connect(); - - // Find all schools that have a migration - const userLoginMigrations = await UserLoginMigration.find({}).lean().exec(); - - if ((userLoginMigrations || []).length === 0) { - alert('No user login migrations found. Nothing to roll back.'); - return; - } - - alert(`Found ${userLoginMigrations.length} UserLoginMigration(s) to roll back.`); - - await Promise.all( - userLoginMigrations.map(async (userLoginMigration) => { - await School.findByIdAndUpdate(userLoginMigration.school, { - oauthMigrationStart: userLoginMigration.startedAt, - oauthMigrationPossible: !userLoginMigration.closedAt ? userLoginMigration.startedAt : undefined, - oauthMigrationMandatory: userLoginMigration.mandatorySince, - oauthMigrationFinished: userLoginMigration.closedAt, - oauthMigrationFinalFinish: userLoginMigration.finishedAt, - }); - }) - ); - - await UserLoginMigration.db.dropCollection('user_login_migrations'); - - await close(); - }, -}; diff --git a/migrations/1682679642924-add-context-tool-admin-permission-to-teachers.js b/migrations/1682679642924-add-context-tool-admin-permission-to-teachers.js deleted file mode 100644 index c672f00a267..00000000000 --- a/migrations/1682679642924-add-context-tool-admin-permission-to-teachers.js +++ /dev/null @@ -1,55 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'roles2804231308', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - await connect(); - - await Roles.updateOne( - { name: 'teacher' }, - { - $addToSet: { - permissions: { - $each: ['CONTEXT_TOOL_ADMIN'], - }, - }, - } - ).exec(); - alert(`Permission CONTEXT_TOOL_ADMIN added to role teacher`); - await close(); - }, - - down: async function down() { - await connect(); - - await Roles.updateOne( - { name: 'teacher' }, - { - $pull: { - permissions: { - $in: ['CONTEXT_TOOL_ADMIN'], - }, - }, - } - ).exec(); - alert(`Permission CONTEXT_TOOL_ADMIN removed from role teacher`); - await close(); - }, -}; diff --git a/migrations/1683271594051-remove-leave-team-permission-from-user.js b/migrations/1683271594051-remove-leave-team-permission-from-user.js deleted file mode 100644 index bcda08a0d67..00000000000 --- a/migrations/1683271594051-remove-leave-team-permission-from-user.js +++ /dev/null @@ -1,101 +0,0 @@ -const mongoose = require('mongoose'); -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'role_202305050910', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }] - }, - { - timestamps: true - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - await connect(); - - await Roles.updateOne( - { - name: 'teamadministrator' - }, - { - $addToSet: { - permissions: { - $each: ['LEAVE_TEAM'] - } - }, - } - ).exec(); - await Roles.updateOne( - { - name: 'teammember' - }, - { - $pull: { - permissions: { - $in: ['LEAVE_TEAM'] - } - }, - } - ).exec(); - await Roles.updateOne( - { - name: 'teamexpert' - }, - { - $pull: { - permissions: { - $in: ['LEAVE_TEAM'] - } - }, - } - ).exec(); - await close(); - }, - - down: async function down() { - await connect(); - await Roles.updateOne( - { - name: 'teamadministrator' - }, - { - $pull: { - permissions: { - $in: ['LEAVE_TEAM'] - } - }, - } - ).exec(); - await Roles.updateOne( - { - name: 'teammember' - }, - { - $addToSet: { - permissions: { - $each: ['LEAVE_TEAM'] - } - }, - } - ).exec(); - await Roles.updateOne( - { - name: 'teamexpert' - }, - { - $addToSet: { - permissions: { - $each: ['LEAVE_TEAM'] - } - }, - } - ).exec(); - await close(); - }, -}; diff --git a/migrations/1684224140719-add-user-login-migration-admin-permission-to-admin.js b/migrations/1684224140719-add-user-login-migration-admin-permission-to-admin.js deleted file mode 100644 index 7bc46acf6c9..00000000000 --- a/migrations/1684224140719-add-user-login-migration-admin-permission-to-admin.js +++ /dev/null @@ -1,67 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error, info } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const Roles = mongoose.model( - 'roles1605231004', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Migration does not add the USER_LOGIN_MIGRATION_ADMIN permission for this instance.'); - return; - } - - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $addToSet: { - permissions: { - $each: ['USER_LOGIN_MIGRATION_ADMIN'], - }, - }, - } - ).exec(); - await close(); - }, - down: async function down() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Migration does not add the USER_LOGIN_MIGRATION_ADMIN permission for this instance.'); - return; - } - - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $pull: { - permissions: { - $in: ['USER_LOGIN_MIGRATION_ADMIN'], - }, - }, - } - ).exec(); - - await close(); - }, -}; diff --git a/migrations/1684235344602-add-context-tool-user-to-user.js b/migrations/1684235344602-add-context-tool-user-to-user.js deleted file mode 100644 index 93d9053a4b5..00000000000 --- a/migrations/1684235344602-add-context-tool-user-to-user.js +++ /dev/null @@ -1,55 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'roles160520231309', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - await connect(); - - await Roles.updateOne( - { name: 'user' }, - { - $addToSet: { - permissions: { - $each: ['CONTEXT_TOOL_USER'], - }, - }, - } - ).exec(); - alert(`Permission CONTEXT_TOOL_USER added to role user`); - await close(); - }, - - down: async function down() { - await connect(); - - await Roles.updateOne( - { name: 'user' }, - { - $pull: { - permissions: { - $in: ['CONTEXT_TOOL_USER'], - }, - }, - } - ).exec(); - alert(`Permission CONTEXT_TOOL_ADMIN removed from role user`); - await close(); - }, -}; diff --git a/migrations/1691056009653-ctl_tools_add_lti_locale.js b/migrations/1691056009653-ctl_tools_add_lti_locale.js deleted file mode 100644 index 123dc3d9a18..00000000000 --- a/migrations/1691056009653-ctl_tools_add_lti_locale.js +++ /dev/null @@ -1,49 +0,0 @@ -const mongoose = require('mongoose'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const ExternalTools = mongoose.model( - 'external_tools1691056009653', - new mongoose.Schema( - { - config_type: { type: String, required: true }, - config_launch_presentation_locale: { type: String, required: true }, - }, - { - timestamps: true, - } - ), - 'external_tools' -); - -// How to use more than one schema per collection on mongodb -// https://stackoverflow.com/questions/14453864/use-more-than-one-schema-per-collection-on-mongodb - -module.exports = { - up: async function up() { - await connect(); - - await ExternalTools.updateMany( - { config_type: 'lti11' }, - { - config_launch_presentation_locale: 'de-DE', - } - ) - .lean() - .exec(); - - await close(); - }, - - down: async function down() { - await connect(); - - await ExternalTools.updateMany({ config_type: 'lti11' }, { $unset: { config_launch_presentation_locale: '' } }) - .lean() - .exec(); - - await close(); - }, -}; diff --git a/migrations/1693571501993-add-join-meeting-permission-to-teacher.js b/migrations/1693571501993-add-join-meeting-permission-to-teacher.js deleted file mode 100644 index 74118543ce8..00000000000 --- a/migrations/1693571501993-add-join-meeting-permission-to-teacher.js +++ /dev/null @@ -1,75 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error, info } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const Roles = mongoose.model( - 'roles0109231450', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -// How to use more than one schema per collection on mongodb -// https://stackoverflow.com/questions/14453864/use-more-than-one-schema-per-collection-on-mongodb - -// TODO npm run migration-persist and remove this line -// TODO update seed data and remove this line - -module.exports = { - up: async function up() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Migration does not add the JOIN_MEETING permission for this instance.'); - return; - } - - await connect(); - - await Roles.updateOne( - { name: 'teacher' }, - { - $addToSet: { - permissions: { - $each: ['JOIN_MEETING'], - }, - }, - } - ).exec(); - alert(`Permission JOIN_MEETING added to role teacher`); - await close(); - }, - - down: async function down() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Migration does not remove the JOIN_MEETING permission for this instance.'); - return; - } - - await connect(); - - await Roles.updateOne( - { name: 'teacher' }, - { - $pull: { - permissions: { - $in: ['JOIN_MEETING'], - }, - }, - } - ).exec(); - alert(`Permission JOIN_MEETING removed from role teacher`); - await close(); - }, -}; diff --git a/migrations/1693574053453-add-start-meeting-permission-to-admin.js b/migrations/1693574053453-add-start-meeting-permission-to-admin.js deleted file mode 100644 index 4d45fe1cc0a..00000000000 --- a/migrations/1693574053453-add-start-meeting-permission-to-admin.js +++ /dev/null @@ -1,75 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error, info } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const Roles = mongoose.model( - 'roles0109231514', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -// How to use more than one schema per collection on mongodb -// https://stackoverflow.com/questions/14453864/use-more-than-one-schema-per-collection-on-mongodb - -// TODO npm run migration-persist and remove this line -// TODO update seed data and remove this line - -module.exports = { - up: async function up() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Migration does not add the START_MEETING permission for this instance.'); - return; - } - - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $addToSet: { - permissions: { - $each: ['START_MEETING'], - }, - }, - } - ).exec(); - alert(`Permission START_MEETING added to role administrator`); - await close(); - }, - - down: async function down() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Migration does not remove the START_MEETING permission for this instance.'); - return; - } - - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $pull: { - permissions: { - $in: ['START_MEETING'], - }, - }, - } - ).exec(); - alert(`Permission START_MEETING removed from role administrator`); - await close(); - }, -}; diff --git a/migrations/1693574924835-add-join-meeting-permission-to-admin.js b/migrations/1693574924835-add-join-meeting-permission-to-admin.js deleted file mode 100644 index ea291b14405..00000000000 --- a/migrations/1693574924835-add-join-meeting-permission-to-admin.js +++ /dev/null @@ -1,75 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error, info } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const Roles = mongoose.model( - 'roles0109231529', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -// How to use more than one schema per collection on mongodb -// https://stackoverflow.com/questions/14453864/use-more-than-one-schema-per-collection-on-mongodb - -// TODO npm run migration-persist and remove this line -// TODO update seed data and remove this line - -module.exports = { - up: async function up() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Migration does not add the JOIN_MEETING permission for this instance.'); - return; - } - - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $addToSet: { - permissions: { - $each: ['JOIN_MEETING'], - }, - }, - } - ).exec(); - alert(`Permission JOIN_MEETING added to role administrator`); - await close(); - }, - - down: async function down() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Migration does not remove the JOIN_MEETING permission for this instance.'); - return; - } - - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $pull: { - permissions: { - $in: ['JOIN_MEETING'], - }, - }, - } - ).exec(); - alert(`Permission JOIN_MEETING removed from role administrator`); - await close(); - }, -}; diff --git a/migrations/1697020818782-remove-moin-schule-logout-endpoint.js b/migrations/1697020818782-remove-moin-schule-logout-endpoint.js deleted file mode 100644 index 793db440b46..00000000000 --- a/migrations/1697020818782-remove-moin-schule-logout-endpoint.js +++ /dev/null @@ -1,87 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Systems = mongoose.model( - 'system2023101111140', - new mongoose.Schema( - { - alias: { type: String }, - oauthConfig: { - type: { - clientId: { type: String, required: true }, - clientSecret: { type: String, required: true }, - grantType: { type: String, required: true }, - redirectUri: { type: String, required: true }, - scope: { type: String, required: true }, - responseType: { type: String, required: true }, - authEndpoint: { type: String, required: true }, - provider: { type: String, required: true }, - logoutEndpoint: { type: String, required: false }, - issuer: { type: String, required: true }, - jwksEndpoint: { type: String, required: true }, - }, - required: false, - }, - }, - { - timestamps: true, - } - ), - 'systems' -); - -module.exports = { - up: async function up() { - await connect(); - - const result = await Systems.findOneAndUpdate( - { alias: 'SANIS' }, - { - $unset: { - 'oauthConfig.logoutEndpoint': 1, - }, - } - ) - .lean() - .exec(); - - if (result) { - alert(`Removed logoutEndpoint from oauthConfig of sanis/moin.schule system`); - } else { - alert('No matching document found with alias "SANIS" and logoutEndpoint'); - } - - await close(); - }, - - down: async function down() { - await connect(); - - const system = await Systems.findOne({ alias: 'SANIS' }).lean().exec(); - - if (system) { - const { authEndpoint } = system.oauthConfig; - const logoutEndpoint = authEndpoint.replace(/\/auth$/, '/logout'); - - const result = await Systems.findOneAndUpdate( - { alias: 'SANIS' }, - { - $set: { - 'oauthConfig.logoutEndpoint': logoutEndpoint, - }, - } - ) - .lean() - .exec(); - - if (result) { - alert(`Added logoutEndpoint to oauthConfig of sanis/moin.schule system`); - } - } - - await close(); - }, -}; diff --git a/migrations/1697553524886-add-group-view-and-list-permission.js b/migrations/1697553524886-add-group-view-and-list-permission.js deleted file mode 100644 index 2bf46755aed..00000000000 --- a/migrations/1697553524886-add-group-view-and-list-permission.js +++ /dev/null @@ -1,100 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { info } = require('winston'); -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'roles2023101716394', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Permissions GROUP_VIEW and GROUP_LIST will not be added for this instance.'); - return; - } - - await connect(); - - const groupViewPermission = await Roles.updateOne( - { name: 'user' }, - { - $addToSet: { - permissions: { - $each: ['GROUP_VIEW'], - }, - }, - } - ).exec(); - if (groupViewPermission) { - alert(`Permission GROUP_VIEW added to role user`); - } - - const groupListPermission = await Roles.updateMany( - { name: { $in: ['teacher', 'administrator', 'superhero'] } }, - { - $addToSet: { - permissions: { - $each: ['GROUP_LIST'], - }, - }, - } - ).exec(); - if (groupListPermission) { - alert(`Permission GROUP_LIST added to role user and administrator`); - } - - await close(); - }, - - down: async function down() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Permissions GROUP_VIEW and GROUP_LIST will not be removed for this instance.'); - return; - } - - await connect(); - - const groupViewRollback = await Roles.updateOne( - { name: 'user' }, - { - $pull: { - permissions: 'GROUP_VIEW', - }, - } - ).exec(); - - if (groupViewRollback) { - alert(`Rollback: Removed permission GROUP_VIEW from role user`); - } - - const groupListRollback = await Roles.updateMany( - { name: { $in: ['teacher', 'administrator', 'superhero'] } }, - { - $pull: { - permissions: 'GROUP_LIST', - }, - } - ).exec(); - - if (groupListRollback) { - alert(`Rollback: Removed permission GROUP_LIST from roles teacher and administrator`); - } - - await close(); - }, -}; diff --git a/migrations/1698325587322-add-group-full-admin-permission.js b/migrations/1698325587322-add-group-full-admin-permission.js deleted file mode 100644 index cb7a10a642a..00000000000 --- a/migrations/1698325587322-add-group-full-admin-permission.js +++ /dev/null @@ -1,74 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { info } = require('winston'); -const { alert } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'roles202310261524', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Permission GROUP_FULL_ADMIN will not be added for this instance.'); - return; - } - - await connect(); - - const adminAndSuperheroRole = await Roles.updateMany( - { name: { $in: ['administrator', 'superhero'] } }, - { - $addToSet: { - permissions: { - $each: ['GROUP_FULL_ADMIN'], - }, - }, - } - ).exec(); - - if (adminAndSuperheroRole) { - alert('Permission GROUP_FULL_ADMIN added to role superhero and administrator'); - } - - await close(); - }, - - down: async function down() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info('Permission GROUP_FULL_ADMIN will not be removed for this instance.'); - return; - } - - await connect(); - - const adminAndSuperheroRole = await Roles.updateMany( - { name: { $in: ['administrator', 'superhero'] } }, - { - $pull: { - permissions: 'GROUP_FULL_ADMIN', - }, - } - ).exec(); - - if (adminAndSuperheroRole) { - alert('Rollback: Removed permission GROUP_FULL_ADMIN from roles superhero and administrator'); - } - - await close(); - }, -}; diff --git a/migrations/1699529266062-tool-and-user-login-migration-renamings.js b/migrations/1699529266062-tool-and-user-login-migration-renamings.js deleted file mode 100644 index e1b0695b444..00000000000 --- a/migrations/1699529266062-tool-and-user-login-migration-renamings.js +++ /dev/null @@ -1,47 +0,0 @@ -const mongoose = require('mongoose'); -const { info, error } = require('../src/logger'); -const { connect, close } = require('../src/utils/database'); - -async function aggregateAndDropCollection(oldName, newName) { - try { - const { connection } = mongoose; - - // Aggregation pipeline for copying the documents - const pipeline = [{ $match: {} }, { $out: newName }]; - - // Copy documents from the old collection to the new collection - await connection.collection(oldName).aggregate(pipeline).toArray(); - info(`Aggregated and copied documents from ${oldName} to ${newName}`); - - // Delete old collection - await connection.collection(oldName).drop(); - info(`Dropped collection ${oldName}`); - } catch (err) { - error(`Error aggregating, copying, and deleting collection ${oldName} to ${newName}: ${err.message}`); - throw err; - } -} - -module.exports = { - up: async function up() { - await connect(); - - await aggregateAndDropCollection('user_login_migrations', 'user-login-migrations'); - await aggregateAndDropCollection('external_tools', 'external-tools'); - await aggregateAndDropCollection('context_external_tools', 'context-external-tools'); - await aggregateAndDropCollection('school_external_tools', 'school-external-tools'); - - await close(); - }, - - down: async function down() { - await connect(); - - await aggregateAndDropCollection('user-login-migrations', 'user_login_migrations'); - await aggregateAndDropCollection('external-tools', 'external_tools'); - await aggregateAndDropCollection('context-external-tools', 'context_external_tools'); - await aggregateAndDropCollection('school-external-tools', 'school_external_tools'); - - await close(); - }, -}; diff --git a/migrations/1701778850147-system-permissions-update.js b/migrations/1701778850147-system-permissions-update.js deleted file mode 100644 index 7a0e4f58cfe..00000000000 --- a/migrations/1701778850147-system-permissions-update.js +++ /dev/null @@ -1,97 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error, info } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'roles20231205', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info( - 'Migration does not add the SYSTEM_CREATE and SYSTEM_EDIT permission to role administrator for this instance.' - ); - return; - } - - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $addToSet: { - permissions: { - $each: ['SYSTEM_CREATE'], - }, - }, - } - ).exec(); - alert(`Permission SYSTEM_CREATE added to role administrator`); - - await Roles.updateOne( - { name: 'administrator' }, - { - $addToSet: { - permissions: { - $each: ['SYSTEM_EDIT'], - }, - }, - } - ).exec(); - alert(`Permission SYSTEM_EDIT added to role administrator`); - - await close(); - }, - - down: async function down() { - // eslint-disable-next-line no-process-env - if (process.env.SC_THEME !== 'n21') { - info( - 'Migration does not remove the SYSTEM_CREATE and SYSTEM_EDIT permission to role administrator for this instance.' - ); - return; - } - - await connect(); - - await Roles.updateOne( - { name: 'administrator' }, - { - $pull: { - permissions: { - $in: ['SYSTEM_CREATE'], - }, - }, - } - ).exec(); - alert(`Permission SYSTEM_CREATE removed from role administrator`); - - await Roles.updateOne( - { name: 'administrator' }, - { - $pull: { - permissions: { - $in: ['SYSTEM_EDIT'], - }, - }, - } - ).exec(); - alert(`Permission SYSTEM_EDIT removed from role administrator`); - - await close(); - }, -}; diff --git a/migrations/1702288347346-add-school-system-view-and-edit.js b/migrations/1702288347346-add-school-system-view-and-edit.js deleted file mode 100644 index 8a75c0eb2a2..00000000000 --- a/migrations/1702288347346-add-school-system-view-and-edit.js +++ /dev/null @@ -1,62 +0,0 @@ -const mongoose = require('mongoose'); -const { alert } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const Roles = mongoose.model( - 'roles202312111053', - new mongoose.Schema( - { - name: { type: String, required: true }, - permissions: [{ type: String }], - }, - { - timestamps: true, - } - ), - 'roles' -); - -module.exports = { - up: async function up() { - await connect(); - - const adminRole = await Roles.updateOne( - { name: 'administrator' }, - { - $addToSet: { - permissions: { - $each: ['SCHOOL_SYSTEM_EDIT', 'SCHOOL_SYSTEM_VIEW'], - }, - }, - } - ).exec(); - - if (adminRole) { - alert('Permission SCHOOL_SYSTEM_EDIT and SCHOOL_SYSTEM_VIEW were added to role administrator'); - } - - await close(); - }, - - down: async function down() { - await connect(); - - const adminRole = await Roles.updateOne( - { name: 'administrator' }, - { - $pull: { - permissions: { - $in: ['SCHOOL_SYSTEM_EDIT', 'SCHOOL_SYSTEM_VIEW'], - }, - }, - } - ).exec(); - - if (adminRole) { - alert('Rollback: Removed permission SCHOOL_SYSTEM_EDIT and SCHOOL_SYSTEM_VIEW from role administrator'); - } - - await close(); - }, -}; diff --git a/migrations/1703072644729-add-protected-field-to-custom-paramters.js b/migrations/1703072644729-add-protected-field-to-custom-paramters.js deleted file mode 100644 index 86d324f214c..00000000000 --- a/migrations/1703072644729-add-protected-field-to-custom-paramters.js +++ /dev/null @@ -1,53 +0,0 @@ -const mongoose = require('mongoose'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const ExternalTools = mongoose.model( - 'external-tools1703072644729', - new mongoose.Schema( - { - parameters: [ - { - isProtected: { type: Boolean, required: true }, - }, - ], - }, - { - timestamps: true, - } - ), - 'external-tools' -); - -// How to use more than one schema per collection on mongodb -// https://stackoverflow.com/questions/14453864/use-more-than-one-schema-per-collection-on-mongodb - -module.exports = { - up: async function up() { - await connect(); - await ExternalTools.updateMany( - { parameters: { $exists: true } }, - { - $set: { - 'parameters.$[].isProtected': false, - }, - } - ) - .lean() - .exec(); - - await close(); - }, - - down: async function down() { - await connect(); - - await ExternalTools.updateMany({ parameters: { $exists: true } }, { $unset: { 'parameters.$[].isProtected': '' } }) - .lean() - .exec(); - - await close(); - }, -}; diff --git a/migrations/1703253259864-school-default-props.js b/migrations/1703253259864-school-default-props.js deleted file mode 100644 index 7a9ef2be0a9..00000000000 --- a/migrations/1703253259864-school-default-props.js +++ /dev/null @@ -1,76 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const { Schema } = mongoose; - -const School = mongoose.model( - 'schools202312111053', - new mongoose.Schema( - { - systems: [{ type: Schema.Types.ObjectId, ref: 'system' }], - federalState: { type: Schema.Types.ObjectId, ref: 'federalstate' }, - }, - { - timestamps: true, - } - ), - 'schools' -); - -const FederalState = mongoose.model( - 'federalState202312111053', - new mongoose.Schema( - { - name: { type: String, required: true }, - }, - { - timestamps: true, - } - ), - 'federalstates' -); - -module.exports = { - up: async function up() { - await connect(); - - await School.updateMany( - { - systems: { $exists: false }, - }, - { - systems: [], - } - ) - .lean() - .exec(); - - const tenant = process.env.SC_THEME; - let federalStateName = 'Brandenburg'; - if (tenant !== 'n21') { - federalStateName = 'Niedersachsen'; - } else if (tenant === 'brb') { - federalStateName = 'Brandenburg'; - } else if (tenant === 'thr') { - federalStateName = 'ThÃŧringen'; - } - - const federalState = await FederalState.findOne({ name: federalStateName }).lean().exec(); - - await School.updateMany( - { - federalState: { $exists: false }, - }, - { - federalState: federalState._id, - } - ) - .lean() - .exec(); - - await close(); - }, -}; diff --git a/migrations/1704369994725-remove-undefined-parameters-from-external-tool.js b/migrations/1704369994725-remove-undefined-parameters-from-external-tool.js deleted file mode 100644 index c46a469d51d..00000000000 --- a/migrations/1704369994725-remove-undefined-parameters-from-external-tool.js +++ /dev/null @@ -1,82 +0,0 @@ -const mongoose = require('mongoose'); -const { info, alert } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -const ContextExternalTool = mongoose.model( - 'contextExternalTools202401041311', - new mongoose.Schema( - { - parameters: [ - { - name: { type: String, required: true }, - value: { type: String, required: false }, - }, - ], - }, - { - timestamps: true, - } - ), - 'context-external-tools' -); - -const SchoolExternalTool = mongoose.model( - 'schoolExternalTools202401041311', - new mongoose.Schema( - { - schoolParameters: [ - { - name: { type: String, required: true }, - value: { type: String, required: false }, - }, - ], - }, - { - timestamps: true, - } - ), - 'school-external-tools' -); - -module.exports = { - up: async function up() { - await connect(); - - const contextExternalToolResponse = await ContextExternalTool.updateMany( - { $or: [{ 'parameters.value': undefined }, { 'parameters.value': '' }] }, - { - $pull: { - parameters: { - $or: [{ value: undefined }, { value: '' }], - }, - }, - } - ) - .lean() - .exec(); - - info(`Removed ${contextExternalToolResponse.nModified} parameter(s) in context-external-tools`); - - const schoolExternalToolResponse = await SchoolExternalTool.updateMany( - { $or: [{ 'schoolParameters.value': undefined }, { 'schoolParameters.value': '' }] }, - { - $pull: { - schoolParameters: { - $or: [{ value: undefined }, { value: '' }], - }, - }, - } - ) - .lean() - .exec(); - - info(`Removed ${schoolExternalToolResponse.nModified} parameter(s) in school-external-tools`); - - await close(); - }, - - down: async function down() { - alert('This migration cannot be undone'); - }, -}; diff --git a/migrations/helpers/DatabaseTaskTemplate.js b/migrations/helpers/DatabaseTaskTemplate.js deleted file mode 100644 index 8d27e6f5deb..00000000000 --- a/migrations/helpers/DatabaseTaskTemplate.js +++ /dev/null @@ -1,70 +0,0 @@ -const ifNotFunction = (e) => !typeof e === 'function'; - -const testLogger = (log = {}) => { - if (ifNotFunction(log.pushModified) || ifNotFunction(log.pushFail)) { - throw new Error('Is not a valid logger. It need the methods pushModified and pushFail.'); - } - return log; -}; - -class DatabaseTaskTemplate { - constructor({ id, _id, $set, set, $unset, unset, isModified = true, log }) { - this.id = _id || id; - this.set = $set || set || {}; - this.unset = $unset || unset || {}; - this.isModified = isModified; - - this.log = log; - } - - setLog(log) { - this.log = log; - } - - getId() { - return { _id: this.id }; - } - - get() { - const req = {}; - if (Object.keys(this.set).length > 0) { - req.$set = this.set; - } - if (Object.keys(this.unset).length > 0) { - req.$unset = this.unset; - } - return req; - } - - pushSet(key, value) { - this.set[key] = value; - } - - pushUnset(key) { - this.unset[key] = 1; - } - - exec(model, operation, _log) { - const log = _log || this.log; - - if (this.isModified === false) { - return Promise.resolve(true); - } - - return model[operation](this.getId(), this.get()) - .then(() => { - if (log) { - return testLogger(log).pushModified(this.getId()); - } - return true; - }) - .catch((err) => { - if (log) { - return testLogger(log).pushFail(this.getId(), err); - } - return false; - }); - } -} - -module.exports = DatabaseTaskTemplate; diff --git a/migrations/helpers/OutputLogTemplate.js b/migrations/helpers/OutputLogTemplate.js deleted file mode 100644 index 97d8d1daa5a..00000000000 --- a/migrations/helpers/OutputLogTemplate.js +++ /dev/null @@ -1,73 +0,0 @@ -const logger = require('../../src/logger'); - -class OutputLogTempalte { - constructor({ total, name, _logger, _detailInformations = false, _lineBreak = '\n' }) { - this.modified = []; - this.fail = []; - this.LF = _lineBreak; - this.total = total || 0; - this.l = _logger || logger; - this.name = name || ''; - this.detailInformations = _detailInformations; - } - - push(type, value) { - if (Array.isArray(this[type])) { - this[type].push(value); - return true; - } - return false; - } - - convertToErrorOutput(_id, error) { - const id = _id.toString(); - - if (typeof error === 'object') { - return { id, ...error }; - } - - if (typeof error === 'string') { - return { - id, - message: error, - }; - } - return error; - } - - pushFail(id, error) { - return this.push('fail', this.convertToErrorOutput(id, error)); - } - - pushModified(id) { - return this.push('modified', id); - } - - printResults(self = this, taskCount) { - const modified = self.modified.length; - const fail = self.fail.length; - const notModified = self.total - modified - fail; - - self.l.info(self.LF); - self.l.info(`total: ${self.total}`); - if (taskCount) { - self.l.info(`tasks: ${taskCount}`); - } - self.l.info(`modified: ${modified}`); - if (self.detailInformations) { - self.l.info(`...: ${self.modified}`); - } - self.l.info(`notModified: ${notModified}`); - if (self.fail.length > 0) { - self.l.warning(`fail: ${fail}`); - self.l.warning(JSON.stringify(self.fail)); - } else { - self.l.info('fail: 0'); - } - self.l.info(self.LF); - self.l.info(`...${self.name} is finished!`); - self.l.info('<---------------------------------->'); - } -} - -module.exports = OutputLogTempalte; diff --git a/migrations/helpers/counties051120.json b/migrations/helpers/counties051120.json deleted file mode 100644 index 8a54bfbc542..00000000000 --- a/migrations/helpers/counties051120.json +++ /dev/null @@ -1,2809 +0,0 @@ -[ -{ - "countyId": 8425, - "name": "Alb-Donau-Kreis", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8211, - "name": "Baden-Baden", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8426, - "name": "Biberach", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8115, - "name": "BÃļblingen", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8435, - "name": "Bodenseekreis", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8315, - "name": "Breisgau-Hochschwarzwald", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8235, - "name": "Calw", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8316, - "name": "Emmendingen", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8236, - "name": "Enzkreis", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8116, - "name": "Esslingen", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8311, - "name": "Freiburg im Breisgau", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8237, - "name": "Freudenstadt", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8117, - "name": "GÃļppingen", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8221, - "name": "Heidelberg", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8135, - "name": "Heidenheim", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8121, - "name": "Heilbronn", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8126, - "name": "Hohenlohekreis", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8212, - "name": "Karlsruhe", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8335, - "name": "Konstanz", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8125, - "name": "Landkreis Heilbronn", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8215, - "name": "Landkreis Karlsruhe", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8336, - "name": "LÃļrrach", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8118, - "name": "Ludwigsburg", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8128, - "name": "Main-Tauber-Kreis", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8222, - "name": "Mannheim", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8225, - "name": "Neckar-Odenwald-Kreis", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8317, - "name": "Ortenaukreis", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8136, - "name": "Ostalbkreis", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8231, - "name": "Pforzheim", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8216, - "name": "Rastatt", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8436, - "name": "Ravensburg", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8119, - "name": "Rems-Murr-Kreis", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8415, - "name": "Reutlingen", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8226, - "name": "Rhein-Neckar-Kreis", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8325, - "name": "Rottweil", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8127, - "name": "Schwäbisch Hall", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8326, - "name": "Schwarzwald-Baar-Kreis", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8437, - "name": "Sigmaringen", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8111, - "name": "Stuttgart", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8416, - "name": "TÃŧbingen", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8327, - "name": "Tuttlingen", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8421, - "name": "Ulm", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8337, - "name": "Waldshut", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 8417, - "name": "Zollernalbkreis", - "antaresKey": "", - "Federal State": "Baden-WÃŧrttemberg", - "federalId": "0000b186816abba584714c50" -}, -{ - "countyId": 9771, - "name": "Aichach-Friedberg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9171, - "name": "AltÃļtting", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9361, - "name": "Amberg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9371, - "name": "Amberg-Sulzbach", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9561, - "name": "Ansbach", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9661, - "name": "Aschaffenburg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9761, - "name": "Augsburg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9672, - "name": "Bad Kissingen", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9173, - "name": "Bad TÃļlz-Wolfratshausen", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9461, - "name": "Bamberg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9462, - "name": "Bayreuth", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9172, - "name": "Berchtesgadener Land", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9372, - "name": "Cham", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9463, - "name": "Coburg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9174, - "name": "Dachau", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9271, - "name": "Deggendorf", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9773, - "name": "Dillingen an der Donau", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9279, - "name": "Dingolfing-Landau", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9779, - "name": "Donau-Ries", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9175, - "name": "Ebersberg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9176, - "name": "Eichstätt", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9177, - "name": "Erding", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9562, - "name": "Erlangen", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9572, - "name": "Erlangen-HÃļchstadt", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9474, - "name": "Forchheim", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9178, - "name": "Freising", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9272, - "name": "Freyung-Grafenau", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9179, - "name": "FÃŧrstenfeldbruck", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9563, - "name": "FÃŧrth", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9180, - "name": "Garmisch-Partenkirchen", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9774, - "name": "GÃŧnzburg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9674, - "name": "Haßberge", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9464, - "name": "Hof", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9161, - "name": "Ingolstadt", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9762, - "name": "Kaufbeuren", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9273, - "name": "Kelheim", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9763, - "name": "Kempten", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9675, - "name": "Kitzingen", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9476, - "name": "Kronach", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9477, - "name": "Kulmbach", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9571, - "name": "Landkreis Ansbach", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9671, - "name": "Landkreis Aschaffenburg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9772, - "name": "Landkreis Augsburg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9471, - "name": "Landkreis Bamberg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9472, - "name": "Landkreis Bayreuth", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9473, - "name": "Landkreis Coburg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9573, - "name": "Landkreis FÃŧrth", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9475, - "name": "Landkreis Hof", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9274, - "name": "Landkreis Landshut", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9184, - "name": "Landkreis MÃŧnchen", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9275, - "name": "Landkreis Passau", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9375, - "name": "Landkreis Regensburg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9187, - "name": "Landkreis Rosenheim", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9678, - "name": "Landkreis Schweinfurt", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9679, - "name": "Landkreis WÃŧrzburg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9181, - "name": "Landsberg am Lech", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9261, - "name": "Landshut", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9478, - "name": "Lichtenfels", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9776, - "name": "Lindau (Bodensee)", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9677, - "name": "Main-Spessart", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9764, - "name": "Memmingen", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9182, - "name": "Miesbach", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9676, - "name": "Miltenberg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9183, - "name": "MÃŧhldorf am Inn", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9162, - "name": "MÃŧnchen", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9775, - "name": "Neu-Ulm", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9185, - "name": "Neuburg-Schrobenhausen", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9373, - "name": "Neumarkt in der Oberpfalz", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9575, - "name": "Neustadt an der Aisch-Bad Windsheim", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9374, - "name": "Neustadt an der Waldnaab", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9564, - "name": "NÃŧrnberg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9574, - "name": "NÃŧrnberger Land", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9780, - "name": "Oberallgäu", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9777, - "name": "Ostallgäu", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9262, - "name": "Passau", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9186, - "name": "Pfaffenhofen an der Ilm", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9276, - "name": "Regen", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9362, - "name": "Regensburg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9673, - "name": "RhÃļn-Grabfeld", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9163, - "name": "Rosenheim", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9576, - "name": "Roth", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9277, - "name": "Rottal-Inn", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9565, - "name": "Schwabach", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9376, - "name": "Schwandorf", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9662, - "name": "Schweinfurt", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9188, - "name": "Starnberg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9263, - "name": "Straubing", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9278, - "name": "Straubing-Bogen", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9377, - "name": "Tirschenreuth", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9189, - "name": "Traunstein", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9778, - "name": "Unterallgäu", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9363, - "name": "Weiden in der Oberpfalz", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9190, - "name": "Weilheim-Schongau", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9577, - "name": "Weißenburg-Gunzenhausen", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9479, - "name": "Wunsiedel im Fichtelgebirge", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 9663, - "name": "WÃŧrzburg", - "antaresKey": "", - "Federal State": "Bayern", - "federalId": "0000b186816abba584714c51" -}, -{ - "countyId": 11001, - "name": "Berlin", - "antaresKey": "", - "Federal State": "Berlin", - "federalId": "0000b186816abba584714c52" -}, -{ - "countyId": 12060, - "name": "Barnim", - "antaresKey": "BAR", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12051, - "name": "Brandenburg an der Havel", - "antaresKey": "", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12052, - "name": "Cottbus", - "antaresKey": "", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12061, - "name": "Dahme-Spreewald", - "antaresKey": "", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12062, - "name": "Elbe-Elster", - "antaresKey": "EE", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12053, - "name": "Frankfurt", - "antaresKey": "FF", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12063, - "name": "Havelland", - "antaresKey": "", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12064, - "name": "Märkisch-Oderland", - "antaresKey": "MOL", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12065, - "name": "Oberhavel", - "antaresKey": "OHV", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12066, - "name": "Oberspreewald-Lausitz", - "antaresKey": "", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12067, - "name": "Oder-Spree", - "antaresKey": "", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12068, - "name": "Ostprignitz-Ruppin", - "antaresKey": "OPR", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12054, - "name": "Potsdam", - "antaresKey": "P", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12069, - "name": "Potsdam-Mittelmark", - "antaresKey": "PM", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12070, - "name": "Prignitz", - "antaresKey": "PR", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12071, - "name": "Spree-Neiße", - "antaresKey": "SPN", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12072, - "name": "Teltow-Fläming", - "antaresKey": "TF", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 12073, - "name": "Uckermark", - "antaresKey": "", - "Federal State": "Brandenburg", - "federalId": "0000b186816abba584714c53" -}, -{ - "countyId": 4011, - "name": "Bremen", - "antaresKey": "", - "Federal State": "Bremen", - "federalId": "0000b186816abba584714c54" -}, -{ - "countyId": 4012, - "name": "Bremerhaven", - "antaresKey": "", - "Federal State": "Bremen", - "federalId": "0000b186816abba584714c54" -}, -{ - "countyId": 2000, - "name": "Hamburg", - "antaresKey": "", - "Federal State": "Hamburg", - "federalId": "0000b186816abba584714c55" -}, -{ - "countyId": 6431, - "name": "Bergstraße", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6411, - "name": "Darmstadt", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6432, - "name": "Darmstadt-Dieburg", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6412, - "name": "Frankfurt am Main", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6631, - "name": "Fulda", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6531, - "name": "Gießen", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6433, - "name": "Groß-Gerau", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6632, - "name": "Hersfeld-Rotenburg", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6434, - "name": "Hochtaunuskreis", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6611, - "name": "Kassel", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6532, - "name": "Lahn-Dill-Kreis", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6633, - "name": "Landkreis Kassel", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6533, - "name": "Limburg-Weilburg", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6435, - "name": "Main-Kinzig-Kreis", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6436, - "name": "Main-Taunus-Kreis", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6534, - "name": "Marburg-Biedenkopf", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6437, - "name": "Odenwaldkreis", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6438, - "name": "Offenbach", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6413, - "name": "Offenbach am Main", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6439, - "name": "Rheingau-Taunus-Kreis", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6634, - "name": "Schwalm-Eder-Kreis", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6535, - "name": "Vogelsbergkreis", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6635, - "name": "Waldeck-Frankenberg", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6636, - "name": "Werra-Meißner-Kreis", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6440, - "name": "Wetteraukreis", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 6414, - "name": "Wiesbaden", - "antaresKey": "", - "Federal State": "Hessen", - "federalId": "0000b186816abba584714c56" -}, -{ - "countyId": 13072, - "name": "Landkreis Rostock", - "antaresKey": "", - "Federal State": "Mecklenburg-Vorpommern", - "federalId": "0000b186816abba584714c57" -}, -{ - "countyId": 13076, - "name": "Ludwigslust-Parchim", - "antaresKey": "", - "Federal State": "Mecklenburg-Vorpommern", - "federalId": "0000b186816abba584714c57" -}, -{ - "countyId": 13071, - "name": "Mecklenburgische Seenplatte", - "antaresKey": "", - "Federal State": "Mecklenburg-Vorpommern", - "federalId": "0000b186816abba584714c57" -}, -{ - "countyId": 13074, - "name": "Nordwestmecklenburg", - "antaresKey": "", - "Federal State": "Mecklenburg-Vorpommern", - "federalId": "0000b186816abba584714c57" -}, -{ - "countyId": 13003, - "name": "Rostock", - "antaresKey": "", - "Federal State": "Mecklenburg-Vorpommern", - "federalId": "0000b186816abba584714c57" -}, -{ - "countyId": 13004, - "name": "Schwerin", - "antaresKey": "", - "Federal State": "Mecklenburg-Vorpommern", - "federalId": "0000b186816abba584714c57" -}, -{ - "countyId": 13075, - "name": "Vorpommern-Greifswald", - "antaresKey": "", - "Federal State": "Mecklenburg-Vorpommern", - "federalId": "0000b186816abba584714c57" -}, -{ - "countyId": 13073, - "name": "Vorpommern-RÃŧgen", - "antaresKey": "", - "Federal State": "Mecklenburg-Vorpommern", - "federalId": "0000b186816abba584714c57" -}, -{ - "countyId": 3451, - "name": "Ammerland", - "antaresKey": "WST", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3452, - "name": "Aurich", - "antaresKey": "AUR", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3101, - "name": "Braunschweig", - "antaresKey": "BS", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3351, - "name": "Celle", - "antaresKey": "CE", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3453, - "name": "Cloppenburg", - "antaresKey": "CLP", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3352, - "name": "Cuxhaven", - "antaresKey": "CUX", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3401, - "name": "Delmenhorst", - "antaresKey": "", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3251, - "name": "Diepholz", - "antaresKey": "DH", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3402, - "name": "Emden", - "antaresKey": "EMD", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3454, - "name": "Emsland", - "antaresKey": "LI", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3455, - "name": "Friesland", - "antaresKey": "FRI", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3151, - "name": "Gifhorn", - "antaresKey": "GF", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3153, - "name": "Goslar", - "antaresKey": "GS", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3159, - "name": "GÃļttingen", - "antaresKey": "GOE", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3456, - "name": "Grafschaft Bentheim", - "antaresKey": "NOH", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3252, - "name": "Hameln-Pyrmont", - "antaresKey": "HM", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3241, - "name": "Hannover", - "antaresKey": "H", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3353, - "name": "Harburg", - "antaresKey": "WL", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3358, - "name": "Heidekreis", - "antaresKey": "SFA", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3154, - "name": "Helmstedt", - "antaresKey": "", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3254, - "name": "Hildesheim", - "antaresKey": "HI", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3255, - "name": "Holzminden", - "antaresKey": "HOL", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3458, - "name": "Landkreis Oldenburg", - "antaresKey": "LKOL", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3459, - "name": "Landkreis OsnabrÃŧck", - "antaresKey": "", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3457, - "name": "Leer", - "antaresKey": "LER", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3354, - "name": "LÃŧchow-Dannenberg", - "antaresKey": "", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3355, - "name": "LÃŧneburg", - "antaresKey": "LG", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3256, - "name": "Nienburg/Weser", - "antaresKey": "NI", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3155, - "name": "Northeim", - "antaresKey": "NOR", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3403, - "name": "Oldenburg", - "antaresKey": "", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3404, - "name": "OsnabrÃŧck", - "antaresKey": "", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3356, - "name": "Osterholz", - "antaresKey": "OHZ", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3157, - "name": "Peine", - "antaresKey": "", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3357, - "name": "Rotenburg (WÃŧmme)", - "antaresKey": "ROW", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3102, - "name": "Salzgitter", - "antaresKey": "SZ", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3257, - "name": "Schaumburg", - "antaresKey": "SHG", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3359, - "name": "Stade", - "antaresKey": "STD", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3360, - "name": "Uelzen", - "antaresKey": "UE", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3460, - "name": "Vechta", - "antaresKey": "VEC", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3361, - "name": "Verden", - "antaresKey": "VER", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3461, - "name": "Wesermarsch", - "antaresKey": "BRA", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3405, - "name": "Wilhelmshaven", - "antaresKey": "", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3462, - "name": "Wittmund", - "antaresKey": "WTM", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3158, - "name": "WolfenbÃŧttel", - "antaresKey": "WF", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 3103, - "name": "Wolfsburg", - "antaresKey": "WOB", - "Federal State": "Niedersachsen", - "federalId": "0000b186816abba584714c58" -}, -{ - "countyId": 5334, - "name": "Aachen", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5711, - "name": "Bielefeld", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5911, - "name": "Bochum", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5314, - "name": "Bonn", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5554, - "name": "Borken", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5512, - "name": "Bottrop", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5558, - "name": "Coesfeld", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5913, - "name": "Dortmund", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5112, - "name": "Duisburg", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5358, - "name": "DÃŧren", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5111, - "name": "DÃŧsseldorf", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5954, - "name": "Ennepe-Ruhr-Kreis", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5113, - "name": "Essen", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5366, - "name": "Euskirchen", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5513, - "name": "Gelsenkirchen", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5754, - "name": "GÃŧtersloh", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5914, - "name": "Hagen", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5915, - "name": "Hamm", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5370, - "name": "Heinsberg", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5758, - "name": "Herford", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5916, - "name": "Herne", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5958, - "name": "Hochsauerlandkreis", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5762, - "name": "HÃļxter", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5154, - "name": "Kleve", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5315, - "name": "KÃļln", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5114, - "name": "Krefeld", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5316, - "name": "Leverkusen", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5766, - "name": "Lippe", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5962, - "name": "Märkischer Kreis", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5158, - "name": "Mettmann", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5770, - "name": "Minden-LÃŧbbecke", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5116, - "name": "MÃļnchengladbach", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5117, - "name": "MÃŧlheim an der Ruhr", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5515, - "name": "MÃŧnster", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5374, - "name": "Oberbergischer Kreis", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5119, - "name": "Oberhausen", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5966, - "name": "Olpe", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5774, - "name": "Paderborn", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5562, - "name": "Recklinghausen", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5120, - "name": "Remscheid", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5362, - "name": "Rhein-Erft-Kreis", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5162, - "name": "Rhein-Kreis Neuss", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5382, - "name": "Rhein-Sieg-Kreis", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5378, - "name": "Rheinisch-Bergischer Kreis", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5970, - "name": "Siegen-Wittgenstein", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5974, - "name": "Soest", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5122, - "name": "Solingen", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5566, - "name": "Steinfurt", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5978, - "name": "Unna", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5166, - "name": "Viersen", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5570, - "name": "Warendorf", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5170, - "name": "Wesel", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 5124, - "name": "Wuppertal", - "antaresKey": "", - "Federal State": "Nordrhein-Westfalen", - "federalId": "0000b186816abba584714c59" -}, -{ - "countyId": 7131, - "name": "Ahrweiler", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7132, - "name": "Altenkirchen (Westerwald)", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7331, - "name": "Alzey-Worms", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7332, - "name": "Bad DÃŧrkheim", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7133, - "name": "Bad Kreuznach", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7231, - "name": "Bernkastel-Wittlich", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7134, - "name": "Birkenfeld", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7135, - "name": "Cochem-Zell", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7333, - "name": "Donnersbergkreis", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7232, - "name": "Eifelkreis Bitburg-PrÃŧm", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7311, - "name": "Frankenthal", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7334, - "name": "Germersheim", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7312, - "name": "Kaiserslautern", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7111, - "name": "Koblenz", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7336, - "name": "Kusel", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7313, - "name": "Landau in der Pfalz", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7335, - "name": "Landkreis Kaiserslautern", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7314, - "name": "Ludwigshafen am Rhein", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7315, - "name": "Mainz", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7339, - "name": "Mainz-Bingen", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7137, - "name": "Mayen-Koblenz", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7316, - "name": "Neustadt an der Weinstraße", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7138, - "name": "Neuwied", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7317, - "name": "Pirmasens", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7140, - "name": "Rhein-HunsrÃŧck-Kreis", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7141, - "name": "Rhein-Lahn-Kreis", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7338, - "name": "Rhein-Pfalz-Kreis", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7318, - "name": "Speyer", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7337, - "name": "SÃŧdliche Weinstraße", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7340, - "name": "SÃŧdwestpfalz", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7211, - "name": "Trier", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7235, - "name": "Trier-Saarburg", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7233, - "name": "Vulkaneifel", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7143, - "name": "Westerwaldkreis", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7319, - "name": "Worms", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 7320, - "name": "ZweibrÃŧcken", - "antaresKey": "", - "Federal State": "Rheinland-Pfalz", - "federalId": "0000b186816abba584714c60" -}, -{ - "countyId": 10042, - "name": "Merzig-Wadern", - "antaresKey": "", - "Federal State": "Saarland", - "federalId": "0000b186816abba584714c61" -}, -{ - "countyId": 10043, - "name": "Neunkirchen", - "antaresKey": "", - "Federal State": "Saarland", - "federalId": "0000b186816abba584714c61" -}, -{ - "countyId": 10041, - "name": "SaarbrÃŧcken, Regionalverband", - "antaresKey": "", - "Federal State": "Saarland", - "federalId": "0000b186816abba584714c61" -}, -{ - "countyId": 10044, - "name": "Saarlouis", - "antaresKey": "", - "Federal State": "Saarland", - "federalId": "0000b186816abba584714c61" -}, -{ - "countyId": 10045, - "name": "Saarpfalz-Kreis", - "antaresKey": "", - "Federal State": "Saarland", - "federalId": "0000b186816abba584714c61" -}, -{ - "countyId": 10046, - "name": "St. Wendel", - "antaresKey": "", - "Federal State": "Saarland", - "federalId": "0000b186816abba584714c61" -}, -{ - "countyId": 14625, - "name": "Bautzen", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 14511, - "name": "Chemnitz", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 14612, - "name": "Dresden", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 14521, - "name": "Erzgebirgskreis", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 14626, - "name": "GÃļrlitz", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 14729, - "name": "Landkreis Leipzig", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 14713, - "name": "Leipzig", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 14627, - "name": "Meißen", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 14522, - "name": "Mittelsachsen", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 14730, - "name": "Nordsachsen", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 14628, - "name": "Sächsische Schweiz-Osterzgebirge", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 14523, - "name": "Vogtlandkreis", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 14524, - "name": "Zwickau", - "antaresKey": "", - "Federal State": "Sachsen", - "federalId": "0000b186816abba584714c62" -}, -{ - "countyId": 15081, - "name": "Altmarkkreis Salzwedel", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15082, - "name": "Anhalt-Bitterfeld", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15083, - "name": "BÃļrde", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15084, - "name": "Burgenlandkreis", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15001, - "name": "Dessau-Roßlau", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15002, - "name": "Halle", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15085, - "name": "Harz", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15086, - "name": "Jerichower Land", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15003, - "name": "Magdeburg", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15087, - "name": "Mansfeld-SÃŧdharz", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15088, - "name": "Saalekreis", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15089, - "name": "Salzlandkreis", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15090, - "name": "Stendal", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 15091, - "name": "Wittenberg", - "antaresKey": "", - "Federal State": "Sachsen-Anhalt", - "federalId": "0000b186816abba584714c63" -}, -{ - "countyId": 1051, - "name": "Dithmarschen", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1001, - "name": "Flensburg", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1053, - "name": "Herzogtum Lauenburg", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1002, - "name": "Kiel", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1003, - "name": "LÃŧbeck", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1004, - "name": "NeumÃŧnster", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1054, - "name": "Nordfriesland", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1055, - "name": "Ostholstein", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1056, - "name": "Pinneberg", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1057, - "name": "PlÃļn", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1058, - "name": "Rendsburg-EckernfÃļrde", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1059, - "name": "Schleswig-Flensburg", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1060, - "name": "Segeberg", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1061, - "name": "Steinburg", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 1062, - "name": "Stormarn", - "antaresKey": "", - "Federal State": "Schleswig-Holstein", - "federalId": "0000b186816abba584714c64" -}, -{ - "countyId": 16077, - "name": "Altenburger Land", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16061, - "name": "Eichsfeld", - "antaresKey": "EIC", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16056, - "name": "Eisenach", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16051, - "name": "Erfurt", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16052, - "name": "Gera", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16067, - "name": "Gotha", - "antaresKey": "GTH", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16076, - "name": "Greiz", - "antaresKey": "GRZ", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16069, - "name": "Hildburghausen", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16070, - "name": "Ilm-Kreis", - "antaresKey": "IK", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16053, - "name": "Jena", - "antaresKey": "J", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16065, - "name": "Kyffhäuserkreis", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16062, - "name": "Nordhausen", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16074, - "name": "Saale-Holzland-Kreis", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16075, - "name": "Saale-Orla-Kreis", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16073, - "name": "Saalfeld-Rudolstadt", - "antaresKey": "SLF", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16066, - "name": "Schmalkalden-Meiningen", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16068, - "name": "SÃļmmerda", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16072, - "name": "Sonneberg", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16054, - "name": "Suhl", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16064, - "name": "Unstrut-Hainich-Kreis", - "antaresKey": "UH", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16063, - "name": "Wartburgkreis", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16055, - "name": "Weimar", - "antaresKey": "", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -}, -{ - "countyId": 16071, - "name": "Weimarer Land", - "antaresKey": "AP", - "Federal State": "ThÃŧringen", - "federalId": "0000b186816abba584714c65" -} -] \ No newline at end of file diff --git a/migrations/helpers/index.js b/migrations/helpers/index.js deleted file mode 100644 index 1f89060aeb3..00000000000 --- a/migrations/helpers/index.js +++ /dev/null @@ -1,7 +0,0 @@ -const OutputLogTemplate = require('./OutputLogTemplate'); -const DatabaseTaskTemplate = require('./DatabaseTaskTemplate'); - -module.exports = { - OutputLogTemplate, - DatabaseTaskTemplate, -}; diff --git a/migrations/index.js b/migrations/index.js deleted file mode 100644 index 4651ca8c018..00000000000 --- a/migrations/index.js +++ /dev/null @@ -1,20 +0,0 @@ -/* -debug config example - { - "request": "launch", - "internalConsoleOptions": "openOnSessionStart", - "name": "Debug Migrations", - "runtimeExecutable": "node", - "runtimeArgs": [ - "./migrations/index.js" - ], - "skipFiles": [ - "/**" - ], - "type": "pwa-node", - } -*/ - -const migration = require('./1613348381637-replaceFileLinks'); - -migration.up(); diff --git a/migrations/scheduled/1622622198664-news_add_target_schools.js b/migrations/scheduled/1622622198664-news_add_target_schools.js deleted file mode 100644 index 9c048f6d11a..00000000000 --- a/migrations/scheduled/1622622198664-news_add_target_schools.js +++ /dev/null @@ -1,36 +0,0 @@ -const mongoose = require('mongoose'); -const { newsSchema } = require('../../src/services/news/model'); - -const { connect, close } = require('../../src/utils/database'); - -const News = mongoose.model('news34838583553', newsSchema, 'news'); - -// How to use more than one schema per collection on mongodb -// https://stackoverflow.com/questions/14453864/use-more-than-one-schema-per-collection-on-mongodb - -module.exports = { - up: async function up() { - await connect(); - // //////////////////////////////////////////////////// - // Make changes to the database here. - // Hint: Access models via this('modelName'), not an imported model to have - // access to the correct database connection. Otherwise Mongoose calls never return. - const newsSchools = await News.distinct('schoolId', { targetModel: null }).lean().exec(); - await Promise.all( - newsSchools.map((school) => - News.updateMany({ schoolId: school, targetModel: null }, { targetModel: 'schools', target: school }) - .lean() - .exec() - ) - ); - - // //////////////////////////////////////////////////// - await close(); - }, - - down: async function down() { - await connect(); - await News.updateMany({ targetModel: 'schools' }, { targetModel: null, target: null }).lean().exec(); - await close(); - }, -}; diff --git a/migrations/template.js b/migrations/template.js deleted file mode 100644 index 55f08f7c761..00000000000 --- a/migrations/template.js +++ /dev/null @@ -1,71 +0,0 @@ -const mongoose = require('mongoose'); -// eslint-disable-next-line no-unused-vars -const { alert, error } = require('../src/logger'); - -const { connect, close } = require('../src/utils/database'); - -// use your own name for your model, otherwise other migrations may fail. -// The third parameter is the actually relevent one for what collection to write to. -const User = mongoose.model( - 'makeMeUnique', - new mongoose.Schema( - { - firstName: { type: String, required: true }, - lastName: { type: String, required: true }, - }, - { - timestamps: true, - } - ), - 'users' -); - -// How to use more than one schema per collection on mongodb -// https://stackoverflow.com/questions/14453864/use-more-than-one-schema-per-collection-on-mongodb - -// TODO npm run migration-persist and remove this line -// TODO update seed data and remove this line - -module.exports = { - up: async function up() { - await connect(); - // //////////////////////////////////////////////////// - // Make changes to the database here. - // - Only use models declared in the migration. - // - Make sure your migration is idempotent. It is not guaranteed to run only once! - // - Avoid any unnecessary references, including environment variables. If you have to run the migration on a single instance, use SC_THEME. - - await User.findOneAndUpdate( - { - firstName: 'Marla', - lastName: 'Mathe', - }, - { - firstName: 'Max', - } - ) - .lean() - .exec(); - // //////////////////////////////////////////////////// - await close(); - }, - - down: async function down() { - await connect(); - // //////////////////////////////////////////////////// - // Implement the necessary steps to roll back the migration here. - await User.findOneAndUpdate( - { - firstName: 'Max', - lastName: 'Mathe', - }, - { - firstName: 'Marla', - } - ) - .lean() - .exec(); - // //////////////////////////////////////////////////// - await close(); - }, -}; diff --git a/nest-cli.json b/nest-cli.json index 73598e6f9dc..43ba410c3d3 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -54,6 +54,24 @@ "tsConfigPath": "apps/server/tsconfig.app.json" } }, + "idp-console": { + "type": "application", + "root": "apps/server", + "entryFile": "apps/idp-console.app", + "sourceRoot": "apps/server/src", + "compilerOptions": { + "tsConfigPath": "apps/server/tsconfig.app.json" + } + }, + "tldraw-console": { + "type": "application", + "root": "apps/server", + "entryFile": "apps/tldraw-console.app", + "sourceRoot": "apps/server/src", + "compilerOptions": { + "tsConfigPath": "apps/server/tsconfig.app.json" + } + }, "files-storage": { "type": "application", "root": "apps/server", diff --git a/package-lock.json b/package-lock.json index 3e348b83388..e6ed9ca45ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "schulcloud-server", - "lockfileVersion": 2, + "lockfileVersion": 3, "requires": true, "packages": { "": { @@ -20,25 +20,29 @@ "@golevelup/nestjs-rabbitmq": "^4.0.0", "@hendt/xml2json": "^1.0.3", "@hpi-schul-cloud/commons": "^1.3.4", - "@keycloak/keycloak-admin-client": "^21.1.2", + "@keycloak/keycloak-admin-client": "^23.0.6", "@lumieducation/h5p-server": "^9.2.0", - "@mikro-orm/core": "^5.5.3", - "@mikro-orm/mongodb": "^5.5.3", + "@mikro-orm/cli": "^5.6.16", + "@mikro-orm/core": "^5.6.16", + "@mikro-orm/migrations-mongodb": "^5.6.16", + "@mikro-orm/mongodb": "^5.6.16", "@mikro-orm/nestjs": "^5.2.1", "@nestjs/axios": "^3.0.0", "@nestjs/cache-manager": "^2.1.0", "@nestjs/common": "^10.2.4", "@nestjs/config": "^3.0.1", "@nestjs/core": "^10.2.4", + "@nestjs/cqrs": "^10.2.7", "@nestjs/jwt": "^10.1.1", "@nestjs/microservices": "^10.2.4", "@nestjs/passport": "^10.0.1", "@nestjs/platform-express": "^10.2.4", - "@nestjs/platform-ws": "^10.2.4", + "@nestjs/platform-ws": "^10.3.0", "@nestjs/swagger": "^7.1.10", - "@nestjs/websockets": "^10.2.4", + "@nestjs/websockets": "^10.3.0", "@types/gm": "^1.25.1", "@types/ldapjs": "^2.2.5", + "@types/pdfmake": "^0.2.8", "@types/xml2js": "^0.4.11", "adm-zip": "^0.5.9", "ajv": "^8.8.2", @@ -56,7 +60,7 @@ "body-parser": "^1.15.2", "bson": "^4.6.0", "busboy": "^1.6.0", - "cache-manager": "^5.3.1", + "cache-manager": "^5.4.0", "cache-manager-redis-yet": "^4.1.2", "chalk": "^5.0.0", "clamscan": "^2.1.2", @@ -66,7 +70,6 @@ "commander": "^8.1.0", "compression": "^1.6.2", "concurrently": "^6.0.0", - "connect-redis": "^7.1.0", "cors": "^2.8.1", "cross-env": "^7.0.0", "crypto-js": "^4.2.0", @@ -83,12 +86,13 @@ "html-entities": "^2.3.2", "i18next": "^23.3.0", "i18next-fs-backend": "^2.1.5", + "ioredis": "^5.3.2", "jose": "^1.28.1", + "jsdom": "^23.2.0", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^2.0.5", "ldapjs": "git://github.com/hpi-schul-cloud/node-ldapjs.git", "lodash": "^4.17.19", - "migrate-mongoose": "^4.0.0", "mixwith": "^0.1.1", "moment": "^2.19.2", "mongodb-uri": "^0.9.7", @@ -110,10 +114,10 @@ "passport-headerapikey": "^1.2.2", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "pdfmake": "^0.2.9", "prom-client": "^13.1.0", "qs": "^6.9.7", "read-chunk": "^3.0.0", - "redis": "^4.6.11", "reflect-metadata": "^0.1.13", "request-promise-core": "^1.1.4", "request-promise-native": "^1.0.3", @@ -134,10 +138,9 @@ "urlsafe-base64": "^1.0.0", "uuid": "^8.3.0", "winston": "^3.8.2", - "ws": "^7.5.7", - "y-mongodb-provider": "^0.1.7", - "y-protocols": "^1.0.5", - "yjs": "^13.6.7" + "ws": "^8.16.0", + "y-protocols": "^1.0.6", + "yjs": "^13.6.11" }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", @@ -159,7 +162,6 @@ "@types/express-session": "^1.17.5", "@types/jest": "^29.2.1", "@types/lodash": "^4.14.196", - "@types/mongodb": "^4.0.7", "@types/node": "^16.18.11", "@types/passport-jwt": "^3.0.5", "@types/passport-local": "^1.0.33", @@ -169,6 +171,7 @@ "@types/source-map-support": "^0.5.3", "@types/supertest": "^2.0.12", "@types/uuid": "^8.3.4", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^5.47.1", "@typescript-eslint/parser": "^5.47.1", "@typescript-eslint/typescript-estree": "^5.47.1", @@ -221,9 +224,8 @@ }, "node_modules/@ampproject/remapping": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.0.tgz", - "integrity": "sha512-d5RysTlJ7hmw5Tw4UxgxcY3lkMe92n8sXCcuLPAyIAHK6j8DefDwtGnVVDgOnv+RnEosulDJ9NPKQL27bDId0g==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@jridgewell/trace-mapping": "^0.3.0" }, @@ -233,9 +235,8 @@ }, "node_modules/@angular-devkit/core": { "version": "16.2.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.2.0.tgz", - "integrity": "sha512-l1k6Rqm3YM16BEn3CWyQKrk9xfu+2ux7Bw3oS+h1TO4/RoxO2PgHj8LLRh/WNrYVarhaqO7QZ5ePBkXNMkzJ1g==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "8.12.0", "ajv-formats": "2.1.1", @@ -259,18 +260,16 @@ }, "node_modules/@angular-devkit/core/node_modules/source-map": { "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">= 8" } }, "node_modules/@angular-devkit/schematics": { "version": "16.2.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-16.2.0.tgz", - "integrity": "sha512-QMDJXPE0+YQJ9Ap3MMzb0v7rx6ZbBEokmHgpdIjN3eILYmbAdsSGE8HTV8NjS9nKmcyE9OGzFCMb7PFrDTlTAw==", "dev": true, + "license": "MIT", "dependencies": { "@angular-devkit/core": "16.2.0", "jsonc-parser": "3.2.0", @@ -286,9 +285,8 @@ }, "node_modules/@angular-devkit/schematics-cli": { "version": "16.2.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-16.2.0.tgz", - "integrity": "sha512-f3HjrDvSrRMvESogLsqsZXsEg//trIBySCHRXCglPrWLVdBbIRctGOhXqZoclRxXimIKUx14zLsOWzDwZG8+HQ==", "dev": true, + "license": "MIT", "dependencies": { "@angular-devkit/core": "16.2.0", "@angular-devkit/schematics": "16.2.0", @@ -308,18 +306,16 @@ }, "node_modules/@angular-devkit/schematics-cli/node_modules/ansi-colors": { "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, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/@angular-devkit/schematics-cli/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -332,9 +328,8 @@ }, "node_modules/@angular-devkit/schematics-cli/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -348,9 +343,8 @@ }, "node_modules/@angular-devkit/schematics-cli/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -360,15 +354,13 @@ }, "node_modules/@angular-devkit/schematics-cli/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@angular-devkit/schematics-cli/node_modules/inquirer": { "version": "8.2.4", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.4.tgz", - "integrity": "sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg==", "dev": true, + "license": "MIT", "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", @@ -392,9 +384,8 @@ }, "node_modules/@angular-devkit/schematics-cli/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -404,17 +395,15 @@ }, "node_modules/@angular-devkit/schematics-cli/node_modules/yargs-parser": { "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, + "license": "ISC", "engines": { "node": ">=12" } }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "9.0.9", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz", - "integrity": "sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==", + "license": "MIT", "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.6", @@ -424,13 +413,11 @@ }, "node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "license": "Python-2.0" }, "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -438,10 +425,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^2.3.1", + "is-potential-custom-element-name": "^1.0.1" + } + }, "node_modules/@aws-crypto/crc32": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", - "integrity": "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==", + "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", @@ -450,13 +445,11 @@ }, "node_modules/@aws-crypto/crc32/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "license": "0BSD" }, "node_modules/@aws-crypto/crc32c": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-3.0.0.tgz", - "integrity": "sha512-ENNPPManmnVJ4BTXlOjAgD7URidbAznURqD0KvfREyc4o20DPYdEldU1f5cQ7Jbj0CJJSPaMIk/9ZshdB3210w==", + "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", @@ -465,26 +458,22 @@ }, "node_modules/@aws-crypto/crc32c/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "license": "0BSD" }, "node_modules/@aws-crypto/ie11-detection": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz", - "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==", + "license": "Apache-2.0", "dependencies": { "tslib": "^1.11.1" } }, "node_modules/@aws-crypto/ie11-detection/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "license": "0BSD" }, "node_modules/@aws-crypto/sha1-browser": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-3.0.0.tgz", - "integrity": "sha512-NJth5c997GLHs6nOYTzFKTbYdMNA6/1XlKVgnZoaZcQ7z7UJlOgj2JdbHE8tiYLS3fzXNCguct77SPGat2raSw==", + "license": "Apache-2.0", "dependencies": { "@aws-crypto/ie11-detection": "^3.0.0", "@aws-crypto/supports-web-crypto": "^3.0.0", @@ -497,13 +486,11 @@ }, "node_modules/@aws-crypto/sha1-browser/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "license": "0BSD" }, "node_modules/@aws-crypto/sha256-browser": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", - "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==", + "license": "Apache-2.0", "dependencies": { "@aws-crypto/ie11-detection": "^3.0.0", "@aws-crypto/sha256-js": "^3.0.0", @@ -517,13 +504,11 @@ }, "node_modules/@aws-crypto/sha256-browser/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "license": "0BSD" }, "node_modules/@aws-crypto/sha256-js": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", - "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==", + "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", @@ -532,26 +517,22 @@ }, "node_modules/@aws-crypto/sha256-js/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "license": "0BSD" }, "node_modules/@aws-crypto/supports-web-crypto": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", - "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==", + "license": "Apache-2.0", "dependencies": { "tslib": "^1.11.1" } }, "node_modules/@aws-crypto/supports-web-crypto/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "license": "0BSD" }, "node_modules/@aws-crypto/util": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", - "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", @@ -560,13 +541,11 @@ }, "node_modules/@aws-crypto/util/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "license": "0BSD" }, "node_modules/@aws-sdk/abort-controller": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.347.0.tgz", - "integrity": "sha512-P/2qE6ntYEmYG4Ez535nJWZbXqgbkJx8CMz7ChEuEg3Gp3dvVYEKg+iEUEvlqQ2U5dWP5J3ehw5po9t86IsVPQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "tslib": "^2.5.0" @@ -577,16 +556,14 @@ }, "node_modules/@aws-sdk/chunked-blob-reader": { "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/chunked-blob-reader/-/chunked-blob-reader-3.310.0.tgz", - "integrity": "sha512-CrJS3exo4mWaLnWxfCH+w88Ou0IcAZSIkk4QbmxiHl/5Dq705OLoxf4385MVyExpqpeVJYOYQ2WaD8i/pQZ2fg==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" } }, "node_modules/@aws-sdk/client-cognito-identity": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.352.0.tgz", - "integrity": "sha512-qXqg7V/DpHu8oyEq22LMskCoHYZU6+ds9gaArwc3SjPwQN/UM6CpIUHtTtxevLEYr7nI5iMIPBBrEcoKOJefxg==", + "license": "Apache-2.0", "optional": true, "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", @@ -632,8 +609,7 @@ }, "node_modules/@aws-sdk/client-s3": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.352.0.tgz", - "integrity": "sha512-RUKXIIaNnSQE4FvLETuLglKAP2QOUn3dbzkLJYq37Pm0M/5rZhx5A7asov9jJDN+/vL/ae+O7pb2t4jpWqO75Q==", + "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha1-browser": "3.0.0", "@aws-crypto/sha256-browser": "3.0.0", @@ -697,8 +673,7 @@ }, "node_modules/@aws-sdk/client-sso": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.352.0.tgz", - "integrity": "sha512-oeO36rvRvYbUlsgzYtLI2/BPwXdUK4KtYw+OFmirYeONUyX5uYx8kWXD66r3oXViIYMqhyHKN3fhkiFmFcVluQ==", + "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", @@ -740,8 +715,7 @@ }, "node_modules/@aws-sdk/client-sts": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.352.0.tgz", - "integrity": "sha512-Lt7uSdwgOrwYx8S6Bhz76ewOeoJNFiPD+Q7v8S/mJK8T7HUE/houjomXC3UnFaJjcecjWv273zEqV67FgP5l5g==", + "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", @@ -787,8 +761,7 @@ }, "node_modules/@aws-sdk/config-resolver": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.347.0.tgz", - "integrity": "sha512-2ja+Sf/VnUO7IQ3nKbDQ5aumYKKJUaTm/BuVJ29wNho8wYHfuf7wHZV0pDTkB8RF5SH7IpHap7zpZAj39Iq+EA==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "@aws-sdk/util-config-provider": "3.310.0", @@ -801,8 +774,7 @@ }, "node_modules/@aws-sdk/credential-provider-cognito-identity": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.352.0.tgz", - "integrity": "sha512-395bdedGD0pangBT6dyyrTvtDRxr3lqbi8lfuJR/+7bpMIEJKVhF5D6IAgdjRDpASDRHUPhHuWzR3Qa9RHAcNA==", + "license": "Apache-2.0", "optional": true, "dependencies": { "@aws-sdk/client-cognito-identity": "3.352.0", @@ -816,8 +788,7 @@ }, "node_modules/@aws-sdk/credential-provider-env": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.347.0.tgz", - "integrity": "sha512-UnEM+LKGpXKzw/1WvYEQsC6Wj9PupYZdQOE+e2Dgy2dqk/pVFy4WueRtFXYDT2B41ppv3drdXUuKZRIDVqIgNQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/property-provider": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -829,8 +800,7 @@ }, "node_modules/@aws-sdk/credential-provider-imds": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.347.0.tgz", - "integrity": "sha512-7scCy/DCDRLIhlqTxff97LQWDnRwRXji3bxxMg+xWOTTaJe7PWx+etGSbBWaL42vsBHFShQjSLvJryEgoBktpw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/node-config-provider": "3.347.0", "@aws-sdk/property-provider": "3.347.0", @@ -844,8 +814,7 @@ }, "node_modules/@aws-sdk/credential-provider-ini": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.352.0.tgz", - "integrity": "sha512-lnQUJznvOhI2er1u/OVf99/2JIyDH7W+6tfWNXEoVgEi4WXtdyZ+GpPNoZsmCtHB2Jwlsh51IxmYdCj6b6SdwQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "3.347.0", "@aws-sdk/credential-provider-imds": "3.347.0", @@ -863,8 +832,7 @@ }, "node_modules/@aws-sdk/credential-provider-node": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.352.0.tgz", - "integrity": "sha512-8UZ5EQpoqHCh+XSGq2CdhzHZyKLOwF1taDw5A/gmV4O5lAWL0AGs0cPIEUORJyggU6Hv43zZOpLgK6dMgWOLgA==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "3.347.0", "@aws-sdk/credential-provider-imds": "3.347.0", @@ -883,8 +851,7 @@ }, "node_modules/@aws-sdk/credential-provider-process": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.347.0.tgz", - "integrity": "sha512-yl1z4MsaBdXd4GQ2halIvYds23S67kElyOwz7g8kaQ4kHj+UoYWxz3JVW/DGusM6XmQ9/F67utBrUVA0uhQYyw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/property-provider": "3.347.0", "@aws-sdk/shared-ini-file-loader": "3.347.0", @@ -897,8 +864,7 @@ }, "node_modules/@aws-sdk/credential-provider-sso": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.352.0.tgz", - "integrity": "sha512-YiooGNy9LYN1bFqKwO2wHC++1pYReiSqQDWBeluJfC3uZWpCyIUMdeYBR1X3XZDVtK6bl5KmhxldxJ3ntt/Q4w==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-sso": "3.352.0", "@aws-sdk/property-provider": "3.347.0", @@ -913,8 +879,7 @@ }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/client-sso-oidc": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.352.0.tgz", - "integrity": "sha512-PQdp0KOr478CaJNohASTgtt03W8Y/qINwsalLNguK01tWIGzellg2N3bA+IdyYXU8Oz3+Ab1oIJMKkUxtuNiGg==", + "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", @@ -956,8 +921,7 @@ }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.352.0.tgz", - "integrity": "sha512-cmmAgieLP/aAl9WdPiBoaC0Abd6KncSLig/ElLPoNsADR10l3QgxQcVF3YMtdX0U0d917+/SeE1PdrPD2x15cw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/client-sso-oidc": "3.352.0", "@aws-sdk/property-provider": "3.347.0", @@ -971,8 +935,7 @@ }, "node_modules/@aws-sdk/credential-provider-web-identity": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.347.0.tgz", - "integrity": "sha512-DxoTlVK8lXjS1zVphtz/Ab+jkN/IZor9d6pP2GjJHNoAIIzXfRwwj5C8vr4eTayx/5VJ7GRP91J8GJ2cKly8Qw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/property-provider": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -984,8 +947,7 @@ }, "node_modules/@aws-sdk/credential-providers": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.352.0.tgz", - "integrity": "sha512-hV6NO7+xzf3CPEsKZRsYflR05eNMvgVvOXFgQnOucUc85Kxt2XTSoH/HFtkolXDbxjA2Hku1pdaRG7qBzbiJHg==", + "license": "Apache-2.0", "optional": true, "dependencies": { "@aws-sdk/client-cognito-identity": "3.352.0", @@ -1009,8 +971,7 @@ }, "node_modules/@aws-sdk/eventstream-codec": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-codec/-/eventstream-codec-3.347.0.tgz", - "integrity": "sha512-61q+SyspjsaQ4sdgjizMyRgVph2CiW4aAtfpoH69EJFJfTxTR/OqnZ9Jx/3YiYi0ksrvDenJddYodfWWJqD8/w==", + "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "3.0.0", "@aws-sdk/types": "3.347.0", @@ -1020,8 +981,7 @@ }, "node_modules/@aws-sdk/eventstream-serde-browser": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-serde-browser/-/eventstream-serde-browser-3.347.0.tgz", - "integrity": "sha512-9BLVTHWgpiTo/hl+k7qt7E9iYu43zVwJN+4TEwA9ZZB3p12068t1Hay6HgCcgJC3+LWMtw/OhvypV6vQAG4UBg==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/eventstream-serde-universal": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1033,8 +993,7 @@ }, "node_modules/@aws-sdk/eventstream-serde-config-resolver": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.347.0.tgz", - "integrity": "sha512-RcXQbNVq0PFmDqfn6+MnjCUWbbobcYVxpimaF6pMDav04o6Mcle+G2Hrefp5NlFr/lZbHW2eUKYsp1sXPaxVlQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "tslib": "^2.5.0" @@ -1045,8 +1004,7 @@ }, "node_modules/@aws-sdk/eventstream-serde-node": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-serde-node/-/eventstream-serde-node-3.347.0.tgz", - "integrity": "sha512-pgQCWH0PkHjcHs04JE7FoGAD3Ww45ffV8Op0MSLUhg9OpGa6EDoO3EOpWi9l/TALtH4f0KRV35PVyUyHJ/wEkA==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/eventstream-serde-universal": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1058,8 +1016,7 @@ }, "node_modules/@aws-sdk/eventstream-serde-universal": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-serde-universal/-/eventstream-serde-universal-3.347.0.tgz", - "integrity": "sha512-4wWj6bz6lOyDIO/dCCjwaLwRz648xzQQnf89R29sLoEqvAPP5XOB7HL+uFaQ/f5tPNh49gL6huNFSVwDm62n4Q==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/eventstream-codec": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1071,8 +1028,7 @@ }, "node_modules/@aws-sdk/fetch-http-handler": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.347.0.tgz", - "integrity": "sha512-sQ5P7ivY8//7wdxfA76LT1sF6V2Tyyz1qF6xXf9sihPN5Q1Y65c+SKpMzXyFSPqWZ82+SQQuDliYZouVyS6kQQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/protocol-http": "3.347.0", "@aws-sdk/querystring-builder": "3.347.0", @@ -1083,8 +1039,7 @@ }, "node_modules/@aws-sdk/hash-blob-browser": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/hash-blob-browser/-/hash-blob-browser-3.347.0.tgz", - "integrity": "sha512-RxgstIldLsdJKN5UHUwSI9PMiatr0xKmKxS4+tnWZ1/OOg6wuWqqpDpWdNOVSJSpxpUaP6kRrvG5Yo5ZevoTXw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/chunked-blob-reader": "3.310.0", "@aws-sdk/types": "3.347.0", @@ -1093,8 +1048,7 @@ }, "node_modules/@aws-sdk/hash-node": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/hash-node/-/hash-node-3.347.0.tgz", - "integrity": "sha512-96+ml/4EaUaVpzBdOLGOxdoXOjkPgkoJp/0i1fxOJEvl8wdAQSwc3IugVK9wZkCxy2DlENtgOe6DfIOhfffm/g==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "@aws-sdk/util-buffer-from": "3.310.0", @@ -1107,8 +1061,7 @@ }, "node_modules/@aws-sdk/hash-stream-node": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/hash-stream-node/-/hash-stream-node-3.347.0.tgz", - "integrity": "sha512-tOBfcvELyt1GVuAlQ4d0mvm3QxoSSmvhH15SWIubM9RP4JWytBVzaFAn/aC02DBAWyvp0acMZ5J+47mxrWJElg==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "@aws-sdk/util-utf8": "3.310.0", @@ -1120,8 +1073,7 @@ }, "node_modules/@aws-sdk/invalid-dependency": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.347.0.tgz", - "integrity": "sha512-8imQcwLwqZ/wTJXZqzXT9pGLIksTRckhGLZaXT60tiBOPKuerTsus2L59UstLs5LP8TKaVZKFFSsjRIn9dQdmQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "tslib": "^2.5.0" @@ -1129,8 +1081,7 @@ }, "node_modules/@aws-sdk/is-array-buffer": { "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.310.0.tgz", - "integrity": "sha512-urnbcCR+h9NWUnmOtet/s4ghvzsidFmspfhYaHAmSRdy9yDjdjBJMFjjsn85A1ODUktztm+cVncXjQ38WCMjMQ==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" }, @@ -1140,8 +1091,7 @@ }, "node_modules/@aws-sdk/lib-storage": { "version": "3.100.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.100.0.tgz", - "integrity": "sha512-IP8Y310+24FOI3bZqdx9mTef1fKUa5YFfMa+Zmfj4+cxMB5/5wrAc2MacyQdPshmdOCIfJ8Izikop6SqeEQqcg==", + "license": "Apache-2.0", "dependencies": { "buffer": "5.6.0", "events": "3.3.0", @@ -1158,8 +1108,7 @@ }, "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==", + "license": "MIT", "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" @@ -1167,8 +1116,7 @@ }, "node_modules/@aws-sdk/md5-js": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/md5-js/-/md5-js-3.347.0.tgz", - "integrity": "sha512-mChE+7DByTY9H4cQ6fnWp2x5jf8e6OZN+AdLp6WQ+W99z35zBeqBxVmgm8ziJwkMIrkSTv9j3Y7T9Ve3RIcSfg==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "@aws-sdk/util-utf8": "3.310.0", @@ -1177,8 +1125,7 @@ }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.347.0.tgz", - "integrity": "sha512-i9n4ylkGmGvizVcTfN4L+oN10OCL2DKvyMa4cCAVE1TJrsnaE0g7IOOyJGUS8p5KJYQrKVR7kcsa2L1S0VeEcA==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/protocol-http": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1192,8 +1139,7 @@ }, "node_modules/@aws-sdk/middleware-content-length": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.347.0.tgz", - "integrity": "sha512-i4qtWTDImMaDUtwKQPbaZpXsReiwiBomM1cWymCU4bhz81HL01oIxOxOBuiM+3NlDoCSPr3KI6txZSz/8cqXCQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/protocol-http": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1205,8 +1151,7 @@ }, "node_modules/@aws-sdk/middleware-endpoint": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint/-/middleware-endpoint-3.347.0.tgz", - "integrity": "sha512-unF0c6dMaUL1ffU+37Ugty43DgMnzPWXr/Jup/8GbK5fzzWT5NQq6dj9KHPubMbWeEjQbmczvhv25JuJdK8gNQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/middleware-serde": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1220,8 +1165,7 @@ }, "node_modules/@aws-sdk/middleware-expect-continue": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.347.0.tgz", - "integrity": "sha512-95M1unD1ENL0tx35dfyenSfx0QuXBSKtOi/qJja6LfX5771C5fm5ZTOrsrzPFJvRg/wj8pCOVWRZk+d5+jvfOQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/protocol-http": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1233,8 +1177,7 @@ }, "node_modules/@aws-sdk/middleware-flexible-checksums": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.347.0.tgz", - "integrity": "sha512-Pda7VMAIyeHw9nMp29rxdFft3EF4KP/tz/vLB6bqVoBNbLujo5rxn3SGOgStgIz7fuMLQQfoWIsmvxUm+Fp+Dw==", + "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "3.0.0", "@aws-crypto/crc32c": "3.0.0", @@ -1250,8 +1193,7 @@ }, "node_modules/@aws-sdk/middleware-host-header": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.347.0.tgz", - "integrity": "sha512-kpKmR9OvMlnReqp5sKcJkozbj1wmlblbVSbnQAIkzeQj2xD5dnVR3Nn2ogQKxSmU1Fv7dEroBtrruJ1o3fY38A==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/protocol-http": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1263,8 +1205,7 @@ }, "node_modules/@aws-sdk/middleware-location-constraint": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.347.0.tgz", - "integrity": "sha512-x5fcEV7q8fQ0OmUO+cLhN5iPqGoLWtC3+aKHIfRRb2BpOO1khyc1FKzsIAdeQz2hfktq4j+WsrmcPvFKv51pSg==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "tslib": "^2.5.0" @@ -1275,8 +1216,7 @@ }, "node_modules/@aws-sdk/middleware-logger": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.347.0.tgz", - "integrity": "sha512-NYC+Id5UCkVn+3P1t/YtmHt75uED06vwaKyxDy0UmB2K66PZLVtwWbLpVWrhbroaw1bvUHYcRyQ9NIfnVcXQjA==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "tslib": "^2.5.0" @@ -1287,8 +1227,7 @@ }, "node_modules/@aws-sdk/middleware-recursion-detection": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.347.0.tgz", - "integrity": "sha512-qfnSvkFKCAMjMHR31NdsT0gv5Sq/ZHTUD4yQsSLpbVQ6iYAS834lrzXt41iyEHt57Y514uG7F/Xfvude3u4icQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/protocol-http": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1300,8 +1239,7 @@ }, "node_modules/@aws-sdk/middleware-retry": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.347.0.tgz", - "integrity": "sha512-CpdM+8dCSbX96agy4FCzOfzDmhNnGBM/pxrgIVLm5nkYTLuXp/d7ubpFEUHULr+4hCd5wakHotMt7yO29NFaVw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/protocol-http": "3.347.0", "@aws-sdk/service-error-classification": "3.347.0", @@ -1317,8 +1255,7 @@ }, "node_modules/@aws-sdk/middleware-sdk-s3": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.347.0.tgz", - "integrity": "sha512-TLr92+HMvamrhJJ0VDhA/PiUh4rTNQz38B9dB9ikohTaRgm+duP+mRiIv16tNPZPGl8v82Thn7Ogk2qPByNDtg==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/protocol-http": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1331,8 +1268,7 @@ }, "node_modules/@aws-sdk/middleware-sdk-sts": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.347.0.tgz", - "integrity": "sha512-38LJ0bkIoVF3W97x6Jyyou72YV9Cfbml4OaDEdnrCOo0EssNZM5d7RhjMvQDwww7/3OBY/BzeOcZKfJlkYUXGw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/middleware-signing": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1344,8 +1280,7 @@ }, "node_modules/@aws-sdk/middleware-serde": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.347.0.tgz", - "integrity": "sha512-x5Foi7jRbVJXDu9bHfyCbhYDH5pKK+31MmsSJ3k8rY8keXLBxm2XEEg/AIoV9/TUF9EeVvZ7F1/RmMpJnWQsEg==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "tslib": "^2.5.0" @@ -1356,8 +1291,7 @@ }, "node_modules/@aws-sdk/middleware-signing": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.347.0.tgz", - "integrity": "sha512-zVBF/4MGKnvhAE/J+oAL/VAehiyv+trs2dqSQXwHou9j8eA8Vm8HS2NdOwpkZQchIxTuwFlqSusDuPEdYFbvGw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/property-provider": "3.347.0", "@aws-sdk/protocol-http": "3.347.0", @@ -1372,8 +1306,7 @@ }, "node_modules/@aws-sdk/middleware-ssec": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.347.0.tgz", - "integrity": "sha512-467VEi2elPmUGcHAgTmzhguZ3lwTpwK+3s+pk312uZtVsS9rP1MAknYhpS3ZvssiqBUVPx8m29cLcC6Tx5nOJg==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "tslib": "^2.5.0" @@ -1384,8 +1317,7 @@ }, "node_modules/@aws-sdk/middleware-stack": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.347.0.tgz", - "integrity": "sha512-Izidg4rqtYMcKuvn2UzgEpPLSmyd8ub9+LQ2oIzG3mpIzCBITq7wp40jN1iNkMg+X6KEnX9vdMJIYZsPYMCYuQ==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" }, @@ -1395,8 +1327,7 @@ }, "node_modules/@aws-sdk/middleware-user-agent": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.352.0.tgz", - "integrity": "sha512-QGqblMTsVDqeomy22KPm9LUW8PHZXBA2Hjk9Hcw8U1uFS8IKYJrewInG3ae2+9FAcTyug4LFWDf8CRr9YH2B3Q==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/protocol-http": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1409,8 +1340,7 @@ }, "node_modules/@aws-sdk/node-config-provider": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/node-config-provider/-/node-config-provider-3.347.0.tgz", - "integrity": "sha512-faU93d3+5uTTUcotGgMXF+sJVFjrKh+ufW+CzYKT4yUHammyaIab/IbTPWy2hIolcEGtuPeVoxXw8TXbkh/tuw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/property-provider": "3.347.0", "@aws-sdk/shared-ini-file-loader": "3.347.0", @@ -1423,8 +1353,7 @@ }, "node_modules/@aws-sdk/node-http-handler": { "version": "3.350.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.350.0.tgz", - "integrity": "sha512-oD96GAlmpzYilCdC8wwyURM5lNfNHZCjm/kxBkQulHKa2kRbIrnD9GfDqdCkWA5cTpjh1NzGLT4D6e6UFDjt9w==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/abort-controller": "3.347.0", "@aws-sdk/protocol-http": "3.347.0", @@ -1438,8 +1367,7 @@ }, "node_modules/@aws-sdk/property-provider": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.347.0.tgz", - "integrity": "sha512-t3nJ8CYPLKAF2v9nIHOHOlF0CviQbTvbFc2L4a+A+EVd/rM4PzL3+3n8ZJsr0h7f6uD04+b5YRFgKgnaqLXlEg==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "tslib": "^2.5.0" @@ -1450,8 +1378,7 @@ }, "node_modules/@aws-sdk/protocol-http": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.347.0.tgz", - "integrity": "sha512-2YdBhc02Wvy03YjhGwUxF0UQgrPWEy8Iq75pfS42N+/0B/+eWX1aQgfjFxIpLg7YSjT5eKtYOQGlYd4MFTgj9g==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "tslib": "^2.5.0" @@ -1462,8 +1389,7 @@ }, "node_modules/@aws-sdk/querystring-builder": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.347.0.tgz", - "integrity": "sha512-phtKTe6FXoV02MoPkIVV6owXI8Mwr5IBN3bPoxhcPvJG2AjEmnetSIrhb8kwc4oNhlwfZwH6Jo5ARW/VEWbZtg==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "@aws-sdk/util-uri-escape": "3.310.0", @@ -1475,8 +1401,7 @@ }, "node_modules/@aws-sdk/querystring-parser": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-parser/-/querystring-parser-3.347.0.tgz", - "integrity": "sha512-5VXOhfZz78T2W7SuXf2avfjKglx1VZgZgp9Zfhrt/Rq+MTu2D+PZc5zmJHhYigD7x83jLSLogpuInQpFMA9LgA==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "tslib": "^2.5.0" @@ -1487,16 +1412,14 @@ }, "node_modules/@aws-sdk/service-error-classification": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.347.0.tgz", - "integrity": "sha512-xZ3MqSY81Oy2gh5g0fCtooAbahqh9VhsF8vcKjVX8+XPbGC8y+kej82+MsMg4gYL8gRFB9u4hgYbNgIS6JTAvg==", + "license": "Apache-2.0", "engines": { "node": ">=14.0.0" } }, "node_modules/@aws-sdk/shared-ini-file-loader": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.347.0.tgz", - "integrity": "sha512-Xw+zAZQVLb+xMNHChXQ29tzzLqm3AEHsD8JJnlkeFjeMnWQtXdUfOARl5s8NzAppcKQNlVe2gPzjaKjoy2jz1Q==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "tslib": "^2.5.0" @@ -1507,8 +1430,7 @@ }, "node_modules/@aws-sdk/signature-v4": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.347.0.tgz", - "integrity": "sha512-58Uq1do+VsTHYkP11dTK+DF53fguoNNJL9rHRWhzP+OcYv3/mBMLoS2WPz/x9FO5mBg4ESFsug0I6mXbd36tjw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/eventstream-codec": "3.347.0", "@aws-sdk/is-array-buffer": "3.310.0", @@ -1525,8 +1447,7 @@ }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.347.0.tgz", - "integrity": "sha512-838h7pbRCVYWlTl8W+r5+Z5ld7uoBObgAn7/RB1MQ4JjlkfLdN7emiITG6ueVL+7gWZNZc/4dXR/FJSzCgrkxQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/protocol-http": "3.347.0", "@aws-sdk/signature-v4": "3.347.0", @@ -1547,8 +1468,7 @@ }, "node_modules/@aws-sdk/smithy-client": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.347.0.tgz", - "integrity": "sha512-PaGTDsJLGK0sTjA6YdYQzILRlPRN3uVFyqeBUkfltXssvUzkm8z2t1lz2H4VyJLAhwnG5ZuZTNEV/2mcWrU7JQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/middleware-stack": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1560,8 +1480,7 @@ }, "node_modules/@aws-sdk/types": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.347.0.tgz", - "integrity": "sha512-GkCMy79mdjU9OTIe5KT58fI/6uqdf8UmMdWqVHmFJ+UpEzOci7L/uw4sOXWo7xpPzLs6cJ7s5ouGZW4GRPmHFA==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" }, @@ -1571,8 +1490,7 @@ }, "node_modules/@aws-sdk/url-parser": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/url-parser/-/url-parser-3.347.0.tgz", - "integrity": "sha512-lhrnVjxdV7hl+yCnJfDZOaVLSqKjxN20MIOiijRiqaWGLGEAiSqBreMhL89X1WKCifxAs4zZf9YB9SbdziRpAA==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/querystring-parser": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1581,8 +1499,7 @@ }, "node_modules/@aws-sdk/util-arn-parser": { "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.310.0.tgz", - "integrity": "sha512-jL8509owp/xB9+Or0pvn3Fe+b94qfklc2yPowZZIFAkFcCSIdkIglz18cPDWnYAcy9JGewpMS1COXKIUhZkJsA==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" }, @@ -1592,8 +1509,7 @@ }, "node_modules/@aws-sdk/util-base64": { "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-base64/-/util-base64-3.310.0.tgz", - "integrity": "sha512-v3+HBKQvqgdzcbL+pFswlx5HQsd9L6ZTlyPVL2LS9nNXnCcR3XgGz9jRskikRUuUvUXtkSG1J88GAOnJ/apTPg==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/util-buffer-from": "3.310.0", "tslib": "^2.5.0" @@ -1604,16 +1520,14 @@ }, "node_modules/@aws-sdk/util-body-length-browser": { "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-browser/-/util-body-length-browser-3.310.0.tgz", - "integrity": "sha512-sxsC3lPBGfpHtNTUoGXMQXLwjmR0zVpx0rSvzTPAuoVILVsp5AU/w5FphNPxD5OVIjNbZv9KsKTuvNTiZjDp9g==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" } }, "node_modules/@aws-sdk/util-body-length-node": { "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-node/-/util-body-length-node-3.310.0.tgz", - "integrity": "sha512-2tqGXdyKhyA6w4zz7UPoS8Ip+7sayOg9BwHNidiGm2ikbDxm1YrCfYXvCBdwaJxa4hJfRVz+aL9e+d3GqPI9pQ==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" }, @@ -1623,8 +1537,7 @@ }, "node_modules/@aws-sdk/util-buffer-from": { "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-buffer-from/-/util-buffer-from-3.310.0.tgz", - "integrity": "sha512-i6LVeXFtGih5Zs8enLrt+ExXY92QV25jtEnTKHsmlFqFAuL3VBeod6boeMXkN2p9lbSVVQ1sAOOYZOHYbYkntw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/is-array-buffer": "3.310.0", "tslib": "^2.5.0" @@ -1635,8 +1548,7 @@ }, "node_modules/@aws-sdk/util-config-provider": { "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-config-provider/-/util-config-provider-3.310.0.tgz", - "integrity": "sha512-xIBaYo8dwiojCw8vnUcIL4Z5tyfb1v3yjqyJKJWV/dqKUFOOS0U591plmXbM+M/QkXyML3ypon1f8+BoaDExrg==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" }, @@ -1646,8 +1558,7 @@ }, "node_modules/@aws-sdk/util-defaults-mode-browser": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-browser/-/util-defaults-mode-browser-3.347.0.tgz", - "integrity": "sha512-+JHFA4reWnW/nMWwrLKqL2Lm/biw/Dzi/Ix54DAkRZ08C462jMKVnUlzAI+TfxQE3YLm99EIa0G7jiEA+p81Qw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/property-provider": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1660,8 +1571,7 @@ }, "node_modules/@aws-sdk/util-defaults-mode-node": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-node/-/util-defaults-mode-node-3.347.0.tgz", - "integrity": "sha512-A8BzIVhAAZE5WEukoAN2kYebzTc99ZgncbwOmgCCbvdaYlk5tzguR/s+uoT4G0JgQGol/4hAMuJEl7elNgU6RQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/config-resolver": "3.347.0", "@aws-sdk/credential-provider-imds": "3.347.0", @@ -1676,8 +1586,7 @@ }, "node_modules/@aws-sdk/util-endpoints": { "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.352.0.tgz", - "integrity": "sha512-PjWMPdoIUWfBPgAWLyOrWFbdSS/3DJtc0OmFb/JrE8C8rKFYl+VGW5f1p0cVdRWiDR0xCGr0s67p8itAakVqjw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "tslib": "^2.5.0" @@ -1688,8 +1597,7 @@ }, "node_modules/@aws-sdk/util-hex-encoding": { "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.310.0.tgz", - "integrity": "sha512-sVN7mcCCDSJ67pI1ZMtk84SKGqyix6/0A1Ab163YKn+lFBQRMKexleZzpYzNGxYzmQS6VanP/cfU7NiLQOaSfA==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" }, @@ -1699,8 +1607,7 @@ }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.49.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.49.0.tgz", - "integrity": "sha512-ryw+t+quF1raaK0nXSplMiCVnahNLNgNDijZCFFkddGTMaCy+L4VRLYyNms3bgwt3G0BmVn9f3uyDWRSkn5sSg==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.0" }, @@ -1710,8 +1617,7 @@ }, "node_modules/@aws-sdk/util-middleware": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-middleware/-/util-middleware-3.347.0.tgz", - "integrity": "sha512-8owqUA3ePufeYTUvlzdJ7Z0miLorTwx+rNol5lourGQZ9JXsVMo23+yGA7nOlFuXSGkoKpMOtn6S0BT2bcfeiw==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" }, @@ -1721,8 +1627,7 @@ }, "node_modules/@aws-sdk/util-retry": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-retry/-/util-retry-3.347.0.tgz", - "integrity": "sha512-NxnQA0/FHFxriQAeEgBonA43Q9/VPFQa8cfJDuT2A1YZruMasgjcltoZszi1dvoIRWSZsFTW42eY2gdOd0nffQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/service-error-classification": "3.347.0", "tslib": "^2.5.0" @@ -1733,8 +1638,7 @@ }, "node_modules/@aws-sdk/util-stream-browser": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-stream-browser/-/util-stream-browser-3.347.0.tgz", - "integrity": "sha512-pIbmzIJfyX26qG622uIESOmJSMGuBkhmNU7I98bzhYCet5ctC0ow9L5FZw9ljOE46P/HkEcsOhh+qTHyCXlCEQ==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/fetch-http-handler": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1746,8 +1650,7 @@ }, "node_modules/@aws-sdk/util-stream-node": { "version": "3.350.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-stream-node/-/util-stream-node-3.350.0.tgz", - "integrity": "sha512-qhcmYEAVMJPjCepog3WTFBaeP3XCkLBbUrM5/+LaB/FASKk+JeV8qBQyjYUd8EVb6Gsk7+y9SE3Tj+ChyHB4WA==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/node-http-handler": "3.350.0", "@aws-sdk/types": "3.347.0", @@ -1760,8 +1663,7 @@ }, "node_modules/@aws-sdk/util-uri-escape": { "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.310.0.tgz", - "integrity": "sha512-drzt+aB2qo2LgtDoiy/3sVG8w63cgLkqFIa2NFlGpUgHFWTXkqtbgf4L5QdjRGKWhmZsnqkbtL7vkSWEcYDJ4Q==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" }, @@ -1771,8 +1673,7 @@ }, "node_modules/@aws-sdk/util-user-agent-browser": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.347.0.tgz", - "integrity": "sha512-ydxtsKVtQefgbk1Dku1q7pMkjDYThauG9/8mQkZUAVik55OUZw71Zzr3XO8J8RKvQG8lmhPXuAQ0FKAyycc0RA==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.347.0", "bowser": "^2.11.0", @@ -1781,8 +1682,7 @@ }, "node_modules/@aws-sdk/util-user-agent-node": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.347.0.tgz", - "integrity": "sha512-6X0b9qGsbD1s80PmbaB6v1/ZtLfSx6fjRX8caM7NN0y/ObuLoX8LhYnW6WlB2f1+xb4EjaCNgpP/zCf98MXosw==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/node-config-provider": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1802,8 +1702,7 @@ }, "node_modules/@aws-sdk/util-utf8": { "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8/-/util-utf8-3.310.0.tgz", - "integrity": "sha512-DnLfFT8uCO22uOJc0pt0DsSNau1GTisngBCDw8jQuWT5CqogMJu4b/uXmwEqfj8B3GX6Xsz8zOd6JpRlPftQoA==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/util-buffer-from": "3.310.0", "tslib": "^2.5.0" @@ -1814,16 +1713,14 @@ }, "node_modules/@aws-sdk/util-utf8-browser": { "version": "3.49.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.49.0.tgz", - "integrity": "sha512-u9ZgAiTWX9yZFQ/ptlnVpYJ/rXF7aE2Wagar1IjhZrnxXbpVJvcX1EeRayxI1P5AAp2y2fiEKHZzX9ugTwOcEg==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.0" } }, "node_modules/@aws-sdk/util-waiter": { "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-waiter/-/util-waiter-3.347.0.tgz", - "integrity": "sha512-3ze/0PkwkzUzLncukx93tZgGL0JX9NaP8DxTi6WzflnL/TEul5Z63PCruRNK0om17iZYAWKrf8q2mFoHYb4grA==", + "license": "Apache-2.0", "dependencies": { "@aws-sdk/abort-controller": "3.347.0", "@aws-sdk/types": "3.347.0", @@ -1835,8 +1732,7 @@ }, "node_modules/@aws-sdk/xml-builder": { "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.310.0.tgz", - "integrity": "sha512-TqELu4mOuSIKQCqj63fGVs86Yh+vBx5nHRpWKNUNhB2nPTpfbziTs5c1X358be3peVWA4wPxW7Nt53KIg1tnNw==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" }, @@ -1846,9 +1742,8 @@ }, "node_modules/@babel/code-frame": { "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz", - "integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" @@ -1859,9 +1754,8 @@ }, "node_modules/@babel/code-frame/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==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -1871,9 +1765,8 @@ }, "node_modules/@babel/code-frame/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==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -1885,27 +1778,24 @@ }, "node_modules/@babel/code-frame/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==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/@babel/code-frame/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==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/@babel/code-frame/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==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -1915,18 +1805,16 @@ }, "node_modules/@babel/compat-data": { "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.0.tgz", - "integrity": "sha512-392byTlpGWXMv4FbyWw3sAZ/FrW/DrwqLGXpy0mbyNe9Taqv1mg9yON5/o0cnr8XYCkFTZbC1eV+c+LAROgrng==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.0.tgz", - "integrity": "sha512-x/5Ea+RO5MvF9ize5DeVICJoVrNv0Mi2RnIABrZEKYvPEpldXwauPkgvYA17cKa6WpU3LoYvYbuEMFtSNFsarA==", "dev": true, + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.0.0", "@babel/code-frame": "^7.16.7", @@ -1954,18 +1842,16 @@ }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/generator": { "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.4.tgz", - "integrity": "sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/types": "^7.23.4", "@jridgewell/gen-mapping": "^0.3.2", @@ -1978,9 +1864,8 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.16.7.tgz", - "integrity": "sha512-mGojBwIWcwGD6rfqgRXVlVYmPAv7eOpIemUG3dGnDdCY4Pae70ROij3XmfrH6Fa1h1aiDylpglbZyktfzyo/hA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/compat-data": "^7.16.4", "@babel/helper-validator-option": "^7.16.7", @@ -1996,27 +1881,24 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "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, + "license": "MIT", "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, + "license": "MIT", "dependencies": { "@babel/template": "^7.22.15", "@babel/types": "^7.23.0" @@ -2027,9 +1909,8 @@ }, "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, + "license": "MIT", "dependencies": { "@babel/types": "^7.22.5" }, @@ -2039,9 +1920,8 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", - "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/types": "^7.16.7" }, @@ -2051,9 +1931,8 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.16.7.tgz", - "integrity": "sha512-gaqtLDxJEFCeQbYp9aLAefjhkKdjKcdh6DB7jniIGU3Pz52WAmP268zK0VgPz9hUNkMSYeH976K2/Y6yPadpng==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-environment-visitor": "^7.16.7", "@babel/helper-module-imports": "^7.16.7", @@ -2070,18 +1949,16 @@ }, "node_modules/@babel/helper-plugin-utils": { "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz", - "integrity": "sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-simple-access": { "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.16.7.tgz", - "integrity": "sha512-ZIzHVyoeLMvXMN/vok/a4LWRy8G2v205mNP0XOuf9XRLyX5/u9CnVulUtDgUTama3lT+bf/UqucuZjqiGuTS1g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/types": "^7.16.7" }, @@ -2091,9 +1968,8 @@ }, "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, + "license": "MIT", "dependencies": { "@babel/types": "^7.22.5" }, @@ -2103,36 +1979,32 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", "dev": true, + "license": "MIT", "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, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", - "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.0.tgz", - "integrity": "sha512-Xe/9NFxjPwELUvW2dsukcMZIp6XwPSbI4ojFBJuX5ramHuVE22SVcZIwqzdWo5uCgeTXW8qV97lMvSOjq+1+nQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/template": "^7.16.7", "@babel/traverse": "^7.17.0", @@ -2144,9 +2016,8 @@ }, "node_modules/@babel/highlight": { "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -2158,9 +2029,8 @@ }, "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==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -2170,9 +2040,8 @@ }, "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==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -2184,27 +2053,24 @@ }, "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": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.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": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "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==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -2214,9 +2080,8 @@ }, "node_modules/@babel/parser": { "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz", - "integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==", "dev": true, + "license": "MIT", "bin": { "parser": "bin/babel-parser.js" }, @@ -2226,9 +2091,8 @@ }, "node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -2238,9 +2102,8 @@ }, "node_modules/@babel/plugin-syntax-bigint": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -2250,9 +2113,8 @@ }, "node_modules/@babel/plugin-syntax-class-properties": { "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, @@ -2262,9 +2124,8 @@ }, "node_modules/@babel/plugin-syntax-import-meta": { "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -2274,9 +2135,8 @@ }, "node_modules/@babel/plugin-syntax-json-strings": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -2286,9 +2146,8 @@ }, "node_modules/@babel/plugin-syntax-jsx": { "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz", - "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.18.6" }, @@ -2301,9 +2160,8 @@ }, "node_modules/@babel/plugin-syntax-logical-assignment-operators": { "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -2313,9 +2171,8 @@ }, "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -2325,9 +2182,8 @@ }, "node_modules/@babel/plugin-syntax-numeric-separator": { "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -2337,9 +2193,8 @@ }, "node_modules/@babel/plugin-syntax-object-rest-spread": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -2349,9 +2204,8 @@ }, "node_modules/@babel/plugin-syntax-optional-catch-binding": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -2361,9 +2215,8 @@ }, "node_modules/@babel/plugin-syntax-optional-chaining": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -2373,9 +2226,8 @@ }, "node_modules/@babel/plugin-syntax-top-level-await": { "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -2388,9 +2240,8 @@ }, "node_modules/@babel/plugin-syntax-typescript": { "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.20.0.tgz", - "integrity": "sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.19.0" }, @@ -2403,8 +2254,7 @@ }, "node_modules/@babel/runtime": { "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.6.tgz", - "integrity": "sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==", + "license": "MIT", "dependencies": { "regenerator-runtime": "^0.13.11" }, @@ -2414,9 +2264,8 @@ }, "node_modules/@babel/runtime-corejs3": { "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.17.0.tgz", - "integrity": "sha512-qeydncU80ravKzovVncW3EYaC1ji3GpntdPgNcJy9g7hHSY6KX+ne1cbV3ov7Zzm4F1z0+QreZPCuw1ynkmYNg==", "dev": true, + "license": "MIT", "dependencies": { "core-js-pure": "^3.20.2", "regenerator-runtime": "^0.13.4" @@ -2427,9 +2276,8 @@ }, "node_modules/@babel/template": { "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.22.13", "@babel/parser": "^7.22.15", @@ -2441,9 +2289,8 @@ }, "node_modules/@babel/traverse": { "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.4.tgz", - "integrity": "sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.23.4", "@babel/generator": "^7.23.4", @@ -2462,9 +2309,8 @@ }, "node_modules/@babel/types": { "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz", - "integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", @@ -2476,23 +2322,20 @@ }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true + "dev": true, + "license": "MIT" }, "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==", + "license": "MIT", "engines": { "node": ">=0.1.90" } }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -2502,9 +2345,8 @@ }, "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -2512,506 +2354,157 @@ }, "node_modules/@dabh/diagnostics": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.2.tgz", - "integrity": "sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q==", + "license": "MIT", "dependencies": { "colorspace": "1.1.x", "enabled": "2.0.x", "kuler": "^2.0.0" } }, - "node_modules/@esbuild/android-arm": { + "node_modules/@esbuild/win32-x64": { "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.10.tgz", - "integrity": "sha512-7YEBfZ5lSem9Tqpsz+tjbdsEshlO9j/REJrfv4DXgKTt1+/MHqGwbtlyxQuaSlMeUZLxUKBaX8wdzlTfHkmnLw==", "cpu": [ - "arm" + "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ - "android" + "win32" ], "engines": { "node": ">=12" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.10.tgz", - "integrity": "sha512-ht1P9CmvrPF5yKDtyC+z43RczVs4rrHpRqrmIuoSvSdn44Fs1n6DGlpZKdK6rM83pFLbVaSUwle8IN+TPmkv7g==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/eslintrc": { + "version": "1.4.0", "dev": true, - "optional": true, - "os": [ - "android" - ], + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.4.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, "engines": { - "node": ">=12" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.10.tgz", - "integrity": "sha512-CYzrm+hTiY5QICji64aJ/xKdN70IK8XZ6iiyq0tZkd3tfnwwSWTYH1t3m6zyaaBxkuj40kxgMyj1km/NqdjQZA==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.10.tgz", - "integrity": "sha512-3HaGIowI+nMZlopqyW6+jxYr01KvNaLB5znXfbyyjuo4lE0VZfvFGcguIJapQeQMS4cX/NEispwOekJt3gr5Dg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } + "license": "Python-2.0" }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.10.tgz", - "integrity": "sha512-J4MJzGchuCRG5n+B4EHpAMoJmBeAE1L3wGYDIN5oWNqX0tEr7VKOzw0ymSwpoeSpdCa030lagGUfnfhS7OvzrQ==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.19.0", "dev": true, - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, "engines": { - "node": ">=12" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.10.tgz", - "integrity": "sha512-ZkX40Z7qCbugeK4U5/gbzna/UQkM9d9LNV+Fro8r7HA7sRof5Rwxc46SsqeMvB5ZaR0b1/ITQ/8Y1NmV2F0fXQ==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.0", "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.10.tgz", - "integrity": "sha512-0m0YX1IWSLG9hWh7tZa3kdAugFbZFFx9XrvfpaCMMvrswSTvUZypp0NFKriUurHpBA3xsHVE9Qb/0u2Bbi/otg==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } + "license": "MIT" }, - "node_modules/@esbuild/linux-arm": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.10.tgz", - "integrity": "sha512-whRdrrl0X+9D6o5f0sTZtDM9s86Xt4wk1bf7ltx6iQqrIIOH+sre1yjpcCdrVXntQPCNw/G+XqsD4HuxeS+2QA==", - "cpu": [ - "arm" - ], + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", "dev": true, - "optional": true, - "os": [ - "linux" - ], + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=12" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.10.tgz", - "integrity": "sha512-g1EZJR1/c+MmCgVwpdZdKi4QAJ8DCLP5uTgLWSAVd9wlqk9GMscaNMEViG3aE1wS+cNMzXXgdWiW/VX4J+5nTA==", - "cpu": [ - "arm64" - ], + "node_modules/@faker-js/faker": { + "version": "8.1.0", "dev": true, - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } ], + "license": "MIT", "engines": { - "node": ">=12" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.10.tgz", - "integrity": "sha512-1vKYCjfv/bEwxngHERp7huYfJ4jJzldfxyfaF7hc3216xiDA62xbXJfRlradiMhGZbdNLj2WA1YwYFzs9IWNPw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], + "node_modules/@feathersjs/adapter-commons": { + "version": "5.0.12", + "license": "MIT", + "dependencies": { + "@feathersjs/commons": "^5.0.12", + "@feathersjs/errors": "^5.0.12", + "@feathersjs/feathers": "^5.0.12" + }, "engines": { - "node": ">=12" + "node": ">= 12" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/feathers" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.10.tgz", - "integrity": "sha512-mvwAr75q3Fgc/qz3K6sya3gBmJIYZCgcJ0s7XshpoqIAIBszzfXsqhpRrRdVFAyV1G9VUjj7VopL2HnAS8aHFA==", - "cpu": [ - "loong64" - ], + "node_modules/@feathersjs/adapter-tests": { + "version": "5.0.12", "dev": true, - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.10.tgz", - "integrity": "sha512-XilKPgM2u1zR1YuvCsFQWl9Fc35BqSqktooumOY2zj7CSn5czJn279j9TE1JEqSqz88izJo7yE4x3LSf7oxHzg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.10.tgz", - "integrity": "sha512-kM4Rmh9l670SwjlGkIe7pYWezk8uxKHX4Lnn5jBZYBNlWpKMBCVfpAgAJqp5doLobhzF3l64VZVrmGeZ8+uKmQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.10.tgz", - "integrity": "sha512-r1m9ZMNJBtOvYYGQVXKy+WvWd0BPvSxMsVq8Hp4GzdMBQvfZRvRr5TtX/1RdN6Va8JMVQGpxqde3O+e8+khNJQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.10.tgz", - "integrity": "sha512-LsY7QvOLPw9WRJ+fU5pNB3qrSfA00u32ND5JVDrn/xG5hIQo3kvTxSlWFRP0NJ0+n6HmhPGG0Q4jtQsb6PFoyg==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.10.tgz", - "integrity": "sha512-zJUfJLebCYzBdIz/Z9vqwFjIA7iSlLCFvVi7glMgnu2MK7XYigwsonXshy9wP9S7szF+nmwrelNaP3WGanstEg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.10.tgz", - "integrity": "sha512-lOMkailn4Ok9Vbp/q7uJfgicpDTbZFlXlnKT2DqC8uBijmm5oGtXAJy2ZZVo5hX7IOVXikV9LpCMj2U8cTguWA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.10.tgz", - "integrity": "sha512-/VE0Kx6y7eekqZ+ZLU4AjMlB80ov9tEz4H067Y0STwnGOYL8CsNg4J+cCmBznk1tMpxMoUOf0AbWlb1d2Pkbig==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.10.tgz", - "integrity": "sha512-ERNO0838OUm8HfUjjsEs71cLjLMu/xt6bhOlxcJ0/1MG3hNqCmbWaS+w/8nFLa0DDjbwZQuGKVtCUJliLmbVgg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.10.tgz", - "integrity": "sha512-fXv+L+Bw2AeK+XJHwDAQ9m3NRlNemG6Z6ijLwJAAVdu4cyoFbBWbEtyZzDeL+rpG2lWI51cXeMt70HA8g2MqIg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.10.tgz", - "integrity": "sha512-3s+HADrOdCdGOi5lnh5DMQEzgbsFsd4w57L/eLKKjMnN0CN4AIEP0DCP3F3N14xnxh3ruNc32A0Na9zYe1Z/AQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.10.tgz", - "integrity": "sha512-oP+zFUjYNaMNmjTwlFtWep85hvwUu19cZklB3QsBOcZSs6y7hmH4LNCJ7075bsqzYaNvZFXJlAVaQ2ApITDXtw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.0.tgz", - "integrity": "sha512-7yfvXy6MWLgWSFsLhz5yH3iQ52St8cdUY6FoGieKkRDVxuxmrNuUetIuu6cmjNWwniUHiWXjxCr5tTXDrbYS5A==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.4.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.19.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz", - "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/@eslint/eslintrc/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@faker-js/faker": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.1.0.tgz", - "integrity": "sha512-38DT60rumHfBYynif3lmtxMqMqmsOQIxQgEuPZxCk2yUYN0eqWpTACgxi0VpidvsJB8CRxCpvP7B3anK85FjtQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/fakerjs" - } - ], - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0", - "npm": ">=6.14.13" - } - }, - "node_modules/@feathersjs/adapter-commons": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/adapter-commons/-/adapter-commons-5.0.12.tgz", - "integrity": "sha512-HP4330c2hhhiih1oFheKzS/YzBChBWvm4dDNhzcv+p4W7kmciclpyShGvDALZehe3R/8JHdxhkjB/3eo6zpn+w==", - "dependencies": { - "@feathersjs/commons": "^5.0.12", - "@feathersjs/errors": "^5.0.12", - "@feathersjs/feathers": "^5.0.12" - }, - "engines": { - "node": ">= 12" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/feathers" - } - }, - "node_modules/@feathersjs/adapter-tests": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/adapter-tests/-/adapter-tests-5.0.12.tgz", - "integrity": "sha512-D+CRrMlWSo+FuREWRrYfgZTiTSVDq8y12AAM+C8b2X54W2CzyvCu9JjxSbU3EjUF6Mg2p7cCrb+JhLZ6Y81I8A==", - "dev": true, - "engines": { - "node": ">= 12" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/feathers" + "node": ">= 12" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/feathers" } }, "node_modules/@feathersjs/authentication": { "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/authentication/-/authentication-5.0.12.tgz", - "integrity": "sha512-eaxRGCPVkvZ6MAh50zDPdYmSofrFDJUHFgT4kaAzEc3+fUGpsriqjUBrrxSF88wWojR6ZHpZIzB8IMYD7oKs7Q==", + "license": "MIT", "dependencies": { "@feathersjs/commons": "^5.0.12", "@feathersjs/errors": "^5.0.12", @@ -3035,8 +2528,7 @@ }, "node_modules/@feathersjs/authentication-local": { "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/authentication-local/-/authentication-local-5.0.12.tgz", - "integrity": "sha512-JvMcz2JxPfuCur5NCTI5zrSLp8Vx15FBHu8izlUl31OuXHox0/pJ2TP/FZV8RFIsoo9MpyrWAByeDwNRhKe1zA==", + "license": "MIT", "dependencies": { "@feathersjs/authentication": "^5.0.12", "@feathersjs/commons": "^5.0.12", @@ -3055,8 +2547,7 @@ }, "node_modules/@feathersjs/authentication/node_modules/@feathersjs/schema": { "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/schema/-/schema-5.0.12.tgz", - "integrity": "sha512-xuiePDDeWPEH24KFzdbP3kSeshxpPa3XuVxylwtEZnBRAq9mN7AxQEMDalKAvjYWlfhbnJ2qGScMlav5BdWGYw==", + "license": "MIT", "dependencies": { "@feathersjs/adapter-commons": "^5.0.12", "@feathersjs/commons": "^5.0.12", @@ -3081,16 +2572,14 @@ }, "node_modules/@feathersjs/authentication/node_modules/@types/jsonwebtoken": { "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", - "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@feathersjs/authentication/node_modules/jsonwebtoken": { "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", @@ -3110,8 +2599,7 @@ }, "node_modules/@feathersjs/authentication/node_modules/typescript": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", - "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", + "license": "Apache-2.0", "peer": true, "bin": { "tsc": "bin/tsc", @@ -3123,20 +2611,18 @@ }, "node_modules/@feathersjs/authentication/node_modules/uuid": { "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } }, "node_modules/@feathersjs/commons": { "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/commons/-/commons-5.0.12.tgz", - "integrity": "sha512-/6LiS4PLu60O39C91EXE7xzv04Ja+WupZ9UcR1p0qwp58OQRlE/60GWLzQ6XPEIVwELb7ylNgakMAq9D7Z9k8A==", + "license": "MIT", "engines": { "node": ">= 12" }, @@ -3147,8 +2633,7 @@ }, "node_modules/@feathersjs/configuration": { "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/configuration/-/configuration-5.0.12.tgz", - "integrity": "sha512-p5N+12AC3sBk+hz/RYcKRlbpzm5W9yo7ifTz9u/lAH6XrJ/lJqFlC1FCixzYE60tJxC4NAuhBFQDduo0V1vSMA==", + "license": "MIT", "dependencies": { "@feathersjs/commons": "^5.0.12", "@feathersjs/feathers": "^5.0.12", @@ -3166,8 +2651,7 @@ }, "node_modules/@feathersjs/configuration/node_modules/@feathersjs/schema": { "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/schema/-/schema-5.0.12.tgz", - "integrity": "sha512-xuiePDDeWPEH24KFzdbP3kSeshxpPa3XuVxylwtEZnBRAq9mN7AxQEMDalKAvjYWlfhbnJ2qGScMlav5BdWGYw==", + "license": "MIT", "dependencies": { "@feathersjs/adapter-commons": "^5.0.12", "@feathersjs/commons": "^5.0.12", @@ -3192,8 +2676,7 @@ }, "node_modules/@feathersjs/configuration/node_modules/typescript": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", - "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", + "license": "Apache-2.0", "peer": true, "bin": { "tsc": "bin/tsc", @@ -3205,16 +2688,14 @@ }, "node_modules/@feathersjs/errors": { "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/errors/-/errors-5.0.12.tgz", - "integrity": "sha512-WU3rDO/tlvXkwLeTG4rgcplxKkNQrirCs5MRWp5SH0pGmoNgoBmzVtq/LrGBuyQcr1TdEOaFASJpmQXx1+pcKw==", + "license": "MIT", "engines": { "node": ">= 12" } }, "node_modules/@feathersjs/express": { "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/express/-/express-5.0.12.tgz", - "integrity": "sha512-8X6OXupTtbg5jHT0eEXvYxBSHomuJ6v3v8dMvShPfrrTq6+shVUycdWVvp3s/U133DPpz38TjDKWNy0IVKK26w==", + "license": "MIT", "dependencies": { "@feathersjs/authentication": "^5.0.12", "@feathersjs/commons": "^5.0.12", @@ -3239,8 +2720,7 @@ }, "node_modules/@feathersjs/feathers": { "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/feathers/-/feathers-5.0.12.tgz", - "integrity": "sha512-m4rj6sFGMBc5fWmZRpphlXPmk2QodzTmKHw0Vb7npc/Q1pGz86NVY3KBwoIxJSahaF7AaaRofzYHooWaCEnOyw==", + "license": "MIT", "dependencies": { "@feathersjs/commons": "^5.0.12", "@feathersjs/hooks": "^0.8.1", @@ -3256,16 +2736,14 @@ }, "node_modules/@feathersjs/hooks": { "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@feathersjs/hooks/-/hooks-0.8.1.tgz", - "integrity": "sha512-q/OGjm2BEhT9cHYYcMZR4YKX4lHyufBJmi5Dz+XRM5YqUuEg9MYtR45CWgDiC1klrd2srNSsdmGNVU1otL4+0Q==", + "license": "MIT", "engines": { "node": ">= 14" } }, "node_modules/@feathersjs/transport-commons": { "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/transport-commons/-/transport-commons-5.0.12.tgz", - "integrity": "sha512-HduReTKT7VHM1MHk8FFp2UaTZtLIKRU87AtGn6RhHAqSKfX01sz1r7OIaB8vPJ/ZAYAqW9P+0UHQaQaA/XDaZQ==", + "license": "MIT", "dependencies": { "@feathersjs/commons": "^5.0.12", "@feathersjs/errors": "^5.0.12", @@ -3281,10 +2759,52 @@ "url": "https://github.com/sponsors/daffl" } }, + "node_modules/@foliojs-fork/fontkit": { + "version": "1.9.1", + "license": "MIT", + "dependencies": { + "@foliojs-fork/restructure": "^2.0.2", + "brfs": "^2.0.0", + "brotli": "^1.2.0", + "browserify-optional": "^1.0.1", + "clone": "^1.0.4", + "deep-equal": "^1.0.0", + "dfa": "^1.2.0", + "tiny-inflate": "^1.0.2", + "unicode-properties": "^1.2.2", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/@foliojs-fork/linebreak": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "base64-js": "1.3.1", + "brfs": "^2.0.2", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/@foliojs-fork/linebreak/node_modules/base64-js": { + "version": "1.3.1", + "license": "MIT" + }, + "node_modules/@foliojs-fork/pdfkit": { + "version": "0.14.0", + "license": "MIT", + "dependencies": { + "@foliojs-fork/fontkit": "^1.9.1", + "@foliojs-fork/linebreak": "^1.1.1", + "crypto-js": "^4.2.0", + "png-js": "^1.0.0" + } + }, + "node_modules/@foliojs-fork/restructure": { + "version": "2.0.2", + "license": "MIT" + }, "node_modules/@golevelup/nestjs-common": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@golevelup/nestjs-common/-/nestjs-common-2.0.0.tgz", - "integrity": "sha512-D9RLXgkqn9SDLnZ2VoMER9l/+g5CM9Z7sZXa+10+0rZs6yevMepoiWmMVsFoUXLzYG2GwfixHLExwUr3XBCHFw==", + "license": "MIT", "dependencies": { "lodash": "^4.17.21", "nanoid": "^3.3.6" @@ -3295,8 +2815,7 @@ }, "node_modules/@golevelup/nestjs-discovery": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.0.tgz", - "integrity": "sha512-iyZLYip9rhVMR0C93vo860xmboRrD5g5F5iEOfpeblGvYSz8ymQrL9RAST7x/Fp3n+TAXSeOLzDIASt+rak68g==", + "license": "MIT", "dependencies": { "lodash": "^4.17.21" }, @@ -3307,8 +2826,7 @@ }, "node_modules/@golevelup/nestjs-modules": { "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@golevelup/nestjs-modules/-/nestjs-modules-0.7.0.tgz", - "integrity": "sha512-4WxGKubYx0IJF2rxL3S4SChKdl4ZDZPwCdSj6HxmmElXRyua/LlcwLH6NYquh4RRIkQGspDd5WpcMTBw3SxR5g==", + "license": "MIT", "dependencies": { "lodash": "^4.17.21" }, @@ -3319,8 +2837,7 @@ }, "node_modules/@golevelup/nestjs-rabbitmq": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@golevelup/nestjs-rabbitmq/-/nestjs-rabbitmq-4.0.0.tgz", - "integrity": "sha512-CQHRq/jyK3GlM7Lv4nVaqd+BJ53tZXsrOtO/8/OZh19i0YOcQxyRM7iDdtULeG8omJB5/aGMZNsbioLuupxoog==", + "license": "MIT", "dependencies": { "@golevelup/nestjs-common": "^2.0.0", "@golevelup/nestjs-discovery": "^4.0.0", @@ -3338,45 +2855,39 @@ }, "node_modules/@golevelup/ts-jest": { "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@golevelup/ts-jest/-/ts-jest-0.3.4.tgz", - "integrity": "sha512-UXas4+20gNmYfVR7DTJ9iRppy00o0ElqQrRQyn9cF3Gq90iM1Xx1lKvipTEe5NniHDD9FiMZNweOfB+9ebZRZA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@hapi/boom": { "version": "9.1.4", - "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", - "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "9.x.x" } }, "node_modules/@hapi/bourne": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.0.0.tgz", - "integrity": "sha512-WEezM1FWztfbzqIUbsDzFRVMxSoLy3HugVcux6KDDtTqzPsLE8NDRHfXvev66aH1i2oOKKar3/XDjbvh/OUBdg==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@hapi/hoek": { "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.1.tgz", - "integrity": "sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@hapi/topo": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.0.0" } }, "node_modules/@hapi/wreck": { "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-17.1.0.tgz", - "integrity": "sha512-nx6sFyfqOpJ+EFrHX+XWwJAxs3ju4iHdbB/bwR8yTNZOiYmuhA8eCe7lYPtYmb4j7vyK/SlbaQsmTtUrMvPEBw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@hapi/boom": "9.x.x", "@hapi/bourne": "2.x.x", @@ -3385,13 +2896,11 @@ }, "node_modules/@hendt/xml2json": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@hendt/xml2json/-/xml2json-1.0.3.tgz", - "integrity": "sha512-9BvcVYnHHS4QGyc1tfE1DBv5++i3DOF/w5hJOm4A5z6HfWd76nI1tBgVxj4LmtRFRRmQSHJm901qSBLXw10Obg==" + "license": "MIT" }, "node_modules/@hpi-schul-cloud/commons": { "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@hpi-schul-cloud/commons/-/commons-1.3.4.tgz", - "integrity": "sha512-RYUhxwSa5VauJUuKinfT9iuJo6rSdh72WP/JckpGuZxtoTDvxJyREkZHoZDLgY2dVQwCdKw28dRSLMMBLIYtYQ==", + "license": "MIT", "dependencies": { "ajv": "^6.12.5", "config": "^3.3.1", @@ -3406,8 +2915,7 @@ }, "node_modules/@hpi-schul-cloud/commons/node_modules/ajv": { "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3421,13 +2929,11 @@ }, "node_modules/@hpi-schul-cloud/commons/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "license": "MIT" }, "node_modules/@httptoolkit/websocket-stream": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@httptoolkit/websocket-stream/-/websocket-stream-6.0.0.tgz", - "integrity": "sha512-EC8m9JbhpGX2okfvLakqrmy4Le0VyNKR7b3IdvFZR/BfFO4ruh/XceBvXhCFHkykchnFxuOSlRwFiqNSXlwcGA==", + "license": "BSD-2-Clause", "optional": true, "peer": true, "dependencies": { @@ -3443,8 +2949,7 @@ }, "node_modules/@httptoolkit/websocket-stream/node_modules/readable-stream": { "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -3459,15 +2964,13 @@ }, "node_modules/@httptoolkit/websocket-stream/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT", "optional": true, "peer": true }, "node_modules/@httptoolkit/websocket-stream/node_modules/string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -3476,9 +2979,8 @@ }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.8", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", - "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@humanwhocodes/object-schema": "^1.2.1", "debug": "^4.1.1", @@ -3490,9 +2992,8 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -3503,22 +3004,17 @@ }, "node_modules/@humanwhocodes/object-schema": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@ioredis/commands": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", - "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", - "optional": true, - "peer": true + "license": "MIT" }, "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", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", "dev": true, + "license": "ISC", "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", @@ -3532,112 +3028,97 @@ }, "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, + "node_modules/@jercle/yargonaut": { + "version": "1.1.5", + "license": "Apache-2.0", "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "chalk": "^4.1.2", + "figlet": "^1.5.2", + "parent-require": "^1.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, + "node_modules/@jercle/yargonaut/node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "color-convert": "^2.0.1" }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, + "node_modules/@jercle/yargonaut/node_modules/chalk": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, + "node_modules/@jercle/yargonaut/node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, "engines": { - "node": ">=8" + "node": ">=7.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" - } + "node_modules/@jercle/yargonaut/node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, + "node_modules/@jercle/yargonaut/node_modules/supports-color": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { "node": ">=8" } }, "node_modules/@jest-mock/express": { "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@jest-mock/express/-/express-1.4.5.tgz", - "integrity": "sha512-bERM1jnutyH7VMahdaOHAKy7lgX47zJ7+RTz2eMz0wlCttd9CkhsKFEyoWmJBSz/ow0nVj3lCuRqLem4QDYFkQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jest/console": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.2.1.tgz", - "integrity": "sha512-MF8Adcw+WPLZGBiNxn76DOuczG3BhODTcMlDCA4+cFi41OkaY/lyI0XUUhi73F88Y+7IHoGmD80pN5CtxQUdSw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "@types/node": "*", @@ -3652,9 +3133,8 @@ }, "node_modules/@jest/console/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -3667,9 +3147,8 @@ }, "node_modules/@jest/console/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3683,9 +3162,8 @@ }, "node_modules/@jest/console/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -3695,15 +3173,13 @@ }, "node_modules/@jest/console/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jest/console/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -3713,9 +3189,8 @@ }, "node_modules/@jest/core": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.2.2.tgz", - "integrity": "sha512-susVl8o2KYLcZhhkvSB+b7xX575CX3TmSvxfeDjpRko7KmT89rHkXj6XkDkNpSeFMBzIENw5qIchO9HC9Sem+A==", "dev": true, + "license": "MIT", "dependencies": { "@jest/console": "^29.2.1", "@jest/reporters": "^29.2.2", @@ -3760,9 +3235,8 @@ }, "node_modules/@jest/core/node_modules/@jest/transform": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.2.2.tgz", - "integrity": "sha512-aPe6rrletyuEIt2axxgdtxljmzH8O/nrov4byy6pDw9S8inIrTV+2PnjyP/oFHMSynzGxJ2s6OHowBNMXp/Jzg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.2.1", @@ -3786,9 +3260,8 @@ }, "node_modules/@jest/core/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3802,9 +3275,8 @@ }, "node_modules/@jest/core/node_modules/chalk/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -3817,9 +3289,8 @@ }, "node_modules/@jest/core/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -3829,15 +3300,13 @@ }, "node_modules/@jest/core/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jest/core/node_modules/jest-haste-map": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "@types/graceful-fs": "^4.1.3", @@ -3860,18 +3329,16 @@ }, "node_modules/@jest/core/node_modules/jest-regex-util": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/core/node_modules/jest-worker": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "jest-util": "^29.2.1", @@ -3884,9 +3351,8 @@ }, "node_modules/@jest/core/node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -3899,9 +3365,8 @@ }, "node_modules/@jest/core/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -3911,9 +3376,8 @@ }, "node_modules/@jest/core/node_modules/write-file-atomic": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" @@ -3924,9 +3388,8 @@ }, "node_modules/@jest/environment": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.2.2.tgz", - "integrity": "sha512-OWn+Vhu0I1yxuGBJEFFekMYc8aGBGrY4rt47SOh/IFaI+D7ZHCk7pKRiSoZ2/Ml7b0Ony3ydmEHRx/tEOC7H1A==", "dev": true, + "license": "MIT", "dependencies": { "@jest/fake-timers": "^29.2.2", "@jest/types": "^29.2.1", @@ -3939,9 +3402,8 @@ }, "node_modules/@jest/expect": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.2.2.tgz", - "integrity": "sha512-zwblIZnrIVt8z/SiEeJ7Q9wKKuB+/GS4yZe9zw7gMqfGf4C5hBLGrVyxu1SzDbVSqyMSlprKl3WL1r80cBNkgg==", "dev": true, + "license": "MIT", "dependencies": { "expect": "^29.2.2", "jest-snapshot": "^29.2.2" @@ -3952,9 +3414,8 @@ }, "node_modules/@jest/expect-utils": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.2.2.tgz", - "integrity": "sha512-vwnVmrVhTmGgQzyvcpze08br91OL61t9O0lJMDyb6Y/D8EKQ9V7rGUb/p7PDt0GPzK0zFYqXWFo4EO2legXmkg==", "dev": true, + "license": "MIT", "dependencies": { "jest-get-type": "^29.2.0" }, @@ -3964,18 +3425,16 @@ }, "node_modules/@jest/expect-utils/node_modules/jest-get-type": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/fake-timers": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.2.2.tgz", - "integrity": "sha512-nqaW3y2aSyZDl7zQ7t1XogsxeavNpH6kkdq+EpXncIDvAkjvFD7hmhcIs1nWloengEWUoWqkqSA6MSbf9w6DgA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "@sinonjs/fake-timers": "^9.1.2", @@ -3990,18 +3449,16 @@ }, "node_modules/@jest/fake-timers/node_modules/@sinonjs/fake-timers": { "version": "9.1.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz", - "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^1.7.0" } }, "node_modules/@jest/globals": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.2.2.tgz", - "integrity": "sha512-/nt+5YMh65kYcfBhj38B3Hm0Trk4IsuMXNDGKE/swp36yydBWfz3OXkLqkSvoAtPW8IJMSJDFCbTM2oj5SNprw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.2.2", "@jest/expect": "^29.2.2", @@ -4014,9 +3471,8 @@ }, "node_modules/@jest/reporters": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.2.2.tgz", - "integrity": "sha512-AzjL2rl2zJC0njIzcooBvjA4sJjvdoq98sDuuNs4aNugtLPSQ+91nysGKRF0uY1to5k0MdGMdOBggUsPqvBcpA==", "dev": true, + "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "^29.2.1", @@ -4057,9 +3513,8 @@ }, "node_modules/@jest/reporters/node_modules/@jest/transform": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.2.2.tgz", - "integrity": "sha512-aPe6rrletyuEIt2axxgdtxljmzH8O/nrov4byy6pDw9S8inIrTV+2PnjyP/oFHMSynzGxJ2s6OHowBNMXp/Jzg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.2.1", @@ -4083,9 +3538,8 @@ }, "node_modules/@jest/reporters/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -4098,9 +3552,8 @@ }, "node_modules/@jest/reporters/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4114,9 +3567,8 @@ }, "node_modules/@jest/reporters/node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -4126,9 +3578,8 @@ }, "node_modules/@jest/reporters/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -4138,15 +3589,13 @@ }, "node_modules/@jest/reporters/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jest/reporters/node_modules/jest-haste-map": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "@types/graceful-fs": "^4.1.3", @@ -4169,18 +3618,16 @@ }, "node_modules/@jest/reporters/node_modules/jest-regex-util": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/reporters/node_modules/jest-worker": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "jest-util": "^29.2.1", @@ -4193,9 +3640,8 @@ }, "node_modules/@jest/reporters/node_modules/write-file-atomic": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" @@ -4206,9 +3652,8 @@ }, "node_modules/@jest/schemas": { "version": "29.0.0", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.0.0.tgz", - "integrity": "sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA==", "dev": true, + "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.24.1" }, @@ -4218,9 +3663,8 @@ }, "node_modules/@jest/source-map": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.2.0.tgz", - "integrity": "sha512-1NX9/7zzI0nqa6+kgpSdKPK+WU1p+SJk3TloWZf5MzPbxri9UEeXX5bWZAPCzbQcyuAzubcdUHA7hcNznmRqWQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.15", "callsites": "^3.0.0", @@ -4232,9 +3676,8 @@ }, "node_modules/@jest/test-result": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.2.1.tgz", - "integrity": "sha512-lS4+H+VkhbX6z64tZP7PAUwPqhwj3kbuEHcaLuaBuB+riyaX7oa1txe0tXgrFj5hRWvZKvqO7LZDlNWeJ7VTPA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/console": "^29.2.1", "@jest/types": "^29.2.1", @@ -4247,9 +3690,8 @@ }, "node_modules/@jest/test-sequencer": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.2.2.tgz", - "integrity": "sha512-Cuc1znc1pl4v9REgmmLf0jBd3Y65UXJpioGYtMr/JNpQEIGEzkmHhy6W6DLbSsXeUA13TDzymPv0ZGZ9jH3eIw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/test-result": "^29.2.1", "graceful-fs": "^4.2.9", @@ -4262,9 +3704,8 @@ }, "node_modules/@jest/test-sequencer/node_modules/jest-haste-map": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "@types/graceful-fs": "^4.1.3", @@ -4287,18 +3728,16 @@ }, "node_modules/@jest/test-sequencer/node_modules/jest-regex-util": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/test-sequencer/node_modules/jest-worker": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "jest-util": "^29.2.1", @@ -4311,9 +3750,8 @@ }, "node_modules/@jest/types": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.2.1.tgz", - "integrity": "sha512-O/QNDQODLnINEPAI0cl9U6zUIDXEWXt6IC1o2N2QENuos7hlGUIthlKyV4p6ki3TvXFX071blj8HUhgLGquPjw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/schemas": "^29.0.0", "@types/istanbul-lib-coverage": "^2.0.0", @@ -4328,9 +3766,8 @@ }, "node_modules/@jest/types/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -4343,9 +3780,8 @@ }, "node_modules/@jest/types/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4359,9 +3795,8 @@ }, "node_modules/@jest/types/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -4371,15 +3806,13 @@ }, "node_modules/@jest/types/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jest/types/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -4389,9 +3822,8 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/set-array": "^1.0.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -4403,27 +3835,24 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/set-array": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/source-map": { "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -4431,15 +3860,13 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.17", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz", - "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "3.1.0", "@jridgewell/sourcemap-codec": "1.4.14" @@ -4447,15 +3874,13 @@ }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" + "license": "MIT" }, "node_modules/@keycloak/keycloak-admin-client": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-21.1.2.tgz", - "integrity": "sha512-YEsYZsTJTp6TQq0yX4u0uyztpqnTyeRJZP6Fke4ZkMaStB2l3nLGDdamg9lOAS9Z7xC11pt1yy7NNDEwtJVtHQ==", + "version": "23.0.6", + "license": "Apache-2.0", "dependencies": { - "camelize-ts": "^2.5.0", + "camelize-ts": "^3.0.0", "lodash-es": "^4.17.21", "url-join": "^5.0.0", "url-template": "^3.1.0" @@ -4466,24 +3891,21 @@ }, "node_modules/@keycloak/keycloak-admin-client/node_modules/url-join": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", - "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, "node_modules/@lukeed/csprng": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", - "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/@lumieducation/h5p-server": { "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@lumieducation/h5p-server/-/h5p-server-9.2.0.tgz", - "integrity": "sha512-npW5hXyFikFS7LakT6O+4FQgJNHEAyEMRm9VTifyZcNuQ+lMWoz2gGbEuoT4PcTyaK+a1f6G8V8G3882fL0qKQ==", + "license": "GPL-3.0-or-later", "dependencies": { "ajv": "^8.11.0", "ajv-keywords": "^5.1.0", @@ -4513,8 +3935,7 @@ }, "node_modules/@lumieducation/h5p-server/node_modules/axios": { "version": "0.27.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", - "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.14.9", "form-data": "^4.0.0" @@ -4522,8 +3943,7 @@ }, "node_modules/@lumieducation/h5p-server/node_modules/cache-manager": { "version": "3.6.3", - "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-3.6.3.tgz", - "integrity": "sha512-dS4DnV6c6cQcVH5OxzIU1XZaACXwvVIiUPkFytnRmLOACuBGv3GQgRQ1RJGRRw4/9DF14ZK2RFlZu1TUgDniMg==", + "license": "MIT", "dependencies": { "async": "3.2.3", "lodash.clonedeep": "^4.5.0", @@ -4532,8 +3952,7 @@ }, "node_modules/@lumieducation/h5p-server/node_modules/debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -4546,18 +3965,21 @@ } } }, - "node_modules/@mikro-orm/core": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/@mikro-orm/core/-/core-5.5.3.tgz", - "integrity": "sha512-/iQ6YKDp8EfTYibAOTkvW44uIc3qk0N+VSsUqMtO3sjb5Y2C6B+Wz4E1Qjb+oSeZtvWZn469HMCiOTgdMl6KSw==", + "node_modules/@mikro-orm/cli": { + "version": "5.6.16", + "resolved": "https://registry.npmjs.org/@mikro-orm/cli/-/cli-5.6.16.tgz", + "integrity": "sha512-ebYoSEf2fr4j2W6a/3zUW+Umi9abj6qOwuxSuKcCk6Uj9zJaC86urypHmnEbrMED0VbYeU9xX+rRpy3OdvaIPQ==", "dependencies": { - "acorn-loose": "8.3.0", - "acorn-walk": "8.2.0", - "dotenv": "16.0.3", - "fs-extra": "10.1.0", - "globby": "11.0.4", - "mikro-orm": "^5.5.3", - "reflect-metadata": "0.1.13" + "@jercle/yargonaut": "1.1.5", + "@mikro-orm/core": "~5.6.16", + "@mikro-orm/knex": "~5.6.16", + "fs-extra": "11.1.1", + "tsconfig-paths": "4.2.0", + "yargs": "15.4.1" + }, + "bin": { + "mikro-orm": "cli.js", + "mikro-orm-esm": "esm.js" }, "engines": { "node": ">= 14.0.0" @@ -4607,38 +4029,321 @@ } } }, - "node_modules/@mikro-orm/core/node_modules/acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "node_modules/@mikro-orm/cli/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=0.4.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@mikro-orm/core/node_modules/dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", - "engines": { - "node": ">=12" + "node_modules/@mikro-orm/cli/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" } }, - "node_modules/@mikro-orm/mongodb": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/@mikro-orm/mongodb/-/mongodb-5.5.3.tgz", - "integrity": "sha512-SXWcxTuNhpC+O6GbkCYf1AFvIuK1wivkww3nSp2xjCry3h9XQ334yg0CnVGpzJ2tmU5q69M0FLyRQg7P6/QHvg==", + "node_modules/@mikro-orm/cli/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dependencies": { - "bson": "^4.7.0", - "mongodb": "4.11.0" + "color-name": "~1.1.4" }, "engines": { - "node": ">= 14.0.0" + "node": ">=7.0.0" + } + }, + "node_modules/@mikro-orm/cli/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/@mikro-orm/cli/node_modules/fs-extra": { + "version": "11.1.1", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, - "peerDependencies": { - "@mikro-orm/core": "^5.0.0", - "@mikro-orm/entity-generator": "^5.0.0", - "@mikro-orm/migrations": "^5.0.0", - "@mikro-orm/migrations-mongodb": "^5.0.0", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@mikro-orm/cli/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/@mikro-orm/cli/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "node_modules/@mikro-orm/cli/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mikro-orm/cli/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@mikro-orm/core": { + "version": "5.6.16", + "resolved": "https://registry.npmjs.org/@mikro-orm/core/-/core-5.6.16.tgz", + "integrity": "sha512-JTrVS4Rb5uVKbf/d26Ni/YgJjMyoHapPEUklr4H+4c+6xlRXbfpPDN6o4ESWNXTc4ubwRGLMI1haMXg0bh6uVQ==", + "dependencies": { + "acorn-loose": "8.3.0", + "acorn-walk": "8.2.0", + "dotenv": "16.0.3", + "fs-extra": "11.1.1", + "globby": "11.1.0", + "mikro-orm": "~5.6.16", + "reflect-metadata": "0.1.13" + }, + "engines": { + "node": ">= 14.0.0" + }, + "peerDependencies": { + "@mikro-orm/better-sqlite": "^5.0.0", + "@mikro-orm/entity-generator": "^5.0.0", + "@mikro-orm/mariadb": "^5.0.0", + "@mikro-orm/migrations": "^5.0.0", + "@mikro-orm/migrations-mongodb": "^5.0.0", + "@mikro-orm/mongodb": "^5.0.0", + "@mikro-orm/mysql": "^5.0.0", + "@mikro-orm/postgresql": "^5.0.0", + "@mikro-orm/seeder": "^5.0.0", + "@mikro-orm/sqlite": "^5.0.0" + }, + "peerDependenciesMeta": { + "@mikro-orm/better-sqlite": { + "optional": true + }, + "@mikro-orm/entity-generator": { + "optional": true + }, + "@mikro-orm/mariadb": { + "optional": true + }, + "@mikro-orm/migrations": { + "optional": true + }, + "@mikro-orm/migrations-mongodb": { + "optional": true + }, + "@mikro-orm/mongodb": { + "optional": true + }, + "@mikro-orm/mysql": { + "optional": true + }, + "@mikro-orm/postgresql": { + "optional": true + }, + "@mikro-orm/seeder": { + "optional": true + }, + "@mikro-orm/sqlite": { + "optional": true + } + } + }, + "node_modules/@mikro-orm/core/node_modules/acorn-walk": { + "version": "8.2.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/@mikro-orm/core/node_modules/dotenv": { + "version": "16.0.3", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/@mikro-orm/core/node_modules/fs-extra": { + "version": "11.1.1", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@mikro-orm/knex": { + "version": "5.6.16", + "resolved": "https://registry.npmjs.org/@mikro-orm/knex/-/knex-5.6.16.tgz", + "integrity": "sha512-uPrRUmRBOsjj4Px7h4vgikitlJsnnIrJRrhsD1zKiGiK3KZGdSkQOrpdwTKdcWSo7bUiJxPabM8AzITlrJBBqQ==", + "dependencies": { + "fs-extra": "11.1.1", + "knex": "2.4.2", + "sqlstring": "2.3.3" + }, + "engines": { + "node": ">= 14.0.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^5.0.0", + "@mikro-orm/entity-generator": "^5.0.0", + "@mikro-orm/migrations": "^5.0.0", + "better-sqlite3": "^8.0.0", + "mssql": "^7.0.0", + "mysql": "^2.18.1", + "mysql2": "^2.1.0", + "pg": "^8.0.3", + "sqlite3": "^5.0.0" + }, + "peerDependenciesMeta": { + "@mikro-orm/entity-generator": { + "optional": true + }, + "@mikro-orm/migrations": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/@mikro-orm/knex/node_modules/fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@mikro-orm/migrations-mongodb": { + "version": "5.6.16", + "resolved": "https://registry.npmjs.org/@mikro-orm/migrations-mongodb/-/migrations-mongodb-5.6.16.tgz", + "integrity": "sha512-c1/sKAvX2WZH/Wiq+CAfuB4Fv0A1rw9vslkPEB043/Kpvb3fn4lbV3/2O/hlaH4juzvfDC9kF+o72zQ69uu05A==", + "dependencies": { + "@mikro-orm/mongodb": "~5.6.16", + "fs-extra": "11.1.1", + "mongodb": "4.13.0", + "umzug": "3.2.1" + }, + "engines": { + "node": ">= 14.0.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^5.0.0" + } + }, + "node_modules/@mikro-orm/migrations-mongodb/node_modules/fs-extra": { + "version": "11.1.1", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@mikro-orm/migrations-mongodb/node_modules/mongodb": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.13.0.tgz", + "integrity": "sha512-+taZ/bV8d1pYuHL4U+gSwkhmDrwkWbH1l4aah4YpmpscMwgFBkufIKxgP/G7m87/NUuQzc2Z75ZTI7ZOyqZLbw==", + "dependencies": { + "bson": "^4.7.0", + "mongodb-connection-string-url": "^2.5.4", + "socks": "^2.7.1" + }, + "engines": { + "node": ">=12.9.0" + }, + "optionalDependencies": { + "@aws-sdk/credential-providers": "^3.186.0", + "saslprep": "^1.0.3" + } + }, + "node_modules/@mikro-orm/mongodb": { + "version": "5.6.16", + "resolved": "https://registry.npmjs.org/@mikro-orm/mongodb/-/mongodb-5.6.16.tgz", + "integrity": "sha512-MH7B4+OkQedLkv+ecTDRD41La8255LfpCuuwHNOnE8+XjF0tCXiD1XO4Cpcau98ubLR959HRs88OtulQwzgCQw==", + "dependencies": { + "bson": "^4.7.0", + "mongodb": "4.13.0" + }, + "engines": { + "node": ">= 14.0.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^5.0.0", + "@mikro-orm/entity-generator": "^5.0.0", + "@mikro-orm/migrations": "^5.0.0", + "@mikro-orm/migrations-mongodb": "^5.0.0", "@mikro-orm/seeder": "^5.0.0" }, "peerDependenciesMeta": { @@ -4656,10 +4361,26 @@ } } }, + "node_modules/@mikro-orm/mongodb/node_modules/mongodb": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.13.0.tgz", + "integrity": "sha512-+taZ/bV8d1pYuHL4U+gSwkhmDrwkWbH1l4aah4YpmpscMwgFBkufIKxgP/G7m87/NUuQzc2Z75ZTI7ZOyqZLbw==", + "dependencies": { + "bson": "^4.7.0", + "mongodb-connection-string-url": "^2.5.4", + "socks": "^2.7.1" + }, + "engines": { + "node": ">=12.9.0" + }, + "optionalDependencies": { + "@aws-sdk/credential-providers": "^3.186.0", + "saslprep": "^1.0.3" + } + }, "node_modules/@mikro-orm/nestjs": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@mikro-orm/nestjs/-/nestjs-5.2.1.tgz", - "integrity": "sha512-TrCdPsM7DApxrK3avBbijT6/6Er4TZhtiQ+qlMqtqva13vMCG4HiF2vIWGrKJbFukkLRuhOfZlES+KZ9Y1Lx2A==", + "license": "MIT", "engines": { "node": ">= 14.0.0" }, @@ -4671,16 +4392,15 @@ }, "node_modules/@mongodb-js/saslprep": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", - "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", + "license": "MIT", + "optional": true, "dependencies": { "sparse-bitfield": "^3.0.3" } }, "node_modules/@nestjs/axios": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.0.tgz", - "integrity": "sha512-ULdH03jDWkS5dy9X69XbUVbhC+0pVnrRcj7bIK/ytTZ76w7CgvTZDJqsIyisg3kNOiljRW/4NIjSf3j6YGvl+g==", + "license": "MIT", "peerDependencies": { "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", "axios": "^1.3.1", @@ -4690,8 +4410,7 @@ }, "node_modules/@nestjs/cache-manager": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.1.0.tgz", - "integrity": "sha512-9kep3a8Mq5cMuXN/anGhSYc0P48CRBXk5wyJJRBFxhNkCH8AIzZF4CASGVDIEMmm3OjVcEUHojjyJwCODS17Qw==", + "license": "MIT", "peerDependencies": { "@nestjs/common": "^9.0.0 || ^10.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0", @@ -4702,9 +4421,8 @@ }, "node_modules/@nestjs/cli": { "version": "10.1.17", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.1.17.tgz", - "integrity": "sha512-jUEnR2DgC15Op+IhcRWb6cyJrhec9CUQO+GtxCF2Dv9MwLcr4sTDq1UOkfs09HAhpuI8otgF2LoWGTlW3qRuqg==", "dev": true, + "license": "MIT", "dependencies": { "@angular-devkit/core": "16.2.0", "@angular-devkit/schematics": "16.2.0", @@ -4750,9 +4468,8 @@ }, "node_modules/@nestjs/cli/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -4765,18 +4482,16 @@ }, "node_modules/@nestjs/cli/node_modules/brace-expansion": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@nestjs/cli/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4790,9 +4505,8 @@ }, "node_modules/@nestjs/cli/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -4802,24 +4516,21 @@ }, "node_modules/@nestjs/cli/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@nestjs/cli/node_modules/commander": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 6" } }, "node_modules/@nestjs/cli/node_modules/glob": { "version": "9.3.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", - "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", "dev": true, + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", @@ -4835,9 +4546,8 @@ }, "node_modules/@nestjs/cli/node_modules/inquirer": { "version": "8.2.6", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", - "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", "dev": true, + "license": "MIT", "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", @@ -4861,9 +4571,8 @@ }, "node_modules/@nestjs/cli/node_modules/minimatch": { "version": "8.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", - "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -4876,18 +4585,16 @@ }, "node_modules/@nestjs/cli/node_modules/minipass": { "version": "4.2.8", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", - "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", "dev": true, + "license": "ISC", "engines": { "node": ">=8" } }, "node_modules/@nestjs/cli/node_modules/rimraf": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.4.1.tgz", - "integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==", "dev": true, + "license": "ISC", "dependencies": { "glob": "^9.2.0" }, @@ -4903,9 +4610,8 @@ }, "node_modules/@nestjs/cli/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -4915,9 +4621,8 @@ }, "node_modules/@nestjs/cli/node_modules/typescript": { "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4928,9 +4633,8 @@ }, "node_modules/@nestjs/cli/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==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -4942,8 +4646,7 @@ }, "node_modules/@nestjs/common": { "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.2.4.tgz", - "integrity": "sha512-3Lg4PUaSDucf14V8rPCH212NqrK09AJbY0NKqFsb4j5OIE+TuOzVZR/yjaJ8JNxH2hjskJNCZie0D/9tA2lzlA==", + "license": "MIT", "dependencies": { "iterare": "1.2.1", "tslib": "2.6.2", @@ -4970,8 +4673,7 @@ }, "node_modules/@nestjs/config": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.0.1.tgz", - "integrity": "sha512-a98MMkDlgUlXTv9qtDbimYfXsuafn/YZOh/S35afutr0Qc5T6KzjyWP5VjxRkv26yI2JM0RhFruByFTM6ezwHA==", + "license": "MIT", "dependencies": { "dotenv": "16.3.1", "dotenv-expand": "10.0.0", @@ -4985,8 +4687,7 @@ }, "node_modules/@nestjs/config/node_modules/dotenv": { "version": "16.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -4996,17 +4697,15 @@ }, "node_modules/@nestjs/config/node_modules/uuid": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } }, "node_modules/@nestjs/core": { "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.2.4.tgz", - "integrity": "sha512-aWeii2l+3pNCc9kIRdLbXQMvrgSZD0jZgXOZv7bZwVf9mClMMi7TussLI4On12VbqVE7LE3gsNgRTwgQJlVC8g==", "hasInstallScript": true, + "license": "MIT", "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -5039,22 +4738,46 @@ } } }, - "node_modules/@nestjs/jwt": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.1.1.tgz", - "integrity": "sha512-sISYylg8y1Mb7saxPx5Zh11i7v9JOh70CEC/rN6g43MrbFlJ57c1eYFrffxip1YAx3DmV4K67yXob3syKZMOew==", + "node_modules/@nestjs/cqrs": { + "version": "10.2.7", + "resolved": "https://registry.npmjs.org/@nestjs/cqrs/-/cqrs-10.2.7.tgz", + "integrity": "sha512-RXhgQOfuT+KzvkueR4S++SB6+6333PL71pOtCzbJAAU/DY3KY56yTCncWRsIdorKfDX5AEwTiQHHJi69XJWdkA==", "dependencies": { - "@types/jsonwebtoken": "9.0.2", - "jsonwebtoken": "9.0.0" + "uuid": "9.0.1" }, "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0" } }, - "node_modules/@nestjs/mapped-types": { + "node_modules/@nestjs/cqrs/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@nestjs/jwt": { + "version": "10.1.1", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.2", + "jsonwebtoken": "9.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/mapped-types": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.2.tgz", - "integrity": "sha512-V0izw6tWs6fTp9+KiiPUbGHWALy563Frn8X6Bm87ANLRuE46iuBMD5acKBDP5lKL/75QFvrzSJT7HkCbB0jTpg==", + "license": "MIT", "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", "class-transformer": "^0.4.0 || ^0.5.0", @@ -5072,8 +4795,7 @@ }, "node_modules/@nestjs/microservices": { "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-10.2.4.tgz", - "integrity": "sha512-GytBFj4onLveWDUm+aj7Ft4518yiRx3dfHwqBzYfekPFWIfzVHNGWQCZUSNpS/jMbTfbM2PAknkuhWFjV1811A==", + "license": "MIT", "dependencies": { "iterare": "1.2.1", "tslib": "2.6.2" @@ -5129,8 +4851,7 @@ }, "node_modules/@nestjs/passport": { "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.1.tgz", - "integrity": "sha512-hS22LeNj0LByS9toBPkpKyZhyKAXoHACLS1EQrjbAJJEQjhocOskVGwcMwvMlz+ohN+VU804/nMF1Zlya4+TiQ==", + "license": "MIT", "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", "passport": "^0.4.0 || ^0.5.0 || ^0.6.0" @@ -5138,8 +4859,7 @@ }, "node_modules/@nestjs/platform-express": { "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.2.4.tgz", - "integrity": "sha512-E9F6WYo6bNwvTT0saJpkr8t4BJLbZRwrX5EKbtBRQqyRcw6NAvlKdacKzoo+Sompdre0IbF8AvNRFk4uLZTWqA==", + "license": "MIT", "dependencies": { "body-parser": "1.20.2", "cors": "2.8.5", @@ -5157,12 +4877,11 @@ } }, "node_modules/@nestjs/platform-ws": { - "version": "10.2.7", - "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.2.7.tgz", - "integrity": "sha512-4H4AeCQgM29Dju+zQb70Jt0JgWhQssOB8mh9n9icsSJ4B/joa+X7OiBBSjn72HZelj0tvX1gal6PaAhEaOdmGQ==", + "version": "10.3.1", + "license": "MIT", "dependencies": { "tslib": "2.6.2", - "ws": "8.14.2" + "ws": "8.16.0" }, "funding": { "type": "opencollective", @@ -5174,31 +4893,10 @@ "rxjs": "^7.1.0" } }, - "node_modules/@nestjs/platform-ws/node_modules/ws": { - "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/@nestjs/schematics": { "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.2.tgz", - "integrity": "sha512-DaZZjymYoIfRqC5W62lnYXIIods1PDY6CGc8+IpRwyinzffjKxZ3DF3exu+mdyvllzkXo9DTXkoX4zOPSJHCkw==", "dev": true, + "license": "MIT", "dependencies": { "@angular-devkit/core": "16.1.8", "@angular-devkit/schematics": "16.1.8", @@ -5212,9 +4910,8 @@ }, "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { "version": "16.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.1.8.tgz", - "integrity": "sha512-dSRD/+bGanArIXkj+kaU1kDFleZeQMzmBiOXX+pK0Ah9/0Yn1VmY3RZh1zcX9vgIQXV+t7UPrTpOjaERMUtVGw==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "8.12.0", "ajv-formats": "2.1.1", @@ -5238,9 +4935,8 @@ }, "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { "version": "16.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-16.1.8.tgz", - "integrity": "sha512-6LyzMdFJs337RTxxkI2U1Ndw0CW5mMX/aXWl8d7cW2odiSrAg8IdlMqpc+AM8+CPfsB0FtS1aWkEZqJLT0jHOg==", "dev": true, + "license": "MIT", "dependencies": { "@angular-devkit/core": "16.1.8", "jsonc-parser": "3.2.0", @@ -5256,9 +4952,8 @@ }, "node_modules/@nestjs/schematics/node_modules/magic-string": { "version": "0.30.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", - "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.13" }, @@ -5268,17 +4963,15 @@ }, "node_modules/@nestjs/schematics/node_modules/source-map": { "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">= 8" } }, "node_modules/@nestjs/swagger": { "version": "7.1.10", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.1.10.tgz", - "integrity": "sha512-qreCcxgHFyFX1mOfK36pxiziy4xoa/XcxC0h4Zr9yH54WuqMqO9aaNFhFyuQ1iyd/3YBVQB21Un4gQnh9iGm0w==", + "license": "MIT", "dependencies": { "@nestjs/mapped-types": "2.0.2", "js-yaml": "4.1.0", @@ -5308,13 +5001,11 @@ }, "node_modules/@nestjs/swagger/node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "license": "Python-2.0" }, "node_modules/@nestjs/swagger/node_modules/js-yaml": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -5324,14 +5015,12 @@ }, "node_modules/@nestjs/swagger/node_modules/swagger-ui-dist": { "version": "5.4.2", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.4.2.tgz", - "integrity": "sha512-vT5QxP/NOr9m4gLZl+SpavWI3M9Fdh30+Sdw9rEtZbkqNmNNEPhjXas2xTD9rsJYYdLzAiMfwXvtooWH3xbLJA==" + "license": "Apache-2.0" }, "node_modules/@nestjs/testing": { "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.2.4.tgz", - "integrity": "sha512-2qqymiuPbC41yCXXhtt4cL8AOcVNu13gBCT13A8roUUdcs4lmtg+H3oXKF/Gc/vlLv2RkSTNO+JuzxP1hydLPg==", "dev": true, + "license": "MIT", "dependencies": { "tslib": "2.6.2" }, @@ -5355,9 +5044,8 @@ } }, "node_modules/@nestjs/websockets": { - "version": "10.2.7", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.2.7.tgz", - "integrity": "sha512-NKJMubkwpUBsudbiyjuLZDT/W68K+fS/pe3vG5Ur8QoPn+fkI9SFCiQw27Cv4K0qVX2eGJ41yNmVfu61zGa4CQ==", + "version": "10.3.1", + "license": "MIT", "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -5378,8 +5066,7 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -5390,16 +5077,14 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", "engines": { "node": ">= 8" } }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -5410,8 +5095,7 @@ }, "node_modules/@nuxtjs/opencollective": { "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", - "integrity": "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==", + "license": "MIT", "dependencies": { "chalk": "^4.1.0", "consola": "^2.15.0", @@ -5427,8 +5111,7 @@ }, "node_modules/@nuxtjs/opencollective/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -5441,8 +5124,7 @@ }, "node_modules/@nuxtjs/opencollective/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -5456,8 +5138,7 @@ }, "node_modules/@nuxtjs/opencollective/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -5467,13 +5148,11 @@ }, "node_modules/@nuxtjs/opencollective/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "license": "MIT" }, "node_modules/@nuxtjs/opencollective/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -5483,17 +5162,15 @@ }, "node_modules/@panva/asn1.js": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", - "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==", + "license": "MIT", "engines": { "node": ">=10.13.0" } }, "node_modules/@pkgr/utils": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz", - "integrity": "sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", "is-glob": "^4.0.3", @@ -5511,16 +5188,14 @@ }, "node_modules/@redis/bloom": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", - "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", "peerDependencies": { "@redis/client": "^1.0.0" } }, "node_modules/@redis/client": { - "version": "1.5.12", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.12.tgz", - "integrity": "sha512-/ZjE18HRzMd80eXIIUIPcH81UoZpwulbo8FmbElrjPqH0QC0SeIKu1BOU49bO5trM5g895kAjhvalt5h77q+4A==", + "version": "1.5.14", + "license": "MIT", "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -5532,91 +5207,96 @@ }, "node_modules/@redis/graph": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", - "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", "peerDependencies": { "@redis/client": "^1.0.0" } }, "node_modules/@redis/json": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.6.tgz", - "integrity": "sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw==", + "license": "MIT", "peerDependencies": { "@redis/client": "^1.0.0" } }, "node_modules/@redis/search": { "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.6.tgz", - "integrity": "sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw==", + "license": "MIT", "peerDependencies": { "@redis/client": "^1.0.0" } }, "node_modules/@redis/time-series": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.5.tgz", - "integrity": "sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==", + "license": "MIT", "peerDependencies": { "@redis/client": "^1.0.0" } }, + "node_modules/@rushstack/ts-command-line": { + "version": "4.17.1", + "license": "MIT", + "dependencies": { + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "colors": "~1.2.1", + "string-argv": "~0.3.1" + } + }, + "node_modules/@rushstack/ts-command-line/node_modules/colors": { + "version": "1.2.5", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@servie/events": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@servie/events/-/events-1.0.0.tgz", - "integrity": "sha512-sBSO19KzdrJCM3gdx6eIxV8M9Gxfgg6iDQmH5TIAGaUu+X9VDdsINXJOnoiZ1Kx3TrHdH4bt5UVglkjsEGBcvw==" + "license": "MIT" }, "node_modules/@sideway/address": { "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.3.tgz", - "integrity": "sha512-8ncEUtmnTsMmL7z1YPB47kPUq7LpKWJNFPsRzHiIajGC5uXlWGn+AmkYPcHNl8S4tcEGx+cnORnNYaw2wvL+LQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.0.0" } }, "node_modules/@sideway/formula": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@sideway/pinpoint": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@sinclair/typebox": { "version": "0.24.51", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", - "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@sinonjs/commons": { "version": "1.8.3", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", - "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" } }, "node_modules/@sinonjs/fake-timers": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz", - "integrity": "sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^1.7.0" } }, "node_modules/@sinonjs/samsam": { "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.1.tgz", - "integrity": "sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^1.6.0", "lodash.get": "^4.4.2", @@ -5625,14 +5305,12 @@ }, "node_modules/@sinonjs/text-encoding": { "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", - "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", - "dev": true + "dev": true, + "license": "(Unlicense OR Apache-2.0)" }, "node_modules/@smithy/protocol-http": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-1.0.1.tgz", - "integrity": "sha512-9OrEn0WfOVtBNYJUjUAn9AOiJ4lzERCJJ/JeZs8E6yajTGxBaFRxUnNBHiNqoDJVg076hY36UmEnPx7xXrvUSg==", + "license": "Apache-2.0", "dependencies": { "@smithy/types": "^1.0.0", "tslib": "^2.5.0" @@ -5643,8 +5321,7 @@ }, "node_modules/@smithy/types": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.0.0.tgz", - "integrity": "sha512-kc1m5wPBHQCTixwuaOh9vnak/iJm21DrSf9UK6yDE5S3mQQ4u11pqAUiKWnlrZnYkeLfAI9UEHj9OaMT1v5Umg==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.5.0" }, @@ -5654,57 +5331,53 @@ }, "node_modules/@tokenizer/token": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" + "license": "MIT" }, "node_modules/@tsconfig/node10": { "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", - "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/adm-zip": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.0.tgz", - "integrity": "sha512-FCJBJq9ODsQZUNURo5ILAQueuA8WJhRvuihS3ke2iI25mJlfV2LK8jG2Qj2z2AWg8U0FtWWqBHVRetceLskSaw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/amqplib": { "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@types/amqplib/-/amqplib-0.8.2.tgz", - "integrity": "sha512-p+TFLzo52f8UanB+Nq6gyUi65yecAcRY3nYowU6MPGFtaJvEDxcnFWrxssSTkF+ts1W3zyQDvgVICLQem5WxRA==", "dev": true, + "license": "MIT", "dependencies": { "@types/bluebird": "*", "@types/node": "*" } }, + "node_modules/@types/argparse": { + "version": "1.0.38", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.1.18", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.18.tgz", - "integrity": "sha512-S7unDjm/C7z2A2R9NzfKCK1I+BAALDtxEmsJBwlB3EzNfb929ykjL++1CK9LO++EIp2fQrC8O+BwjKvz6UeDyQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0", @@ -5715,18 +5388,16 @@ }, "node_modules/@types/babel__generator": { "version": "7.6.4", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", - "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" } }, "node_modules/@types/babel__template": { "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", - "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" @@ -5734,62 +5405,47 @@ }, "node_modules/@types/babel__traverse": { "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.14.2.tgz", - "integrity": "sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/types": "^7.3.0" } }, "node_modules/@types/bcryptjs": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.2.tgz", - "integrity": "sha512-LiMQ6EOPob/4yUL66SZzu6Yh77cbzJFYll+ZfaPiPPFswtIlA/Fs1MzdKYA7JApHU49zQTbJGX3PDmCpIdDBRQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/bluebird": { "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.38.tgz", - "integrity": "sha512-yR/Kxc0dd4FfwtEoLZMoqJbM/VE/W7hXn/MIjb+axcwag0iFmSPK7OBUZq1YWLynJUoWQkfUrI7T0HDqGApNSg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/body-parser": { "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "license": "MIT", "dependencies": { "@types/connect": "*", "@types/node": "*" } }, - "node_modules/@types/bson": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.5.tgz", - "integrity": "sha512-vVLwMUqhYJSQ/WKcE60eFqcyuWse5fGH+NMAXHuKrUAPoryq3ATxk5o4bgYNtg5aOM4APVg7Hnb3ASqUYG0PKg==", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/busboy": { "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.0.tgz", - "integrity": "sha512-ncOOhwmyFDW76c/Tuvv9MA9VGYUCn8blzyWmzYELcNGDb0WXWLSmFi7hJq25YdRBYJrmMBB5jZZwUjlJe9HCjQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/chai": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.0.tgz", - "integrity": "sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/clamscan": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.0.5.tgz", - "integrity": "sha512-bFqdscswqBia3yKEJZVVWELOVvWKHUR1dCmH4xshYwu0T9YSfZd35Q8Z9jYW0ygxqGlHjLXMb2/7C6CJITbDgg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "axios": "^0.24.0" @@ -5797,65 +5453,56 @@ }, "node_modules/@types/clamscan/node_modules/axios": { "version": "0.24.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", - "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", "dev": true, + "license": "MIT", "dependencies": { "follow-redirects": "^1.14.4" } }, "node_modules/@types/compression": { "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz", - "integrity": "sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==", + "license": "MIT", "dependencies": { "@types/express": "*" } }, "node_modules/@types/config": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@types/config/-/config-3.3.3.tgz", - "integrity": "sha512-BB8DBAud88EgiAKlz8WQStzI771Kb6F3j4dioRJ4GD+tP4tzcZyMlz86aXuZT4s9hyesFORehMQE6eqtA5O+Vg==" + "license": "MIT" }, "node_modules/@types/connect": { "version": "3.4.35", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", - "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/cookie": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/cookiejar": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", - "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/cors": { "version": "2.8.17", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", - "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/crypto-js": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.0.tgz", - "integrity": "sha512-DCFfy/vh2lG6qHSGezQ+Sn2Ulf/1Mx51dqOdmOKyW5nMK3maLlxeS3onC7r212OnBM2pBR95HkAmAjjF08YkxQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/eslint": { "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", - "integrity": "sha512-GE44+DNEyxxh2Kc6ro/VkIj+9ma0pO0bwv9+uHSyBrikYOHr8zYcdPvnBOp1aw8s+CjRvuSx7CyWqRrNFQ59mA==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5863,9 +5510,8 @@ }, "node_modules/@types/eslint-scope": { "version": "3.7.3", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.3.tgz", - "integrity": "sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g==", "dev": true, + "license": "MIT", "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -5873,20 +5519,17 @@ }, "node_modules/@types/eslint-visitor-keys": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", - "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", - "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/express": { "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -5896,8 +5539,7 @@ }, "node_modules/@types/express-jwt": { "version": "0.0.42", - "resolved": "https://registry.npmjs.org/@types/express-jwt/-/express-jwt-0.0.42.tgz", - "integrity": "sha512-WszgUddvM1t5dPpJ3LhWNH8kfNN8GPIBrAGxgIYXVCEGx6Bx4A036aAuf/r5WH9DIEdlmp7gHOYvSM6U87B0ag==", + "license": "MIT", "dependencies": { "@types/express": "*", "@types/express-unless": "*" @@ -5905,8 +5547,7 @@ }, "node_modules/@types/express-serve-static-core": { "version": "4.17.41", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz", - "integrity": "sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==", + "license": "MIT", "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -5916,26 +5557,23 @@ }, "node_modules/@types/express-session": { "version": "1.17.5", - "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.5.tgz", - "integrity": "sha512-l0DhkvNVfyUPEEis8fcwbd46VptfA/jmMwHfob2TfDMf3HyPLiB9mKD71LXhz5TMUobODXPD27zXSwtFQLHm+w==", "dev": true, + "license": "MIT", "dependencies": { "@types/express": "*" } }, "node_modules/@types/express-unless": { "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-0.5.3.tgz", - "integrity": "sha512-TyPLQaF6w8UlWdv4gj8i46B+INBVzURBNRahCozCSXfsK2VTlL1wNyTlMKw817VHygBtlcl5jfnPadlydr06Yw==", + "license": "MIT", "dependencies": { "@types/express": "*" } }, "node_modules/@types/glob": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", "dev": true, + "license": "MIT", "dependencies": { "@types/minimatch": "*", "@types/node": "*" @@ -5943,50 +5581,44 @@ }, "node_modules/@types/gm": { "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@types/gm/-/gm-1.25.1.tgz", - "integrity": "sha512-WLqlPvjot5jxpt1AFxaWm0fgWZUBGXOPJC3ZrQgRpvpHYjwYbvr/4GwRzd0mXFfxzX+TrvXaow+/WbmWFHomlQ==", + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/graceful-fs": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", - "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", "dev": true, + "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "node_modules/@types/istanbul-reports": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", "dev": true, + "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" } }, "node_modules/@types/jest": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.2.1.tgz", - "integrity": "sha512-nKixEdnGDqFOZkMTF74avFNr3yRqB1ZJ6sRZv5/28D5x2oLN14KApv7F9mfDT/vUic0L3tRCsh3XWpWjtJisUQ==", "dev": true, + "license": "MIT", "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" @@ -5994,91 +5626,69 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + "license": "MIT" }, "node_modules/@types/json5": { "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/jsonwebtoken": { "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-drE6uz7QBKq1fYqqoFKTDRdFCPHd5TCub75BM+D+cMx7NU9hUz7SESLfC2fSCXVFMO5Yj8sOWHuGqPgjc+fz0Q==", + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/ldapjs": { "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-2.2.5.tgz", - "integrity": "sha512-Lv/nD6QDCmcT+V1vaTRnEKE8UgOilVv5pHcQuzkU1LcRe4mbHHuUo/KHi0LKrpdHhQY8FJzryF38fcVdeUIrzg==", + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/lodash": { "version": "4.14.196", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.196.tgz", - "integrity": "sha512-22y3o88f4a94mKljsZcanlNWPzO0uBsBdzLAngf2tp533LzZcQzb6+eZPJ+vCTt+bqF2XnvT9gejTLsAcJAJyQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/mime": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", - "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" + "license": "MIT" }, "node_modules/@types/minimatch": { "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", - "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", - "dev": true - }, - "node_modules/@types/mongodb": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-4.0.7.tgz", - "integrity": "sha512-lPUYPpzA43baXqnd36cZ9xxorprybxXDzteVKCPAdp14ppHtFJHnXYvNpmBvtMUTb5fKXVv6sVbzo1LHkWhJlw==", - "deprecated": "mongodb provides its own types. @types/mongodb is no longer needed.", "dev": true, - "dependencies": { - "mongodb": "*" - } + "license": "MIT" }, "node_modules/@types/multer": { "version": "1.4.7", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", - "integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==", + "license": "MIT", "dependencies": { "@types/express": "*" } }, "node_modules/@types/node": { "version": "16.18.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz", - "integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==" + "license": "MIT" }, "node_modules/@types/parse-json": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/passport": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", - "integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==", "dev": true, + "license": "MIT", "dependencies": { "@types/express": "*" } }, "node_modules/@types/passport-jwt": { "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.6.tgz", - "integrity": "sha512-cmAAMIRTaEwpqxlrZyiEY9kdibk94gP5KTF8AT1Ra4rWNZYHNMreqhKUEeC5WJtuN5SJZjPQmV+XO2P5PlnvNQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/express": "*", "@types/jsonwebtoken": "*", @@ -6087,9 +5697,8 @@ }, "node_modules/@types/passport-local": { "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.34.tgz", - "integrity": "sha512-PSc07UdYx+jhadySxxIYWuv6sAnY5e+gesn/5lkPKfBeGuIYn9OPR+AAEDq73VRUh6NBTpvE/iPE62rzZUslog==", "dev": true, + "license": "MIT", "dependencies": { "@types/express": "*", "@types/passport": "*", @@ -6098,35 +5707,45 @@ }, "node_modules/@types/passport-strategy": { "version": "0.2.35", - "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", - "integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==", "dev": true, + "license": "MIT", "dependencies": { "@types/express": "*", "@types/passport": "*" } }, + "node_modules/@types/pdfkit": { + "version": "0.13.3", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pdfmake": { + "version": "0.2.8", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/pdfkit": "*" + } + }, "node_modules/@types/prettier": { "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/qs": { "version": "6.9.10", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.10.tgz", - "integrity": "sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==" + "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + "license": "MIT" }, "node_modules/@types/response-time": { "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@types/response-time/-/response-time-2.3.5.tgz", - "integrity": "sha512-4ANzp+I3K7sztFFAGPALWBvSl4ayaDSKzI2Bok+WNz+en2eB2Pvk6VCjR47PBXBWOkEg2r4uWpZOlXA5DNINOQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/express": "*", "@types/node": "*" @@ -6134,9 +5753,8 @@ }, "node_modules/@types/rimraf": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-F3OznnSLAUxFrCEu/L5PY8+ny8DtcFRjx7fZZ9bycvXRi3KPTRS9HOitGZwvPg0juRhXFWIeKX58cnX5YqLohQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/glob": "*", "@types/node": "*" @@ -6144,23 +5762,20 @@ }, "node_modules/@types/sanitize-html": { "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.6.2.tgz", - "integrity": "sha512-7Lu2zMQnmHHQGKXVvCOhSziQMpa+R2hMHFefzbYoYMHeaXR0uXqNeOc3JeQQQ8/6Xa2Br/P1IQTLzV09xxAiUQ==", "dev": true, + "license": "MIT", "dependencies": { "htmlparser2": "^6.0.0" } }, "node_modules/@types/semver": { "version": "7.3.13", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", - "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/send": { "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "license": "MIT", "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -6168,8 +5783,7 @@ }, "node_modules/@types/serve-static": { "version": "1.13.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", - "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", + "license": "MIT", "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -6177,33 +5791,29 @@ }, "node_modules/@types/sinon": { "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.2.tgz", - "integrity": "sha512-BHn8Bpkapj8Wdfxvh2jWIUoaYB/9/XhsL0oOvBfRagJtKlSl9NWPcFOz2lRukI9szwGxFtYZCTejJSqsGDbdmw==", "dev": true, + "license": "MIT", "dependencies": { "@sinonjs/fake-timers": "^7.1.0" } }, "node_modules/@types/source-map-support": { "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@types/source-map-support/-/source-map-support-0.5.4.tgz", - "integrity": "sha512-9zGujX1sOPg32XLyfgEB/0G9ZnrjthL/Iv1ZfuAjj8LEilHZEpQSQs1scpRXPhHzGYgWiLz9ldF1cI8JhL+yMw==", "dev": true, + "license": "MIT", "dependencies": { "source-map": "^0.6.0" } }, "node_modules/@types/stack-utils": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/superagent": { "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.16.tgz", - "integrity": "sha512-tLfnlJf6A5mB6ddqF159GqcDizfzbMUB1/DeT59/wBNqzRTNNKsaw79A/1TZ84X+f/EwWH8FeuSkjlCLyqS/zQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/cookiejar": "*", "@types/node": "*" @@ -6211,87 +5821,74 @@ }, "node_modules/@types/supertest": { "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.12.tgz", - "integrity": "sha512-X3HPWTwXRerBZS7Mo1k6vMVR1Z6zmJcDVn5O/31whe0tnjE4te6ZJSJGq1RiqHPjzPdMTfjCFogDJmwng9xHaQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/superagent": "*" } }, "node_modules/@types/tmp": { "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-dDZH/tXzwjutnuk4UacGgFRwV+JSLaXL1ikvidfJprkb7L9Nx1njcRHHmi3Dsvt7pgqqTEeucQuOrWHPFgzVHA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/tough-cookie": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-2.3.8.tgz", - "integrity": "sha512-7axfYN8SW9pWg78NgenHasSproWQee5rzyPVLC9HpaQSDgNArsnKJD88EaMfi4Pl48AyciO3agYCFqpHS1gLpg==" + "license": "MIT" }, "node_modules/@types/uuid": { "version": "8.3.4", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", - "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/validator": { "version": "13.7.14", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz", - "integrity": "sha512-J6OAed6rhN6zyqL9Of6ZMamhlsOEU/poBVvbHr/dKOYKTeuYYMlDkMv+b6UUV0o2i0tw73cgyv/97WTWaUl0/g==" + "license": "MIT" }, "node_modules/@types/webidl-conversions": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==" + "license": "MIT" }, "node_modules/@types/whatwg-url": { "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", - "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "license": "MIT", "dependencies": { "@types/node": "*", "@types/webidl-conversions": "*" } }, "node_modules/@types/ws": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.2.2.tgz", - "integrity": "sha512-NOn5eIcgWLOo6qW8AcuLZ7G8PycXu0xTxxkS6Q18VWFxgPUSOwV0pBj2a/4viNZVu25i7RIB7GttdkAIUUXOOg==", - "optional": true, - "peer": true, + "version": "8.5.10", + "devOptional": true, + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/xml2js": { "version": "0.4.11", - "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.11.tgz", - "integrity": "sha512-JdigeAKmCyoJUiQljjr7tQG3if9NkqGUgwEUqBvV0N7LM4HyQk7UXCnusRa1lnvXAEYJ8mw8GtZWioagNztOwA==", + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/yargs": { "version": "17.0.13", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.13.tgz", - "integrity": "sha512-9sWaruZk2JGxIQU+IhI1fhPYRcQ0UuTNuKuCW9bR5fp7qi2Llf7WDzNa17Cy7TKnh3cdxDOiyTu6gaLS0eDatg==", "dev": true, + "license": "MIT", "dependencies": { "@types/yargs-parser": "*" } }, "node_modules/@types/yargs-parser": { "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", - "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.47.1.tgz", - "integrity": "sha512-r4RZ2Jl9kcQN7K/dcOT+J7NAimbiis4sSM9spvWimsBvDegMhKLA5vri2jG19PmIPbDjPeWzfUPQ2hjEzA4Nmg==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/scope-manager": "5.47.1", "@typescript-eslint/type-utils": "5.47.1", @@ -6322,9 +5919,8 @@ }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -6339,9 +5935,8 @@ }, "node_modules/@typescript-eslint/experimental-utils": { "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.10.1.tgz", - "integrity": "sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw==", "dev": true, + "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.3", "@typescript-eslint/types": "3.10.1", @@ -6362,9 +5957,8 @@ }, "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/types": { "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz", - "integrity": "sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ==", "dev": true, + "license": "MIT", "engines": { "node": "^8.10.0 || ^10.13.0 || >=11.10.1" }, @@ -6375,9 +5969,8 @@ }, "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/typescript-estree": { "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz", - "integrity": "sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/types": "3.10.1", "@typescript-eslint/visitor-keys": "3.10.1", @@ -6403,9 +5996,8 @@ }, "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/visitor-keys": { "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz", - "integrity": "sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^1.1.0" }, @@ -6419,9 +6011,8 @@ }, "node_modules/@typescript-eslint/experimental-utils/node_modules/eslint-utils": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^1.1.0" }, @@ -6434,18 +6025,16 @@ }, "node_modules/@typescript-eslint/experimental-utils/node_modules/eslint-visitor-keys": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=4" } }, "node_modules/@typescript-eslint/parser": { "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.47.1.tgz", - "integrity": "sha512-9Vb+KIv29r6GPu4EboWOnQM7T+UjpjXvjCPhNORlgm40a9Ia9bvaPJswvtae1gip2QEeVeGh6YquqAzEgoRAlw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/scope-manager": "5.47.1", "@typescript-eslint/types": "5.47.1", @@ -6470,9 +6059,8 @@ }, "node_modules/@typescript-eslint/parser/node_modules/debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -6487,9 +6075,8 @@ }, "node_modules/@typescript-eslint/scope-manager": { "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.47.1.tgz", - "integrity": "sha512-9hsFDsgUwrdOoW1D97Ewog7DYSHaq4WKuNs0LHF9RiCmqB0Z+XRR4Pf7u7u9z/8CciHuJ6yxNws1XznI3ddjEw==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/types": "5.47.1", "@typescript-eslint/visitor-keys": "5.47.1" @@ -6504,9 +6091,8 @@ }, "node_modules/@typescript-eslint/type-utils": { "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.47.1.tgz", - "integrity": "sha512-/UKOeo8ee80A7/GJA427oIrBi/Gd4osk/3auBUg4Rn9EahFpevVV1mUK8hjyQD5lHPqX397x6CwOk5WGh1E/1w==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/typescript-estree": "5.47.1", "@typescript-eslint/utils": "5.47.1", @@ -6531,9 +6117,8 @@ }, "node_modules/@typescript-eslint/type-utils/node_modules/debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -6548,9 +6133,8 @@ }, "node_modules/@typescript-eslint/types": { "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.47.1.tgz", - "integrity": "sha512-CmALY9YWXEpwuu6377ybJBZdtSAnzXLSQcxLSqSQSbC7VfpMu/HLVdrnVJj7ycI138EHqocW02LPJErE35cE9A==", "dev": true, + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -6561,9 +6145,8 @@ }, "node_modules/@typescript-eslint/typescript-estree": { "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.47.1.tgz", - "integrity": "sha512-4+ZhFSuISAvRi2xUszEj0xXbNTHceV9GbH9S8oAD2a/F9SW57aJNQVOCxG8GPfSWH/X4eOPdMEU2jYVuWKEpWA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/types": "5.47.1", "@typescript-eslint/visitor-keys": "5.47.1", @@ -6588,9 +6171,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -6603,31 +6185,10 @@ } } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@typescript-eslint/utils": { "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.47.1.tgz", - "integrity": "sha512-l90SdwqfmkuIVaREZ2ykEfCezepCLxzWMo5gVfcJsJCaT4jHT+QjgSkYhs5BMQmWqE9k3AtIfk4g211z/sTMVw==", "dev": true, + "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", @@ -6651,9 +6212,8 @@ }, "node_modules/@typescript-eslint/visitor-keys": { "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.47.1.tgz", - "integrity": "sha512-rF3pmut2JCCjh6BLRhNKdYjULMb1brvoaiWDlHfLNVgmnZ0sBVJrs3SyaKE1XoDDnJuAx/hDQryHYmPUuNq0ig==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/types": "5.47.1", "eslint-visitor-keys": "^3.3.0" @@ -6668,15 +6228,13 @@ }, "node_modules/@ungap/promise-all-settled": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", - "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", - "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", "dev": true, + "license": "MIT", "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -6684,27 +6242,23 @@ }, "node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", - "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", - "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", "dev": true, + "license": "MIT", "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -6713,15 +6267,13 @@ }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", - "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", "dev": true, + "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.11.6", "@webassemblyjs/helper-buffer": "1.11.6", @@ -6731,33 +6283,29 @@ }, "node_modules/@webassemblyjs/ieee754": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", - "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", "dev": true, + "license": "MIT", "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "node_modules/@webassemblyjs/leb128": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", - "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/utf8": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", - "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", "dev": true, + "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.11.6", "@webassemblyjs/helper-buffer": "1.11.6", @@ -6771,9 +6319,8 @@ }, "node_modules/@webassemblyjs/wasm-gen": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", - "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", "dev": true, + "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", @@ -6784,9 +6331,8 @@ }, "node_modules/@webassemblyjs/wasm-opt": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", - "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", "dev": true, + "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.11.6", "@webassemblyjs/helper-buffer": "1.11.6", @@ -6796,9 +6342,8 @@ }, "node_modules/@webassemblyjs/wasm-parser": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", - "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", "dev": true, + "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -6810,9 +6355,8 @@ }, "node_modules/@webassemblyjs/wast-printer": { "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", - "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", "dev": true, + "license": "MIT", "dependencies": { "@webassemblyjs/ast": "1.11.6", "@xtuc/long": "4.2.2" @@ -6820,26 +6364,22 @@ }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "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 + "dev": true, + "license": "ISC" }, "node_modules/accepts": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -6850,9 +6390,7 @@ }, "node_modules/acorn": { "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -6862,17 +6400,15 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/acorn-loose": { "version": "8.3.0", - "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.3.0.tgz", - "integrity": "sha512-75lAs9H19ldmW+fAbyqHdjgdCrz0pWGXKmnqFoh8PyVd1L2RIb4RzYrSjmopeqv3E1G3/Pimu6GgLlrGbrkF7w==", + "license": "MIT", "dependencies": { "acorn": "^8.5.0" }, @@ -6882,8 +6418,7 @@ }, "node_modules/acorn-loose/node_modules/acorn": { "version": "8.8.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz", - "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==", + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -6891,18 +6426,32 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-node": { + "version": "1.8.2", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^7.0.0", + "acorn-walk": "^7.0.0", + "xtend": "^4.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "7.2.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/adm-zip": { "version": "0.5.9", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.9.tgz", - "integrity": "sha512-s+3fXLkeeLjZ2kLjCBwQufpI5fuN+kIGBxu6530nVQZGVol0d7Y/M88/xw9HGGUcJjKf8LutN3VPRUBq6N7Ajg==", + "license": "MIT", "engines": { "node": ">=6.0" } }, "node_modules/agent-base": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", "dependencies": { "debug": "4" }, @@ -6912,9 +6461,8 @@ }, "node_modules/aggregate-error": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", "dev": true, + "license": "MIT", "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -6925,8 +6473,7 @@ }, "node_modules/ajv": { "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -6940,8 +6487,7 @@ }, "node_modules/ajv-formats": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", "dependencies": { "ajv": "^8.0.0" }, @@ -6956,8 +6502,7 @@ }, "node_modules/ajv-keywords": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -6965,10 +6510,17 @@ "ajv": "^8.8.2" } }, + "node_modules/amdefine": { + "version": "1.0.1", + "license": "BSD-3-Clause OR MIT", + "optional": true, + "engines": { + "node": ">=0.4.2" + } + }, "node_modules/amqp-connection-manager": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/amqp-connection-manager/-/amqp-connection-manager-3.9.0.tgz", - "integrity": "sha512-ZKw9ckJKz40Lc2pC7DY0NVocpzPalMaCgv0sBn+N4er2QFAJul9pIiMOm/FsPHeCzB+FulV7PckOpmZvWvewGQ==", + "license": "MIT", "dependencies": { "promise-breaker": "^5.0.0" }, @@ -6982,8 +6534,7 @@ }, "node_modules/amqplib": { "version": "0.8.0", - "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.8.0.tgz", - "integrity": "sha512-icU+a4kkq4Y1PS4NNi+YPDMwdlbFcZ1EZTQT2nigW3fvOb6AOgUQ9+Mk4ue0Zu5cBg/XpDzB40oH10ysrk2dmA==", + "license": "MIT", "dependencies": { "bitsyntax": "~0.1.0", "bluebird": "^3.7.2", @@ -6998,25 +6549,22 @@ }, "node_modules/ansi": { "version": "0.3.1", - "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz", - "integrity": "sha1-DELU+xcWDVqa8eSEus4cZpIsGyE=", + "license": "MIT", "optional": true, "peer": true }, "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==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/ansi-escapes": { "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.21.3" }, @@ -7029,17 +6577,15 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/ansi-styles": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -7049,15 +6595,13 @@ }, "node_modules/any-promise": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", - "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", "dev": true, + "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -7068,14 +6612,12 @@ }, "node_modules/append-field": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + "license": "MIT" }, "node_modules/append-transform": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", - "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", "dev": true, + "license": "MIT", "dependencies": { "default-require-extensions": "^3.0.0" }, @@ -7085,14 +6627,12 @@ }, "node_modules/archy": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/are-we-there-yet": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.0.6.tgz", - "integrity": "sha1-otKMkxAqpsyWJFomy5VN4G7FPww=", + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -7102,22 +6642,18 @@ }, "node_modules/arg": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", - "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==" + "license": "MIT" }, "node_modules/argparse": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, + "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" } }, "node_modules/args": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/args/-/args-5.0.1.tgz", - "integrity": "sha512-1kqmFCFsPffavQFGt8OxJdIcETti99kySRUPMpOhaGjL6mRJn8HFU1OxKY5bMqfZKUwTQc1mZkAjmGYaVOHFtQ==", + "license": "MIT", "dependencies": { "camelcase": "5.0.0", "chalk": "2.4.2", @@ -7130,8 +6666,7 @@ }, "node_modules/args/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==", + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -7141,8 +6676,7 @@ }, "node_modules/args/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==", + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -7154,24 +6688,21 @@ }, "node_modules/args/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": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/args/node_modules/has-flag": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/args/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==", + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -7181,9 +6712,8 @@ }, "node_modules/aria-query": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.10.2", "@babel/runtime-corejs3": "^7.10.2" @@ -7194,8 +6724,7 @@ }, "node_modules/array-buffer-byte-length": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "is-array-buffer": "^3.0.1" @@ -7206,14 +6735,16 @@ }, "node_modules/array-flatten": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + "license": "MIT" + }, + "node_modules/array-from": { + "version": "2.1.1", + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.7", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", - "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -7230,33 +6761,28 @@ }, "node_modules/array-parallel": { "version": "0.1.3", - "resolved": "https://registry.npmjs.org/array-parallel/-/array-parallel-0.1.3.tgz", - "integrity": "sha512-TDPTwSWW5E4oiFiKmz6RGJ/a80Y91GuLgUYuLd49+XBS75tYo8PNgaT2K/OxuQYqkoI852MDGBorg9OcUSTQ8w==" + "license": "MIT" }, "node_modules/array-series": { "version": "0.1.5", - "resolved": "https://registry.npmjs.org/array-series/-/array-series-0.1.5.tgz", - "integrity": "sha512-L0XlBwfx9QetHOsbLDrE/vh2t018w9462HM3iaFfxRiK83aJjAt/Ja3NMkOW7FICwWTlQBa3ZbL5FKhuQWkDrg==" + "license": "MIT" }, "node_modules/array-timsort": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", - "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/array-union": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/array.prototype.findlastindex": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", - "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -7273,9 +6799,8 @@ }, "node_modules/array.prototype.flat": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -7291,9 +6816,8 @@ }, "node_modules/array.prototype.flatmap": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", - "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -7309,8 +6833,7 @@ }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", - "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.0", "call-bind": "^1.0.2", @@ -7329,74 +6852,129 @@ }, "node_modules/asap": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/asn1": { "version": "0.2.3", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", - "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" + "license": "MIT" }, "node_modules/assert-plus": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "license": "MIT", "engines": { "node": ">=0.8" } }, "node_modules/assertion-error": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true, + "license": "MIT", "engines": { "node": "*" } }, + "node_modules/ast-transform": { + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "escodegen": "~1.2.0", + "esprima": "~1.0.4", + "through": "~2.3.4" + } + }, + "node_modules/ast-transform/node_modules/escodegen": { + "version": "1.2.0", + "dependencies": { + "esprima": "~1.0.4", + "estraverse": "~1.5.0", + "esutils": "~1.0.0" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=0.4.0" + }, + "optionalDependencies": { + "source-map": "~0.1.30" + } + }, + "node_modules/ast-transform/node_modules/esprima": { + "version": "1.0.4", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ast-transform/node_modules/estraverse": { + "version": "1.5.1", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ast-transform/node_modules/esutils": { + "version": "1.0.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ast-transform/node_modules/source-map": { + "version": "0.1.43", + "optional": true, + "dependencies": { + "amdefine": ">=0.0.4" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ast-types": { + "version": "0.7.8", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ast-types-flow": { "version": "0.0.7", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", - "integrity": "sha1-9wtzXGvKGlycItmCw+Oef+ujva0=", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/astral-regex": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/async": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" + "license": "MIT" }, "node_modules/async-lock": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.0.tgz", - "integrity": "sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==" + "license": "MIT" }, "node_modules/async-mutex": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz", - "integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==", + "license": "MIT", "dependencies": { "tslib": "^2.4.0" } }, "node_modules/asynckit": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + "license": "MIT" }, "node_modules/available-typed-arrays": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -7406,9 +6984,8 @@ }, "node_modules/aws-crt": { "version": "1.10.6", - "resolved": "https://registry.npmjs.org/aws-crt/-/aws-crt-1.10.6.tgz", - "integrity": "sha512-LCOFwFdUk3NJNUkm0YuiqU2jPtnC5OZYeLqIZyqVzDoSpK2wL7QAaA553v4YPA5xs3QU7jCmaDl6y3LjJTm4+w==", "hasInstallScript": true, + "license": "Apache-2.0", "optional": true, "peer": true, "dependencies": { @@ -7424,18 +7001,37 @@ }, "node_modules/aws-crt/node_modules/axios": { "version": "0.24.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", - "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { "follow-redirects": "^1.14.4" } }, + "node_modules/aws-crt/node_modules/ws": { + "version": "7.5.9", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/aws-sdk": { "version": "2.1375.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1375.0.tgz", - "integrity": "sha512-4JusqLa0+TJ4a2rfxuiPiaEHZVxVWDzREN8rAI4zhL+u4QbqGq95yfMh9v5QtSDkdNCAReA5DSSVXPOHbS80pA==", + "license": "Apache-2.0", "dependencies": { "buffer": "4.9.2", "events": "1.1.1", @@ -7454,9 +7050,8 @@ }, "node_modules/aws-sdk-client-mock": { "version": "0.5.6", - "resolved": "https://registry.npmjs.org/aws-sdk-client-mock/-/aws-sdk-client-mock-0.5.6.tgz", - "integrity": "sha512-67C+6vlSMPhVGaDlUak3XVR/qvah4ENMMJMl+aWVnK42pBznQQuNvYzlg+OBeW+aBa6kOVDSAYstIqNfInIB/A==", "dev": true, + "license": "MIT", "dependencies": { "@types/sinon": "10.0.2", "sinon": "^11.1.1", @@ -7469,24 +7064,21 @@ }, "node_modules/aws-sdk/node_modules/events": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", - "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=", + "license": "MIT", "engines": { "node": ">=0.4.x" } }, "node_modules/aws-sdk/node_modules/uuid": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", - "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } }, "node_modules/aws-sdk/node_modules/xml2js": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", - "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" @@ -7497,30 +7089,26 @@ }, "node_modules/aws-sign2": { "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "license": "Apache-2.0", "engines": { "node": "*" } }, "node_modules/aws4": { "version": "1.11.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", - "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" + "license": "MIT" }, "node_modules/axe-core": { "version": "4.6.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.6.1.tgz", - "integrity": "sha512-lCZN5XRuOnpG4bpMq8v0khrWtUOn+i8lZSb6wHZH56ZfbIEv6XwJV84AAueh9/zi7qPVJ/E4yz6fmsiyOmXR4w==", "dev": true, + "license": "MPL-2.0", "engines": { "node": ">=4" } }, "node_modules/axios": { "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -7529,8 +7117,7 @@ }, "node_modules/axios-mock-adapter": { "version": "1.21.2", - "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.21.2.tgz", - "integrity": "sha512-jzyNxU3JzB2XVhplZboUcF0YDs7xuExzoRSHXPHr+UQajaGmcTqvkkUADgkVI2WkGlpZ1zZlMVdcTMU0ejV8zQ==", + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "is-buffer": "^2.0.5" @@ -7541,15 +7128,13 @@ }, "node_modules/axobject-query": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", - "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", @@ -7563,9 +7148,8 @@ }, "node_modules/babel-preset-current-node-syntax": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", @@ -7586,8 +7170,7 @@ }, "node_modules/backoff": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", - "integrity": "sha1-9hbtqdPktmuMp/ynn2lXIsX44m8=", + "license": "MIT", "dependencies": { "precond": "0.2" }, @@ -7597,13 +7180,10 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", @@ -7617,12 +7197,12 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/bbb-promise": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/bbb-promise/-/bbb-promise-1.2.0.tgz", - "integrity": "sha512-Ox+L3yDXiRk+Our/cEKndKkGEIJbWxa7cvgx5rWLZy9qrz33xd9nFTQnD4+C6KM+AFAhwWpLJ3iM8xDX8hAyxQ==", + "license": "ISC", "dependencies": { "build-url": "^1.0.9", "request": "^2.81.0", @@ -7633,21 +7213,25 @@ }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "license": "BSD-3-Clause", "dependencies": { "tweetnacl": "^0.14.3" } }, "node_modules/bcryptjs": { "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } }, "node_modules/big-integer": { "version": "1.6.51", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", - "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==", + "license": "Unlicense", "optional": true, "peer": true, "engines": { @@ -7656,36 +7240,28 @@ }, "node_modules/binary": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", - "integrity": "sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=", + "license": "MIT", "optional": true, "peer": true, "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" - }, - "engines": { - "node": "*" } }, "node_modules/binary-extensions": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/bintrees": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz", - "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=" + "version": "1.0.1" }, "node_modules/bitsyntax": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/bitsyntax/-/bitsyntax-0.1.0.tgz", - "integrity": "sha512-ikAdCnrloKmFOugAfxWws89/fPc+nw0OOG1IzIE72uSOg/A3cYptKCjSUhDTuj7fhsJtzkzlv7l3b8PzRHLN0Q==", + "license": "MIT", "dependencies": { "buffer-more-ints": "~1.0.0", "debug": "~2.6.9", @@ -7697,67 +7273,26 @@ }, "node_modules/bitsyntax/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } }, "node_modules/bitsyntax/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + "license": "MIT" }, "node_modules/bitsyntax/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/bl": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", - "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", - "dependencies": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/bl/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/bl/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dependencies": { - "safe-buffer": "~5.1.0" - } + "license": "MIT" }, "node_modules/bluebird": { "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + "license": "MIT" }, "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==", + "license": "MIT", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -7779,24 +7314,21 @@ }, "node_modules/body-parser/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } }, "node_modules/body-parser/node_modules/depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/body-parser/node_modules/http-errors": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", @@ -7810,13 +7342,11 @@ }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + "license": "MIT" }, "node_modules/body-parser/node_modules/on-finished": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -7826,8 +7356,7 @@ }, "node_modules/body-parser/node_modules/raw-body": { "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -7840,26 +7369,22 @@ }, "node_modules/body-parser/node_modules/statuses": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/boolbase": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + "license": "ISC" }, "node_modules/bowser": { "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", - "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" + "license": "MIT" }, "node_modules/brace-expansion": { "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7867,8 +7392,7 @@ }, "node_modules/braces": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "license": "MIT", "dependencies": { "fill-range": "^7.0.1" }, @@ -7876,17 +7400,55 @@ "node": ">=8" } }, + "node_modules/brfs": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "quote-stream": "^1.0.1", + "resolve": "^1.1.5", + "static-module": "^3.0.2", + "through2": "^2.0.0" + }, + "bin": { + "brfs": "bin/cmd.js" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browser-resolve": { + "version": "1.11.3", + "license": "MIT", + "dependencies": { + "resolve": "1.1.7" + } + }, + "node_modules/browser-resolve/node_modules/resolve": { + "version": "1.1.7", + "license": "MIT" + }, "node_modules/browser-stdout": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true + "dev": true, + "license": "ISC" + }, + "node_modules/browserify-optional": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "ast-transform": "0.0.0", + "ast-types": "^0.7.0", + "browser-resolve": "^1.8.1" + } }, "node_modules/browserslist": { "version": "4.19.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.19.1.tgz", - "integrity": "sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A==", "dev": true, + "license": "MIT", "dependencies": { "caniuse-lite": "^1.0.30001286", "electron-to-chromium": "^1.4.17", @@ -7907,9 +7469,8 @@ }, "node_modules/bs-logger": { "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", "dev": true, + "license": "MIT", "dependencies": { "fast-json-stable-stringify": "2.x" }, @@ -7919,17 +7480,16 @@ }, "node_modules/bser": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "node-int64": "^0.4.0" } }, "node_modules/bson": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.0.tgz", - "integrity": "sha512-VrlEE4vuiO1WTpfof4VmaVolCVYkYTgB9iWgYNOrVlnifpME/06fhFRmONgBhClD5pFC1t9ZWqFUQEQAzY43bA==", + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz", + "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==", "dependencies": { "buffer": "^5.6.0" }, @@ -7962,8 +7522,7 @@ }, "node_modules/buffer": { "version": "4.9.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", - "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "license": "MIT", "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", @@ -7972,26 +7531,29 @@ }, "node_modules/buffer-crc32": { "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", "engines": { "node": "*" } }, + "node_modules/buffer-equal": { + "version": "0.0.1", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + "license": "BSD-3-Clause" }, "node_modules/buffer-from": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + "license": "MIT" }, "node_modules/buffer-indexof-polyfill": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", - "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", "optional": true, "peer": true, "engines": { @@ -8000,20 +7562,16 @@ }, "node_modules/buffer-more-ints": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz", - "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==" + "license": "MIT" }, "node_modules/buffer-shims": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", - "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E=", + "license": "MIT", "optional": true, "peer": true }, "node_modules/buffers": { "version": "0.1.1", - "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", - "integrity": "sha1-skV5w77U1tOWru5tmorn9Ugqt7s=", "optional": true, "peer": true, "engines": { @@ -8022,15 +7580,12 @@ }, "node_modules/build-url": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/build-url/-/build-url-1.3.3.tgz", - "integrity": "sha512-uSC8d+d4SlbXTu/9nBhwEKi33CE0KQgCvfy8QwyrrO5vCuXr9hN021ZBh8ip5vxPbMOrZiPwgqcupuhezxiP3g==", - "deprecated": "This package is no longer maintained" + "license": "MIT" }, "node_modules/bundle-require": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-3.1.2.tgz", - "integrity": "sha512-Of6l6JBAxiyQ5axFxUM6dYeP/W7X2Sozeo/4EYB9sJhL+dqL7TKjg+shwxp6jlu/6ZSERfsYtIpSJ1/x3XkAEA==", "dev": true, + "license": "MIT", "dependencies": { "load-tsconfig": "^0.2.0" }, @@ -8043,11 +7598,10 @@ }, "node_modules/bunyan": { "version": "1.8.15", - "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz", - "integrity": "sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==", "engines": [ "node >=0.10.0" ], + "license": "MIT", "bin": { "bunyan": "bin/bunyan" }, @@ -8060,8 +7614,6 @@ }, "node_modules/busboy": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", "dependencies": { "streamsearch": "^1.1.0" }, @@ -8071,40 +7623,35 @@ }, "node_modules/byte-length": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", - "integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==" + "license": "MIT" }, "node_modules/bytes": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/cac": { "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/cache-manager": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.3.1.tgz", - "integrity": "sha512-9HP6nc1ZqyZgcVEpy5XS2ns9MYE6cPEM6InA1wQhR6M7GviJzLH2NTFYnf3NEfRmLE351NCSkDo2VISX8dlG+w==", + "version": "5.4.0", + "license": "MIT", "dependencies": { "lodash.clonedeep": "^4.5.0", - "lru-cache": "^10.0.2", - "promise-coalesce": "^1.1.1" + "lru-cache": "^10.1.0", + "promise-coalesce": "^1.1.2" } }, "node_modules/cache-manager-redis-yet": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/cache-manager-redis-yet/-/cache-manager-redis-yet-4.1.2.tgz", - "integrity": "sha512-pM2K1ZlOv8gQpE1Z5mcDrfLj5CsNKVRiYua/SZ12j7LEDgfDeFVntI6JSgIw0siFSR/9P/FpG30scI3frHwibA==", + "license": "MIT", "dependencies": { "@redis/bloom": "^1.2.0", "@redis/client": "^1.5.8", @@ -8120,18 +7667,16 @@ } }, "node_modules/cache-manager/node_modules/lru-cache": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", - "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", + "version": "10.2.0", + "license": "ISC", "engines": { "node": "14 || >=16.14" } }, "node_modules/caching-transform": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", - "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", "dev": true, + "license": "MIT", "dependencies": { "hasha": "^5.0.0", "make-dir": "^3.0.0", @@ -8144,8 +7689,7 @@ }, "node_modules/call-bind": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2", "get-intrinsic": "^1.2.1", @@ -8157,39 +7701,34 @@ }, "node_modules/call-me-maybe": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", - "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms=" + "license": "MIT" }, "node_modules/callsites": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/camelcase": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz", - "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==", + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/camelize-ts": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/camelize-ts/-/camelize-ts-2.5.0.tgz", - "integrity": "sha512-ERaOJadw+ID9MuKGeTOF1kQOb/zZIv6Vkt44kFYZraiAFiZU6E3TwnJebp8jofsW/hDxME/U63lFdNKIkSqijw==", + "version": "3.0.0", + "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, "node_modules/caniuse-lite": { "version": "1.0.30001309", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001309.tgz", - "integrity": "sha512-Pl8vfigmBXXq+/yUz1jUwULeq9xhMJznzdc/xwl4WclDAuebcTHVefpz8lE/bMI+UN7TOkSSe7B7RnZd6+dzjA==", "dev": true, + "license": "CC-BY-4.0", "funding": { "type": "opencollective", "url": "https://opencollective.com/browserslist" @@ -8197,14 +7736,12 @@ }, "node_modules/caseless": { "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + "license": "Apache-2.0" }, "node_modules/chai": { "version": "4.3.6", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz", - "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==", "dev": true, + "license": "MIT", "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.2", @@ -8220,9 +7757,8 @@ }, "node_modules/chai-as-promised": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", - "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", "dev": true, + "license": "WTFPL", "dependencies": { "check-error": "^1.0.2" }, @@ -8232,9 +7768,8 @@ }, "node_modules/chai-http": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/chai-http/-/chai-http-4.3.0.tgz", - "integrity": "sha512-zFTxlN7HLMv+7+SPXZdkd5wUlK+KxH6Q7bIEMiEx0FK3zuuMqL7cwICAQ0V1+yYRozBburYuxN1qZstgHpFZQg==", "dev": true, + "license": "MIT", "dependencies": { "@types/chai": "4", "@types/superagent": "^3.8.3", @@ -8250,9 +7785,8 @@ }, "node_modules/chai-http/node_modules/@types/superagent": { "version": "3.8.7", - "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-3.8.7.tgz", - "integrity": "sha512-9KhCkyXv268A2nZ1Wvu7rQWM+BmdYUVkycFeNnYrUL5Zwu7o8wPQ3wBfW59dDP+wuoxw0ww8YKgTNv8j/cgscA==", "dev": true, + "license": "MIT", "dependencies": { "@types/cookiejar": "*", "@types/node": "*" @@ -8260,31 +7794,22 @@ }, "node_modules/chainsaw": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", - "integrity": "sha1-XqtQsor+WAdNDVgpE4iCi15fvJg=", + "license": "MIT/X11", "optional": true, "peer": true, "dependencies": { "traverse": ">=0.3.0 <0.4" - }, - "engines": { - "node": "*" } }, "node_modules/chainsaw/node_modules/traverse": { "version": "0.3.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", - "integrity": "sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=", + "license": "MIT/X11", "optional": true, - "peer": true, - "engines": { - "node": "*" - } + "peer": true }, "node_modules/chalk": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.0.tgz", - "integrity": "sha512-/duVOqst+luxCQRKEo4bNxinsOQtMP80ZYm7mMqzuh5PociNL0PvmHFvREJ9ueYL2TxlHjBcmLCdmocx9Vg+IQ==", + "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" }, @@ -8294,40 +7819,35 @@ }, "node_modules/char-regex": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/chardet": { "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/charenc": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=", + "license": "BSD-3-Clause", "engines": { "node": "*" } }, "node_modules/check-error": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", "dev": true, + "license": "MIT", "engines": { "node": "*" } }, "node_modules/cheerio": { "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", - "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "license": "MIT", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", @@ -8346,8 +7866,7 @@ }, "node_modules/cheerio-select": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", @@ -8362,8 +7881,7 @@ }, "node_modules/cheerio-select/node_modules/dom-serializer": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -8375,8 +7893,7 @@ }, "node_modules/cheerio-select/node_modules/domhandler": { "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" }, @@ -8389,8 +7906,7 @@ }, "node_modules/cheerio-select/node_modules/domutils": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -8402,8 +7918,7 @@ }, "node_modules/cheerio-select/node_modules/entities": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -8413,8 +7928,7 @@ }, "node_modules/cheerio/node_modules/dom-serializer": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -8426,8 +7940,7 @@ }, "node_modules/cheerio/node_modules/domhandler": { "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" }, @@ -8440,8 +7953,7 @@ }, "node_modules/cheerio/node_modules/domutils": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -8453,8 +7965,7 @@ }, "node_modules/cheerio/node_modules/entities": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -8464,8 +7975,6 @@ }, "node_modules/cheerio/node_modules/htmlparser2": { "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -8473,6 +7982,7 @@ "url": "https://github.com/sponsors/fb55" } ], + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", @@ -8482,8 +7992,6 @@ }, "node_modules/chokidar": { "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", "dev": true, "funding": [ { @@ -8491,6 +7999,7 @@ "url": "https://paulmillr.com/funding/" } ], + "license": "MIT", "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -8509,8 +8018,7 @@ }, "node_modules/chownr": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", "optional": true, "peer": true, "engines": { @@ -8519,42 +8027,36 @@ }, "node_modules/chrome-trace-event": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0" } }, "node_modules/ci-info": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", - "integrity": "sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cjs-module-lexer": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", - "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/clamscan": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.1.2.tgz", - "integrity": "sha512-pcovgLHcrg3l/mI51Kuk0kN++07pSZdBTskISw0UFvsm8UXda8oNCm0eLeODxFg85Mz+k+TtSS9+XPlriJ8/Fg==", + "license": "MIT", "engines": { "node": ">=12.0.0" } }, "node_modules/class-transformer": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz", - "integrity": "sha512-ETWD/H2TbWbKEi7m9N4Km5+cw1hNcqJSxlSYhsLsNjQzWWiZIYA1zafxpK9PwVfaZ6AqR5rrjPVUBGESm5tQUA==" + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.0", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.0.tgz", - "integrity": "sha512-ct3ltplN8I9fOwUd8GrP8UQixwff129BkEtuWDKL5W45cQuLd19xqmTLu5ge78YDm/fdje6FMt0hGOhl0lii3A==", + "license": "MIT", "dependencies": { "@types/validator": "^13.7.10", "libphonenumber-js": "^1.10.14", @@ -8563,18 +8065,16 @@ }, "node_modules/clean-stack": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/cli-cursor": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "dev": true, + "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" }, @@ -8584,9 +8084,8 @@ }, "node_modules/cli-spinners": { "version": "2.6.1", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz", - "integrity": "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" }, @@ -8596,9 +8095,8 @@ }, "node_modules/cli-table3": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", - "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==", "dev": true, + "license": "MIT", "dependencies": { "string-width": "^4.2.0" }, @@ -8611,17 +8109,15 @@ }, "node_modules/cli-width": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", "dev": true, + "license": "ISC", "engines": { "node": ">= 10" } }, "node_modules/client-oauth2": { "version": "4.3.3", - "resolved": "https://registry.npmjs.org/client-oauth2/-/client-oauth2-4.3.3.tgz", - "integrity": "sha512-k8AvUYJon0vv75ufoVo4nALYb/qwFFicO3I0+39C6xEdflqVtr+f9cy+0ZxAduoVSTfhP5DX2tY2XICAd5hy6Q==", + "license": "Apache-2.0", "dependencies": { "popsicle": "^12.0.5", "safe-buffer": "^5.2.0" @@ -8632,8 +8128,9 @@ }, "node_modules/cliui": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", - "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "string-width": "^1.0.1", "strip-ansi": "^3.0.1", @@ -8642,16 +8139,18 @@ }, "node_modules/cliui/node_modules/ansi-regex": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/cliui/node_modules/is-fullwidth-code-point": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "number-is-nan": "^1.0.0" }, @@ -8661,8 +8160,9 @@ }, "node_modules/cliui/node_modules/string-width": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -8674,8 +8174,9 @@ }, "node_modules/cliui/node_modules/strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ansi-regex": "^2.0.0" }, @@ -8685,8 +8186,9 @@ }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", - "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "string-width": "^1.0.1", "strip-ansi": "^3.0.1" @@ -8695,18 +8197,23 @@ "node": ">=0.10.0" } }, + "node_modules/clone": { + "version": "1.0.4", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", "engines": { "node": ">=0.10.0" } }, "node_modules/cmake-js": { "version": "6.3.0", - "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-6.3.0.tgz", - "integrity": "sha512-1uqTOmFt6BIqKlrX+39/aewU/JVhyZWDqwAL+6psToUwxj3yWPJiwxiZFmV0XdcoWmqGs7peZTxTbJtAcH8hxw==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -8735,8 +8242,7 @@ }, "node_modules/cmake-js/node_modules/axios": { "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -8745,15 +8251,13 @@ }, "node_modules/cmake-js/node_modules/chownr": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", "optional": true, "peer": true }, "node_modules/cmake-js/node_modules/fs-extra": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-5.0.0.tgz", - "integrity": "sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -8764,8 +8268,7 @@ }, "node_modules/cmake-js/node_modules/fs-minipass": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", - "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -8774,8 +8277,7 @@ }, "node_modules/cmake-js/node_modules/jsonfile": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "license": "MIT", "optional": true, "peer": true, "optionalDependencies": { @@ -8784,8 +8286,7 @@ }, "node_modules/cmake-js/node_modules/minipass": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", - "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -8795,8 +8296,7 @@ }, "node_modules/cmake-js/node_modules/minizlib": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", - "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -8805,8 +8305,7 @@ }, "node_modules/cmake-js/node_modules/mkdirp": { "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -8818,8 +8317,7 @@ }, "node_modules/cmake-js/node_modules/semver": { "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", "optional": true, "peer": true, "bin": { @@ -8828,8 +8326,7 @@ }, "node_modules/cmake-js/node_modules/tar": { "version": "4.4.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz", - "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==", + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -8847,8 +8344,7 @@ }, "node_modules/cmake-js/node_modules/universalify": { "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", "optional": true, "peer": true, "engines": { @@ -8857,16 +8353,14 @@ }, "node_modules/cmake-js/node_modules/yallist": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC", "optional": true, "peer": true }, "node_modules/co": { "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", "dev": true, + "license": "MIT", "engines": { "iojs": ">= 1.0.0", "node": ">= 0.12.0" @@ -8874,22 +8368,21 @@ }, "node_modules/code-point-at": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/collect-v8-coverage": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", - "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/color": { "version": "3.2.1", - "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", - "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", "dependencies": { "color-convert": "^1.9.3", "color-string": "^1.6.0" @@ -8897,38 +8390,31 @@ }, "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==", + "license": "MIT", "dependencies": { "color-name": "1.1.3" } }, "node_modules/color-name": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "license": "MIT" }, "node_modules/color-string": { "version": "1.9.0", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.0.tgz", - "integrity": "sha512-9Mrz2AQLefkH1UvASKj6v6hj/7eWgjnT/cVsR8CumieLoT+g900exWeNogqtweI8dxloXN9BDQTYro1oWu/5CQ==", + "license": "MIT", "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, - "node_modules/colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "engines": { - "node": ">=0.1.90" - } + "node_modules/colorette": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", + "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==" }, "node_modules/colorspace": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", - "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "license": "MIT", "dependencies": { "color": "^3.1.3", "text-hex": "1.0.x" @@ -8936,8 +8422,7 @@ }, "node_modules/combined-stream": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -8947,17 +8432,15 @@ }, "node_modules/commander": { "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", "engines": { "node": ">= 12" } }, "node_modules/comment-json": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.3.tgz", - "integrity": "sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw==", "dev": true, + "license": "MIT", "dependencies": { "array-timsort": "^1.0.3", "core-util-is": "^1.0.3", @@ -8971,8 +8454,7 @@ }, "node_modules/commist": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/commist/-/commist-1.1.0.tgz", - "integrity": "sha512-rraC8NXWOEjhADbZe9QBNzLAN5Q3fsTPQtBV+fEVj6xKIgDgNiEVE6ZNfHpZOqfQ21YUzfVNUXLOEZquYvQPPg==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -8982,29 +8464,25 @@ }, "node_modules/common-tags": { "version": "1.8.2", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", - "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", "dev": true, + "license": "MIT", "engines": { "node": ">=4.0.0" } }, "node_modules/commondir": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/component-emitter": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/compressible": { "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", "dependencies": { "mime-db": ">= 1.43.0 < 2" }, @@ -9014,8 +8492,7 @@ }, "node_modules/compression": { "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "license": "MIT", "dependencies": { "accepts": "~1.3.5", "bytes": "3.0.0", @@ -9031,42 +8508,36 @@ }, "node_modules/compression/node_modules/bytes": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/compression/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } }, "node_modules/compression/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + "license": "MIT" }, "node_modules/compression/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "license": "MIT" }, "node_modules/concat-stream": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", "engines": [ "node >= 6.0" ], + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -9078,8 +8549,7 @@ }, "node_modules/concat-stream/node_modules/readable-stream": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -9093,8 +8563,7 @@ }, "node_modules/concat-stream/node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -9103,8 +8572,7 @@ }, "node_modules/concurrently": { "version": "6.5.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-6.5.1.tgz", - "integrity": "sha512-FlSwNpGjWQfRwPLXvJ/OgysbBxPkWpiVjy1042b0U7on7S7qwwMIILRj7WTN1mTgqa582bG6NFuScOoh6Zgdag==", + "license": "MIT", "dependencies": { "chalk": "^4.1.0", "date-fns": "^2.16.1", @@ -9124,8 +8592,7 @@ }, "node_modules/concurrently/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -9138,8 +8605,7 @@ }, "node_modules/concurrently/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -9153,8 +8619,7 @@ }, "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -9164,8 +8629,7 @@ }, "node_modules/concurrently/node_modules/cliui": { "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -9174,8 +8638,7 @@ }, "node_modules/concurrently/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -9185,13 +8648,11 @@ }, "node_modules/concurrently/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "license": "MIT" }, "node_modules/concurrently/node_modules/rxjs": { "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "license": "Apache-2.0", "dependencies": { "tslib": "^1.9.0" }, @@ -9201,21 +8662,18 @@ }, "node_modules/concurrently/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "license": "0BSD" }, "node_modules/concurrently/node_modules/y18n": { "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/concurrently/node_modules/yargs": { "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -9231,8 +8689,7 @@ }, "node_modules/config": { "version": "3.3.9", - "resolved": "https://registry.npmjs.org/config/-/config-3.3.9.tgz", - "integrity": "sha512-G17nfe+cY7kR0wVpc49NCYvNtelm/pPy8czHoFkAgtV1lkmcp7DHtWCdDu+C9Z7gb2WVqa9Tm3uF9aKaPbCfhg==", + "license": "MIT", "dependencies": { "json5": "^2.2.3" }, @@ -9242,30 +8699,16 @@ }, "node_modules/confusing-browser-globals": { "version": "1.0.11", - "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", - "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", - "dev": true - }, - "node_modules/connect-redis": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-7.1.0.tgz", - "integrity": "sha512-UaqO1EirWjON2ENsyau7N5lbkrdYBpS6mYlXSeff/OYXsd6EGZ+SXSmNPoljL2PSua8fgjAEaldSA73PMZQ9Eg==", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "express-session": ">=1" - } + "dev": true, + "license": "MIT" }, "node_modules/consola": { "version": "2.15.3", - "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", - "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==" + "license": "MIT" }, "node_modules/content-disposition": { "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" }, @@ -9275,51 +8718,42 @@ }, "node_modules/content-type": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/convert-source-map": { "version": "1.8.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", - "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", - "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "~5.1.1" } }, "node_modules/convert-source-map/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "license": "MIT" }, "node_modules/cookie": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/cookie-signature": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + "license": "MIT" }, "node_modules/cookiejar": { "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/copyfiles": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", - "integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==", "dev": true, + "license": "MIT", "dependencies": { "glob": "^7.0.5", "minimatch": "^3.0.3", @@ -9336,9 +8770,8 @@ }, "node_modules/copyfiles/node_modules/cliui": { "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -9347,18 +8780,16 @@ }, "node_modules/copyfiles/node_modules/y18n": { "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/copyfiles/node_modules/yargs": { "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -9374,10 +8805,9 @@ }, "node_modules/core-js-pure": { "version": "3.21.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.21.0.tgz", - "integrity": "sha512-VaJUunCZLnxuDbo1rNOzwbet9E1K9joiXS5+DQMPtgxd24wfsZbJZMMfQLGYMlCUvSxLfsRUUhoOR2x28mFfeg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -9385,13 +8815,11 @@ }, "node_modules/core-util-is": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + "license": "MIT" }, "node_modules/cors": { "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", "dependencies": { "object-assign": "^4", "vary": "^1" @@ -9402,9 +8830,8 @@ }, "node_modules/cosmiconfig": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -9418,14 +8845,12 @@ }, "node_modules/create-require": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cross-env": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.1" }, @@ -9441,8 +8866,7 @@ }, "node_modules/cross-spawn": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -9454,8 +8878,7 @@ }, "node_modules/cross-spawn/node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -9468,21 +8891,18 @@ }, "node_modules/crypt": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=", + "license": "BSD-3-Clause", "engines": { "node": "*" } }, "node_modules/crypto-js": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + "license": "MIT" }, "node_modules/css-select": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", @@ -9496,8 +8916,7 @@ }, "node_modules/css-select/node_modules/dom-serializer": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -9509,8 +8928,7 @@ }, "node_modules/css-select/node_modules/domhandler": { "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" }, @@ -9523,8 +8941,7 @@ }, "node_modules/css-select/node_modules/domutils": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -9536,8 +8953,7 @@ }, "node_modules/css-select/node_modules/entities": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -9545,10 +8961,20 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/css-tree": { + "version": "2.3.1", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "license": "BSD-2-Clause", "engines": { "node": ">= 6" }, @@ -9556,24 +8982,42 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssstyle": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/d": { + "version": "1.0.1", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + }, "node_modules/daemon": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/daemon/-/daemon-1.1.0.tgz", - "integrity": "sha1-bFECyB2wvoVvyQCPwsk1s5iGSug=", "engines": { "node": ">= 0.8.0" } }, "node_modules/damerau-levenshtein": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/dash-ast": { + "version": "2.0.1", + "license": "Apache-2.0" }, "node_modules/dashdash": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0" }, @@ -9581,10 +9025,41 @@ "node": ">=0.10" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.0.0", + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "2.28.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz", - "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==", + "license": "MIT", "engines": { "node": ">=0.11" }, @@ -9595,8 +9070,7 @@ }, "node_modules/debug": { "version": "4.3.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", - "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -9611,23 +9085,24 @@ }, "node_modules/decamelize": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "license": "MIT" + }, "node_modules/dedent": { "version": "0.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/deep-eql": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", - "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", "dev": true, + "license": "MIT", "dependencies": { "type-detect": "^4.0.0" }, @@ -9635,10 +9110,27 @@ "node": ">=0.12" } }, + "node_modules/deep-equal": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-extend": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", "optional": true, "peer": true, "engines": { @@ -9647,22 +9139,19 @@ }, "node_modules/deep-is": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + "license": "MIT" }, "node_modules/deepmerge": { "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/default-require-extensions": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.0.tgz", - "integrity": "sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==", "dev": true, + "license": "MIT", "dependencies": { "strip-bom": "^4.0.0" }, @@ -9672,26 +9161,15 @@ }, "node_modules/defaults": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", - "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", "dev": true, + "license": "MIT", "dependencies": { "clone": "^1.0.2" } }, - "node_modules/defaults/node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", - "dev": true, - "engines": { - "node": ">=0.8" - } - }, "node_modules/define-data-property": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "license": "MIT", "dependencies": { "get-intrinsic": "^1.2.1", "gopd": "^1.0.1", @@ -9703,17 +9181,15 @@ }, "node_modules/define-lazy-prop": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/define-properties": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -9728,39 +9204,35 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "license": "MIT", "engines": { "node": ">=0.4.0" } }, "node_modules/delegates": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", + "license": "MIT", "optional": true, "peer": true }, "node_modules/denque": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", - "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "version": "2.1.0", + "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=0.10" } }, "node_modules/depd": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/destroy": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -9768,36 +9240,36 @@ }, "node_modules/detect-newline": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/dezalgo": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", "dev": true, + "license": "ISC", "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, + "node_modules/dfa": { + "version": "1.2.0", + "license": "MIT" + }, "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==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } }, "node_modules/dir-glob": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "license": "MIT", "dependencies": { "path-type": "^4.0.0" }, @@ -9807,20 +9279,17 @@ }, "node_modules/disposable-email-domains": { "version": "1.0.59", - "resolved": "https://registry.npmjs.org/disposable-email-domains/-/disposable-email-domains-1.0.59.tgz", - "integrity": "sha512-45NbOP1Oboaddf0pD5mGnT+1msEifY6VUcR9Msq4zBHk2EeGv9PxiwuoynIfdGID1BSFR3U3egPfMbERkqXxUQ==" + "license": "MIT" }, "node_modules/dlv": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/doctrine": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, + "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -9830,8 +9299,7 @@ }, "node_modules/dom-serializer": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz", - "integrity": "sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==", + "license": "MIT", "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", @@ -9843,19 +9311,17 @@ }, "node_modules/domelementtype": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/fb55" } - ] + ], + "license": "BSD-2-Clause" }, "node_modules/domhandler": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.0.tgz", - "integrity": "sha512-fC0aXNQXqKSFTr2wDNZDhsEYjCiYsDWl3D01kwt25hm1YIPyDGHvvi3rw+PLqHAl/m71MaiF7d5zvBr0p5UB2g==", + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.2.0" }, @@ -9868,8 +9334,7 @@ }, "node_modules/domutils": { "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", @@ -9881,8 +9346,6 @@ }, "node_modules/dot-object": { "version": "2.1.4", - "resolved": "https://registry.npmjs.org/dot-object/-/dot-object-2.1.4.tgz", - "integrity": "sha512-7FXnyyCLFawNYJ+NhkqyP9Wd2yzuo+7n9pGiYpkmXCTYa8Ci2U0eUNDVg5OuO5Pm6aFXI2SWN8/N/w7SJWu1WA==", "dependencies": { "commander": "^4.0.0", "glob": "^7.1.5" @@ -9893,33 +9356,29 @@ }, "node_modules/dot-object/node_modules/commander": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", "engines": { "node": ">= 6" } }, "node_modules/dotenv": { "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", - "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "license": "BSD-2-Clause", "engines": { "node": ">=10" } }, "node_modules/dotenv-expand": { "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" } }, "node_modules/dtrace-provider": { "version": "0.8.8", - "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", - "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", "hasInstallScript": true, + "license": "BSD-2-Clause", "optional": true, "dependencies": { "nan": "^2.14.0" @@ -9930,20 +9389,14 @@ }, "node_modules/duplexer2": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", - "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", - "optional": true, - "peer": true, + "license": "BSD-3-Clause", "dependencies": { "readable-stream": "^2.0.2" } }, "node_modules/duplexer2/node_modules/readable-stream": { "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -9956,25 +9409,18 @@ }, "node_modules/duplexer2/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/duplexer2/node_modules/string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/duplexify": { "version": "3.7.1", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", - "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -9986,8 +9432,7 @@ }, "node_modules/duplexify/node_modules/readable-stream": { "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -10002,15 +9447,13 @@ }, "node_modules/duplexify/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT", "optional": true, "peer": true }, "node_modules/duplexify/node_modules/string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -10019,8 +9462,7 @@ }, "node_modules/ecc-jsbn": { "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "license": "MIT", "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -10028,28 +9470,24 @@ }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" } }, "node_modules/ee-first": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.4.66", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.66.tgz", - "integrity": "sha512-f1RXFMsvwufWLwYUxTiP7HmjprKXrqEWHiQkjAYa9DJeVIlZk5v8gBGcaV+FhtXLly6C1OTVzQY+2UQrACiLlg==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/emittery": { "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -10059,37 +9497,32 @@ }, "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==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/enabled": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + "license": "MIT" }, "node_modules/encodeurl": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/end-of-stream": { "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", "devOptional": true, + "license": "MIT", "dependencies": { "once": "^1.4.0" } }, "node_modules/enhanced-resolve": { "version": "5.15.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", - "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -10100,9 +9533,8 @@ }, "node_modules/enquirer": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", - "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", "dev": true, + "license": "MIT", "dependencies": { "ansi-colors": "^4.1.1" }, @@ -10112,24 +9544,22 @@ }, "node_modules/entities": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/error-ex": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" } }, "node_modules/es-abstract": { "version": "1.22.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", - "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.0", "arraybuffer.prototype.slice": "^1.0.2", @@ -10180,14 +9610,12 @@ }, "node_modules/es-module-lexer": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.0.tgz", - "integrity": "sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/es-set-tostringtag": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", - "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "license": "MIT", "dependencies": { "get-intrinsic": "^1.2.2", "has-tostringtag": "^1.0.0", @@ -10199,17 +9627,15 @@ }, "node_modules/es-shim-unscopables": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", "dev": true, + "license": "MIT", "dependencies": { "hasown": "^2.0.0" } }, "node_modules/es-to-primitive": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "license": "MIT", "dependencies": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -10222,26 +9648,84 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es5-ext": { + "version": "0.10.62", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/es6-error": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-map": { + "version": "0.1.5", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14", + "es6-iterator": "~2.0.1", + "es6-set": "~0.1.5", + "es6-symbol": "~3.1.1", + "event-emitter": "~0.3.5" + } }, "node_modules/es6-promisify": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-7.0.0.tgz", - "integrity": "sha512-ginqzK3J90Rd4/Yz7qRrqUeIpe3TwSXTPPZtPne7tGBPeAaQiU8qt4fpKApnxHcq1AwtUdHVg5P77x/yrggG8Q==", + "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/es6-set": { + "version": "0.1.6", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "es6-iterator": "~2.0.3", + "es6-symbol": "^3.1.3", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es6-set/node_modules/type": { + "version": "2.7.2", + "license": "ISC" + }, + "node_modules/es6-symbol": { + "version": "3.1.3", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "ext": "^1.1.2" + } + }, "node_modules/esbuild": { "version": "0.17.10", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.10.tgz", - "integrity": "sha512-n7V3v29IuZy5qgxx25TKJrEm0FHghAlS6QweUcyIgh/U0zYmQcvogWROitrTyZId1mHSkuhhuyEXtI9OXioq7A==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -10273,267 +9757,10 @@ "@esbuild/win32-x64": "0.17.10" } }, - "node_modules/esbuild-android-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz", - "integrity": "sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-android-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz", - "integrity": "sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-darwin-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz", - "integrity": "sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-darwin-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz", - "integrity": "sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-freebsd-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz", - "integrity": "sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-freebsd-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz", - "integrity": "sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-32": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz", - "integrity": "sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz", - "integrity": "sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-arm": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz", - "integrity": "sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz", - "integrity": "sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-mips64le": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz", - "integrity": "sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-ppc64le": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz", - "integrity": "sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-riscv64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz", - "integrity": "sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-linux-s390x": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz", - "integrity": "sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-netbsd-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz", - "integrity": "sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-openbsd-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz", - "integrity": "sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/esbuild-plugin-d.ts": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/esbuild-plugin-d.ts/-/esbuild-plugin-d.ts-1.1.0.tgz", - "integrity": "sha512-3oSR3kUS4fNdKHLYLcST9YOfD2dULe7/UbXnrnu/mRybJYW+jZlYNgklb9Pt7osg6B1qwAYMyr2jTC+Ijj2YbQ==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "4.x", "jju": "^1.4.0", @@ -10549,9 +9776,8 @@ }, "node_modules/esbuild-plugin-d.ts/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -10564,9 +9790,8 @@ }, "node_modules/esbuild-plugin-d.ts/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10580,9 +9805,8 @@ }, "node_modules/esbuild-plugin-d.ts/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -10592,15 +9816,13 @@ }, "node_modules/esbuild-plugin-d.ts/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/esbuild-plugin-d.ts/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -10610,9 +9832,8 @@ }, "node_modules/esbuild-plugin-d.ts/node_modules/tmp": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", "dev": true, + "license": "MIT", "dependencies": { "rimraf": "^3.0.0" }, @@ -10620,62 +9841,13 @@ "node": ">=8.17.0" } }, - "node_modules/esbuild-sunos-64": { + "node_modules/esbuild-windows-64": { "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz", - "integrity": "sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==", "cpu": [ "x64" ], "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-32": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz", - "integrity": "sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz", - "integrity": "sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild-windows-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz", - "integrity": "sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==", - "cpu": [ - "arm64" - ], - "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -10686,21 +9858,18 @@ }, "node_modules/escalade": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/escape-html": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + "license": "MIT" }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -10710,8 +9879,7 @@ }, "node_modules/escodegen": { "version": "1.14.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", - "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "license": "BSD-2-Clause", "dependencies": { "esprima": "^4.0.1", "estraverse": "^4.2.0", @@ -10731,16 +9899,14 @@ }, "node_modules/escodegen/node_modules/estraverse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, "node_modules/escodegen/node_modules/levn": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "license": "MIT", "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" @@ -10751,8 +9917,7 @@ }, "node_modules/escodegen/node_modules/optionator": { "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "license": "MIT", "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", @@ -10767,16 +9932,13 @@ }, "node_modules/escodegen/node_modules/prelude-ls": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", "engines": { "node": ">= 0.8.0" } }, "node_modules/escodegen/node_modules/type-check": { "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "license": "MIT", "dependencies": { "prelude-ls": "~1.1.2" }, @@ -10786,9 +9948,8 @@ }, "node_modules/eslint": { "version": "8.30.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.30.0.tgz", - "integrity": "sha512-MGADB39QqYuzEGov+F/qb18r4i7DohCDOfatHaxI2iGlPuC65bwG2gxgO+7DkyL38dRFaRH7RaRAgU6JKL9rMQ==", "dev": true, + "license": "MIT", "dependencies": { "@eslint/eslintrc": "^1.4.0", "@humanwhocodes/config-array": "^0.11.8", @@ -10842,9 +10003,8 @@ }, "node_modules/eslint-config-airbnb-base": { "version": "15.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz", - "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", "dev": true, + "license": "MIT", "dependencies": { "confusing-browser-globals": "^1.0.10", "object.assign": "^4.1.2", @@ -10861,18 +10021,16 @@ }, "node_modules/eslint-config-airbnb-base/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/eslint-config-airbnb-typescript": { "version": "17.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-17.0.0.tgz", - "integrity": "sha512-elNiuzD0kPAPTXjFWg+lE24nMdHMtuxgYoD30OyMD6yrW1AhFZPAg27VX7d3tzOErw+dgJTNWfRSDqEcXb4V0g==", "dev": true, + "license": "MIT", "dependencies": { "eslint-config-airbnb-base": "^15.0.0" }, @@ -10885,9 +10043,8 @@ }, "node_modules/eslint-config-prettier": { "version": "8.5.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz", - "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", "dev": true, + "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10897,9 +10054,8 @@ }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", @@ -10908,18 +10064,16 @@ }, "node_modules/eslint-import-resolver-node/node_modules/debug": { "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/eslint-import-resolver-typescript": { "version": "3.5.2", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.5.2.tgz", - "integrity": "sha512-zX4ebnnyXiykjhcBvKIf5TNvt8K7yX6bllTRZ14MiurKPjDpCAZujlszTdB8pcNXhZcOf+god4s9SjQa5GnytQ==", "dev": true, + "license": "ISC", "dependencies": { "debug": "^4.3.4", "enhanced-resolve": "^5.10.0", @@ -10942,9 +10096,8 @@ }, "node_modules/eslint-import-resolver-typescript/node_modules/debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -10959,9 +10112,8 @@ }, "node_modules/eslint-import-resolver-typescript/node_modules/globby": { "version": "13.1.3", - "resolved": "https://registry.npmjs.org/globby/-/globby-13.1.3.tgz", - "integrity": "sha512-8krCNHXvlCgHDpegPzleMq07yMYTO2sXKASmZmquEYWEmCx6J5UTRbp5RwMJkTJGtcQ44YpiUYUiN0b9mzy8Bw==", "dev": true, + "license": "MIT", "dependencies": { "dir-glob": "^3.0.1", "fast-glob": "^3.2.11", @@ -10978,9 +10130,8 @@ }, "node_modules/eslint-import-resolver-typescript/node_modules/slash": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -10990,9 +10141,8 @@ }, "node_modules/eslint-module-utils": { "version": "2.8.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", - "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^3.2.7" }, @@ -11007,18 +10157,16 @@ }, "node_modules/eslint-module-utils/node_modules/debug": { "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/eslint-plugin-import": { "version": "2.29.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", - "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", "dev": true, + "license": "MIT", "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -11047,18 +10195,16 @@ }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/eslint-plugin-import/node_modules/doctrine": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -11068,9 +10214,8 @@ }, "node_modules/eslint-plugin-import/node_modules/json5": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, + "license": "MIT", "dependencies": { "minimist": "^1.2.0" }, @@ -11080,27 +10225,24 @@ }, "node_modules/eslint-plugin-import/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/eslint-plugin-import/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, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/eslint-plugin-import/node_modules/tsconfig-paths": { "version": "3.14.2", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", - "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", "dev": true, + "license": "MIT", "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", @@ -11110,9 +10252,8 @@ }, "node_modules/eslint-plugin-jest": { "version": "27.1.7", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.1.7.tgz", - "integrity": "sha512-0QVzf+og4YI1Qr3UoprkqqhezAZjFffdi62b0IurkCXMqPtRW84/UT4CKsYT80h/D82LA9avjO/80Ou1LdgbaQ==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/utils": "^5.10.0" }, @@ -11134,9 +10275,8 @@ }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.6.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz", - "integrity": "sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.9", "aria-query": "^4.2.2", @@ -11161,27 +10301,24 @@ }, "node_modules/eslint-plugin-jsx-a11y/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/eslint-plugin-no-only-tests": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-no-only-tests/-/eslint-plugin-no-only-tests-3.1.0.tgz", - "integrity": "sha512-Lf4YW/bL6Un1R6A76pRZyE1dl1vr31G/ev8UzIc/geCgFWyrKil8hVjYqWVKGB/UIGmb6Slzs9T0wNezdSVegw==", "dev": true, + "license": "MIT", "engines": { "node": ">=5.0.0" } }, "node_modules/eslint-plugin-prettier": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", - "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", "dev": true, + "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0" }, @@ -11200,9 +10337,8 @@ }, "node_modules/eslint-plugin-promise": { "version": "6.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz", - "integrity": "sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==", "dev": true, + "license": "ISC", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -11212,9 +10348,8 @@ }, "node_modules/eslint-scope": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -11225,18 +10360,16 @@ }, "node_modules/eslint-scope/node_modules/estraverse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, "node_modules/eslint-utils": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^2.0.0" }, @@ -11252,27 +10385,24 @@ }, "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=10" } }, "node_modules/eslint-visitor-keys": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/eslint/node_modules/ajv": { "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -11286,9 +10416,8 @@ }, "node_modules/eslint/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -11301,15 +10430,13 @@ }, "node_modules/eslint/node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "dev": true, + "license": "Python-2.0" }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -11323,9 +10450,8 @@ }, "node_modules/eslint/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -11335,15 +10461,13 @@ }, "node_modules/eslint/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/eslint/node_modules/eslint-scope": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -11354,9 +10478,8 @@ }, "node_modules/eslint/node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -11370,9 +10493,8 @@ }, "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, + "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -11382,9 +10504,8 @@ }, "node_modules/eslint/node_modules/globals": { "version": "13.19.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz", - "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -11397,9 +10518,8 @@ }, "node_modules/eslint/node_modules/js-sdsl": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", - "integrity": "sha512-dyBIzQBDkCqCu+0upx25Y2jGdbTGxE9fshMsCdK0ViOongpV+n5tXRcZY9v7CaVQ79AGS9KA1KHtojxiM7aXSQ==", "dev": true, + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/js-sdsl" @@ -11407,9 +10527,8 @@ }, "node_modules/eslint/node_modules/js-yaml": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -11419,15 +10538,13 @@ }, "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", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/eslint/node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -11440,9 +10557,8 @@ }, "node_modules/eslint/node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -11453,20 +10569,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -11476,9 +10582,8 @@ }, "node_modules/eslint/node_modules/type-fest": { "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -11486,11 +10591,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "engines": { + "node": ">=6" + } + }, "node_modules/espree": { "version": "9.4.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz", - "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", @@ -11505,9 +10617,8 @@ }, "node_modules/espree/node_modules/acorn": { "version": "8.8.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz", - "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -11517,8 +10628,7 @@ }, "node_modules/esprima": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -11529,9 +10639,8 @@ }, "node_modules/esquery": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -11541,9 +10650,8 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -11553,47 +10661,52 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, + "node_modules/estree-is-function": { + "version": "1.0.0", + "license": "Apache-2.0" + }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } }, "node_modules/etag": { "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "license": "MIT", "engines": { "node": ">= 0.6" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/events": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", "engines": { "node": ">=0.8.x" } }, "node_modules/events-intercept": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/events-intercept/-/events-intercept-2.0.0.tgz", - "integrity": "sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==" + "license": "MIT" }, "node_modules/execa": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", @@ -11614,26 +10727,15 @@ }, "node_modules/exit": { "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", "dev": true, "engines": { "node": ">= 0.8.0" } }, - "node_modules/exit-hook": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", - "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/expect": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.2.2.tgz", - "integrity": "sha512-hE09QerxZ5wXiOhqkXy5d2G9ar+EqOyifnCXCpMNu+vZ6DG9TJ6CO2c2kPDSLqERTTWrO7OZj8EkYHQqSd78Yw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/expect-utils": "^29.2.2", "jest-get-type": "^29.2.0", @@ -11647,17 +10749,15 @@ }, "node_modules/expect/node_modules/jest-get-type": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/express": { "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -11697,8 +10797,7 @@ }, "node_modules/express-openapi-validator": { "version": "4.13.8", - "resolved": "https://registry.npmjs.org/express-openapi-validator/-/express-openapi-validator-4.13.8.tgz", - "integrity": "sha512-89/sdkq+BKBuIyykaMl/vR9grFc3WFUPTjFo0THHbu+5g+q8rA7fKeoMfz+h84yOQIBcztmJ5ZJdk5uhEls31A==", + "license": "MIT", "dependencies": { "@types/multer": "^1.4.7", "ajv": "^6.12.6", @@ -11716,8 +10815,7 @@ }, "node_modules/express-openapi-validator/node_modules/ajv": { "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -11731,11 +10829,10 @@ }, "node_modules/express-openapi-validator/node_modules/concat-stream": { "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "engines": [ "node >= 0.8" ], + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", @@ -11745,13 +10842,11 @@ }, "node_modules/express-openapi-validator/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "license": "MIT" }, "node_modules/express-openapi-validator/node_modules/mkdirp": { "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", "dependencies": { "minimist": "^1.2.6" }, @@ -11761,8 +10856,7 @@ }, "node_modules/express-openapi-validator/node_modules/multer": { "version": "1.4.5-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", - "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", + "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.0.0", @@ -11778,13 +10872,11 @@ }, "node_modules/express-openapi-validator/node_modules/path-to-regexp": { "version": "6.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.0.tgz", - "integrity": "sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==" + "license": "MIT" }, "node_modules/express-openapi-validator/node_modules/readable-stream": { "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -11797,21 +10889,18 @@ }, "node_modules/express-openapi-validator/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "license": "MIT" }, "node_modules/express-openapi-validator/node_modules/string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/express-session": { "version": "1.17.3", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", - "integrity": "sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==", + "license": "MIT", "dependencies": { "cookie": "0.4.2", "cookie-signature": "1.0.6", @@ -11828,37 +10917,32 @@ }, "node_modules/express-session/node_modules/cookie": { "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/express-session/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } }, "node_modules/express-session/node_modules/depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/express-session/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "license": "MIT" }, "node_modules/express/node_modules/body-parser": { "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "license": "MIT", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.4", @@ -11880,24 +10964,21 @@ }, "node_modules/express/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } }, "node_modules/express/node_modules/depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/express/node_modules/finalhandler": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "license": "MIT", "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -11913,8 +10994,7 @@ }, "node_modules/express/node_modules/http-errors": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", @@ -11928,13 +11008,11 @@ }, "node_modules/express/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + "license": "MIT" }, "node_modules/express/node_modules/on-finished": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -11944,27 +11022,34 @@ }, "node_modules/express/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": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + "license": "MIT" }, "node_modules/express/node_modules/statuses": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, + "node_modules/ext": { + "version": "1.7.0", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/ext/node_modules/type": { + "version": "2.7.2", + "license": "ISC" + }, "node_modules/extend": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + "license": "MIT" }, "node_modules/external-editor": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", "dev": true, + "license": "MIT", "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", @@ -11976,27 +11061,23 @@ }, "node_modules/extsprintf": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", "engines": [ "node >=0.6.0" - ] + ], + "license": "MIT" }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "license": "MIT" }, "node_modules/fast-diff": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", - "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/fast-glob": { "version": "3.2.11", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", - "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -12010,18 +11091,15 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" + "license": "MIT" }, "node_modules/fast-safe-stringify": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + "license": "MIT" }, "node_modules/fast-xml-parser": { "version": "4.2.5", @@ -12046,40 +11124,35 @@ }, "node_modules/fastestsmallesttextencoderdecoder": { "version": "1.0.22", - "resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", - "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==", + "license": "CC0-1.0", "optional": true, "peer": true }, "node_modules/fastq": { "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "license": "ISC", "dependencies": { "reusify": "^1.0.4" } }, "node_modules/fb-watchman": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", - "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==", "dev": true, + "license": "Apache-2.0", "dependencies": { "bser": "2.1.1" } }, "node_modules/fd-slicer": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", "dependencies": { "pend": "~1.2.0" } }, "node_modules/feathers-hooks-common": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/feathers-hooks-common/-/feathers-hooks-common-8.1.1.tgz", - "integrity": "sha512-l33cpzRAjBnX/koyyCdeRY7dFPJBZVRKmN1fR1926S8p3gjS+gq5HE7wIipFcIPLDFKfwE9o2ZU5FlM2eCteXA==", + "license": "MIT", "dependencies": { "@feathersjs/errors": "^5.0.8", "ajv": "^6.12.6", @@ -12097,8 +11170,7 @@ }, "node_modules/feathers-hooks-common/node_modules/ajv": { "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12112,8 +11184,7 @@ }, "node_modules/feathers-hooks-common/node_modules/debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -12128,13 +11199,11 @@ }, "node_modules/feathers-hooks-common/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "license": "MIT" }, "node_modules/feathers-swagger": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/feathers-swagger/-/feathers-swagger-3.0.0.tgz", - "integrity": "sha512-RBM3FZpV9teyPZA1TrsZ49an2q07LEFZUVMJeib2hEo2+H2nj3YM3wlm/kXePUZQPD5ugaWXwgnJJlADW/+w1A==", + "license": "MIT", "dependencies": { "lodash": "^4.17.21" }, @@ -12144,14 +11213,22 @@ }, "node_modules/fecha": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.1.tgz", - "integrity": "sha512-MMMQ0ludy/nBs1/o0zVOiKTpG7qMbonKUzjJgQFEuvq6INZ1OraKPRAWkBq5vlKLOUMpmNYG1JoN3oDPUQ9m3Q==" + "license": "MIT" + }, + "node_modules/figlet": { + "version": "1.7.0", + "license": "MIT", + "bin": { + "figlet": "bin/index.js" + }, + "engines": { + "node": ">= 0.4.0" + } }, "node_modules/figures": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", "dev": true, + "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" }, @@ -12164,18 +11241,16 @@ }, "node_modules/figures/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": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/file-entry-cache": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, + "license": "MIT", "dependencies": { "flat-cache": "^3.0.4" }, @@ -12185,8 +11260,7 @@ }, "node_modules/file-type": { "version": "18.5.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.5.0.tgz", - "integrity": "sha512-yvpl5U868+V6PqXHMmsESpg6unQ5GfnPssl4dxdJudBrr9qy7Fddt7EVX1VLlddFfe8Gj9N7goCZH22FXuSQXQ==", + "license": "MIT", "dependencies": { "readable-web-to-node-stream": "^3.0.2", "strtok3": "^7.0.0", @@ -12201,9 +11275,8 @@ }, "node_modules/fill-keys": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", - "integrity": "sha1-mo+jb06K1jTjv2tPPIiCVRRS6yA=", "dev": true, + "license": "MIT", "dependencies": { "is-object": "~1.0.1", "merge-descriptors": "~1.0.0" @@ -12214,8 +11287,7 @@ }, "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==", + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -12225,9 +11297,8 @@ }, "node_modules/find-cache-dir": { "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", "dev": true, + "license": "MIT", "dependencies": { "commondir": "^1.0.1", "make-dir": "^3.0.2", @@ -12240,28 +11311,36 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, + "node_modules/find-up": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fishery": { "version": "2.2.2", - "resolved": "https://registry.npmjs.org/fishery/-/fishery-2.2.2.tgz", - "integrity": "sha512-jeU0nDhPHJkupmjX+r9niKgVMTBDB8X+U/pktoGHAiWOSyNlMd0HhmqnjrpjUOCDPJYaSSu4Ze16h6dZOKSp2w==", "dev": true, + "license": "MIT", "dependencies": { "lodash.mergewith": "^4.6.2" } }, "node_modules/flat": { "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "license": "BSD-3-Clause", "bin": { "flat": "cli.js" } }, "node_modules/flat-cache": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", "dev": true, + "license": "MIT", "dependencies": { "flatted": "^3.1.0", "rimraf": "^3.0.2" @@ -12272,25 +11351,22 @@ }, "node_modules/flatted": { "version": "3.2.5", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz", - "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/fn.name": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + "license": "MIT" }, "node_modules/follow-redirects": { "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -12302,17 +11378,15 @@ }, "node_modules/for-each": { "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "license": "MIT", "dependencies": { "is-callable": "^1.1.3" } }, "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, + "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^3.0.2" @@ -12323,17 +11397,15 @@ }, "node_modules/forever-agent": { "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "license": "Apache-2.0", "engines": { "node": "*" } }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-8.0.0.tgz", - "integrity": "sha512-mX3qW3idpueT2klaQXBzrIM/pHw+T0B/V9KHEvNrqijTq9NFnMZU6oreVxDYcf33P8a5cW+67PjodNHthGnNVg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.16.7", "chalk": "^4.1.2", @@ -12359,9 +11431,8 @@ }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -12374,9 +11445,8 @@ }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -12390,9 +11460,8 @@ }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -12402,15 +11471,13 @@ }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -12420,8 +11487,7 @@ }, "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==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -12433,39 +11499,32 @@ }, "node_modules/formidable": { "version": "1.2.6", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", - "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==", - "deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau", "dev": true, + "license": "MIT", "funding": { "url": "https://ko-fi.com/tunnckoCore/commissions" } }, "node_modules/forwarded": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/freeport": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/freeport/-/freeport-1.0.5.tgz", - "integrity": "sha1-JV6KuEFwwzuoXZkOghrl9KGpvF0=" + "license": "Apache-2.0" }, "node_modules/fresh": { "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/fromentries": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", - "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", "dev": true, "funding": [ { @@ -12480,18 +11539,17 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/fs-constants": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fs-extra": { "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -12501,10 +11559,27 @@ "node": ">=12" } }, + "node_modules/fs-jetpack": { + "version": "4.3.1", + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.2", + "rimraf": "^2.6.3" + } + }, + "node_modules/fs-jetpack/node_modules/rimraf": { + "version": "2.7.1", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/fs-minipass": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -12516,33 +11591,16 @@ }, "node_modules/fs-monkey": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", - "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", - "dev": true + "dev": true, + "license": "Unlicense" }, "node_modules/fs.realpath": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } + "license": "ISC" }, "node_modules/fstream": { "version": "1.0.12", - "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", - "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -12557,8 +11615,7 @@ }, "node_modules/fstream/node_modules/mkdirp": { "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -12570,8 +11627,7 @@ }, "node_modules/fstream/node_modules/rimraf": { "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -12583,16 +11639,14 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/function.prototype.name": { "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -12608,22 +11662,19 @@ }, "node_modules/functional-red-black-tree": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/functions-have-names": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/gauge": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-1.2.7.tgz", - "integrity": "sha1-6c7FSD09TuDvRLYKfZnkk14TbZM=", + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -12636,50 +11687,48 @@ }, "node_modules/generic-pool": { "version": "3.9.0", - "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", - "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/gensync": { "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/get-all-files": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/get-all-files/-/get-all-files-4.1.0.tgz", - "integrity": "sha512-ZH0Sbr6VQLMCcjrWNWyK0Wii9Kfw7ALnaZL6/5tTYf2/9lMtTTx9jSVAU92D3HfH9K8veAIehq3PbCZA7yde2g==", + "license": "MIT", "engines": { "node": ">= 12.17" } }, + "node_modules/get-assigned-identifiers": { + "version": "1.2.0", + "license": "Apache-2.0" + }, "node_modules/get-caller-file": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } }, "node_modules/get-func-name": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", "dev": true, + "license": "MIT", "engines": { "node": "*" } }, "node_modules/get-intrinsic": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2", "has-proto": "^1.0.1", @@ -12692,18 +11741,15 @@ }, "node_modules/get-package-type": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, + "license": "MIT", "engines": { "node": ">=8.0.0" } }, "node_modules/get-port": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", - "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -12713,9 +11759,8 @@ }, "node_modules/get-stream": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -12725,8 +11770,7 @@ }, "node_modules/get-symbol-description": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.1" @@ -12740,25 +11784,27 @@ }, "node_modules/get-tsconfig": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.2.0.tgz", - "integrity": "sha512-X8u8fREiYOE6S8hLbq99PeykTDoLVnxvF4DjWKJmz9xy2nNRdUcV8ZN9tniJFeKyTU3qnC9lL8n4Chd6LmVKHg==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/getopts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", + "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==" + }, "node_modules/getpass": { "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0" } }, "node_modules/glob": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -12776,8 +11822,7 @@ }, "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==", + "license": "ISC", "dependencies": { "is-glob": "^4.0.1" }, @@ -12787,23 +11832,20 @@ }, "node_modules/glob-to-regexp": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true + "dev": true, + "license": "BSD-2-Clause" }, "node_modules/globals": { "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/globalthis": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "license": "MIT", "dependencies": { "define-properties": "^1.1.3" }, @@ -12816,20 +11858,18 @@ }, "node_modules/globalyzer": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", - "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/globby": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz", - "integrity": "sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==", + "version": "11.1.0", + "license": "MIT", "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", - "fast-glob": "^3.1.1", - "ignore": "^5.1.4", - "merge2": "^1.3.0", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", "slash": "^3.0.0" }, "engines": { @@ -12841,14 +11881,12 @@ }, "node_modules/globrex": { "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/gm": { "version": "1.25.0", - "resolved": "https://registry.npmjs.org/gm/-/gm-1.25.0.tgz", - "integrity": "sha512-4kKdWXTtgQ4biIo7hZA396HT062nDVVHPjQcurNZ3o/voYN+o5FUC5kOwuORbpExp3XbTJ3SU7iRipiIhQtovw==", + "license": "MIT", "dependencies": { "array-parallel": "~0.1.3", "array-series": "~0.1.5", @@ -12861,8 +11899,7 @@ }, "node_modules/gm/node_modules/cross-spawn": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", - "integrity": "sha512-yAXz/pA1tD8Gtg2S98Ekf/sewp3Lcp3YoFKJ4Hkp5h5yLWnKVTDU0kwjKJ8NDCYcfTLfyGkzTikst+jWypT1iA==", + "license": "MIT", "dependencies": { "lru-cache": "^4.0.1", "which": "^1.2.9" @@ -12870,16 +11907,14 @@ }, "node_modules/gm/node_modules/debug": { "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/gm/node_modules/lru-cache": { "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "license": "ISC", "dependencies": { "pseudomap": "^1.0.2", "yallist": "^2.1.2" @@ -12887,13 +11922,11 @@ }, "node_modules/gm/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==" + "license": "ISC" }, "node_modules/gopd": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "license": "MIT", "dependencies": { "get-intrinsic": "^1.1.3" }, @@ -12903,45 +11936,38 @@ }, "node_modules/graceful-fs": { "version": "4.2.9", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz", - "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==" + "license": "ISC" }, "node_modules/grapheme-splitter": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/graphql": { "version": "16.8.1", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", - "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, "node_modules/growl": { "version": "1.10.5", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", - "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", "dev": true, + "license": "MIT", "engines": { "node": ">=4.x" } }, "node_modules/har-schema": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "license": "ISC", "engines": { "node": ">=4" } }, "node_modules/har-validator": { "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "deprecated": "this library is no longer supported", + "license": "MIT", "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" @@ -12952,8 +11978,7 @@ }, "node_modules/har-validator/node_modules/ajv": { "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12967,14 +11992,11 @@ }, "node_modules/har-validator/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "license": "MIT" }, "node_modules/has": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, + "license": "MIT", "dependencies": { "function-bind": "^1.1.1" }, @@ -12982,35 +12004,50 @@ "node": ">= 0.4.0" } }, + "node_modules/has-ansi": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/has-bigints": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/has-own-prop": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", - "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/has-property-descriptors": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "license": "MIT", "dependencies": { "get-intrinsic": "^1.1.1" }, @@ -13020,8 +12057,7 @@ }, "node_modules/has-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -13031,8 +12067,7 @@ }, "node_modules/has-symbols": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -13042,8 +12077,7 @@ }, "node_modules/has-tostringtag": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "license": "MIT", "dependencies": { "has-symbols": "^1.0.2" }, @@ -13056,16 +12090,14 @@ }, "node_modules/has-unicode": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", + "license": "ISC", "optional": true, "peer": true }, "node_modules/hasha": { "version": "5.2.2", - "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", - "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", "dev": true, + "license": "MIT", "dependencies": { "is-stream": "^2.0.0", "type-fest": "^0.8.0" @@ -13079,17 +12111,15 @@ }, "node_modules/hasha/node_modules/type-fest": { "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=8" } }, "node_modules/hasown": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -13099,17 +12129,15 @@ }, "node_modules/he": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true, + "license": "MIT", "bin": { "he": "bin/he" } }, "node_modules/help-me": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-3.0.0.tgz", - "integrity": "sha512-hx73jClhyk910sidBB7ERlnhMlFsJJIBqSVMFDwPN8o2v9nmp5KgLq1Xz1Bf1fCMMZ6mPrX159iG0VLy/fPMtQ==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -13119,8 +12147,7 @@ }, "node_modules/help-me/node_modules/readable-stream": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -13134,8 +12161,7 @@ }, "node_modules/help-me/node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -13144,33 +12170,33 @@ }, "node_modules/hexoid": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", - "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } }, "node_modules/html-entities": { "version": "2.3.2", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.2.tgz", - "integrity": "sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ==" + "license": "MIT" }, "node_modules/html-escaper": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/htmlparser2": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", - "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -13178,6 +12204,7 @@ "url": "https://github.com/sponsors/fb55" } ], + "license": "MIT", "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.0.0", @@ -13185,10 +12212,45 @@ "entities": "^2.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.3.4", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/http-signature": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -13201,8 +12263,7 @@ }, "node_modules/https-proxy-agent": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", "dependencies": { "agent-base": "6", "debug": "4" @@ -13213,17 +12274,14 @@ }, "node_modules/human-signals": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=10.17.0" } }, "node_modules/i18next": { "version": "23.3.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.3.0.tgz", - "integrity": "sha512-xd/UzWT71zYudCT7qVn6tB4yUVuXAhgCorsowYgM2EOdc14WqQBp5P2wEsxgfiDgdLN5XwJvTbzxrMfoY/nxnw==", "funding": [ { "type": "individual", @@ -13238,19 +12296,18 @@ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" } ], + "license": "MIT", "dependencies": { "@babel/runtime": "^7.22.5" } }, "node_modules/i18next-fs-backend": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.1.5.tgz", - "integrity": "sha512-7fgSH8nVhXSBYPHR/W3tEXXhcnwHwNiND4Dfx9knzPzdsWTUTL/TdDVV+DY0dL0asHKLbdoJaXS4LdVW6R8MVQ==" + "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -13260,27 +12317,23 @@ }, "node_modules/ieee754": { "version": "1.1.13", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", - "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", - "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", + "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/ignore-by-default": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/image-size": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz", - "integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==", + "license": "MIT", "dependencies": { "queue": "6.0.2" }, @@ -13293,9 +12346,8 @@ }, "node_modules/import-fresh": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dev": true, + "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -13309,9 +12361,8 @@ }, "node_modules/import-local": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", "dev": true, + "license": "MIT", "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" @@ -13328,26 +12379,23 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.19" } }, "node_modules/indent-string": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/inflight": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -13355,21 +12403,18 @@ }, "node_modules/inherits": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC", "optional": true, "peer": true }, "node_modules/inquirer": { "version": "7.3.3", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", - "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.0", @@ -13391,9 +12436,8 @@ }, "node_modules/inquirer/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -13406,9 +12450,8 @@ }, "node_modules/inquirer/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -13422,9 +12465,8 @@ }, "node_modules/inquirer/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -13434,15 +12476,13 @@ }, "node_modules/inquirer/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/inquirer/node_modules/rxjs": { "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "tslib": "^1.9.0" }, @@ -13452,9 +12492,8 @@ }, "node_modules/inquirer/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -13464,14 +12503,12 @@ }, "node_modules/inquirer/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "dev": true, + "license": "0BSD" }, "node_modules/internal-slot": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", - "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "license": "MIT", "dependencies": { "get-intrinsic": "^1.2.2", "hasown": "^2.0.0", @@ -13483,27 +12520,24 @@ }, "node_modules/interpret": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", - "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.10" } }, "node_modules/invert-kv": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", - "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", + "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ioredis": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", - "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", @@ -13525,10 +12559,7 @@ }, "node_modules/ioredis/node_modules/debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -13543,39 +12574,32 @@ }, "node_modules/ioredis/node_modules/denque": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "optional": true, - "peer": true, + "license": "Apache-2.0", "engines": { "node": ">=0.10" } }, "node_modules/ip": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" + "license": "MIT" }, "node_modules/ip-regex": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", - "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=", + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/ipaddr.js": { "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", "engines": { "node": ">= 0.10" } }, "node_modules/is-arguments": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -13589,8 +12613,7 @@ }, "node_modules/is-array-buffer": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.2.0", @@ -13602,13 +12625,12 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" + "dev": true, + "license": "MIT" }, "node_modules/is-bigint": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "license": "MIT", "dependencies": { "has-bigints": "^1.0.1" }, @@ -13618,9 +12640,8 @@ }, "node_modules/is-binary-path": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, + "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" }, @@ -13630,8 +12651,7 @@ }, "node_modules/is-boolean-object": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -13645,8 +12665,6 @@ }, "node_modules/is-buffer": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", "funding": [ { "type": "github", @@ -13661,14 +12679,14 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/is-callable": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -13678,8 +12696,7 @@ }, "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==", + "license": "MIT", "dependencies": { "hasown": "^2.0.0" }, @@ -13689,8 +12706,7 @@ }, "node_modules/is-date-object": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "license": "MIT", "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -13703,9 +12719,8 @@ }, "node_modules/is-docker": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "dev": true, + "license": "MIT", "bin": { "is-docker": "cli.js" }, @@ -13718,33 +12733,29 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/is-generator-fn": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/is-generator-function": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "license": "MIT", "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -13757,8 +12768,7 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -13768,25 +12778,22 @@ }, "node_modules/is-interactive": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/is-iojs": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-iojs/-/is-iojs-1.1.0.tgz", - "integrity": "sha1-TBEDO11dlNbqs3dd7cm+fQCDJfE=", + "license": "MIT", "optional": true, "peer": true }, "node_modules/is-ip": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-2.0.0.tgz", - "integrity": "sha1-aO6gfooKCpTC0IDdZ0xzGrKkYas=", "dev": true, + "license": "MIT", "dependencies": { "ip-regex": "^2.0.0" }, @@ -13796,8 +12803,7 @@ }, "node_modules/is-negative-zero": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -13807,16 +12813,14 @@ }, "node_modules/is-number": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", "engines": { "node": ">=0.12.0" } }, "node_modules/is-number-object": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "license": "MIT", "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -13829,35 +12833,35 @@ }, "node_modules/is-object": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", - "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-path-inside": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/is-plain-obj": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -13871,8 +12875,7 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2" }, @@ -13882,8 +12885,7 @@ }, "node_modules/is-stream": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", "engines": { "node": ">=8" }, @@ -13893,8 +12895,7 @@ }, "node_modules/is-string": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "license": "MIT", "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -13907,8 +12908,7 @@ }, "node_modules/is-symbol": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "license": "MIT", "dependencies": { "has-symbols": "^1.0.2" }, @@ -13921,8 +12921,7 @@ }, "node_modules/is-typed-array": { "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "license": "MIT", "dependencies": { "which-typed-array": "^1.1.11" }, @@ -13935,14 +12934,12 @@ }, "node_modules/is-typedarray": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + "license": "MIT" }, "node_modules/is-unicode-supported": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -13950,15 +12947,9 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-utf8": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", - "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=" - }, "node_modules/is-weakref": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2" }, @@ -13968,18 +12959,16 @@ }, "node_modules/is-windows": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/is-wsl": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "license": "MIT", "dependencies": { "is-docker": "^2.0.0" }, @@ -13989,18 +12978,15 @@ }, "node_modules/isarray": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + "license": "ISC" }, "node_modules/isomorphic-ws": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", - "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", + "license": "MIT", "optional": true, "peer": true, "peerDependencies": { @@ -14009,8 +12995,7 @@ }, "node_modules/isomorphic.js": { "version": "0.2.5", - "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", - "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" @@ -14018,23 +13003,20 @@ }, "node_modules/isstream": { "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + "license": "MIT" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", - "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=8" } }, "node_modules/istanbul-lib-hook": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", - "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "append-transform": "^2.0.0" }, @@ -14044,9 +13026,8 @@ }, "node_modules/istanbul-lib-instrument": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.1.0.tgz", - "integrity": "sha512-czwUz525rkOFDJxfKK6mYfIs9zBKILyrZQxjz3ABhjQXhbhFsSbo1HW/BFcsDnfJYJWA6thRR5/TUY2qs5W99Q==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", @@ -14060,18 +13041,16 @@ }, "node_modules/istanbul-lib-instrument/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/istanbul-lib-processinfo": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz", - "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==", "dev": true, + "license": "ISC", "dependencies": { "archy": "^1.0.0", "cross-spawn": "^7.0.0", @@ -14087,19 +13066,16 @@ }, "node_modules/istanbul-lib-processinfo/node_modules/uuid": { "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", "dev": true, + "license": "MIT", "bin": { "uuid": "bin/uuid" } }, "node_modules/istanbul-lib-report": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^3.0.0", @@ -14111,9 +13087,8 @@ }, "node_modules/istanbul-lib-report/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14123,9 +13098,8 @@ }, "node_modules/istanbul-lib-source-maps": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0", @@ -14137,9 +13111,8 @@ }, "node_modules/istanbul-reports": { "version": "3.1.4", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.4.tgz", - "integrity": "sha512-r1/DshN4KSE7xWEknZLLLLDn5CJybV3nw01VTkp6D5jzLuELlcbudfj/eSQFvrKsJuTVCGnePO7ho82Nw9zzfw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -14150,17 +13123,15 @@ }, "node_modules/iterare": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", - "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", "engines": { "node": ">=6" } }, "node_modules/jest": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.2.2.tgz", - "integrity": "sha512-r+0zCN9kUqoON6IjDdjbrsWobXM/09Nd45kIPRD8kloaRh1z5ZCMdVsgLXGxmlL7UpAJsvCYOQNO+NjvG/gqiQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/core": "^29.2.2", "@jest/types": "^29.2.1", @@ -14184,9 +13155,8 @@ }, "node_modules/jest-changed-files": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.2.0.tgz", - "integrity": "sha512-qPVmLLyBmvF5HJrY7krDisx6Voi8DmlV3GZYX0aFNbaQsZeoz1hfxcCMbqDGuQCxU1dJy9eYc2xscE8QrCCYaA==", "dev": true, + "license": "MIT", "dependencies": { "execa": "^5.0.0", "p-limit": "^3.1.0" @@ -14197,9 +13167,8 @@ }, "node_modules/jest-circus": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.2.2.tgz", - "integrity": "sha512-upSdWxx+Mh4DV7oueuZndJ1NVdgtTsqM4YgywHEx05UMH5nxxA2Qu9T9T9XVuR021XxqSoaKvSmmpAbjwwwxMw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.2.2", "@jest/expect": "^29.2.2", @@ -14227,9 +13196,8 @@ }, "node_modules/jest-circus/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14243,9 +13211,8 @@ }, "node_modules/jest-circus/node_modules/chalk/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -14258,9 +13225,8 @@ }, "node_modules/jest-circus/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -14270,15 +13236,13 @@ }, "node_modules/jest-circus/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-circus/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14288,9 +13252,8 @@ }, "node_modules/jest-cli": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.2.2.tgz", - "integrity": "sha512-R45ygnnb2CQOfd8rTPFR+/fls0d+1zXS6JPYTBBrnLPrhr58SSuPTiA5Tplv8/PXpz4zXR/AYNxmwIj6J6nrvg==", "dev": true, + "license": "MIT", "dependencies": { "@jest/core": "^29.2.2", "@jest/test-result": "^29.2.1", @@ -14322,9 +13285,8 @@ }, "node_modules/jest-cli/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -14337,9 +13299,8 @@ }, "node_modules/jest-cli/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14353,9 +13314,8 @@ }, "node_modules/jest-cli/node_modules/cliui": { "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -14367,9 +13327,8 @@ }, "node_modules/jest-cli/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -14379,15 +13338,13 @@ }, "node_modules/jest-cli/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-cli/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14397,18 +13354,16 @@ }, "node_modules/jest-cli/node_modules/y18n": { "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/jest-cli/node_modules/yargs": { "version": "17.6.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.6.0.tgz", - "integrity": "sha512-8H/wTDqlSwoSnScvV2N/JHfLWOKuh5MVla9hqLjK3nsfyy6Y4kDSYSvkU5YCUEPOSnRXfIyx3Sq+B/IWudTo4g==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -14424,18 +13379,16 @@ }, "node_modules/jest-cli/node_modules/yargs-parser": { "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, + "license": "ISC", "engines": { "node": ">=12" } }, "node_modules/jest-config": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.2.2.tgz", - "integrity": "sha512-Q0JX54a5g1lP63keRfKR8EuC7n7wwny2HoTRDb8cx78IwQOiaYUVZAdjViY3WcTxpR02rPUpvNVmZ1fkIlZPcw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@jest/test-sequencer": "^29.2.2", @@ -14478,9 +13431,8 @@ }, "node_modules/jest-config/node_modules/@jest/transform": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.2.2.tgz", - "integrity": "sha512-aPe6rrletyuEIt2axxgdtxljmzH8O/nrov4byy6pDw9S8inIrTV+2PnjyP/oFHMSynzGxJ2s6OHowBNMXp/Jzg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.2.1", @@ -14504,9 +13456,8 @@ }, "node_modules/jest-config/node_modules/babel-jest": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.2.2.tgz", - "integrity": "sha512-kkq2QSDIuvpgfoac3WZ1OOcHsQQDU5xYk2Ql7tLdJ8BVAYbefEXal+NfS45Y5LVZA7cxC8KYcQMObpCt1J025w==", "dev": true, + "license": "MIT", "dependencies": { "@jest/transform": "^29.2.2", "@types/babel__core": "^7.1.14", @@ -14525,9 +13476,8 @@ }, "node_modules/jest-config/node_modules/babel-plugin-jest-hoist": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.2.0.tgz", - "integrity": "sha512-TnspP2WNiR3GLfCsUNHqeXw0RoQ2f9U5hQ5L3XFpwuO8htQmSrhh8qsB6vi5Yi8+kuynN1yjDjQsPfkebmB6ZA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", @@ -14540,9 +13490,8 @@ }, "node_modules/jest-config/node_modules/babel-preset-jest": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.2.0.tgz", - "integrity": "sha512-z9JmMJppMxNv8N7fNRHvhMg9cvIkMxQBXgFkane3yKVEvEOP+kB50lk8DFRvF9PGqbyXxlmebKWhuDORO8RgdA==", "dev": true, + "license": "MIT", "dependencies": { "babel-plugin-jest-hoist": "^29.2.0", "babel-preset-current-node-syntax": "^1.0.0" @@ -14556,9 +13505,8 @@ }, "node_modules/jest-config/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14572,9 +13520,8 @@ }, "node_modules/jest-config/node_modules/chalk/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -14587,9 +13534,8 @@ }, "node_modules/jest-config/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -14599,24 +13545,21 @@ }, "node_modules/jest-config/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-config/node_modules/jest-get-type": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-config/node_modules/jest-haste-map": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "@types/graceful-fs": "^4.1.3", @@ -14639,18 +13582,16 @@ }, "node_modules/jest-config/node_modules/jest-regex-util": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-config/node_modules/jest-worker": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "jest-util": "^29.2.1", @@ -14663,9 +13604,8 @@ }, "node_modules/jest-config/node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14678,9 +13618,8 @@ }, "node_modules/jest-config/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14690,9 +13629,8 @@ }, "node_modules/jest-config/node_modules/write-file-atomic": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" @@ -14703,9 +13641,8 @@ }, "node_modules/jest-docblock": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.2.0.tgz", - "integrity": "sha512-bkxUsxTgWQGbXV5IENmfiIuqZhJcyvF7tU4zJ/7ioTutdz4ToB5Yx6JOFBpgI+TphRY4lhOyCWGNH/QFQh5T6A==", "dev": true, + "license": "MIT", "dependencies": { "detect-newline": "^3.0.0" }, @@ -14715,9 +13652,8 @@ }, "node_modules/jest-each": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.2.1.tgz", - "integrity": "sha512-sGP86H/CpWHMyK3qGIGFCgP6mt+o5tu9qG4+tobl0LNdgny0aitLXs9/EBacLy3Bwqy+v4uXClqJgASJWcruYw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "chalk": "^4.0.0", @@ -14731,9 +13667,8 @@ }, "node_modules/jest-each/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14747,9 +13682,8 @@ }, "node_modules/jest-each/node_modules/chalk/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -14762,9 +13696,8 @@ }, "node_modules/jest-each/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -14774,24 +13707,21 @@ }, "node_modules/jest-each/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-each/node_modules/jest-get-type": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-each/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14801,9 +13731,8 @@ }, "node_modules/jest-environment-node": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.2.2.tgz", - "integrity": "sha512-B7qDxQjkIakQf+YyrqV5dICNs7tlCO55WJ4OMSXsqz1lpI/0PmeuXdx2F7eU8rnPbRkUR/fItSSUh0jvE2y/tw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.2.2", "@jest/fake-timers": "^29.2.2", @@ -14818,9 +13747,8 @@ }, "node_modules/jest-leak-detector": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.2.1.tgz", - "integrity": "sha512-1YvSqYoiurxKOJtySc+CGVmw/e1v4yNY27BjWTVzp0aTduQeA7pdieLiW05wTYG/twlKOp2xS/pWuikQEmklug==", "dev": true, + "license": "MIT", "dependencies": { "jest-get-type": "^29.2.0", "pretty-format": "^29.2.1" @@ -14831,18 +13759,16 @@ }, "node_modules/jest-leak-detector/node_modules/jest-get-type": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-matcher-utils": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.2.2.tgz", - "integrity": "sha512-4DkJ1sDPT+UX2MR7Y3od6KtvRi9Im1ZGLGgdLFLm4lPexbTaCgJW5NN3IOXlQHF7NSHY/VHhflQ+WoKtD/vyCw==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.2.1", @@ -14855,9 +13781,8 @@ }, "node_modules/jest-matcher-utils/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14871,9 +13796,8 @@ }, "node_modules/jest-matcher-utils/node_modules/chalk/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -14886,9 +13810,8 @@ }, "node_modules/jest-matcher-utils/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -14898,24 +13821,21 @@ }, "node_modules/jest-matcher-utils/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-matcher-utils/node_modules/diff-sequences": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.2.0.tgz", - "integrity": "sha512-413SY5JpYeSBZxmenGEmCVQ8mCgtFJF0w9PROdaS6z987XC2Pd2GOKqOITLtMftmyFZqgtCOb/QA7/Z3ZXfzIw==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-matcher-utils/node_modules/jest-diff": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.2.1.tgz", - "integrity": "sha512-gfh/SMNlQmP3MOUgdzxPOd4XETDJifADpT937fN1iUGz+9DgOu2eUPHH25JDkLVcLwwqxv3GzVyK4VBUr9fjfA==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.2.0", @@ -14928,18 +13848,16 @@ }, "node_modules/jest-matcher-utils/node_modules/jest-get-type": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-matcher-utils/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14949,9 +13867,8 @@ }, "node_modules/jest-message-util": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.2.1.tgz", - "integrity": "sha512-Dx5nEjw9V8C1/Yj10S/8ivA8F439VS8vTq1L7hEgwHFn9ovSKNpYW/kwNh7UglaEgXO42XxzKJB+2x0nSglFVw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.2.1", @@ -14969,9 +13886,8 @@ }, "node_modules/jest-message-util/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14985,9 +13901,8 @@ }, "node_modules/jest-message-util/node_modules/chalk/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -15000,9 +13915,8 @@ }, "node_modules/jest-message-util/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -15012,15 +13926,13 @@ }, "node_modules/jest-message-util/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-message-util/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15030,9 +13942,8 @@ }, "node_modules/jest-mock": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.2.2.tgz", - "integrity": "sha512-1leySQxNAnivvbcx0sCB37itu8f4OX2S/+gxLAV4Z62shT4r4dTG9tACDywUAEZoLSr36aYUTsVp3WKwWt4PMQ==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "@types/node": "*", @@ -15044,9 +13955,8 @@ }, "node_modules/jest-pnp-resolver": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", - "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" }, @@ -15061,9 +13971,8 @@ }, "node_modules/jest-resolve": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.2.2.tgz", - "integrity": "sha512-3gaLpiC3kr14rJR3w7vWh0CBX2QAhfpfiQTwrFPvVrcHe5VUBtIXaR004aWE/X9B2CFrITOQAp5gxLONGrk6GA==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.0.0", "graceful-fs": "^4.2.9", @@ -15081,9 +13990,8 @@ }, "node_modules/jest-resolve-dependencies": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.2.2.tgz", - "integrity": "sha512-wWOmgbkbIC2NmFsq8Lb+3EkHuW5oZfctffTGvwsA4JcJ1IRk8b2tg+hz44f0lngvRTeHvp3Kyix9ACgudHH9aQ==", "dev": true, + "license": "MIT", "dependencies": { "jest-regex-util": "^29.2.0", "jest-snapshot": "^29.2.2" @@ -15094,18 +14002,16 @@ }, "node_modules/jest-resolve-dependencies/node_modules/jest-regex-util": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-resolve/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -15118,9 +14024,8 @@ }, "node_modules/jest-resolve/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -15134,9 +14039,8 @@ }, "node_modules/jest-resolve/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -15146,15 +14050,13 @@ }, "node_modules/jest-resolve/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-resolve/node_modules/jest-haste-map": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "@types/graceful-fs": "^4.1.3", @@ -15177,18 +14079,16 @@ }, "node_modules/jest-resolve/node_modules/jest-regex-util": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-resolve/node_modules/jest-worker": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "jest-util": "^29.2.1", @@ -15201,9 +14101,8 @@ }, "node_modules/jest-resolve/node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15216,9 +14115,8 @@ }, "node_modules/jest-resolve/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15228,9 +14126,8 @@ }, "node_modules/jest-runner": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.2.2.tgz", - "integrity": "sha512-1CpUxXDrbsfy9Hr9/1zCUUhT813kGGK//58HeIw/t8fa/DmkecEwZSWlb1N/xDKXg3uCFHQp1GCvlSClfImMxg==", "dev": true, + "license": "MIT", "dependencies": { "@jest/console": "^29.2.1", "@jest/environment": "^29.2.2", @@ -15260,9 +14157,8 @@ }, "node_modules/jest-runner/node_modules/@jest/transform": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.2.2.tgz", - "integrity": "sha512-aPe6rrletyuEIt2axxgdtxljmzH8O/nrov4byy6pDw9S8inIrTV+2PnjyP/oFHMSynzGxJ2s6OHowBNMXp/Jzg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.2.1", @@ -15286,9 +14182,8 @@ }, "node_modules/jest-runner/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -15301,9 +14196,8 @@ }, "node_modules/jest-runner/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -15317,9 +14211,8 @@ }, "node_modules/jest-runner/node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15329,9 +14222,8 @@ }, "node_modules/jest-runner/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -15341,15 +14233,13 @@ }, "node_modules/jest-runner/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-runner/node_modules/jest-haste-map": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "@types/graceful-fs": "^4.1.3", @@ -15372,18 +14262,16 @@ }, "node_modules/jest-runner/node_modules/jest-regex-util": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-runner/node_modules/jest-worker": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "jest-util": "^29.2.1", @@ -15396,9 +14284,8 @@ }, "node_modules/jest-runner/node_modules/source-map-support": { "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", "dev": true, + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -15406,9 +14293,8 @@ }, "node_modules/jest-runner/node_modules/write-file-atomic": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" @@ -15419,9 +14305,8 @@ }, "node_modules/jest-runtime": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.2.2.tgz", - "integrity": "sha512-TpR1V6zRdLynckKDIQaY41od4o0xWL+KOPUCZvJK2bu5P1UXhjobt5nJ2ICNeIxgyj9NGkO0aWgDqYPVhDNKjA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/environment": "^29.2.2", "@jest/fake-timers": "^29.2.2", @@ -15452,9 +14337,8 @@ }, "node_modules/jest-runtime/node_modules/@jest/transform": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.2.2.tgz", - "integrity": "sha512-aPe6rrletyuEIt2axxgdtxljmzH8O/nrov4byy6pDw9S8inIrTV+2PnjyP/oFHMSynzGxJ2s6OHowBNMXp/Jzg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.2.1", @@ -15478,9 +14362,8 @@ }, "node_modules/jest-runtime/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -15493,9 +14376,8 @@ }, "node_modules/jest-runtime/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -15509,9 +14391,8 @@ }, "node_modules/jest-runtime/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -15521,15 +14402,13 @@ }, "node_modules/jest-runtime/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-runtime/node_modules/jest-haste-map": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "@types/graceful-fs": "^4.1.3", @@ -15552,18 +14431,16 @@ }, "node_modules/jest-runtime/node_modules/jest-regex-util": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-runtime/node_modules/jest-worker": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "jest-util": "^29.2.1", @@ -15576,9 +14453,8 @@ }, "node_modules/jest-runtime/node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15591,9 +14467,8 @@ }, "node_modules/jest-runtime/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15603,9 +14478,8 @@ }, "node_modules/jest-runtime/node_modules/write-file-atomic": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" @@ -15616,9 +14490,8 @@ }, "node_modules/jest-snapshot": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.2.2.tgz", - "integrity": "sha512-GfKJrpZ5SMqhli3NJ+mOspDqtZfJBryGA8RIBxF+G+WbDoC7HCqKaeAss4Z/Sab6bAW11ffasx8/vGsj83jyjA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@babel/generator": "^7.7.2", @@ -15651,9 +14524,8 @@ }, "node_modules/jest-snapshot/node_modules/@jest/transform": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.2.2.tgz", - "integrity": "sha512-aPe6rrletyuEIt2axxgdtxljmzH8O/nrov4byy6pDw9S8inIrTV+2PnjyP/oFHMSynzGxJ2s6OHowBNMXp/Jzg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", "@jest/types": "^29.2.1", @@ -15677,9 +14549,8 @@ }, "node_modules/jest-snapshot/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -15693,9 +14564,8 @@ }, "node_modules/jest-snapshot/node_modules/chalk/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -15708,9 +14578,8 @@ }, "node_modules/jest-snapshot/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -15720,24 +14589,21 @@ }, "node_modules/jest-snapshot/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-snapshot/node_modules/diff-sequences": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.2.0.tgz", - "integrity": "sha512-413SY5JpYeSBZxmenGEmCVQ8mCgtFJF0w9PROdaS6z987XC2Pd2GOKqOITLtMftmyFZqgtCOb/QA7/Z3ZXfzIw==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-snapshot/node_modules/jest-diff": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.2.1.tgz", - "integrity": "sha512-gfh/SMNlQmP3MOUgdzxPOd4XETDJifADpT937fN1iUGz+9DgOu2eUPHH25JDkLVcLwwqxv3GzVyK4VBUr9fjfA==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.2.0", @@ -15750,18 +14616,16 @@ }, "node_modules/jest-snapshot/node_modules/jest-get-type": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-snapshot/node_modules/jest-haste-map": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "@types/graceful-fs": "^4.1.3", @@ -15784,18 +14648,16 @@ }, "node_modules/jest-snapshot/node_modules/jest-regex-util": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-snapshot/node_modules/jest-worker": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "jest-util": "^29.2.1", @@ -15808,9 +14670,8 @@ }, "node_modules/jest-snapshot/node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15823,9 +14684,8 @@ }, "node_modules/jest-snapshot/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15835,9 +14695,8 @@ }, "node_modules/jest-snapshot/node_modules/write-file-atomic": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" @@ -15848,9 +14707,8 @@ }, "node_modules/jest-util": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.2.1.tgz", - "integrity": "sha512-P5VWDj25r7kj7kl4pN2rG/RN2c1TLfYYYZYULnS/35nFDjBai+hBeo3MDrYZS7p6IoY3YHZnt2vq4L6mKnLk0g==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "@types/node": "*", @@ -15865,9 +14723,8 @@ }, "node_modules/jest-util/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -15880,9 +14737,8 @@ }, "node_modules/jest-util/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -15896,9 +14752,8 @@ }, "node_modules/jest-util/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -15908,15 +14763,13 @@ }, "node_modules/jest-util/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-util/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15926,9 +14779,8 @@ }, "node_modules/jest-validate": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.2.2.tgz", - "integrity": "sha512-eJXATaKaSnOuxNfs8CLHgdABFgUrd0TtWS8QckiJ4L/QVDF4KVbZFBBOwCBZHOS0Rc5fOxqngXeGXE3nGQkpQA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/types": "^29.2.1", "camelcase": "^6.2.0", @@ -15943,9 +14795,8 @@ }, "node_modules/jest-validate/node_modules/camelcase": { "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -15955,9 +14806,8 @@ }, "node_modules/jest-validate/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -15971,9 +14821,8 @@ }, "node_modules/jest-validate/node_modules/chalk/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -15986,9 +14835,8 @@ }, "node_modules/jest-validate/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -15998,33 +14846,29 @@ }, "node_modules/jest-validate/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-validate/node_modules/jest-get-type": { "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", "dev": true, + "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-validate/node_modules/leven": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/jest-validate/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -16034,9 +14878,8 @@ }, "node_modules/jest-watcher": { "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.2.2.tgz", - "integrity": "sha512-j2otfqh7mOvMgN2WlJ0n7gIx9XCMWntheYGlBK7+5g3b1Su13/UAK7pdKGyd4kDlrLwtH2QPvRv5oNIxWvsJ1w==", "dev": true, + "license": "MIT", "dependencies": { "@jest/test-result": "^29.2.1", "@jest/types": "^29.2.1", @@ -16053,9 +14896,8 @@ }, "node_modules/jest-watcher/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -16068,9 +14910,8 @@ }, "node_modules/jest-watcher/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -16084,9 +14925,8 @@ }, "node_modules/jest-watcher/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -16096,15 +14936,13 @@ }, "node_modules/jest-watcher/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-watcher/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -16114,9 +14952,8 @@ }, "node_modules/jest-worker": { "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -16128,23 +14965,20 @@ }, "node_modules/jju": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", - "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jmespath": { "version": "0.16.0", - "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", - "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "license": "Apache-2.0", "engines": { "node": ">= 0.6.0" } }, "node_modules/joi": { "version": "17.6.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.6.0.tgz", - "integrity": "sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.0.0", "@hapi/topo": "^5.0.0", @@ -16155,8 +14989,7 @@ }, "node_modules/jose": { "version": "1.28.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-1.28.2.tgz", - "integrity": "sha512-wWy51U2MXxYi3g8zk2lsQ8M6O1lartpkxuq1TYexzPKYLgHLZkCjklaATP36I5BUoWjF2sInB9U1Qf18fBZxNA==", + "license": "MIT", "dependencies": { "@panva/asn1.js": "^1.0.0" }, @@ -16169,31 +15002,27 @@ }, "node_modules/joycon": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/js-sdsl": { "version": "2.1.4", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-2.1.4.tgz", - "integrity": "sha512-/Ew+CJWHNddr7sjwgxaVeIORIH4AMVC9dy0hPf540ZGMVgS9d3ajwuVdyhDt6/QUvT8ATjR3yuYBKsS79F+H4A==", + "license": "MIT", "optional": true, "peer": true }, "node_modules/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/js-yaml": { "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -16204,14 +15033,107 @@ }, "node_modules/jsbn": { "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "23.2.0", + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^2.0.1", + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.16.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.0", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/debug": { + "version": "4.3.4", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.4", + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.0.0", + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } }, "node_modules/jsesc": { "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true, + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, @@ -16221,19 +15143,16 @@ }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-schema": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + "license": "(AFL-2.1 OR BSD-3-Clause)" }, "node_modules/json-schema-ref-parser": { "version": "9.0.9", - "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz", - "integrity": "sha512-qcP2lmGy+JUoQJ4DOQeLaZDqH9qSkeGCK3suKWxJXS82dg728Mn3j97azDMaOUmJAN4uCq91LdPx4K7E8F1a7Q==", + "license": "MIT", "dependencies": { "@apidevtools/json-schema-ref-parser": "9.0.9" }, @@ -16243,8 +15162,7 @@ }, "node_modules/json-schema-to-ts": { "version": "2.9.2", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-2.9.2.tgz", - "integrity": "sha512-h9WqLkTVpBbiaPb5OmeUpz/FBLS/kvIJw4oRCPiEisIu2WjMh+aai0QIY2LoOhRFx5r92taGLcerIrzxKBAP6g==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", "@types/json-schema": "^7.0.9", @@ -16256,24 +15174,20 @@ }, "node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-stringify-safe": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + "license": "ISC" }, "node_modules/json5": { "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -16283,14 +15197,12 @@ }, "node_modules/jsonc-parser": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jsonfile": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, @@ -16300,8 +15212,7 @@ }, "node_modules/jsonpath": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.1.1.tgz", - "integrity": "sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w==", + "license": "MIT", "dependencies": { "esprima": "1.2.2", "static-eval": "2.0.2", @@ -16310,8 +15221,6 @@ }, "node_modules/jsonpath/node_modules/esprima": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.2.tgz", - "integrity": "sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A==", "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -16322,8 +15231,7 @@ }, "node_modules/jsonwebtoken": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", - "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", + "license": "MIT", "dependencies": { "jws": "^3.2.2", "lodash": "^4.17.21", @@ -16337,8 +15245,7 @@ }, "node_modules/jsprim": { "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", - "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -16351,16 +15258,14 @@ }, "node_modules/jsprim/node_modules/core-util-is": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + "license": "MIT" }, "node_modules/jsprim/node_modules/verror": { "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", "engines": [ "node >=0.6.0" ], + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -16369,9 +15274,8 @@ }, "node_modules/jsx-ast-utils": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", - "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", "dev": true, + "license": "MIT", "dependencies": { "array-includes": "^3.1.5", "object.assign": "^4.1.3" @@ -16382,14 +15286,12 @@ }, "node_modules/just-extend": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", - "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jwa": { "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", "dependencies": { "buffer-equal-constant-time": "1.0.1", "ecdsa-sig-formatter": "1.0.11", @@ -16398,8 +15300,7 @@ }, "node_modules/jwks-rsa": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-2.0.5.tgz", - "integrity": "sha512-fliHfsiBRzEU0nXzSvwnh0hynzGB0WihF+CinKbSRlaqRxbqqKf2xbBPgwc8mzf18/WgwlG8e5eTpfSTBcU4DQ==", + "license": "MIT", "dependencies": { "@types/express-jwt": "0.0.42", "debug": "^4.3.2", @@ -16413,8 +15314,7 @@ }, "node_modules/jwks-rsa/node_modules/jose": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.6.tgz", - "integrity": "sha512-FVoPY7SflDodE4lknJmbAHSUjLCzE2H1F6MS0RYKMQ8SR+lNccpMf8R4eqkNYyyUjR5qZReOzZo5C5YiHOCjjg==", + "license": "MIT", "dependencies": { "@panva/asn1.js": "^1.0.0" }, @@ -16427,8 +15327,7 @@ }, "node_modules/jws": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" @@ -16436,63 +15335,158 @@ }, "node_modules/jwt-decode": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", - "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/kareem": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", - "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", + "license": "Apache-2.0", "engines": { "node": ">=12.0.0" } }, "node_modules/kleur": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" - }, - "node_modules/language-subtag-registry": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz", - "integrity": "sha512-L0IqwlIXjilBVVYKFT37X9Ih11Um5NEl9cbJIuU/SwP/zEEAbBPOnEeeuxVMf45ydWQRDQN3Nqc96OgbH1K+Pg==", - "dev": true - }, - "node_modules/language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha1-0yHbxNowuovzAk4ED6XBRmH5GTo=", - "dev": true, + "node_modules/knex": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/knex/-/knex-2.4.2.tgz", + "integrity": "sha512-tMI1M7a+xwHhPxjbl/H9K1kHX+VncEYcvCx5K00M16bWvpYPKAZd6QrCu68PtHAdIZNQPWZn0GVhqVBEthGWCg==", "dependencies": { - "language-subtag-registry": "~0.3.2" + "colorette": "2.0.19", + "commander": "^9.1.0", + "debug": "4.3.4", + "escalade": "^3.1.1", + "esm": "^3.2.25", + "get-package-type": "^0.1.0", + "getopts": "2.3.0", + "interpret": "^2.2.0", + "lodash": "^4.17.21", + "pg-connection-string": "2.5.0", + "rechoir": "^0.8.0", + "resolve-from": "^5.0.0", + "tarn": "^3.0.2", + "tildify": "2.0.0" + }, + "bin": { + "knex": "bin/cli.js" + }, + "engines": { + "node": ">=12" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "mysql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } } }, - "node_modules/lcid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", - "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", - "dependencies": { - "invert-kv": "^1.0.0" - }, + "node_modules/knex/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", "engines": { - "node": ">=0.10.0" + "node": "^12.20.0 || >=14" } }, - "node_modules/ldap-filter": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.2.2.tgz", - "integrity": "sha1-8rhCvguG2jNSeYUFsx68rlkNd9A=", - "dependencies": { + "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/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/knex/node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/knex/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/language-subtag-registry": { + "version": "0.3.21", + "dev": true, + "license": "ODC-By-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "~0.3.2" + } + }, + "node_modules/lcid": { + "version": "1.0.0", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "invert-kv": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ldap-filter": { + "version": "0.2.2", + "license": "MIT", + "dependencies": { "assert-plus": "0.1.5" }, "engines": { @@ -16501,8 +15495,6 @@ }, "node_modules/ldap-filter/node_modules/assert-plus": { "version": "0.1.5", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz", - "integrity": "sha1-7nQAlBMALYTOxyGcasgRgS5yMWA=", "engines": { "node": ">=0.8" } @@ -16538,17 +15530,15 @@ }, "node_modules/leven": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", - "integrity": "sha1-wuep93IJTe6dNCAq6KzORoeHVYA=", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/levn": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -16559,8 +15549,7 @@ }, "node_modules/lib0": { "version": "0.2.87", - "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.87.tgz", - "integrity": "sha512-TbB63XJixvNToW2IHWAFsCJj9tVnajmwjE14p69i51Rx8byOQd2IP4ourE8v4d7vhyO++nVm1sQk3ePslfbucg==", + "license": "MIT", "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -16578,251 +15567,173 @@ }, "node_modules/libphonenumber-js": { "version": "1.10.24", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.24.tgz", - "integrity": "sha512-3Dk8f5AmrcWqg+oHhmm9hwSTqpWHBdSqsHmjCJGroULFubi0+x7JEIGmRZCuL3TI8Tx39xaKqfnhsDQ4ALa/Nw==" + "license": "MIT" }, "node_modules/lilconfig": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/limiter": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", - "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + "version": "1.1.5" }, "node_modules/lines-and-columns": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/listenercount": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", - "integrity": "sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc=", + "license": "ISC", "optional": true, "peer": true }, - "node_modules/load-json-file": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", - "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", - "dependencies": { - "graceful-fs": "^4.1.2", - "parse-json": "^2.2.0", - "pify": "^2.0.0", - "pinkie-promise": "^2.0.0", - "strip-bom": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/load-json-file/node_modules/parse-json": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", - "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", - "dependencies": { - "error-ex": "^1.2.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/load-json-file/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/load-json-file/node_modules/strip-bom": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", - "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", - "dependencies": { - "is-utf8": "^0.2.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/load-tsconfig": { "version": "0.2.3", - "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.3.tgz", - "integrity": "sha512-iyT2MXws+dc2Wi6o3grCFtGXpeMvHmJqS27sMPGtV2eUu4PeFnG+33I8BlFK1t1NWMjOpcx9bridn5yxLDX2gQ==", "dev": true, + "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, "node_modules/loader-runner": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz", - "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.11.5" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" - }, - "node_modules/lodash.assign": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", - "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=" + "license": "MIT" }, "node_modules/lodash.clonedeep": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" + "license": "MIT" }, "node_modules/lodash.defaults": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/lodash.flattendeep": { "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", - "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.get": { "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + "license": "MIT" }, "node_modules/lodash.includes": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + "license": "MIT" }, "node_modules/lodash.isarguments": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/lodash.isboolean": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + "license": "MIT" }, "node_modules/lodash.isinteger": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + "license": "MIT" }, "node_modules/lodash.isnumber": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + "license": "MIT" }, "node_modules/lodash.isstring": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + "license": "MIT" }, "node_modules/lodash.memoize": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.mergewith": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.once": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + "license": "MIT" }, "node_modules/lodash.pad": { "version": "4.5.1", - "resolved": "https://registry.npmjs.org/lodash.pad/-/lodash.pad-4.5.1.tgz", - "integrity": "sha1-QzCUmoM6fI2iLMIPaibE1Z3runA=", + "license": "MIT", "optional": true, "peer": true }, "node_modules/lodash.padend": { "version": "4.6.1", - "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", - "integrity": "sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=", + "license": "MIT", "optional": true, "peer": true }, "node_modules/lodash.padstart": { "version": "4.6.1", - "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz", - "integrity": "sha1-0uPuv/DZ05rVD1y9G1KnvOa7YRs=", + "license": "MIT", "optional": true, "peer": true }, "node_modules/lodash.set": { "version": "4.3.2", - "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", - "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.sortby": { "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.truncate": { "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.uniq": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" + "license": "MIT" }, "node_modules/lodash.zipobject": { "version": "4.1.3", - "resolved": "https://registry.npmjs.org/lodash.zipobject/-/lodash.zipobject-4.1.3.tgz", - "integrity": "sha1-s5n1q6j/YqdG9peb8gshT5ZNvvg=" + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -16836,9 +15747,8 @@ }, "node_modules/log-symbols/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -16851,9 +15761,8 @@ }, "node_modules/log-symbols/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -16867,9 +15776,8 @@ }, "node_modules/log-symbols/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -16879,15 +15787,13 @@ }, "node_modules/log-symbols/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/log-symbols/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -16897,8 +15803,7 @@ }, "node_modules/logform": { "version": "2.4.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.4.0.tgz", - "integrity": "sha512-CPSJw4ftjf517EhXZGGvTHHkYobo7ZCc0kvwUoOYcjfR2UVrI66RHj8MCrfAdEitdmFqbu2BYdYs8FHHZSb6iw==", + "license": "MIT", "dependencies": { "@colors/colors": "1.5.0", "fecha": "^4.2.0", @@ -16909,9 +15814,8 @@ }, "node_modules/loglevel": { "version": "1.8.0", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.0.tgz", - "integrity": "sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6.0" }, @@ -16922,9 +15826,8 @@ }, "node_modules/loglevel-colored-level-prefix": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/loglevel-colored-level-prefix/-/loglevel-colored-level-prefix-1.0.0.tgz", - "integrity": "sha1-akAhj9x64V/HbD0PPmdsRlOIYD4=", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^1.1.3", "loglevel": "^1.4.1" @@ -16932,27 +15835,24 @@ }, "node_modules/loglevel-colored-level-prefix/node_modules/ansi-regex": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/loglevel-colored-level-prefix/node_modules/ansi-styles": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/loglevel-colored-level-prefix/node_modules/chalk": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^2.2.1", "escape-string-regexp": "^1.0.2", @@ -16966,30 +15866,16 @@ }, "node_modules/loglevel-colored-level-prefix/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": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } }, - "node_modules/loglevel-colored-level-prefix/node_modules/has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", - "dev": true, - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/loglevel-colored-level-prefix/node_modules/strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^2.0.0" }, @@ -16999,31 +15885,27 @@ }, "node_modules/loglevel-colored-level-prefix/node_modules/supports-color": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/long-timeout": { "version": "0.1.1", - "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", - "integrity": "sha1-lyHXiLR+C8taJMLivuGg2lXatRQ=" + "license": "MIT" }, "node_modules/loupe": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.3.tgz", - "integrity": "sha512-krIV4Cf1BIGIx2t1e6tucThhrBemUnIUjMtD2vN4mrMxnxpBvrcosBSpooqunBqP/hOEEV1w/Cr1YskGtqw5Jg==", "dev": true, + "license": "MIT", "dependencies": { "get-func-name": "^2.0.0" } }, "node_modules/lru-cache": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -17033,8 +15915,7 @@ }, "node_modules/lru-memoizer": { "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.1.4.tgz", - "integrity": "sha512-IXAq50s4qwrOBrXJklY+KhgZF+5y98PDaNo0gi/v2KQBFLyWr+JyFvijZXkGKjQj/h9c0OwoE+JZbwUXce76hQ==", + "license": "MIT", "dependencies": { "lodash.clonedeep": "^4.5.0", "lru-cache": "~4.0.0" @@ -17042,8 +15923,7 @@ }, "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": "sha1-HRdnnAac2l0ECZGgnbwsDbN35V4=", + "license": "ISC", "dependencies": { "pseudomap": "^1.0.1", "yallist": "^2.0.0" @@ -17051,14 +15931,12 @@ }, "node_modules/lru-memoizer/node_modules/yallist": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" + "license": "ISC" }, "node_modules/macos-release": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz", - "integrity": "sha512-EIgv+QZ9r+814gjJj0Bt5vSLJLzswGmSUbUpbi9AIr/fsN2IWFBl2NucV9PAiek+U1STK468tEkxmVYUtuAN3g==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" }, @@ -17068,9 +15946,8 @@ }, "node_modules/magic-string": { "version": "0.30.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.1.tgz", - "integrity": "sha512-mbVKXPmS0z0G4XqFDCTllmDQ6coZzn94aMlb0o/A4HEHJCKcanlDZwYJgwnkmgD3jyWhUgj9VsPrfd972yPffA==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, @@ -17080,15 +15957,13 @@ }, "node_modules/magic-string/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==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/make-dir": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^6.0.0" }, @@ -17101,40 +15976,35 @@ }, "node_modules/make-dir/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/make-error": { "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" + "license": "ISC" }, "node_modules/make-error-cause": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/make-error-cause/-/make-error-cause-2.3.0.tgz", - "integrity": "sha512-etgt+n4LlOkGSJbBTV9VROHA5R7ekIPS4vfh+bCAoJgRrJWdqJCBbpS3osRJ/HrT7R68MzMiY3L3sDJ/Fd8aBg==", + "license": "Apache-2.0", "dependencies": { "make-error": "^1.3.5" } }, "node_modules/makeerror": { "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "tmpl": "1.0.5" } }, "node_modules/md5-file": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/md5-file/-/md5-file-5.0.0.tgz", - "integrity": "sha512-xbEFXCYVWrSx/gEKS1VPlg84h/4L20znVIulKw6kMfmBUAZNAnF00eczz9ICMl+/hjQGo5KSXRxbL/47X3rmMw==", "dev": true, + "license": "MIT", "bin": { "md5-file": "cli.js" }, @@ -17142,19 +16012,21 @@ "node": ">=10.13.0" } }, + "node_modules/mdn-data": { + "version": "2.0.30", + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/memfs": { "version": "3.5.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.0.tgz", - "integrity": "sha512-yK6o8xVJlQerz57kvPROwTMgx5WtGwC2ZxDtOUsnGl49rHjYkfQoPNZPCKH73VdLE1BwBu/+Fx/NL8NYMUw2aA==", "dev": true, + "license": "Unlicense", "dependencies": { "fs-monkey": "^1.0.3" }, @@ -17164,13 +16036,12 @@ }, "node_modules/memory-pager": { "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + "license": "MIT", + "optional": true }, "node_modules/memory-stream": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/memory-stream/-/memory-stream-0.0.3.tgz", - "integrity": "sha1-6+jdHDuLw4wOeUHp3dWuvmtN6D8=", + "license": "MMIT", "optional": true, "peer": true, "dependencies": { @@ -17179,15 +16050,13 @@ }, "node_modules/memory-stream/node_modules/isarray": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "license": "MIT", "optional": true, "peer": true }, "node_modules/memory-stream/node_modules/readable-stream": { "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -17199,40 +16068,48 @@ }, "node_modules/merge": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/merge/-/merge-2.1.1.tgz", - "integrity": "sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==" + "license": "MIT" }, "node_modules/merge-descriptors": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + "license": "MIT" + }, + "node_modules/merge-source-map": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "source-map": "^0.5.6" + } + }, + "node_modules/merge-source-map/node_modules/source-map": { + "version": "0.5.7", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/merge-stream": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", "engines": { "node": ">= 8" } }, "node_modules/methods": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/micromatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", - "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "license": "MIT", "dependencies": { "braces": "^3.0.1", "picomatch": "^2.2.3" @@ -17241,482 +16118,66 @@ "node": ">=8.6" } }, - "node_modules/migrate-mongoose": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/migrate-mongoose/-/migrate-mongoose-4.0.0.tgz", - "integrity": "sha512-Zf4Jk+CvBZUrZx4q/vvYr2pRGYAo7RO4BJx/3aTAR9VhNa34/iV0Rhqj87Tflk0n14SgwZpqvixyJzEpmSAikg==", - "dependencies": { - "bluebird": "^3.3.3", - "colors": "^1.1.2", - "dotenv": "^8.0.0", - "inquirer": "^0.12.0", - "mkdirp": "^0.5.1", - "mongoose": "^5.6.3", - "yargs": "^4.8.1" - }, - "bin": { - "migrate": "src/cli.js" - } - }, - "node_modules/migrate-mongoose/node_modules/@types/mongodb": { - "version": "3.6.20", - "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.6.20.tgz", - "integrity": "sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ==", - "dependencies": { - "@types/bson": "*", - "@types/node": "*" - } - }, - "node_modules/migrate-mongoose/node_modules/ansi-escapes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", - "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/migrate-mongoose/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "node_modules/mikro-orm": { + "version": "5.6.16", + "resolved": "https://registry.npmjs.org/mikro-orm/-/mikro-orm-5.6.16.tgz", + "integrity": "sha512-HgG079qA5hWgGWlq9u3BjgE3ynGnDFsGRtvFhgo6W3Itkz46SsQ4oeQxRcAetd8mj/qM4SOLuy0k71pI6h0PkQ==", "engines": { - "node": ">=0.10.0" + "node": ">= 14.0.0" } }, - "node_modules/migrate-mongoose/node_modules/ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, "engines": { - "node": ">=0.10.0" + "node": ">=4" } }, - "node_modules/migrate-mongoose/node_modules/bluebird": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", - "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==" - }, - "node_modules/migrate-mongoose/node_modules/bson": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz", - "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==", + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", "engines": { - "node": ">=0.6.19" + "node": ">= 0.6" } }, - "node_modules/migrate-mongoose/node_modules/camelcase": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", - "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.6" } }, - "node_modules/migrate-mongoose/node_modules/chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "dependencies": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "dev": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/migrate-mongoose/node_modules/cli-cursor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", - "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", + "node_modules/minimatch": { + "version": "3.1.2", + "license": "ISC", "dependencies": { - "restore-cursor": "^1.0.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/migrate-mongoose/node_modules/cli-width": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", - "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==" - }, - "node_modules/migrate-mongoose/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/migrate-mongoose/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/migrate-mongoose/node_modules/dotenv": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", - "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", - "engines": { - "node": ">=10" - } - }, - "node_modules/migrate-mongoose/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": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/migrate-mongoose/node_modules/figures": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", - "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", - "dependencies": { - "escape-string-regexp": "^1.0.5", - "object-assign": "^4.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/migrate-mongoose/node_modules/get-caller-file": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", - "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==" - }, - "node_modules/migrate-mongoose/node_modules/has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/migrate-mongoose/node_modules/inquirer": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", - "integrity": "sha1-HvK/1jUE3wvHV4X/+MLEHfEvB34=", - "dependencies": { - "ansi-escapes": "^1.1.0", - "ansi-regex": "^2.0.0", - "chalk": "^1.0.0", - "cli-cursor": "^1.0.1", - "cli-width": "^2.0.0", - "figures": "^1.3.5", - "lodash": "^4.3.0", - "readline2": "^1.0.1", - "run-async": "^0.1.0", - "rx-lite": "^3.1.2", - "string-width": "^1.0.1", - "strip-ansi": "^3.0.0", - "through": "^2.3.6" - } - }, - "node_modules/migrate-mongoose/node_modules/is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "dependencies": { - "number-is-nan": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/migrate-mongoose/node_modules/kareem": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.2.tgz", - "integrity": "sha512-STHz9P7X2L4Kwn72fA4rGyqyXdmrMSdxqHx9IXon/FXluXieaFA6KJ2upcHAHxQPQ0LeM/OjLrhFxifHewOALQ==" - }, - "node_modules/migrate-mongoose/node_modules/mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "dependencies": { - "minimist": "^1.2.5" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/migrate-mongoose/node_modules/mongodb": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz", - "integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==", - "dependencies": { - "bl": "^2.2.1", - "bson": "^1.1.4", - "denque": "^1.4.1", - "optional-require": "^1.1.8", - "safe-buffer": "^5.1.2" - }, - "engines": { - "node": ">=4" - }, - "optionalDependencies": { - "saslprep": "^1.0.0" - }, - "peerDependenciesMeta": { - "aws4": { - "optional": true - }, - "bson-ext": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "mongodb-extjson": { - "optional": true - }, - "snappy": { - "optional": true - } - } - }, - "node_modules/migrate-mongoose/node_modules/mongodb/node_modules/optional-require": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.8.tgz", - "integrity": "sha512-jq83qaUb0wNg9Krv1c5OQ+58EK+vHde6aBPzLvPPqJm89UQWsvSuFy9X/OSNJnFeSOKo7btE0n8Nl2+nE+z5nA==", - "dependencies": { - "require-at": "^1.0.6" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/migrate-mongoose/node_modules/mongoose": { - "version": "5.13.21", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.13.21.tgz", - "integrity": "sha512-EvSrXrCBogenxY131qKasFcT1Pj+9Pg5AXj17vQ8S1mOEArK3CpOx965u1wTIrdnQ7DjFC+SRwPxNcqUjMAVyQ==", - "dependencies": { - "@types/bson": "1.x || 4.0.x", - "@types/mongodb": "^3.5.27", - "bson": "^1.1.4", - "kareem": "2.3.2", - "mongodb": "3.7.4", - "mongoose-legacy-pluralize": "1.0.2", - "mpath": "0.8.4", - "mquery": "3.2.5", - "ms": "2.1.2", - "optional-require": "1.0.x", - "regexp-clone": "1.0.0", - "safe-buffer": "5.2.1", - "sift": "13.5.2", - "sliced": "1.0.1" - }, - "engines": { - "node": ">=4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, - "node_modules/migrate-mongoose/node_modules/mquery": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.5.tgz", - "integrity": "sha512-VjOKHHgU84wij7IUoZzFRU07IAxd5kWJaDmyUzQlbjHjyoeK5TNeeo8ZsFDtTYnSgpW6n/nMNIHvE3u8Lbrf4A==", - "dependencies": { - "bluebird": "3.5.1", - "debug": "3.1.0", - "regexp-clone": "^1.0.0", - "safe-buffer": "5.1.2", - "sliced": "1.0.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/migrate-mongoose/node_modules/mquery/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/migrate-mongoose/node_modules/onetime": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", - "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/migrate-mongoose/node_modules/restore-cursor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", - "integrity": "sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=", - "dependencies": { - "exit-hook": "^1.0.0", - "onetime": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/migrate-mongoose/node_modules/run-async": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz", - "integrity": "sha1-yK1KXhEGYeQCp9IbUw4AnyX444k=", - "dependencies": { - "once": "^1.3.0" - } - }, - "node_modules/migrate-mongoose/node_modules/sift": { - "version": "13.5.2", - "resolved": "https://registry.npmjs.org/sift/-/sift-13.5.2.tgz", - "integrity": "sha512-+gxdEOMA2J+AI+fVsCqeNn7Tgx3M9ZN9jdi95939l1IJ8cZsqS8sqpJyOkic2SJk+1+98Uwryt/gL6XDaV+UZA==" - }, - "node_modules/migrate-mongoose/node_modules/string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "dependencies": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/migrate-mongoose/node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/migrate-mongoose/node_modules/supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/migrate-mongoose/node_modules/window-size": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.2.0.tgz", - "integrity": "sha1-tDFbtCFKPXBY6+7okuE/ok2YsHU=", - "bin": { - "window-size": "cli.js" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/migrate-mongoose/node_modules/yargs": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-4.8.1.tgz", - "integrity": "sha512-LqodLrnIDM3IFT+Hf/5sxBnEGECrfdC1uIbgZeJmESCSo4HoCAaKEus8MylXHAkdacGc0ye+Qa+dpkuom8uVYA==", - "dependencies": { - "cliui": "^3.2.0", - "decamelize": "^1.1.1", - "get-caller-file": "^1.0.1", - "lodash.assign": "^4.0.3", - "os-locale": "^1.4.0", - "read-pkg-up": "^1.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^1.0.1", - "set-blocking": "^2.0.0", - "string-width": "^1.0.1", - "which-module": "^1.0.0", - "window-size": "^0.2.0", - "y18n": "^3.2.1", - "yargs-parser": "^2.4.1" - } - }, - "node_modules/migrate-mongoose/node_modules/yargs-parser": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-2.4.1.tgz", - "integrity": "sha512-9pIKIJhnI5tonzG6OnCFlz/yln8xHYcGl+pn3xR0Vzff0vzN1PbNRaelgfgRUwZ3s4i3jvxT9WhmUGL4whnasA==", - "dependencies": { - "camelcase": "^3.0.0", - "lodash.assign": "^4.0.6" - } - }, - "node_modules/mikro-orm": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/mikro-orm/-/mikro-orm-5.9.3.tgz", - "integrity": "sha512-lLBWENtV7yUE5KraqJEMaaKDPotnab6i/uf+wOyjILxYPjaXivH+oq7g9U3WS7K1fLUpQlR+bdQTOExHLy1FtQ==", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": "*" } }, "node_modules/minimist": { "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" + "license": "MIT" }, "node_modules/minipass": { "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -17728,8 +16189,7 @@ }, "node_modules/minizlib": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -17742,14 +16202,12 @@ }, "node_modules/mixwith": { "version": "0.1.1", - "resolved": "https://registry.npmjs.org/mixwith/-/mixwith-0.1.1.tgz", - "integrity": "sha1-yJlZGMW2H7/amtN3qFfNR3UFQcA=" + "license": "Apache-2.0" }, "node_modules/mkdirp": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "devOptional": true, + "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" }, @@ -17759,9 +16217,8 @@ }, "node_modules/mocha": { "version": "9.2.2", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.2.2.tgz", - "integrity": "sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g==", "dev": true, + "license": "MIT", "dependencies": { "@ungap/promise-all-settled": "1.1.2", "ansi-colors": "4.1.1", @@ -17802,15 +16259,13 @@ }, "node_modules/mocha/node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "dev": true, + "license": "Python-2.0" }, "node_modules/mocha/node_modules/cliui": { "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -17819,9 +16274,8 @@ }, "node_modules/mocha/node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -17835,9 +16289,8 @@ }, "node_modules/mocha/node_modules/js-yaml": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -17847,9 +16300,8 @@ }, "node_modules/mocha/node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -17862,9 +16314,8 @@ }, "node_modules/mocha/node_modules/minimatch": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-4.2.1.tgz", - "integrity": "sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -17874,15 +16325,13 @@ }, "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 + "dev": true, + "license": "MIT" }, "node_modules/mocha/node_modules/nanoid": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz", - "integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==", "dev": true, + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -17892,9 +16341,8 @@ }, "node_modules/mocha/node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -17905,20 +16353,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mocha/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/mocha/node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -17931,18 +16369,16 @@ }, "node_modules/mocha/node_modules/y18n": { "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/mocha/node_modules/yargs": { "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -17958,46 +16394,23 @@ }, "node_modules/mockery": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mockery/-/mockery-2.1.0.tgz", - "integrity": "sha512-9VkOmxKlWXoDO/h1jDZaS4lH33aWfRiJiNT/tKj+8OGzrcFDLo8d0syGdbsc3Bc4GvRXPb+NMMvojotmuGJTvA==", "dev": true }, "node_modules/module-not-found-error": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", - "integrity": "sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/moment": { "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "license": "MIT", "engines": { "node": "*" } }, - "node_modules/mongodb": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.11.0.tgz", - "integrity": "sha512-9l9n4Nk2BYZzljW3vHah3Z0rfS5npKw6ktnkmFgTcnzaXH1DRm3pDl6VMHu84EVb1lzmSaJC4OzWZqTkB5i2wg==", - "dependencies": { - "bson": "^4.7.0", - "denque": "^2.1.0", - "mongodb-connection-string-url": "^2.5.4", - "socks": "^2.7.1" - }, - "engines": { - "node": ">=12.9.0" - }, - "optionalDependencies": { - "@aws-sdk/credential-providers": "^3.186.0", - "saslprep": "^1.0.3" - } - }, "node_modules/mongodb-connection-string-url": { "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", - "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "license": "Apache-2.0", "dependencies": { "@types/whatwg-url": "^8.2.1", "whatwg-url": "^11.0.0" @@ -18005,9 +16418,8 @@ }, "node_modules/mongodb-memory-server-core": { "version": "8.10.2", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-8.10.2.tgz", - "integrity": "sha512-ro4k1eGcjk6p8214wFpv31dsB4eaBUMRr9WYLBcQDbmzCkM7ARn6vsJhlrKWH8eoayLZf0X6557j013t/Ld8aA==", "dev": true, + "license": "MIT", "dependencies": { "@types/tmp": "^0.2.3", "async-mutex": "^0.3.2", @@ -18032,18 +16444,16 @@ }, "node_modules/mongodb-memory-server-core/node_modules/async-mutex": { "version": "0.3.2", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz", - "integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==", "dev": true, + "license": "MIT", "dependencies": { "tslib": "^2.3.1" } }, "node_modules/mongodb-memory-server-core/node_modules/camelcase": { "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -18053,9 +16463,8 @@ }, "node_modules/mongodb-memory-server-core/node_modules/debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -18068,11 +16477,28 @@ } } }, + "node_modules/mongodb-memory-server-core/node_modules/mongodb": { + "version": "4.11.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bson": "^4.7.0", + "denque": "^2.1.0", + "mongodb-connection-string-url": "^2.5.4", + "socks": "^2.7.1" + }, + "engines": { + "node": ">=12.9.0" + }, + "optionalDependencies": { + "@aws-sdk/credential-providers": "^3.186.0", + "saslprep": "^1.0.3" + } + }, "node_modules/mongodb-memory-server-core/node_modules/tmp": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", "dev": true, + "license": "MIT", "dependencies": { "rimraf": "^3.0.0" }, @@ -18082,10 +16508,9 @@ }, "node_modules/mongodb-memory-server-global-4.4": { "version": "8.10.2", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-global-4.4/-/mongodb-memory-server-global-4.4-8.10.2.tgz", - "integrity": "sha512-gRtF8LAkXJMJkrSRhLoVEy87cs0y4B3BLa3OIIDf6Hn669Jm68e5Lg1cL0yjIc+mx0rPFCm9IhjZ0aO/miyhSQ==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { "mongodb-memory-server-core": "8.10.2", "tslib": "^2.4.1" @@ -18096,24 +16521,14 @@ }, "node_modules/mongodb-uri": { "version": "0.9.7", - "resolved": "https://registry.npmjs.org/mongodb-uri/-/mongodb-uri-0.9.7.tgz", - "integrity": "sha1-D3ca0W9IOuZfQoeWlCjp+8SqYYE=", + "license": "MIT", "engines": { "node": ">= 0.6.0" } }, - "node_modules/mongodb/node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "engines": { - "node": ">=0.10" - } - }, "node_modules/mongoose": { "version": "6.12.3", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.12.3.tgz", - "integrity": "sha512-MNJymaaXali7w7rHBxVUoQ3HzHHMk/7I/+yeeoSa4rUzdjZwIWQznBNvVgc0A8ghuJwsuIkb5LyLV6gSjGjWyQ==", + "license": "MIT", "dependencies": { "bson": "^4.7.2", "kareem": "2.5.1", @@ -18133,33 +16548,22 @@ }, "node_modules/mongoose-delete": { "version": "0.5.4", - "resolved": "https://registry.npmjs.org/mongoose-delete/-/mongoose-delete-0.5.4.tgz", - "integrity": "sha512-zKkcWFEwTneVG4Oiy+qJRJPFCiGlqOJToHeU3a3meJybWsHCnWxdHkGEkFWNY5cmYEv5av3WwByOhPlQ6I+FmQ==", + "license": "MIT", "peerDependencies": { "mongoose": "4.x || 5.x || 6.x" } }, "node_modules/mongoose-id-validator": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/mongoose-id-validator/-/mongoose-id-validator-0.6.0.tgz", - "integrity": "sha512-y3b3/PkmaiMKSbKB8tsEEGUjgCgKQGpD2Ood7jaVEob3V2HWgnmNKCgiSQUpEtQuDU0lUnLJQ5JE9PH1Bytziw==", + "license": "LGPL-3.0+", "dependencies": { "clone": "^1.0.2", "traverse": "^0.6.6" } }, - "node_modules/mongoose-id-validator/node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", - "engines": { - "node": ">=0.8" - } - }, "node_modules/mongoose-lean-virtuals": { "version": "0.8.1", - "resolved": "https://registry.npmjs.org/mongoose-lean-virtuals/-/mongoose-lean-virtuals-0.8.1.tgz", - "integrity": "sha512-XbL6V1Yg5mMtiCPZ0nsoq/55qhxOfdo/LX8cGTe5scTkk2BIzkOBFED+s8mLxWK0o0yVxigSTiF+tAdBp89QfQ==", + "license": "Apache 2.0", "dependencies": { "array.prototype.flat": "1.2.3", "mpath": "^0.8.4" @@ -18170,8 +16574,7 @@ }, "node_modules/mongoose-lean-virtuals/node_modules/array.prototype.flat": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz", - "integrity": "sha512-gBlRZV0VSmfPIeWfuuy56XZMvbVfbEUnOXUvt3F/eUUUSyzlgLxhEX4YAEpxNAogRGehPSnfXyPtYyKAhkzQhQ==", + "license": "MIT", "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.0-next.1" @@ -18183,14 +16586,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mongoose-legacy-pluralize": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz", - "integrity": "sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ==", - "peerDependencies": { - "mongoose": "*" - } - }, "node_modules/mongoose-shortid-nodeps": { "version": "0.6.5", "resolved": "git+ssh://git@github.com/leeroybrun/mongoose-shortid-nodeps.git#3f6afe8f95504e0e7f1c59001b8a1dbcc164dfbb", @@ -18199,44 +16594,9 @@ "mongoose": ">= 4.4.0" } }, - "node_modules/mongoose/node_modules/bson": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz", - "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==", - "dependencies": { - "buffer": "^5.6.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/mongoose/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "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.1.13" - } - }, "node_modules/mongoose/node_modules/mongodb": { "version": "4.17.1", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.17.1.tgz", - "integrity": "sha512-MBuyYiPUPRTqfH2dV0ya4dcr2E5N52ocBuZ8Sgg/M030nGF78v855B3Z27mZJnp8PxjnUquEnAtjOsphgMZOlQ==", + "license": "Apache-2.0", "dependencies": { "bson": "^4.7.2", "mongodb-connection-string-url": "^2.6.0", @@ -18252,21 +16612,18 @@ }, "node_modules/mongoose/node_modules/mpath": { "version": "0.9.0", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", "engines": { "node": ">=4.0.0" } }, "node_modules/mongoose/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==" + "license": "MIT" }, "node_modules/moodle-client": { "version": "0.5.2", - "resolved": "https://registry.npmjs.org/moodle-client/-/moodle-client-0.5.2.tgz", - "integrity": "sha512-3ETg3cp5O0ASXn3YgVzQ8M7MkxOVgn5Mjp2O7nrUkGzLF6jA3OwWowIzhr/yHx+gLiANErM/+xulspFAJhQMhw==", + "license": "BSD-2-Clause", "dependencies": { "bluebird": "^3.5.2", "request": "^2.88.0", @@ -18275,16 +16632,14 @@ }, "node_modules/mpath": { "version": "0.8.4", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.8.4.tgz", - "integrity": "sha512-DTxNZomBcTWlrMW76jy1wvV37X/cNNxPW1y2Jzd4DZkAaC5ZGsm8bfGfNOthcDuRJujXLqiuS6o3Tpy0JEoh7g==", + "license": "MIT", "engines": { "node": ">=4.0.0" } }, "node_modules/mqtt": { "version": "4.3.5", - "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-4.3.5.tgz", - "integrity": "sha512-l29WGHAc0EayK1cjb6moozc+rlgK6YRCPbP3zB1CrJw84Bjk4kG9EJCXojdn4r29lA80SCqxRKq1QJ87+Xevng==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -18317,8 +16672,7 @@ }, "node_modules/mqtt-packet": { "version": "6.10.0", - "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-6.10.0.tgz", - "integrity": "sha512-ja8+mFKIHdB1Tpl6vac+sktqy3gA8t9Mduom1BA75cI+R9AHnZOiaBQwpGiWnaVJLDGRdNhQmFaAqd7tkKSMGA==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -18329,8 +16683,7 @@ }, "node_modules/mqtt-packet/node_modules/bl": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -18341,8 +16694,6 @@ }, "node_modules/mqtt-packet/node_modules/buffer": { "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "funding": [ { "type": "github", @@ -18357,6 +16708,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -18366,8 +16718,7 @@ }, "node_modules/mqtt-packet/node_modules/readable-stream": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -18381,8 +16732,7 @@ }, "node_modules/mqtt-packet/node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -18391,8 +16741,7 @@ }, "node_modules/mqtt/node_modules/duplexify": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", - "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -18404,8 +16753,7 @@ }, "node_modules/mqtt/node_modules/readable-stream": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -18419,18 +16767,37 @@ }, "node_modules/mqtt/node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } }, + "node_modules/mqtt/node_modules/ws": { + "version": "7.5.9", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/mquery": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz", - "integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==", + "license": "MIT", "dependencies": { "debug": "4.x" }, @@ -18440,21 +16807,18 @@ }, "node_modules/mri": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", - "integrity": "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==", + "license": "MIT", "engines": { "node": ">=4" } }, "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==" + "license": "MIT" }, "node_modules/multer": { "version": "1.4.4-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", - "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.0.0", @@ -18470,11 +16834,10 @@ }, "node_modules/multer/node_modules/concat-stream": { "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "engines": [ "node >= 0.8" ], + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", @@ -18484,8 +16847,7 @@ }, "node_modules/multer/node_modules/mkdirp": { "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", "dependencies": { "minimist": "^1.2.6" }, @@ -18495,8 +16857,7 @@ }, "node_modules/multer/node_modules/readable-stream": { "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -18509,27 +16870,23 @@ }, "node_modules/multer/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "license": "MIT" }, "node_modules/multer/node_modules/string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/mute-stream": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/mv": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", - "integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=", + "license": "MIT", "optional": true, "dependencies": { "mkdirp": "~0.5.1", @@ -18542,8 +16899,7 @@ }, "node_modules/mv/node_modules/glob": { "version": "6.0.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", - "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=", + "license": "ISC", "optional": true, "dependencies": { "inflight": "^1.0.4", @@ -18558,8 +16914,7 @@ }, "node_modules/mv/node_modules/mkdirp": { "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "license": "MIT", "optional": true, "dependencies": { "minimist": "^1.2.5" @@ -18570,8 +16925,7 @@ }, "node_modules/mv/node_modules/rimraf": { "version": "2.4.5", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", - "integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=", + "license": "ISC", "optional": true, "dependencies": { "glob": "^6.0.1" @@ -18582,9 +16936,8 @@ }, "node_modules/mz": { "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", "dev": true, + "license": "MIT", "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", @@ -18593,20 +16946,18 @@ }, "node_modules/nan": { "version": "2.15.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", - "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==", + "license": "MIT", "optional": true }, "node_modules/nanoid": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -18616,20 +16967,17 @@ }, "node_modules/natural-compare": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/natural-compare-lite": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ncp": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", - "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=", + "license": "MIT", "optional": true, "bin": { "ncp": "bin/ncp" @@ -18637,22 +16985,19 @@ }, "node_modules/negotiator": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/neo-async": { "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/nest-winston": { "version": "1.9.4", - "resolved": "https://registry.npmjs.org/nest-winston/-/nest-winston-1.9.4.tgz", - "integrity": "sha512-ilEmHuuYSAI6aMNR120fLBl42EdY13QI9WRggHdEizt9M7qZlmXJwpbemVWKW/tqRmULjSx/otKNQ3GMQbfoUQ==", + "license": "MIT", "dependencies": { "fast-safe-stringify": "^2.1.1" }, @@ -18663,8 +17008,7 @@ }, "node_modules/nestjs-console": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nestjs-console/-/nestjs-console-9.0.0.tgz", - "integrity": "sha512-5t9r0E9WHei2aCxPTPrtSq0h1rQBsO8TWok9HEwuRDpT3OEZJfA5FF0LaJDTD0DkusDq+GufSK1pZpgs21EeKQ==", + "license": "MIT", "dependencies": { "commander": "^11.0.0" }, @@ -18678,17 +17022,15 @@ }, "node_modules/nestjs-console/node_modules/commander": { "version": "11.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz", - "integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==", + "license": "MIT", "engines": { "node": ">=16" } }, "node_modules/new-find-package-json": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", - "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.3.4" }, @@ -18698,9 +17040,8 @@ }, "node_modules/new-find-package-json/node_modules/debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -18713,17 +17054,19 @@ } } }, + "node_modules/next-tick": { + "version": "1.1.0", + "license": "ISC" + }, "node_modules/nice-try": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/nise": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.1.tgz", - "integrity": "sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^1.8.3", "@sinonjs/fake-timers": ">=5", @@ -18734,24 +17077,21 @@ }, "node_modules/nise/node_modules/isarray": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/nise/node_modules/path-to-regexp": { "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", "dev": true, + "license": "MIT", "dependencies": { "isarray": "0.0.1" } }, "node_modules/nock": { "version": "13.2.4", - "resolved": "https://registry.npmjs.org/nock/-/nock-13.2.4.tgz", - "integrity": "sha512-8GPznwxcPNCH/h8B+XZcKjYPXnUV5clOKCjAqyjsiqA++MpNx9E9+t8YPp0MbThO+KauRo7aZJ1WuIZmOrT2Ug==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.0", "json-stringify-safe": "^5.0.1", @@ -18764,23 +17104,20 @@ }, "node_modules/node-abort-controller": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/node-emoji": { "version": "1.11.0", - "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", - "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", "dev": true, + "license": "MIT", "dependencies": { "lodash": "^4.17.21" } }, "node_modules/node-fetch": { "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -18798,18 +17135,15 @@ }, "node_modules/node-fetch/node_modules/tr46": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + "license": "MIT" }, "node_modules/node-fetch/node_modules/webidl-conversions": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + "license": "BSD-2-Clause" }, "node_modules/node-fetch/node_modules/whatwg-url": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "license": "MIT", "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -18817,20 +17151,17 @@ }, "node_modules/node-int64": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/node-machine-id": { "version": "1.1.12", - "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", - "integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==" + "license": "MIT" }, "node_modules/node-preload": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", - "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", "dev": true, + "license": "MIT", "dependencies": { "process-on-spawn": "^1.0.0" }, @@ -18840,15 +17171,13 @@ }, "node_modules/node-releases": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz", - "integrity": "sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/nodemon": { "version": "2.0.20", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz", - "integrity": "sha512-Km2mWHKKY5GzRg6i1j5OxOHQtuvVsgskLfigG25yTtbyfRGn/GNvIbRyOf1PSCKJ2aT/58TiuUsuOU5UToVViw==", "dev": true, + "license": "MIT", "dependencies": { "chokidar": "^3.5.2", "debug": "^3.2.7", @@ -18874,36 +17203,32 @@ }, "node_modules/nodemon/node_modules/debug": { "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/nodemon/node_modules/has-flag": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/nodemon/node_modules/semver": { "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver" } }, "node_modules/nodemon/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==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -18913,9 +17238,8 @@ }, "node_modules/noms": { "version": "0.0.0", - "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", - "integrity": "sha1-2o69nzr51nYJGbJ9nNyAkqczKFk=", "dev": true, + "license": "ISC", "dependencies": { "inherits": "^2.0.1", "readable-stream": "~1.0.31" @@ -18923,15 +17247,13 @@ }, "node_modules/noms/node_modules/isarray": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/noms/node_modules/readable-stream": { "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", "dev": true, + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", @@ -18941,52 +17263,27 @@ }, "node_modules/nopt": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", "dev": true, + "license": "MIT", "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", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "bin": { - "semver": "bin/semver" } }, "node_modules/normalize-path": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/npm-run-path": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.0.0" }, @@ -18996,8 +17293,7 @@ }, "node_modules/npmlog": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-1.2.1.tgz", - "integrity": "sha1-KOe+YZYJtT960d0wChDWTXFiaLY=", + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -19008,8 +17304,7 @@ }, "node_modules/nth-check": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" }, @@ -19019,8 +17314,7 @@ }, "node_modules/number-allocator": { "version": "1.0.9", - "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.9.tgz", - "integrity": "sha512-sIIF0dZKMs3roPUD7rLreH8H3x47QKV9dHZ+PeSnH24gL0CxKxz/823woGZC0hLBSb2Ar/rOOeHiNbnPBum/Mw==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -19030,17 +17324,17 @@ }, "node_modules/number-is-nan": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/nyc": { "version": "15.1.0", - "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", - "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==", "dev": true, + "license": "ISC", "dependencies": { "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.2", @@ -19079,9 +17373,8 @@ }, "node_modules/nyc/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -19094,9 +17387,8 @@ }, "node_modules/nyc/node_modules/cliui": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -19105,9 +17397,8 @@ }, "node_modules/nyc/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -19117,28 +17408,13 @@ }, "node_modules/nyc/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/nyc/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } + "license": "MIT" }, "node_modules/nyc/node_modules/istanbul-lib-instrument": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", - "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.7.5", "@istanbuljs/schema": "^0.1.2", @@ -19149,98 +17425,26 @@ "node": ">=8" } }, - "node_modules/nyc/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/nyc/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/nyc/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true - }, "node_modules/nyc/node_modules/resolve-from": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/nyc/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, - "node_modules/nyc/node_modules/which-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", - "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", - "dev": true - }, "node_modules/nyc/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==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -19252,15 +17456,13 @@ }, "node_modules/nyc/node_modules/y18n": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/nyc/node_modules/yargs": { "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", @@ -19280,9 +17482,8 @@ }, "node_modules/nyc/node_modules/yargs-parser": { "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "dev": true, + "license": "ISC", "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" @@ -19293,53 +17494,60 @@ }, "node_modules/oauth-1.0a": { "version": "2.2.6", - "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", - "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==" + "license": "MIT" }, "node_modules/oauth-sign": { "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", "engines": { "node": "*" } }, "node_modules/object-assign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/object-hash": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", "engines": { "node": ">= 6" } }, "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.5", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/object-keys": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/object.assign": { "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -19355,9 +17563,8 @@ }, "node_modules/object.entries": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz", - "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.3", @@ -19369,9 +17576,8 @@ }, "node_modules/object.fromentries": { "version": "2.0.7", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", - "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -19386,9 +17592,8 @@ }, "node_modules/object.groupby": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", - "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -19398,9 +17603,8 @@ }, "node_modules/object.values": { "version": "1.1.7", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", - "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", "dev": true, + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -19415,33 +17619,29 @@ }, "node_modules/on-headers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "license": "ISC", "dependencies": { "wrappy": "1" } }, "node_modules/one-time": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", "dependencies": { "fn.name": "1.x.x" } }, "node_modules/onetime": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, + "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" }, @@ -19454,17 +17654,15 @@ }, "node_modules/ono": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-9jnfVriq7uJM4o5ganUY54ntUm+5EK21EGaQ5NWnkWg3zz5ywbbonlBguRcnmF1/HDiIe3zxNxXcO1YPBmPcQQ==", + "license": "MIT", "dependencies": { "@jsdevtools/ono": "7.1.3" } }, "node_modules/open": { "version": "8.4.0", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz", - "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==", "dev": true, + "license": "MIT", "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", @@ -19479,8 +17677,7 @@ }, "node_modules/open-graph-scraper": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.2.2.tgz", - "integrity": "sha512-cQO0c0HF9ZMhSoIEOKMyxbSYwKn6qWBDEdQeCvZnAVwKCxSWj2DV8AwC1J4JCiwZbn/C4grGCJXpvmlAyTXrBg==", + "license": "MIT", "dependencies": { "chardet": "^1.6.0", "cheerio": "^1.0.0-rc.12", @@ -19493,22 +17690,12 @@ }, "node_modules/open-graph-scraper/node_modules/chardet": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.6.0.tgz", - "integrity": "sha512-+QOTw3otC4+FxdjK9RopGpNOglADbr4WPFi0SonkO99JbpkTPbMxmdm4NenhF5Zs+4gPXLI1+y2uazws5TMe8w==" - }, - "node_modules/optional-require": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz", - "integrity": "sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA==", - "engines": { - "node": ">=4" - } + "license": "MIT" }, "node_modules/optionator": { "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", "dev": true, + "license": "MIT", "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -19523,9 +17710,8 @@ }, "node_modules/ora": { "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "dev": true, + "license": "MIT", "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", @@ -19546,9 +17732,8 @@ }, "node_modules/ora/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -19561,9 +17746,8 @@ }, "node_modules/ora/node_modules/bl": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, + "license": "MIT", "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -19572,8 +17756,6 @@ }, "node_modules/ora/node_modules/buffer": { "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dev": true, "funding": [ { @@ -19589,6 +17771,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -19596,9 +17779,8 @@ }, "node_modules/ora/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -19612,9 +17794,8 @@ }, "node_modules/ora/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -19624,15 +17805,13 @@ }, "node_modules/ora/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ora/node_modules/readable-stream": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", "dev": true, + "license": "MIT", "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -19644,18 +17823,16 @@ }, "node_modules/ora/node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" } }, "node_modules/ora/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -19665,8 +17842,9 @@ }, "node_modules/os-locale": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", - "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", + "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "lcid": "^1.0.0" }, @@ -19676,9 +17854,8 @@ }, "node_modules/os-name": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/os-name/-/os-name-4.0.1.tgz", - "integrity": "sha512-xl9MAoU97MH1Xt5K9ERft2YfCAoaO6msy1OBA0ozxEC0x0TmIoE6K3QvgJMMZA9yKGLmHXNY/YZoDbiGDj4zYw==", "dev": true, + "license": "MIT", "dependencies": { "macos-release": "^2.5.0", "windows-release": "^4.0.0" @@ -19692,25 +17869,22 @@ }, "node_modules/os-tmpdir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/p-finally": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -19721,11 +17895,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", - "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", "dev": true, + "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" }, @@ -19733,11 +17929,17 @@ "node": ">=8" } }, + "node_modules/p-try": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-hash": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", - "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", "dev": true, + "license": "ISC", "dependencies": { "graceful-fs": "^4.1.15", "hasha": "^5.0.0", @@ -19748,16 +17950,18 @@ "node": ">=8" } }, + "node_modules/pako": { + "version": "0.2.9", + "license": "MIT" + }, "node_modules/papaparse": { "version": "5.3.1", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.1.tgz", - "integrity": "sha512-Dbt2yjLJrCwH2sRqKFFJaN5XgIASO9YOFeFP8rIBRG2Ain8mqk5r1M6DkfvqEVozVcz3r3HaUGw253hA1nLIcA==" + "license": "MIT" }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -19765,11 +17969,16 @@ "node": ">=6" } }, + "node_modules/parent-require": { + "version": "1.0.0", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/parse-json": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -19785,13 +17994,11 @@ }, "node_modules/parse-srcset": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", - "integrity": "sha1-8r0iH2zJcKk42IVWq8WJyqqiveE=" + "license": "MIT" }, "node_modules/parse5": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "license": "MIT", "dependencies": { "entities": "^4.4.0" }, @@ -19801,8 +18008,7 @@ }, "node_modules/parse5-htmlparser2-tree-adapter": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", - "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "license": "MIT", "dependencies": { "domhandler": "^5.0.2", "parse5": "^7.0.0" @@ -19813,8 +18019,7 @@ }, "node_modules/parse5-htmlparser2-tree-adapter/node_modules/domhandler": { "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" }, @@ -19827,8 +18032,7 @@ }, "node_modules/parse5/node_modules/entities": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -19838,16 +18042,14 @@ }, "node_modules/parseurl": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/passport": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", - "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", + "license": "MIT", "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -19863,8 +18065,7 @@ }, "node_modules/passport-custom": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", - "integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==", + "license": "MIT", "dependencies": { "passport-strategy": "1.x.x" }, @@ -19874,8 +18075,7 @@ }, "node_modules/passport-headerapikey": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/passport-headerapikey/-/passport-headerapikey-1.2.2.tgz", - "integrity": "sha512-4BvVJRrWsNJPrd3UoZfcnnl4zvUWYKEtfYkoDsaOKBsrWHYmzTApCjs7qUbncOLexE9ul0IRiYBFfBG0y9IVQA==", + "license": "MIT", "dependencies": { "lodash": "^4.17.15", "passport-strategy": "^1.0.0" @@ -19883,8 +18083,7 @@ }, "node_modules/passport-jwt": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", - "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", "dependencies": { "jsonwebtoken": "^9.0.0", "passport-strategy": "^1.0.0" @@ -19892,8 +18091,6 @@ }, "node_modules/passport-local": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", - "integrity": "sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4=", "dependencies": { "passport-strategy": "1.x.x" }, @@ -19903,38 +18100,39 @@ }, "node_modules/passport-strategy": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", - "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=", "engines": { "node": ">= 0.4.0" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/path-parse": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "license": "MIT" }, "node_modules/path-scurry": { "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^9.1.1 || ^10.0.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -19948,53 +18146,68 @@ }, "node_modules/path-scurry/node_modules/lru-cache": { "version": "10.0.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz", - "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==", "dev": true, + "license": "ISC", "engines": { "node": "14 || >=16.14" } }, "node_modules/path-scurry/node_modules/minipass": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.3.tgz", - "integrity": "sha512-LhbbwCfz3vsb12j/WkWQPZfKTsgqIe1Nf/ti1pKjYESGLHIVjWU96G9/ljLH4F9mWNVhlQOm0VySdAWzf05dpg==", "dev": true, + "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/path-to-regexp": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/pathval": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", "dev": true, + "license": "MIT", "engines": { "node": "*" } }, "node_modules/pause": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", - "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" + "version": "0.0.1" + }, + "node_modules/pdfmake": { + "version": "0.2.9", + "license": "MIT", + "dependencies": { + "@foliojs-fork/linebreak": "^1.1.1", + "@foliojs-fork/pdfkit": "^0.14.0", + "iconv-lite": "^0.6.3", + "xmldoc": "^1.1.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/pdfmake/node_modules/iconv-lite": { + "version": "0.6.3", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } }, "node_modules/peek-readable": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.0.0.tgz", - "integrity": "sha512-YtCKvLUOvwtMGmrniQPdO7MwPjgkFBtFIrmfSbYmYuq3tKDV/mcfAhBth1+C3ru7uXIZasc/pHnb+YDYNkkj4A==", + "license": "MIT", "engines": { "node": ">=14.16" }, @@ -20005,23 +18218,24 @@ }, "node_modules/pend": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + "license": "MIT" }, "node_modules/performance-now": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + "license": "MIT" + }, + "node_modules/pg-connection-string": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", + "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" }, "node_modules/picocolors": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -20031,45 +18245,23 @@ }, "node_modules/pify": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/pinkie": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pinkie-promise": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", - "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", - "dependencies": { - "pinkie": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pirates": { "version": "4.0.5", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", - "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 6" } }, "node_modules/pkg-dir": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", "dev": true, + "license": "MIT", "dependencies": { "find-up": "^4.0.0" }, @@ -20077,89 +18269,27 @@ "node": ">=8" } }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "node_modules/pluralize": { + "version": "8.0.0", "dev": true, + "license": "MIT", "engines": { - "node": ">=6" + "node": ">=4" } }, - "node_modules/pkg-dir/node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } + "node_modules/png-js": { + "version": "1.0.0" }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true, + "node_modules/pony-cause": { + "version": "2.1.10", + "license": "0BSD", "engines": { - "node": ">=4" + "node": ">=12.0.0" } }, "node_modules/popsicle": { "version": "12.1.0", - "resolved": "https://registry.npmjs.org/popsicle/-/popsicle-12.1.0.tgz", - "integrity": "sha512-muNC/cIrWhfR6HqqhHazkxjob3eyECBe8uZYSQ/N5vixNAgssacVleerXnE8Are5fspR0a+d2qWaBR1g7RYlmw==", + "license": "MIT", "dependencies": { "popsicle-content-encoding": "^1.0.0", "popsicle-cookie-jar": "^1.0.0", @@ -20173,16 +18303,14 @@ }, "node_modules/popsicle-content-encoding": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/popsicle-content-encoding/-/popsicle-content-encoding-1.0.0.tgz", - "integrity": "sha512-4Df+vTfM8wCCJVTzPujiI6eOl3SiWQkcZg0AMrOkD1enMXsF3glIkFUZGvour1Sj7jOWCsNSEhBxpbbhclHhzw==", + "license": "MIT", "peerDependencies": { "servie": "^4.0.0" } }, "node_modules/popsicle-cookie-jar": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/popsicle-cookie-jar/-/popsicle-cookie-jar-1.0.0.tgz", - "integrity": "sha512-vrlOGvNVELko0+J8NpGC5lHWDGrk8LQJq9nwAMIVEVBfN1Lib3BLxAaLRGDTuUnvl45j5N9dT2H85PULz6IjjQ==", + "license": "MIT", "dependencies": { "@types/tough-cookie": "^2.3.5", "tough-cookie": "^3.0.1" @@ -20193,8 +18321,7 @@ }, "node_modules/popsicle-cookie-jar/node_modules/tough-cookie": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz", - "integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==", + "license": "BSD-3-Clause", "dependencies": { "ip-regex": "^2.1.0", "psl": "^1.1.28", @@ -20206,16 +18333,14 @@ }, "node_modules/popsicle-redirects": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/popsicle-redirects/-/popsicle-redirects-1.1.0.tgz", - "integrity": "sha512-XCpzVjVk7tty+IJnSdqWevmOr1n8HNDhL86v7mZ6T1JIIf2KGybxUk9mm7ZFOhWMkGB0e8XkacHip7BV8AQWQA==", + "license": "MIT", "peerDependencies": { "servie": "^4.1.0" } }, "node_modules/popsicle-transport-http": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/popsicle-transport-http/-/popsicle-transport-http-1.2.1.tgz", - "integrity": "sha512-i5r3IGHkGiBDm1oPFvOfEeSGWR0lQJcsdTqwvvDjXqcTHYJJi4iSi3ecXIttDiTBoBtRAFAE9nF91fspQr63FQ==", + "license": "MIT", "dependencies": { "make-error-cause": "^2.2.0" }, @@ -20225,24 +18350,20 @@ }, "node_modules/popsicle-transport-xhr": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/popsicle-transport-xhr/-/popsicle-transport-xhr-2.0.0.tgz", - "integrity": "sha512-5Sbud4Widngf1dodJE5cjEYXkzEUIl8CzyYRYR57t6vpy9a9KPGQX6KBKdPjmBZlR5A06pOBXuJnVr23l27rtA==", + "license": "MIT", "peerDependencies": { "servie": "^4.2.0" } }, "node_modules/popsicle-user-agent": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/popsicle-user-agent/-/popsicle-user-agent-1.0.0.tgz", - "integrity": "sha512-epKaq3TTfTzXcxBxjpoKYMcTTcAX8Rykus6QZu77XNhJuRHSRxMd+JJrbX/3PFI0opFGSN0BabbAYCbGxbu0mA==", + "license": "MIT", "peerDependencies": { "servie": "^4.0.0" } }, "node_modules/postcss": { "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", "funding": [ { "type": "opencollective", @@ -20253,6 +18374,7 @@ "url": "https://tidelift.com/funding/github/npm/postcss" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.4", "picocolors": "^1.0.0", @@ -20264,9 +18386,8 @@ }, "node_modules/postcss-load-config": { "version": "3.1.4", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", "dev": true, + "license": "MIT", "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" @@ -20293,26 +18414,22 @@ }, "node_modules/precond": { "version": "0.2.3", - "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", - "integrity": "sha1-qpWRvKokkj8eD0hJ0kD0fvwQdaw=", "engines": { "node": ">= 0.6" } }, "node_modules/prelude-ls": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/prettier": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz", - "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin-prettier.js" }, @@ -20325,9 +18442,8 @@ }, "node_modules/prettier-eslint": { "version": "12.0.0", - "resolved": "https://registry.npmjs.org/prettier-eslint/-/prettier-eslint-12.0.0.tgz", - "integrity": "sha512-N8SGGQwAosISXTNl1E57sBbtnqUGlyRWjcfIUxyD3HF4ynehA9GZ8IfJgiep/OfYvCof/JEpy9ZqSl250Wia7A==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/parser": "^3.0.0", "common-tags": "^1.4.0", @@ -20348,18 +18464,16 @@ }, "node_modules/prettier-eslint/node_modules/@babel/code-frame": { "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", - "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/highlight": "^7.10.4" } }, "node_modules/prettier-eslint/node_modules/@eslint/eslintrc": { "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", - "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.1.1", @@ -20377,9 +18491,8 @@ }, "node_modules/prettier-eslint/node_modules/@humanwhocodes/config-array": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", - "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@humanwhocodes/object-schema": "^1.2.0", "debug": "^4.1.1", @@ -20391,9 +18504,8 @@ }, "node_modules/prettier-eslint/node_modules/@typescript-eslint/parser": { "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-3.10.1.tgz", - "integrity": "sha512-Ug1RcWcrJP02hmtaXVS3axPPTTPnZjupqhgj+NnZ6BCkwSImWk/283347+x9wN+lqOdK9Eo3vsyiyDHgsmiEJw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@types/eslint-visitor-keys": "^1.0.0", "@typescript-eslint/experimental-utils": "3.10.1", @@ -20419,9 +18531,8 @@ }, "node_modules/prettier-eslint/node_modules/@typescript-eslint/types": { "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz", - "integrity": "sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ==", "dev": true, + "license": "MIT", "engines": { "node": "^8.10.0 || ^10.13.0 || >=11.10.1" }, @@ -20432,9 +18543,8 @@ }, "node_modules/prettier-eslint/node_modules/@typescript-eslint/typescript-estree": { "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz", - "integrity": "sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/types": "3.10.1", "@typescript-eslint/visitor-keys": "3.10.1", @@ -20460,9 +18570,8 @@ }, "node_modules/prettier-eslint/node_modules/@typescript-eslint/visitor-keys": { "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz", - "integrity": "sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^1.1.0" }, @@ -20476,9 +18585,8 @@ }, "node_modules/prettier-eslint/node_modules/ajv": { "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -20492,18 +18600,16 @@ }, "node_modules/prettier-eslint/node_modules/ansi-regex": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", - "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/prettier-eslint/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==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -20513,9 +18619,8 @@ }, "node_modules/prettier-eslint/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -20529,9 +18634,8 @@ }, "node_modules/prettier-eslint/node_modules/chalk/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -20544,9 +18648,8 @@ }, "node_modules/prettier-eslint/node_modules/chalk/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -20556,15 +18659,13 @@ }, "node_modules/prettier-eslint/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/prettier-eslint/node_modules/eslint": { "version": "7.32.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", - "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -20619,9 +18720,8 @@ }, "node_modules/prettier-eslint/node_modules/eslint-utils": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^1.1.0" }, @@ -20634,27 +18734,24 @@ }, "node_modules/prettier-eslint/node_modules/eslint-visitor-keys": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=4" } }, "node_modules/prettier-eslint/node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=10" } }, "node_modules/prettier-eslint/node_modules/espree": { "version": "7.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", - "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^7.4.0", "acorn-jsx": "^5.3.1", @@ -20666,9 +18763,8 @@ }, "node_modules/prettier-eslint/node_modules/globals": { "version": "13.19.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz", - "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -20681,24 +18777,21 @@ }, "node_modules/prettier-eslint/node_modules/ignore": { "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/prettier-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", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/prettier-eslint/node_modules/pretty-format": { "version": "23.6.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", - "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^3.0.0", "ansi-styles": "^3.2.0" @@ -20706,9 +18799,8 @@ }, "node_modules/prettier-eslint/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -20718,9 +18810,8 @@ }, "node_modules/prettier-eslint/node_modules/type-fest": { "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -20730,9 +18821,8 @@ }, "node_modules/prettier-eslint/node_modules/typescript": { "version": "3.9.10", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz", - "integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20743,9 +18833,8 @@ }, "node_modules/prettier-linter-helpers": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", "dev": true, + "license": "MIT", "dependencies": { "fast-diff": "^1.1.2" }, @@ -20755,9 +18844,8 @@ }, "node_modules/pretty-format": { "version": "29.2.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.2.1.tgz", - "integrity": "sha512-Y41Sa4aLCtKAXvwuIpTvcFBkyeYp2gdFWzXGA+ZNES3VwURIB165XO/z7CjETwzCCS53MjW/rLMyyqEnTtaOfA==", "dev": true, + "license": "MIT", "dependencies": { "@jest/schemas": "^29.0.0", "ansi-styles": "^5.0.0", @@ -20769,14 +18857,12 @@ }, "node_modules/process-nextick-args": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + "license": "MIT" }, "node_modules/process-on-spawn": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", - "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", "dev": true, + "license": "MIT", "dependencies": { "fromentries": "^1.2.0" }, @@ -20786,17 +18872,15 @@ }, "node_modules/progress": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.4.0" } }, "node_modules/prom-client": { "version": "13.2.0", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-13.2.0.tgz", - "integrity": "sha512-wGr5mlNNdRNzEhRYXgboUU2LxHWIojxscJKmtG3R8f4/KiWqyYgXTLHs0+Ted7tG3zFT7pgHJbtomzZ1L0ARaQ==", + "license": "Apache-2.0", "dependencies": { "tdigest": "^0.1.1" }, @@ -20806,27 +18890,23 @@ }, "node_modules/promise-breaker": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/promise-breaker/-/promise-breaker-5.0.0.tgz", - "integrity": "sha512-mgsWQuG4kJ1dtO6e/QlNDLFtMkMzzecsC69aI5hlLEjGHFNpHrvGhFi4LiK5jg2SMQj74/diH+wZliL9LpGsyA==" + "license": "MIT" }, "node_modules/promise-coalesce": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.1.tgz", - "integrity": "sha512-k7+VaIwZc5dRfSF6RELqRY1+LCmcCkrnuNV9HzIpA6iwRHKke+j9yb0LBTTHQ2RRgf6AlMl9TntuTzcgV/BZwg==", + "version": "1.1.2", + "license": "BSD-3-Clause", "engines": { - "node": ">=18" + "node": ">=16" } }, "node_modules/promisepipe": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/promisepipe/-/promisepipe-3.0.0.tgz", - "integrity": "sha512-V6TbZDJ/ZswevgkDNpGt/YqNCiZP9ASfgU+p83uJE6NrGtvSGoOcHLiDCqkMs2+yg7F5qHdLV8d0aS8O26G/KA==" + "license": "MIT" }, "node_modules/prompts": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", "dev": true, + "license": "MIT", "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" @@ -20837,17 +18917,15 @@ }, "node_modules/propagate": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } }, "node_modules/proxy-addr": { "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -20858,14 +18936,12 @@ }, "node_modules/proxy-from-env": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "license": "MIT" }, "node_modules/proxyquire": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", - "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", "dev": true, + "license": "MIT", "dependencies": { "fill-keys": "^1.0.2", "module-not-found-error": "^1.0.1", @@ -20874,42 +18950,36 @@ }, "node_modules/pseudomap": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" + "license": "ISC" }, "node_modules/psl": { "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" + "license": "MIT" }, "node_modules/pstree.remy": { "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/pump": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", "devOptional": true, + "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.1", + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/qs": { "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.0.4" }, @@ -20922,30 +18992,23 @@ }, "node_modules/querystring": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", - "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", "engines": { "node": ">=0.4.x" } }, "node_modules/querystringify": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + "license": "MIT" }, "node_modules/queue": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", - "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", "dependencies": { "inherits": "~2.0.3" } }, "node_modules/queue-microtask": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "funding": [ { "type": "github", @@ -20959,37 +19022,46 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" + }, + "node_modules/quote-stream": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "buffer-equal": "0.0.1", + "minimist": "^1.1.3", + "through2": "^2.0.0" + }, + "bin": { + "quote-stream": "bin/cmd.js" + } }, "node_modules/random-bytes": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", - "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/randombytes": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" } }, "node_modules/range-parser": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/raw-body": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -21002,16 +19074,14 @@ }, "node_modules/raw-body/node_modules/depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/raw-body/node_modules/http-errors": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", @@ -21025,16 +19095,14 @@ }, "node_modules/raw-body/node_modules/statuses": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/rc": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", "optional": true, "peer": true, "dependencies": { @@ -21049,8 +19117,7 @@ }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "license": "MIT", "optional": true, "peer": true, "engines": { @@ -21059,14 +19126,12 @@ }, "node_modules/react-is": { "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/read-chunk": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-3.2.0.tgz", - "integrity": "sha512-CEjy9LCzhmD7nUpJ1oVOE6s/hBkejlcJEgLQHVnQznOSilOPb+kpKktlLfFDK3/WP43+F80xkUTM2VOkYoSYvQ==", + "license": "MIT", "dependencies": { "pify": "^4.0.1", "with-open-file": "^0.1.6" @@ -21075,79 +19140,9 @@ "node": ">=6" } }, - "node_modules/read-pkg": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", - "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", - "dependencies": { - "load-json-file": "^1.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-pkg-up": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", - "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", - "dependencies": { - "find-up": "^1.0.0", - "read-pkg": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-pkg-up/node_modules/find-up": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", - "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", - "dependencies": { - "path-exists": "^2.0.0", - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-pkg-up/node_modules/path-exists": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", - "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", - "dependencies": { - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-pkg/node_modules/path-type": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", - "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", - "dependencies": { - "graceful-fs": "^4.1.2", - "pify": "^2.0.0", - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-pkg/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/readable-stream": { "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", @@ -21157,13 +19152,11 @@ }, "node_modules/readable-stream/node_modules/isarray": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + "license": "MIT" }, "node_modules/readable-web-to-node-stream": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", - "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", + "license": "MIT", "dependencies": { "readable-stream": "^3.6.0" }, @@ -21177,8 +19170,7 @@ }, "node_modules/readable-web-to-node-stream/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==", + "license": "MIT", "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -21190,17 +19182,15 @@ }, "node_modules/readable-web-to-node-stream/node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" } }, "node_modules/readdirp": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, + "license": "MIT", "dependencies": { "picomatch": "^2.2.1" }, @@ -21208,36 +19198,8 @@ "node": ">=8.10.0" } }, - "node_modules/readline2": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz", - "integrity": "sha1-QQWWCP/BVHV7cV2ZidGZ/783LjU=", - "dependencies": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "mute-stream": "0.0.5" - } - }, - "node_modules/readline2/node_modules/is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "dependencies": { - "number-is-nan": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readline2/node_modules/mute-stream": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz", - "integrity": "sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA=" - }, "node_modules/rechoir": { "version": "0.6.2", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", - "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", "dev": true, "dependencies": { "resolve": "^1.1.6" @@ -21247,12 +19209,14 @@ } }, "node_modules/redis": { - "version": "4.6.11", - "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.11.tgz", - "integrity": "sha512-kg1Lt4NZLYkAjPOj/WcyIGWfZfnyfKo1Wg9YKVSlzhFwxpFIl3LYI8BWy1Ab963LLDsTz2+OwdsesHKljB3WMQ==", + "version": "4.6.13", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], "dependencies": { "@redis/bloom": "1.2.0", - "@redis/client": "1.5.12", + "@redis/client": "1.5.14", "@redis/graph": "1.1.1", "@redis/json": "1.0.6", "@redis/search": "1.1.6", @@ -21261,20 +19225,14 @@ }, "node_modules/redis-errors": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "optional": true, - "peer": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/redis-parser": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { "redis-errors": "^1.0.0" }, @@ -21284,23 +19242,15 @@ }, "node_modules/reflect-metadata": { "version": "0.1.13", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", - "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" + "license": "Apache-2.0" }, "node_modules/regenerator-runtime": { "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" - }, - "node_modules/regexp-clone": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz", - "integrity": "sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw==" + "license": "MIT" }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", - "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -21315,9 +19265,8 @@ }, "node_modules/regexpp": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -21327,16 +19276,14 @@ }, "node_modules/reinterval": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reinterval/-/reinterval-1.1.0.tgz", - "integrity": "sha1-M2Hs+jymwYKDOA3Qu5VG85D17Oc=", + "license": "MIT", "optional": true, "peer": true }, "node_modules/release-zalgo": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", - "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", "dev": true, + "license": "ISC", "dependencies": { "es6-error": "^4.0.1" }, @@ -21346,18 +19293,15 @@ }, "node_modules/repeat-string": { "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10" } }, "node_modules/request": { "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -21386,9 +19330,7 @@ }, "node_modules/request-promise": { "version": "4.2.6", - "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.6.tgz", - "integrity": "sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==", - "deprecated": "request-promise has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142", + "license": "ISC", "dependencies": { "bluebird": "^3.5.0", "request-promise-core": "1.1.4", @@ -21404,8 +19346,7 @@ }, "node_modules/request-promise-core": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", - "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", + "license": "ISC", "dependencies": { "lodash": "^4.17.19" }, @@ -21418,9 +19359,7 @@ }, "node_modules/request-promise-native": { "version": "1.0.9", - "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.9.tgz", - "integrity": "sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==", - "deprecated": "request-promise-native has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142", + "license": "ISC", "dependencies": { "request-promise-core": "1.1.4", "stealthy-require": "^1.1.1", @@ -21435,8 +19374,7 @@ }, "node_modules/request-promise-native/node_modules/tough-cookie": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" @@ -21447,8 +19385,7 @@ }, "node_modules/request-promise/node_modules/tough-cookie": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" @@ -21459,8 +19396,7 @@ }, "node_modules/request/node_modules/form-data": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", @@ -21472,16 +19408,14 @@ }, "node_modules/request/node_modules/qs": { "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.6" } }, "node_modules/request/node_modules/tough-cookie": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" @@ -21492,57 +19426,42 @@ }, "node_modules/request/node_modules/uuid": { "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT", "bin": { "uuid": "bin/uuid" } }, - "node_modules/require-at": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/require-at/-/require-at-1.0.6.tgz", - "integrity": "sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==", - "engines": { - "node": ">=4" - } - }, "node_modules/require-directory": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/require-from-string": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/require-main-filename": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", - "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" }, "node_modules/require-relative": { "version": "0.8.7", - "resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz", - "integrity": "sha1-eZlTn8ngR6N5KPoZb44VY9q9Nt4=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/requires-port": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" + "license": "MIT" }, "node_modules/resolve": { "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "license": "MIT", "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -21557,9 +19476,8 @@ }, "node_modules/resolve-cwd": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", "dev": true, + "license": "MIT", "dependencies": { "resolve-from": "^5.0.0" }, @@ -21569,35 +19487,31 @@ }, "node_modules/resolve-cwd/node_modules/resolve-from": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/resolve.exports": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz", - "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/response-time": { "version": "2.3.2", - "resolved": "https://registry.npmjs.org/response-time/-/response-time-2.3.2.tgz", - "integrity": "sha512-MUIDaDQf+CVqflfTdQ5yam+aYCkXj1PY8fjlPDQ6ppxJlmgZb864pHtA750mayywNg8tx4rS7qH9JXd/OF+3gw==", + "license": "MIT", "dependencies": { "depd": "~1.1.0", "on-headers": "~1.0.1" @@ -21608,9 +19522,8 @@ }, "node_modules/restore-cursor": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "dev": true, + "license": "MIT", "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" @@ -21621,8 +19534,7 @@ }, "node_modules/reusify": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -21630,18 +19542,16 @@ }, "node_modules/rewire": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/rewire/-/rewire-5.0.0.tgz", - "integrity": "sha512-1zfitNyp9RH5UDyGGLe9/1N0bMlPQ0WrX0Tmg11kMHBpqwPJI4gfPpP7YngFyLbFmhXh19SToAG0sKKEFcOIJA==", "dev": true, + "license": "MIT", "dependencies": { "eslint": "^6.8.0" } }, "node_modules/rewire/node_modules/ajv": { "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -21655,18 +19565,16 @@ }, "node_modules/rewire/node_modules/ansi-regex": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/rewire/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==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -21676,18 +19584,16 @@ }, "node_modules/rewire/node_modules/astral-regex": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", - "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/rewire/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==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -21699,9 +19605,8 @@ }, "node_modules/rewire/node_modules/cross-spawn": { "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", "dev": true, + "license": "MIT", "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", @@ -21715,33 +19620,29 @@ }, "node_modules/rewire/node_modules/cross-spawn/node_modules/semver": { "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver" } }, "node_modules/rewire/node_modules/emoji-regex": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/rewire/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": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/rewire/node_modules/eslint": { "version": "6.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.8.0.tgz", - "integrity": "sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", "ajv": "^6.10.0", @@ -21793,9 +19694,8 @@ }, "node_modules/rewire/node_modules/eslint-utils": { "version": "1.4.3", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", - "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^1.1.0" }, @@ -21805,18 +19705,16 @@ }, "node_modules/rewire/node_modules/eslint-visitor-keys": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=4" } }, "node_modules/rewire/node_modules/espree": { "version": "6.2.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-6.2.1.tgz", - "integrity": "sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^7.1.1", "acorn-jsx": "^5.2.0", @@ -21828,9 +19726,8 @@ }, "node_modules/rewire/node_modules/file-entry-cache": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", - "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==", "dev": true, + "license": "MIT", "dependencies": { "flat-cache": "^2.0.1" }, @@ -21840,9 +19737,8 @@ }, "node_modules/rewire/node_modules/flat-cache": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", - "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", "dev": true, + "license": "MIT", "dependencies": { "flatted": "^2.0.0", "rimraf": "2.6.3", @@ -21854,15 +19750,13 @@ }, "node_modules/rewire/node_modules/flatted": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz", - "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/rewire/node_modules/globals": { "version": "12.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", - "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.8.1" }, @@ -21875,42 +19769,37 @@ }, "node_modules/rewire/node_modules/has-flag": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/rewire/node_modules/ignore": { "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/rewire/node_modules/is-fullwidth-code-point": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/rewire/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/rewire/node_modules/levn": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" @@ -21921,9 +19810,8 @@ }, "node_modules/rewire/node_modules/mkdirp": { "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", "dev": true, + "license": "MIT", "dependencies": { "minimist": "^1.2.5" }, @@ -21933,9 +19821,8 @@ }, "node_modules/rewire/node_modules/optionator": { "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", "dev": true, + "license": "MIT", "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", @@ -21950,17 +19837,14 @@ }, "node_modules/rewire/node_modules/path-key": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/rewire/node_modules/prelude-ls": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", "dev": true, "engines": { "node": ">= 0.8.0" @@ -21968,18 +19852,16 @@ }, "node_modules/rewire/node_modules/regexpp": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", - "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.5.0" } }, "node_modules/rewire/node_modules/rimraf": { "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "dev": true, + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -21989,18 +19871,16 @@ }, "node_modules/rewire/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/rewire/node_modules/shebang-command": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", "dev": true, + "license": "MIT", "dependencies": { "shebang-regex": "^1.0.0" }, @@ -22010,18 +19890,16 @@ }, "node_modules/rewire/node_modules/shebang-regex": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/rewire/node_modules/slice-ansi": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", - "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", @@ -22033,9 +19911,8 @@ }, "node_modules/rewire/node_modules/string-width": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^7.0.1", "is-fullwidth-code-point": "^2.0.0", @@ -22047,9 +19924,8 @@ }, "node_modules/rewire/node_modules/strip-ansi": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^4.1.0" }, @@ -22059,9 +19935,8 @@ }, "node_modules/rewire/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==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -22071,9 +19946,8 @@ }, "node_modules/rewire/node_modules/table": { "version": "5.4.6", - "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", - "integrity": "sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "ajv": "^6.10.2", "lodash": "^4.17.14", @@ -22086,9 +19960,8 @@ }, "node_modules/rewire/node_modules/type-check": { "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "~1.1.2" }, @@ -22098,24 +19971,21 @@ }, "node_modules/rewire/node_modules/type-fest": { "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=8" } }, "node_modules/rfdc": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", - "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", + "license": "MIT", "optional": true, "peer": true }, "node_modules/rimraf": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -22128,9 +19998,8 @@ }, "node_modules/rollup": { "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", "dev": true, + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -22141,10 +20010,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "license": "MIT" + }, "node_modules/rss-parser": { "version": "3.13.0", - "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", - "integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==", + "license": "MIT", "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" @@ -22152,8 +20024,7 @@ }, "node_modules/rss-parser/node_modules/xml2js": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", - "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" @@ -22164,17 +20035,14 @@ }, "node_modules/run-async": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } }, "node_modules/run-parallel": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "funding": [ { "type": "github", @@ -22189,27 +20057,21 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } }, - "node_modules/rx-lite": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz", - "integrity": "sha1-Gc5QLKVyZl87ZHsQk5+X/RYV8QI=" - }, "node_modules/rxjs": { "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" } }, "node_modules/safe-array-concat": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", - "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.2.1", @@ -22225,13 +20087,10 @@ }, "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==" + "license": "MIT" }, "node_modules/safe-buffer": { "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", @@ -22245,18 +20104,17 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/safe-json-stringify": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", - "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", + "license": "MIT", "optional": true }, "node_modules/safe-regex-test": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -22268,21 +20126,18 @@ }, "node_modules/safe-stable-stringify": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz", - "integrity": "sha512-kYBSfT+troD9cDA85VDnHZ1rpHC50O0g1e6WlGHVCz/g+JS+9WKLj+XwFYyR8UbrZN8ll9HUpDAAddY58MGisg==", + "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "license": "MIT" }, "node_modules/sanitize-html": { "version": "2.7.0", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.7.0.tgz", - "integrity": "sha512-jfQelabOn5voO7FAfnQF7v+jsA6z9zC/O4ec0z3E35XPEtHYJT/OdUziVWlKW4irCr2kXaQAyXTXDHWAibg1tA==", + "license": "MIT", "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", @@ -22294,16 +20149,14 @@ }, "node_modules/sanitize-html/node_modules/is-plain-object": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/saslprep": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", - "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", + "license": "MIT", "optional": true, "dependencies": { "sparse-bitfield": "^3.0.3" @@ -22314,14 +20167,22 @@ }, "node_modules/sax": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", - "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + "license": "ISC" + }, + "node_modules/saxes": { + "version": "6.0.0", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } }, "node_modules/schema-utils": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", "dev": true, + "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -22337,9 +20198,8 @@ }, "node_modules/schema-utils/node_modules/ajv": { "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -22353,23 +20213,33 @@ }, "node_modules/schema-utils/node_modules/ajv-keywords": { "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", "dev": true, + "license": "MIT", "peerDependencies": { "ajv": "^6.9.1" } }, "node_modules/schema-utils/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/scope-analyzer": { + "version": "2.1.2", + "license": "Apache-2.0", + "dependencies": { + "array-from": "^2.1.1", + "dash-ast": "^2.0.1", + "es6-map": "^0.1.5", + "es6-set": "^0.1.5", + "es6-symbol": "^3.1.1", + "estree-is-function": "^1.0.0", + "get-assigned-identifiers": "^1.1.0" + } }, "node_modules/semver": { "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -22382,8 +20252,7 @@ }, "node_modules/send": { "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -22405,29 +20274,25 @@ }, "node_modules/send/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } }, "node_modules/send/node_modules/debug/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + "license": "MIT" }, "node_modules/send/node_modules/depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/send/node_modules/http-errors": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", @@ -22441,13 +20306,11 @@ }, "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==" + "license": "MIT" }, "node_modules/send/node_modules/on-finished": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -22457,25 +20320,22 @@ }, "node_modules/send/node_modules/statuses": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "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==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" } }, "node_modules/serve-favicon": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.5.0.tgz", - "integrity": "sha1-k10kDN/g9YBTB/3+ln2IlCosvPA=", + "license": "MIT", "dependencies": { "etag": "~1.8.1", "fresh": "0.5.2", @@ -22489,18 +20349,15 @@ }, "node_modules/serve-favicon/node_modules/ms": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", - "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + "license": "MIT" }, "node_modules/serve-favicon/node_modules/safe-buffer": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", - "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + "license": "MIT" }, "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==", + "license": "MIT", "dependencies": { "encodeurl": "~1.0.2", "escape-html": "~1.0.3", @@ -22513,19 +20370,13 @@ }, "node_modules/service": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/service/-/service-0.1.4.tgz", - "integrity": "sha1-0Kuf+8K51Yda+LAd7DYzl4RSW0Q=", "dependencies": { "daemon": ">=0.3.0" - }, - "engines": { - "node": "*" } }, "node_modules/servie": { "version": "4.3.3", - "resolved": "https://registry.npmjs.org/servie/-/servie-4.3.3.tgz", - "integrity": "sha512-b0IrY3b1gVMsWvJppCf19g1p3JSnS0hQi6xu4Hi40CIhf0Lx8pQHcvBL+xunShpmOiQzg1NOia812NAWdSaShw==", + "license": "Apache-2.0", "dependencies": { "@servie/events": "^1.0.0", "byte-length": "^1.0.2", @@ -22534,13 +20385,11 @@ }, "node_modules/set-blocking": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" + "license": "ISC" }, "node_modules/set-function-length": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", - "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "license": "MIT", "dependencies": { "define-data-property": "^1.1.1", "get-intrinsic": "^1.2.1", @@ -22553,8 +20402,7 @@ }, "node_modules/set-function-name": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", - "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", "functions-have-names": "^1.2.3", @@ -22566,20 +20414,17 @@ }, "node_modules/setimmediate": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", + "license": "MIT", "optional": true, "peer": true }, "node_modules/setprototypeof": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + "license": "ISC" }, "node_modules/sha1": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/sha1/-/sha1-1.1.1.tgz", - "integrity": "sha1-rdqnqTFo85PxnrKxUJFhjicA+Eg=", + "license": "BSD-3-Clause", "dependencies": { "charenc": ">= 0.0.1", "crypt": ">= 0.0.1" @@ -22588,10 +20433,13 @@ "node": "*" } }, + "node_modules/shallow-copy": { + "version": "0.0.1", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -22601,17 +20449,15 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/shelljs": { "version": "0.8.5", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", - "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "glob": "^7.0.0", "interpret": "^1.0.0", @@ -22626,9 +20472,8 @@ }, "node_modules/shx": { "version": "0.3.4", - "resolved": "https://registry.npmjs.org/shx/-/shx-0.3.4.tgz", - "integrity": "sha512-N6A9MLVqjxZYcVn8hLmtneQWIJtp8IKzMP4eMnx+nqkvXoqinUPCbUFLp2UcWTEIUONhlk0ewxr/jaVGlc+J+g==", "dev": true, + "license": "MIT", "dependencies": { "minimist": "^1.2.3", "shelljs": "^0.8.5" @@ -22642,8 +20487,7 @@ }, "node_modules/side-channel": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", @@ -22655,20 +20499,17 @@ }, "node_modules/sift": { "version": "16.0.1", - "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", - "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" + "license": "MIT" }, "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 + "dev": true, + "license": "ISC" }, "node_modules/simple-oauth2": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/simple-oauth2/-/simple-oauth2-4.3.0.tgz", - "integrity": "sha512-gjLIfy7M7WZSf3k5IZCQfEozbQwmW80zR9YMH4ph/WWG6S4U6sGhPujz8X6Hj6sZ8l7acSAxiyM4tF0vIN+E+A==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@hapi/hoek": "^9.0.4", "@hapi/wreck": "^17.0.0", @@ -22678,22 +20519,19 @@ }, "node_modules/simple-swizzle": { "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "license": "MIT", "dependencies": { "is-arrayish": "^0.3.1" } }, "node_modules/simple-swizzle/node_modules/is-arrayish": { "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + "license": "MIT" }, "node_modules/simple-update-notifier": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.0.7.tgz", - "integrity": "sha512-BBKgR84BJQJm6WjWFMHgLVuo61FBDSj1z/xSFUIozqO6wO7ii0JxCqlIud7Enr/+LhlbNI0whErq96P2qHNWew==", "dev": true, + "license": "MIT", "dependencies": { "semver": "~7.0.0" }, @@ -22703,18 +20541,16 @@ }, "node_modules/simple-update-notifier/node_modules/semver": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", - "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/sinon": { "version": "11.1.2", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-11.1.2.tgz", - "integrity": "sha512-59237HChms4kg7/sXhiRcUzdSkKuydDeTiamT/jesUVHshBgL8XAmhgFo0GfK6RruMDM/iRSij1EybmMog9cJw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^1.8.3", "@sinonjs/fake-timers": "^7.1.2", @@ -22730,9 +20566,8 @@ }, "node_modules/sinon-chai": { "version": "3.7.0", - "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-3.7.0.tgz", - "integrity": "sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g==", "dev": true, + "license": "(BSD-2-Clause OR WTFPL)", "peerDependencies": { "chai": "^4.0.0", "sinon": ">=4.0.0" @@ -22740,9 +20575,8 @@ }, "node_modules/sinon/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -22752,23 +20586,20 @@ }, "node_modules/sisteransi": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/slash": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/slice-ansi": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", @@ -22783,9 +20614,8 @@ }, "node_modules/slice-ansi/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -22798,9 +20628,8 @@ }, "node_modules/slice-ansi/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -22810,19 +20639,12 @@ }, "node_modules/slice-ansi/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/sliced": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", - "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==" + "dev": true, + "license": "MIT" }, "node_modules/smart-buffer": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -22830,13 +20652,11 @@ }, "node_modules/socketio-file-upload": { "version": "0.7.3", - "resolved": "https://registry.npmjs.org/socketio-file-upload/-/socketio-file-upload-0.7.3.tgz", - "integrity": "sha512-JUvzi8Vvp2+GBfQtOehPSfecetZOo4g1JTl6+zmKhPiljn+z09lLL8zYeX4AJVSNpmRkGJnypbHkiPjPSkk5UA==" + "license": "X11" }, "node_modules/socks": { "version": "2.7.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", - "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "license": "MIT", "dependencies": { "ip": "^2.0.0", "smart-buffer": "^4.2.0" @@ -22848,47 +20668,46 @@ }, "node_modules/source-map": { "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, "node_modules/source-map-js": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, "node_modules/source-map-support": { "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "license": "MIT" + }, "node_modules/sparse-bitfield": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", + "license": "MIT", + "optional": true, "dependencies": { "memory-pager": "^1.0.2" } }, "node_modules/spawn-command": { "version": "0.0.2-1", - "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz", - "integrity": "sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A=" + "license": "MIT" }, "node_modules/spawn-wrap": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", - "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", "dev": true, + "license": "ISC", "dependencies": { "foreground-child": "^2.0.0", "is-windows": "^1.0.2", @@ -22903,9 +20722,8 @@ }, "node_modules/spawn-wrap/node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -22916,38 +20734,9 @@ "node": ">= 8" } }, - "node_modules/spdx-correct": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", - "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz", - "integrity": "sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g==" - }, "node_modules/split2": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", - "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -22956,8 +20745,7 @@ }, "node_modules/split2/node_modules/readable-stream": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -22971,8 +20759,7 @@ }, "node_modules/split2/node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -22981,21 +20768,25 @@ }, "node_modules/splitargs": { "version": "0.0.7", - "resolved": "https://registry.npmjs.org/splitargs/-/splitargs-0.0.7.tgz", - "integrity": "sha1-/p965lc3GzOxDLgNoUPPgknPazs=", + "license": "ISC", "optional": true, "peer": true }, "node_modules/sprintf-js": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true + "license": "BSD-3-Clause" + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "engines": { + "node": ">= 0.6" + } }, "node_modules/sshpk": { "version": "1.17.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", - "integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==", + "license": "MIT", "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -23018,17 +20809,15 @@ }, "node_modules/stack-trace": { "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=", + "license": "MIT", "engines": { "node": "*" } }, "node_modules/stack-utils": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz", - "integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==", "dev": true, + "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" }, @@ -23038,40 +20827,123 @@ }, "node_modules/stack-utils/node_modules/escape-string-regexp": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/standard-as-callback": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/static-eval": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", - "integrity": "sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg==", + "license": "MIT", "dependencies": { "escodegen": "^1.8.1" } }, + "node_modules/static-module": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "acorn-node": "^1.3.0", + "concat-stream": "~1.6.0", + "convert-source-map": "^1.5.1", + "duplexer2": "~0.1.4", + "escodegen": "^1.11.1", + "has": "^1.0.1", + "magic-string": "0.25.1", + "merge-source-map": "1.0.4", + "object-inspect": "^1.6.0", + "readable-stream": "~2.3.3", + "scope-analyzer": "^2.0.1", + "shallow-copy": "~0.0.1", + "static-eval": "^2.0.5", + "through2": "~2.0.3" + } + }, + "node_modules/static-module/node_modules/concat-stream": { + "version": "1.6.2", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/static-module/node_modules/magic-string": { + "version": "0.25.1", + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.1" + } + }, + "node_modules/static-module/node_modules/readable-stream": { + "version": "2.3.8", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/static-module/node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/static-module/node_modules/static-eval": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "escodegen": "^2.1.0" + } + }, + "node_modules/static-module/node_modules/static-eval/node_modules/escodegen": { + "version": "2.1.0", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/static-module/node_modules/string_decoder": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/stealthy-require": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", + "license": "ISC", "engines": { "node": ">=0.10.0" } }, "node_modules/stream-browserify": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", - "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "license": "MIT", "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" @@ -23079,8 +20951,7 @@ }, "node_modules/stream-browserify/node_modules/readable-stream": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "license": "MIT", "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -23092,45 +20963,45 @@ }, "node_modules/stream-browserify/node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" } }, "node_modules/stream-buffers": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.2.tgz", - "integrity": "sha512-DQi1h8VEBA/lURbSwFtEHnSTb9s2/pwLEaFuNhXwy1Dx3Sa0lOuYT2yNUr4/j2fs8oCAMANtrZ5OrPZtyVs3MQ==", + "license": "Unlicense", "engines": { "node": ">= 0.10.0" } }, "node_modules/stream-shift": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", - "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", + "license": "MIT", "optional": true, "peer": true }, "node_modules/streamsearch": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", "engines": { "node": ">=10.0.0" } }, "node_modules/string_decoder": { "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + "license": "MIT" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } }, "node_modules/string-length": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", "dev": true, + "license": "MIT", "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" @@ -23141,8 +21012,7 @@ }, "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==", + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -23154,13 +21024,11 @@ }, "node_modules/string-width/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==" + "license": "MIT" }, "node_modules/string.prototype.trim": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", - "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -23175,8 +21043,7 @@ }, "node_modules/string.prototype.trimend": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", - "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -23188,8 +21055,7 @@ }, "node_modules/string.prototype.trimstart": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", - "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -23201,8 +21067,7 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -23212,26 +21077,23 @@ }, "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==", + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/strip-final-newline": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/strip-json-comments": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -23241,13 +21103,11 @@ }, "node_modules/strnum": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", - "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + "license": "MIT" }, "node_modules/strtok3": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-7.0.0.tgz", - "integrity": "sha512-pQ+V+nYQdC5H3Q7qBZAz/MO6lwGhoC2gOAjuouGf/VO0m7vQRh8QNMl2Uf6SwAtzZ9bOw3UIeBukEGNJl5dtXQ==", + "license": "MIT", "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^5.0.0" @@ -23262,9 +21122,8 @@ }, "node_modules/sucrase": { "version": "3.29.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.29.0.tgz", - "integrity": "sha512-bZPAuGA5SdFHuzqIhTAqt9fvNEo9rESqXIG3oiKdF8K4UmkQxC4KlNL3lVyAErXp+mPvUqZ5l13qx6TrDIGf3A==", "dev": true, + "license": "MIT", "dependencies": { "commander": "^4.0.0", "glob": "7.1.6", @@ -23283,18 +21142,16 @@ }, "node_modules/sucrase/node_modules/commander": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 6" } }, "node_modules/sucrase/node_modules/glob": { "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", "dev": true, + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -23312,10 +21169,8 @@ }, "node_modules/superagent": { "version": "3.8.3", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", - "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", - "deprecated": "Please upgrade to v7.0.2+ of superagent. We have fixed numerous issues with streams, form-data, attach(), filesystem errors not bubbling up (ENOENT on attach()), and all tests are now passing. See the releases tab for more information at . Thanks to @shadowgate15, @spence-s, and @niftylettuce. Superagent is sponsored by Forward Email at .", "dev": true, + "license": "MIT", "dependencies": { "component-emitter": "^1.2.0", "cookiejar": "^2.1.0", @@ -23334,18 +21189,16 @@ }, "node_modules/superagent/node_modules/debug": { "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/superagent/node_modules/form-data": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", - "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", "dev": true, + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", @@ -23357,9 +21210,8 @@ }, "node_modules/superagent/node_modules/readable-stream": { "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", "dev": true, + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -23372,24 +21224,21 @@ }, "node_modules/superagent/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/superagent/node_modules/string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/supertest": { "version": "6.3.2", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.2.tgz", - "integrity": "sha512-mSmbW/sPpBU6K8w8189ZiHdc62zMe7dCHpC2ktS9tc0/d2DN0FaxNbDJJNFknZD4jCrGJpxkiFoVyemvKgOdwA==", "dev": true, + "license": "MIT", "dependencies": { "methods": "^1.1.2", "superagent": "^8.0.3" @@ -23400,9 +21249,8 @@ }, "node_modules/supertest/node_modules/debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -23417,9 +21265,8 @@ }, "node_modules/supertest/node_modules/formidable": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.1.tgz", - "integrity": "sha512-0EcS9wCFEzLvfiks7omJ+SiYJAiD+TzK4Pcw1UlUoGnhUxDcMKjt0P7x8wEb0u6OHu8Nb98WG3nxtlF5C7bvUQ==", "dev": true, + "license": "MIT", "dependencies": { "dezalgo": "^1.0.4", "hexoid": "^1.0.0", @@ -23432,9 +21279,8 @@ }, "node_modules/supertest/node_modules/mime": { "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "dev": true, + "license": "MIT", "bin": { "mime": "cli.js" }, @@ -23444,9 +21290,8 @@ }, "node_modules/supertest/node_modules/superagent": { "version": "8.0.5", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.5.tgz", - "integrity": "sha512-lQVE0Praz7nHiSaJLKBM/cZyi7J0E4io8tWnGSBdBrqAzhzrjQ/F5iGP9Zr29CJC8N5zYdhG2kKaNcB6dKxp7g==", "dev": true, + "license": "MIT", "dependencies": { "component-emitter": "^1.3.0", "cookiejar": "^2.1.3", @@ -23465,8 +21310,7 @@ }, "node_modules/supports-color": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -23479,8 +21323,7 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -23490,13 +21333,11 @@ }, "node_modules/swagger-ui-dist": { "version": "4.18.2", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.18.2.tgz", - "integrity": "sha512-oVBoBl9Dg+VJw8uRWDxlyUyHoNEDC0c1ysT6+Boy6CTgr2rUcLcfPon4RvxgS2/taNW6O0+US+Z/dlAsWFjOAQ==" + "license": "Apache-2.0" }, "node_modules/swagger-ui-express": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz", - "integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==", + "license": "MIT", "dependencies": { "swagger-ui-dist": ">=4.1.3" }, @@ -23509,18 +21350,20 @@ }, "node_modules/symbol-observable": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", - "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "license": "MIT" + }, "node_modules/synckit": { "version": "0.8.4", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.4.tgz", - "integrity": "sha512-Dn2ZkzMdSX827QbowGbU/4yjWuvNaCoScLLoMo/yKbu+P4GBR6cRGKZH27k6a9bRzdqcyd1DE96pQtQ6uNkmyw==", "dev": true, + "license": "MIT", "dependencies": { "@pkgr/utils": "^2.3.1", "tslib": "^2.4.0" @@ -23534,9 +21377,8 @@ }, "node_modules/table": { "version": "6.8.1", - "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz", - "integrity": "sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", @@ -23550,17 +21392,15 @@ }, "node_modules/tapable": { "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/tar": { "version": "6.1.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", - "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -23577,9 +21417,8 @@ }, "node_modules/tar-stream": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, + "license": "MIT", "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -23593,9 +21432,8 @@ }, "node_modules/tar-stream/node_modules/bl": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, + "license": "MIT", "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -23604,8 +21442,6 @@ }, "node_modules/tar-stream/node_modules/buffer": { "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dev": true, "funding": [ { @@ -23621,6 +21457,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -23628,9 +21465,8 @@ }, "node_modules/tar-stream/node_modules/readable-stream": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", "dev": true, + "license": "MIT", "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -23642,26 +21478,31 @@ }, "node_modules/tar-stream/node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" } }, + "node_modules/tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/tdigest": { "version": "0.1.1", - "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz", - "integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=", + "license": "MIT", "dependencies": { "bintrees": "1.0.1" } }, "node_modules/terser": { "version": "5.19.4", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.4.tgz", - "integrity": "sha512-6p1DjHeuluwxDXcuT9VR8p64klWJKo1ILiy19s6C9+0Bh2+NWTX6nD9EPppiER4ICkHDVB1RkVpin/YW2nQn/g==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -23677,9 +21518,8 @@ }, "node_modules/terser-webpack-plugin": { "version": "5.3.9", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", - "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.17", "jest-worker": "^27.4.5", @@ -23711,18 +21551,16 @@ }, "node_modules/terser-webpack-plugin/node_modules/serialize-javascript": { "version": "6.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" } }, "node_modules/terser/node_modules/acorn": { "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -23732,15 +21570,13 @@ }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/test-exclude": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "dev": true, + "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", @@ -23752,29 +21588,25 @@ }, "node_modules/text-hex": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + "license": "MIT" }, "node_modules/text-table": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/thenify": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "dev": true, + "license": "MIT", "dependencies": { "any-promise": "^1.0.0" } }, "node_modules/thenify-all": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", "dev": true, + "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" }, @@ -23784,14 +21616,11 @@ }, "node_modules/through": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + "license": "MIT" }, "node_modules/through2": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, + "license": "MIT", "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" @@ -23799,9 +21628,7 @@ }, "node_modules/through2/node_modules/readable-stream": { "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -23814,28 +21641,30 @@ }, "node_modules/through2/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "license": "MIT" }, "node_modules/through2/node_modules/string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/throwback": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/throwback/-/throwback-4.1.0.tgz", - "integrity": "sha512-dLFe8bU8SeH0xeqeKL7BNo8XoPC/o91nz9/ooeplZPiso+DZukhoyZcSz9TFnUNScm+cA9qjU1m1853M6sPOng==" + "license": "MIT" + }, + "node_modules/tildify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", + "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", + "engines": { + "node": ">=8" + } }, "node_modules/tiny-async-pool": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.2.0.tgz", - "integrity": "sha512-PY/OiSenYGBU3c1nTuP1HLKRkhKFDXsAibYI5GeHbHw2WVpt6OFzAPIRP94dGnS66Jhrkheim2CHAXUNI4XwMg==", + "license": "MIT", "dependencies": { "semver": "^5.5.0", "yaassertion": "^1.0.0" @@ -23843,27 +21672,28 @@ }, "node_modules/tiny-async-pool/node_modules/semver": { "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", "bin": { "semver": "bin/semver" } }, "node_modules/tiny-glob": { "version": "0.2.9", - "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", - "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", "dev": true, + "license": "MIT", "dependencies": { "globalyzer": "0.1.0", "globrex": "^0.1.2" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", "dev": true, + "license": "MIT", "dependencies": { "os-tmpdir": "~1.0.2" }, @@ -23873,16 +21703,14 @@ }, "node_modules/tmp-promise": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "license": "MIT", "dependencies": { "tmp": "^0.2.0" } }, "node_modules/tmp-promise/node_modules/tmp": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "license": "MIT", "dependencies": { "rimraf": "^3.0.0" }, @@ -23892,23 +21720,20 @@ }, "node_modules/tmpl": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "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": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", "dev": true, + "license": "MIT", "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", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -23918,16 +21743,14 @@ }, "node_modules/toidentifier": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", "engines": { "node": ">=0.6" } }, "node_modules/token-types": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-5.0.1.tgz", - "integrity": "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==", + "license": "MIT", "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" @@ -23942,8 +21765,6 @@ }, "node_modules/token-types/node_modules/ieee754": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "funding": [ { "type": "github", @@ -23957,13 +21778,13 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "BSD-3-Clause" }, "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==", "dev": true, + "license": "ISC", "dependencies": { "nopt": "~1.0.10" }, @@ -23971,10 +21792,29 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "4.1.3", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "license": "MIT", "dependencies": { "punycode": "^2.1.1" }, @@ -23984,46 +21824,39 @@ }, "node_modules/traverse": { "version": "0.6.7", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.7.tgz", - "integrity": "sha512-/y956gpUo9ZNCb99YjxG7OaslxZWHfCHAUUfshwqOXmxUIvqLjVO581BT+gM59+QV9tFe6/CGG53tsA1Y7RSdg==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/tree-kill": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", "bin": { "tree-kill": "cli.js" } }, "node_modules/triple-beam": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", - "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" + "license": "MIT" }, "node_modules/ts-algebra": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-1.2.2.tgz", - "integrity": "sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA==" + "license": "MIT" }, "node_modules/ts-expect": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-expect/-/ts-expect-1.3.0.tgz", - "integrity": "sha512-e4g0EJtAjk64xgnFPD6kTBUtpnMVzDrMb12N1YZV0VvSlhnVT3SGxiYTLdGy8Q5cYHOIC/FAHmZ10eGrAguicQ==" + "license": "MIT" }, "node_modules/ts-interface-checker": { "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/ts-jest": { "version": "29.0.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.0.5.tgz", - "integrity": "sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==", "dev": true, + "license": "MIT", "dependencies": { "bs-logger": "0.x", "fast-json-stable-stringify": "2.x", @@ -24064,18 +21897,16 @@ }, "node_modules/ts-jest/node_modules/yargs-parser": { "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, + "license": "ISC", "engines": { "node": ">=12" } }, "node_modules/ts-loader": { "version": "9.4.2", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.4.2.tgz", - "integrity": "sha512-OmlC4WVmFv5I0PpaxYb+qGeGOdm5giHU7HwDDUjw59emP2UYMHy9fFSDcYgSNoH8sXcj4hGCSEhlDZ9ULeDraA==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.0.0", @@ -24092,9 +21923,8 @@ }, "node_modules/ts-loader/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -24107,9 +21937,8 @@ }, "node_modules/ts-loader/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -24123,9 +21952,8 @@ }, "node_modules/ts-loader/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -24135,15 +21963,13 @@ }, "node_modules/ts-loader/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ts-loader/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -24153,9 +21979,8 @@ }, "node_modules/ts-node": { "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", "dev": true, + "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -24196,9 +22021,8 @@ }, "node_modules/ts-node/node_modules/acorn": { "version": "8.8.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz", - "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -24208,33 +22032,28 @@ }, "node_modules/ts-node/node_modules/acorn-walk": { "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.4.0" } }, "node_modules/ts-node/node_modules/arg": { "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ts-node/node_modules/diff": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } }, "node_modules/tsconfig-paths": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, + "license": "MIT", "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", @@ -24246,9 +22065,8 @@ }, "node_modules/tsconfig-paths-webpack-plugin": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.1.0.tgz", - "integrity": "sha512-xWFISjviPydmtmgeUAuXp4N1fky+VCtfhOkDUFIv5ea7p4wuTomI4QTrXvFBX2S4jZsmyTSrStQl+E+4w+RzxA==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.7.0", @@ -24260,9 +22078,8 @@ }, "node_modules/tsconfig-paths-webpack-plugin/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -24275,9 +22092,8 @@ }, "node_modules/tsconfig-paths-webpack-plugin/node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -24291,9 +22107,8 @@ }, "node_modules/tsconfig-paths-webpack-plugin/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -24303,15 +22118,13 @@ }, "node_modules/tsconfig-paths-webpack-plugin/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tsconfig-paths-webpack-plugin/node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -24321,23 +22134,19 @@ }, "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": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", - "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/tslib": { "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "license": "0BSD" }, "node_modules/tsup": { "version": "5.12.9", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-5.12.9.tgz", - "integrity": "sha512-dUpuouWZYe40lLufo64qEhDpIDsWhRbr2expv5dHEMjwqeKJS2aXA/FPqs1dxO4T6mBojo7rvo3jP9NNzaKyDg==", "dev": true, + "license": "MIT", "dependencies": { "bundle-require": "^3.0.2", "cac": "^6.7.12", @@ -24375,28 +22184,11 @@ } } }, - "node_modules/tsup/node_modules/@esbuild/linux-loong64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz", - "integrity": "sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/tsup/node_modules/esbuild": { "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.54.tgz", - "integrity": "sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -24429,18 +22221,16 @@ }, "node_modules/tsup/node_modules/resolve-from": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/tsup/node_modules/source-map": { "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "whatwg-url": "^7.0.0" }, @@ -24450,24 +22240,21 @@ }, "node_modules/tsup/node_modules/tr46": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", "dev": true, + "license": "MIT", "dependencies": { "punycode": "^2.1.0" } }, "node_modules/tsup/node_modules/webidl-conversions": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true + "dev": true, + "license": "BSD-2-Clause" }, "node_modules/tsup/node_modules/whatwg-url": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", "dev": true, + "license": "MIT", "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", @@ -24476,9 +22263,8 @@ }, "node_modules/tsutils": { "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", "dev": true, + "license": "MIT", "dependencies": { "tslib": "^1.8.1" }, @@ -24491,14 +22277,12 @@ }, "node_modules/tsutils/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "dev": true, + "license": "0BSD" }, "node_modules/tunnel-agent": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" }, @@ -24508,14 +22292,16 @@ }, "node_modules/tweetnacl": { "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + "license": "Unlicense" + }, + "node_modules/type": { + "version": "1.2.0", + "license": "ISC" }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -24525,18 +22311,16 @@ }, "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, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/type-fest": { "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -24546,8 +22330,7 @@ }, "node_modules/type-is": { "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -24558,16 +22341,14 @@ }, "node_modules/type-is/node_modules/media-typer": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/typed-array-buffer": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", - "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.2.1", @@ -24579,8 +22360,7 @@ }, "node_modules/typed-array-byte-length": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", - "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "for-each": "^0.3.3", @@ -24596,8 +22376,7 @@ }, "node_modules/typed-array-byte-offset": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", - "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", @@ -24614,8 +22393,7 @@ }, "node_modules/typed-array-length": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", - "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "for-each": "^0.3.3", @@ -24627,23 +22405,20 @@ }, "node_modules/typedarray": { "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + "license": "MIT" }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", "dev": true, + "license": "MIT", "dependencies": { "is-typedarray": "^1.0.0" } }, "node_modules/typescript": { "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -24654,8 +22429,7 @@ }, "node_modules/uid": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", - "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", "dependencies": { "@lukeed/csprng": "^1.0.0" }, @@ -24665,8 +22439,7 @@ }, "node_modules/uid-safe": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", - "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", "dependencies": { "random-bytes": "~1.0.0" }, @@ -24674,35 +22447,100 @@ "node": ">= 0.8" } }, - "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "node_modules/umzug": { + "version": "3.2.1", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" + "@rushstack/ts-command-line": "^4.12.2", + "emittery": "^0.12.1", + "fs-jetpack": "^4.3.1", + "glob": "^8.0.3", + "pony-cause": "^2.1.2", + "type-fest": "^2.18.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=12" + } + }, + "node_modules/umzug/node_modules/brace-expansion": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/umzug/node_modules/emittery": { + "version": "0.12.1", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/umzug/node_modules/glob": { + "version": "8.1.0", + "license": "ISC", + "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/umzug/node_modules/minimatch": { + "version": "5.1.6", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/umzug/node_modules/type-fest": { + "version": "2.19.0", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/undefsafe": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/underscore": { "version": "1.12.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", - "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" + "license": "MIT" }, "node_modules/undici": { "version": "5.25.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.25.2.tgz", - "integrity": "sha512-tch8RbCfn1UUH1PeVCXva4V8gDpGAud/w0WubD6sHC46vYQ3KDxL+xv1A2UxK0N6jrVedutuPHxe1XIoqerwMw==", + "license": "MIT", "dependencies": { "busboy": "^1.6.0" }, @@ -24710,10 +22548,25 @@ "node": ">=14.0" } }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/universal-analytics": { "version": "0.5.3", - "resolved": "https://registry.npmjs.org/universal-analytics/-/universal-analytics-0.5.3.tgz", - "integrity": "sha512-HXSMyIcf2XTvwZ6ZZQLfxfViRm/yTGoRgDeTbojtq6rezeyKB0sTBcKH2fhddnteAHRcHiKgr/ACpbgjGOC6RQ==", + "license": "MIT", "dependencies": { "debug": "^4.3.1", "uuid": "^8.0.0" @@ -24724,33 +22577,29 @@ }, "node_modules/universalify": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "license": "MIT", "engines": { "node": ">= 10.0.0" } }, "node_modules/unpipe": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/untildify": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/unzipper": { "version": "0.8.14", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.8.14.tgz", - "integrity": "sha512-8rFtE7EP5ssOwGpN2dt1Q4njl0N1hUXJ7sSPz0leU2hRdq6+pra57z4YPBlVqm40vcgv6ooKZEAx48fMTv9x4w==", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -24767,22 +22616,19 @@ }, "node_modules/unzipper/node_modules/bluebird": { "version": "3.4.7", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", - "integrity": "sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM=", + "license": "MIT", "optional": true, "peer": true }, "node_modules/unzipper/node_modules/process-nextick-args": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", - "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", + "license": "MIT", "optional": true, "peer": true }, "node_modules/unzipper/node_modules/readable-stream": { "version": "2.1.5", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz", - "integrity": "sha1-ZvqLcg4UOLNkaB8q0aY8YYRIydA=", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -24797,8 +22643,7 @@ }, "node_modules/upath": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", - "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", + "license": "MIT", "engines": { "node": ">=4", "yarn": "*" @@ -24806,16 +22651,14 @@ }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } }, "node_modules/url": { "version": "0.10.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "license": "MIT", "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" @@ -24823,15 +22666,13 @@ }, "node_modules/url-join": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-0.0.1.tgz", - "integrity": "sha1-HbSK1CLTQCRpqH99l73r/k+x48g=", + "license": "MIT", "optional": true, "peer": true }, "node_modules/url-parse": { "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -24839,26 +22680,21 @@ }, "node_modules/url-template": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/url-template/-/url-template-3.1.0.tgz", - "integrity": "sha512-vB/eHWttzhN+NZzk9FcQB2h1cSEgb7zDYyvyxPhw02LYw7YqIzO+w1AqkcKvZ51gPH8o4+nyiWve/xuQqMdJZw==", + "license": "BSD-3-Clause", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, "node_modules/url/node_modules/punycode": { "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + "license": "MIT" }, "node_modules/urlsafe-base64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/urlsafe-base64/-/urlsafe-base64-1.0.0.tgz", - "integrity": "sha1-I/iQaabGL0bPOh07ABac77kL4MY=" + "version": "1.0.0" }, "node_modules/util": { "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", @@ -24869,42 +22705,36 @@ }, "node_modules/util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + "license": "MIT" }, "node_modules/utils-merge": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "license": "MIT", "engines": { "node": ">= 0.4.0" } }, "node_modules/uuid": { "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } }, "node_modules/v8-compile-cache": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/v8-to-istanbul": { "version": "9.0.1", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", - "integrity": "sha512-74Y4LqY74kLE6IFyIjPtkSTWzUZmj8tdHT9Ii/26dvQ6K9Dl2NbEfj0XgU2sHCtKgt5VupqhlO/5aWuqS+IY1w==", "dev": true, + "license": "ISC", "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", @@ -24914,65 +22744,50 @@ "node": ">=10.12.0" } }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, "node_modules/validator": { "version": "13.11.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", - "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", + "license": "MIT", "engines": { "node": ">= 0.10" } }, "node_modules/vary": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/vasync": { "version": "1.6.4", - "resolved": "https://registry.npmjs.org/vasync/-/vasync-1.6.4.tgz", - "integrity": "sha1-3+k2Fq0OeugBszKp2Iv8XNyOHR8=", "engines": [ "node >=0.6.0" ], + "license": "MIT", "dependencies": { "verror": "1.6.0" } }, "node_modules/vasync/node_modules/extsprintf": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz", - "integrity": "sha1-WtlGwi9bMrp/jNdCZxHG6KP8JSk=", "engines": [ "node >=0.6.0" - ] + ], + "license": "MIT" }, "node_modules/vasync/node_modules/verror": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.6.0.tgz", - "integrity": "sha1-fROyex+swuLakEBetepuW90lLqU=", "engines": [ "node >=0.6.0" ], + "license": "MIT", "dependencies": { "extsprintf": "1.2.0" } }, "node_modules/verror": { "version": "1.10.1", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -24984,14 +22799,12 @@ }, "node_modules/verror/node_modules/core-util-is": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + "license": "MIT" }, "node_modules/vue-eslint-parser": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-7.1.1.tgz", - "integrity": "sha512-8FdXi0gieEwh1IprIBafpiJWcApwrU+l2FEj8c1HtHFdNXMd0+2jUSjBVmcQYohf/E72irwAXEXLga6TQcB3FA==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.1", "eslint-scope": "^5.0.0", @@ -25012,18 +22825,16 @@ }, "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=4" } }, "node_modules/vue-eslint-parser/node_modules/espree": { "version": "6.2.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-6.2.1.tgz", - "integrity": "sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^7.1.1", "acorn-jsx": "^5.2.0", @@ -25033,20 +22844,28 @@ "node": ">=6.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "makeerror": "1.0.12" } }, "node_modules/watchpack": { "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", "dev": true, + "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -25057,26 +22876,23 @@ }, "node_modules/wcwidth": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", "dev": true, + "license": "MIT", "dependencies": { "defaults": "^1.0.3" } }, "node_modules/webidl-conversions": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" } }, "node_modules/webpack": { "version": "5.88.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", - "integrity": "sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", @@ -25121,27 +22937,24 @@ }, "node_modules/webpack-node-externals": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", - "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/webpack-sources": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.13.0" } }, "node_modules/webpack/node_modules/acorn": { "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -25151,17 +22964,42 @@ }, "node_modules/webpack/node_modules/acorn-import-assertions": { "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^8" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "license": "MIT", "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" @@ -25172,8 +23010,7 @@ }, "node_modules/which": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -25183,8 +23020,7 @@ }, "node_modules/which-boxed-primitive": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "license": "MIT", "dependencies": { "is-bigint": "^1.0.1", "is-boolean-object": "^1.1.0", @@ -25197,14 +23033,13 @@ } }, "node_modules/which-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", - "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=" + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" }, "node_modules/which-typed-array": { "version": "1.1.13", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", - "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.4", @@ -25221,8 +23056,7 @@ }, "node_modules/window-size": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz", - "integrity": "sha1-+OGqHuWlPsW/FR/6CXQqatdpeHY=", + "license": "MIT", "optional": true, "peer": true, "bin": { @@ -25234,9 +23068,8 @@ }, "node_modules/windows-release": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-4.0.0.tgz", - "integrity": "sha512-OxmV4wzDKB1x7AZaZgXMVsdJ1qER1ed83ZrTYd5Bwq2HfJVg3DJS8nqlAG4sMoJ7mu8cuRmLEYyU13BKwctRAg==", "dev": true, + "license": "MIT", "dependencies": { "execa": "^4.0.2" }, @@ -25249,9 +23082,8 @@ }, "node_modules/windows-release/node_modules/execa": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.0", "get-stream": "^5.0.0", @@ -25272,9 +23104,8 @@ }, "node_modules/windows-release/node_modules/get-stream": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "dev": true, + "license": "MIT", "dependencies": { "pump": "^3.0.0" }, @@ -25287,17 +23118,15 @@ }, "node_modules/windows-release/node_modules/human-signals": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8.12.0" } }, "node_modules/winston": { "version": "3.8.2", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.8.2.tgz", - "integrity": "sha512-MsE1gRx1m5jdTTO9Ld/vND4krP2To+lgDoMEHGGa4HIlAUyXJtfc7CxQcGXVyz2IBpw5hbFkj2b/AtUdQwyRew==", + "license": "MIT", "dependencies": { "@colors/colors": "1.5.0", "@dabh/diagnostics": "^2.0.2", @@ -25317,8 +23146,7 @@ }, "node_modules/winston-transport": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.5.0.tgz", - "integrity": "sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q==", + "license": "MIT", "dependencies": { "logform": "^2.3.2", "readable-stream": "^3.6.0", @@ -25330,8 +23158,7 @@ }, "node_modules/winston-transport/node_modules/readable-stream": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "license": "MIT", "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -25343,16 +23170,14 @@ }, "node_modules/winston-transport/node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" } }, "node_modules/winston/node_modules/readable-stream": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "license": "MIT", "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -25364,16 +23189,14 @@ }, "node_modules/winston/node_modules/string_decoder": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" } }, "node_modules/with-open-file": { "version": "0.1.7", - "resolved": "https://registry.npmjs.org/with-open-file/-/with-open-file-0.1.7.tgz", - "integrity": "sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==", + "license": "MIT", "dependencies": { "p-finally": "^1.0.0", "p-try": "^2.1.0", @@ -25383,32 +23206,21 @@ "node": ">=6" } }, - "node_modules/with-open-file/node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "engines": { - "node": ">=6" - } - }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/workerpool": { "version": "6.2.0", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.0.tgz", - "integrity": "sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/wrap-ansi": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -25423,8 +23235,7 @@ }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -25437,8 +23248,7 @@ }, "node_modules/wrap-ansi/node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -25448,19 +23258,16 @@ }, "node_modules/wrap-ansi/node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "license": "MIT" }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "license": "ISC" }, "node_modules/write": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", - "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", "dev": true, + "license": "MIT", "dependencies": { "mkdirp": "^0.5.1" }, @@ -25470,9 +23277,8 @@ }, "node_modules/write-file-atomic": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", "dev": true, + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", "is-typedarray": "^1.0.0", @@ -25482,9 +23288,8 @@ }, "node_modules/write/node_modules/mkdirp": { "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", "dev": true, + "license": "MIT", "dependencies": { "minimist": "^1.2.5" }, @@ -25493,15 +23298,14 @@ } }, "node_modules/ws": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", - "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", + "version": "8.16.0", + "license": "MIT", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -25512,10 +23316,16 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml2js": { "version": "0.4.23", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", - "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "license": "MIT", "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" @@ -25526,98 +23336,43 @@ }, "node_modules/xml2js-es6-promise": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/xml2js-es6-promise/-/xml2js-es6-promise-1.1.1.tgz", - "integrity": "sha1-zVaI2dY0TmfJCPceWwo9iNEo6aI=", - "deprecated": "Use xml2js.parseStringPromise(data /*, options */) from the xml2js package instead. https://www.npmjs.com/package/xml2js#promise-usage", + "license": "ISC", "dependencies": { "xml2js": "^0.4.16" } }, "node_modules/xmlbuilder": { "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", "engines": { "node": ">=4.0" } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "engines": { - "node": ">=0.4" - } + "node_modules/xmlchars": { + "version": "2.2.0", + "license": "MIT" }, - "node_modules/y-mongodb-provider": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/y-mongodb-provider/-/y-mongodb-provider-0.1.8.tgz", - "integrity": "sha512-yV+rtS9nBEMqb6fG6sqyWNpMGzmTYe7hPiwWwWyrzK4frjMnkrQvJvyUiWjZI7eFFSKYzxYHucGEFA0j3QJEgA==", + "node_modules/xmldoc": { + "version": "1.3.0", + "license": "MIT", "dependencies": { - "lib0": "^0.2.85", - "mongodb": "^6.1.0" - }, - "peerDependencies": { - "yjs": "^13.6.8" + "sax": "^1.2.4" } }, - "node_modules/y-mongodb-provider/node_modules/bson": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.1.0.tgz", - "integrity": "sha512-yiQ3KxvpVoRpx1oD1uPz4Jit9tAVTJgjdmjDKtUErkOoL9VNoF8Dd58qtAOL5E40exx2jvAT9sqdRSK/r+SHlA==", - "engines": { - "node": ">=16.20.1" - } + "node_modules/xmldoc/node_modules/sax": { + "version": "1.3.0", + "license": "ISC" }, - "node_modules/y-mongodb-provider/node_modules/mongodb": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.1.0.tgz", - "integrity": "sha512-AvzNY0zMkpothZ5mJAaIo2bGDjlJQqqAbn9fvtVgwIIUPEfdrqGxqNjjbuKyrgQxg2EvCmfWdjq+4uj96c0YPw==", - "dependencies": { - "@mongodb-js/saslprep": "^1.1.0", - "bson": "^6.1.0", - "mongodb-connection-string-url": "^2.6.0" - }, + "node_modules/xtend": { + "version": "4.0.2", + "license": "MIT", "engines": { - "node": ">=16.20.1" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0", - "gcp-metadata": "^5.2.0", - "kerberos": "^2.0.1", - "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.2.2", - "socks": "^2.7.1" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } + "node": ">=0.4" } }, "node_modules/y-protocols": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", - "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "license": "MIT", "dependencies": { "lib0": "^0.2.85" }, @@ -25635,32 +23390,29 @@ }, "node_modules/y18n": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", - "integrity": "sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/yaassertion": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/yaassertion/-/yaassertion-1.0.2.tgz", - "integrity": "sha512-sBoJBg5vTr3lOpRX0yFD+tz7wv/l2UPMFthag4HGTMPrypBRKerjjS8jiEnNMjcAEtPXjbHiKE0UwRR1W1GXBg==" + "license": "MIT" }, "node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "license": "ISC" }, "node_modules/yaml": { "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "dev": true, + "license": "ISC", "engines": { "node": ">= 6" } }, "node_modules/yargs": { "version": "3.32.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz", - "integrity": "sha1-AwiOnr+edWtpdRYR0qXvWRSCyZU=", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -25675,17 +23427,15 @@ }, "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==", + "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/yargs-unparser": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", "dev": true, + "license": "MIT", "dependencies": { "camelcase": "^6.0.0", "decamelize": "^4.0.0", @@ -25698,9 +23448,8 @@ }, "node_modules/yargs-unparser/node_modules/camelcase": { "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -25710,9 +23459,8 @@ }, "node_modules/yargs-unparser/node_modules/decamelize": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -25722,8 +23470,7 @@ }, "node_modules/yargs/node_modules/ansi-regex": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "license": "MIT", "optional": true, "peer": true, "engines": { @@ -25732,8 +23479,7 @@ }, "node_modules/yargs/node_modules/camelcase": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", - "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=", + "license": "MIT", "optional": true, "peer": true, "engines": { @@ -25742,8 +23488,7 @@ }, "node_modules/yargs/node_modules/is-fullwidth-code-point": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -25755,8 +23500,7 @@ }, "node_modules/yargs/node_modules/string-width": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -25770,8 +23514,7 @@ }, "node_modules/yargs/node_modules/strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -25783,8 +23526,7 @@ }, "node_modules/yauzl": { "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" @@ -25792,8 +23534,7 @@ }, "node_modules/yauzl-clone": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/yauzl-clone/-/yauzl-clone-1.0.4.tgz", - "integrity": "sha512-igM2RRCf3k8TvZoxR2oguuw4z1xasOnA31joCqHIyLkeWrvAc2Jgay5ISQ2ZplinkoGaJ6orCz56Ey456c5ESA==", + "license": "MIT", "dependencies": { "events-intercept": "^2.0.0" }, @@ -25803,8 +23544,7 @@ }, "node_modules/yauzl-promise": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yauzl-promise/-/yauzl-promise-2.1.3.tgz", - "integrity": "sha512-A1pf6fzh6eYkK0L4Qp7g9jzJSDrM6nN0bOn5T0IbY4Yo3w+YkWlHFkJP7mzknMXjqusHFHlKsK2N+4OLsK2MRA==", + "license": "MIT", "dependencies": { "yauzl": "^2.9.1", "yauzl-clone": "^1.0.4" @@ -25815,18 +23555,16 @@ }, "node_modules/yazl": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", - "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3" } }, "node_modules/yjs": { - "version": "13.6.8", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.8.tgz", - "integrity": "sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==", + "version": "13.6.11", + "license": "MIT", "dependencies": { - "lib0": "^0.2.74" + "lib0": "^0.2.86" }, "engines": { "node": ">=16.0.0", @@ -25839,17 +23577,15 @@ }, "node_modules/yn": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/yocto-queue": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -25857,19393 +23593,5 @@ "url": "https://github.com/sponsors/sindresorhus" } } - }, - "dependencies": { - "@ampproject/remapping": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.0.tgz", - "integrity": "sha512-d5RysTlJ7hmw5Tw4UxgxcY3lkMe92n8sXCcuLPAyIAHK6j8DefDwtGnVVDgOnv+RnEosulDJ9NPKQL27bDId0g==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.0" - } - }, - "@angular-devkit/core": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.2.0.tgz", - "integrity": "sha512-l1k6Rqm3YM16BEn3CWyQKrk9xfu+2ux7Bw3oS+h1TO4/RoxO2PgHj8LLRh/WNrYVarhaqO7QZ5ePBkXNMkzJ1g==", - "dev": true, - "requires": { - "ajv": "8.12.0", - "ajv-formats": "2.1.1", - "jsonc-parser": "3.2.0", - "rxjs": "7.8.1", - "source-map": "0.7.4" - }, - "dependencies": { - "source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true - } - } - }, - "@angular-devkit/schematics": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-16.2.0.tgz", - "integrity": "sha512-QMDJXPE0+YQJ9Ap3MMzb0v7rx6ZbBEokmHgpdIjN3eILYmbAdsSGE8HTV8NjS9nKmcyE9OGzFCMb7PFrDTlTAw==", - "dev": true, - "requires": { - "@angular-devkit/core": "16.2.0", - "jsonc-parser": "3.2.0", - "magic-string": "0.30.1", - "ora": "5.4.1", - "rxjs": "7.8.1" - } - }, - "@angular-devkit/schematics-cli": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-16.2.0.tgz", - "integrity": "sha512-f3HjrDvSrRMvESogLsqsZXsEg//trIBySCHRXCglPrWLVdBbIRctGOhXqZoclRxXimIKUx14zLsOWzDwZG8+HQ==", - "dev": true, - "requires": { - "@angular-devkit/core": "16.2.0", - "@angular-devkit/schematics": "16.2.0", - "ansi-colors": "4.1.3", - "inquirer": "8.2.4", - "symbol-observable": "4.0.0", - "yargs-parser": "21.1.1" - }, - "dependencies": { - "ansi-colors": { - "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 - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "inquirer": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.4.tgz", - "integrity": "sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg==", - "dev": true, - "requires": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^7.0.0" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true - } - } - }, - "@apidevtools/json-schema-ref-parser": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz", - "integrity": "sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==", - "requires": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.6", - "call-me-maybe": "^1.0.1", - "js-yaml": "^4.1.0" - }, - "dependencies": { - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "requires": { - "argparse": "^2.0.1" - } - } - } - }, - "@aws-crypto/crc32": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", - "integrity": "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==", - "requires": { - "@aws-crypto/util": "^3.0.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@aws-crypto/crc32c": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-3.0.0.tgz", - "integrity": "sha512-ENNPPManmnVJ4BTXlOjAgD7URidbAznURqD0KvfREyc4o20DPYdEldU1f5cQ7Jbj0CJJSPaMIk/9ZshdB3210w==", - "requires": { - "@aws-crypto/util": "^3.0.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@aws-crypto/ie11-detection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/ie11-detection/-/ie11-detection-3.0.0.tgz", - "integrity": "sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==", - "requires": { - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@aws-crypto/sha1-browser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-3.0.0.tgz", - "integrity": "sha512-NJth5c997GLHs6nOYTzFKTbYdMNA6/1XlKVgnZoaZcQ7z7UJlOgj2JdbHE8tiYLS3fzXNCguct77SPGat2raSw==", - "requires": { - "@aws-crypto/ie11-detection": "^3.0.0", - "@aws-crypto/supports-web-crypto": "^3.0.0", - "@aws-crypto/util": "^3.0.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@aws-crypto/sha256-browser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-3.0.0.tgz", - "integrity": "sha512-8VLmW2B+gjFbU5uMeqtQM6Nj0/F1bro80xQXCW6CQBWgosFWXTx77aeOF5CAIAmbOK64SdMBJdNr6J41yP5mvQ==", - "requires": { - "@aws-crypto/ie11-detection": "^3.0.0", - "@aws-crypto/sha256-js": "^3.0.0", - "@aws-crypto/supports-web-crypto": "^3.0.0", - "@aws-crypto/util": "^3.0.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@aws-crypto/sha256-js": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-3.0.0.tgz", - "integrity": "sha512-PnNN7os0+yd1XvXAy23CFOmTbMaDxgxXtTKHybrJ39Y8kGzBATgBFibWJKH6BhytLI/Zyszs87xCOBNyBig6vQ==", - "requires": { - "@aws-crypto/util": "^3.0.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@aws-crypto/supports-web-crypto": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-3.0.0.tgz", - "integrity": "sha512-06hBdMwUAb2WFTuGG73LSC0wfPu93xWwo5vL2et9eymgmu3Id5vFAHBbajVWiGhPO37qcsdCap/FqXvJGJWPIg==", - "requires": { - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@aws-crypto/util": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", - "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", - "requires": { - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@aws-sdk/abort-controller": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/abort-controller/-/abort-controller-3.347.0.tgz", - "integrity": "sha512-P/2qE6ntYEmYG4Ez535nJWZbXqgbkJx8CMz7ChEuEg3Gp3dvVYEKg+iEUEvlqQ2U5dWP5J3ehw5po9t86IsVPQ==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/chunked-blob-reader": { - "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/chunked-blob-reader/-/chunked-blob-reader-3.310.0.tgz", - "integrity": "sha512-CrJS3exo4mWaLnWxfCH+w88Ou0IcAZSIkk4QbmxiHl/5Dq705OLoxf4385MVyExpqpeVJYOYQ2WaD8i/pQZ2fg==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@aws-sdk/client-cognito-identity": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.352.0.tgz", - "integrity": "sha512-qXqg7V/DpHu8oyEq22LMskCoHYZU6+ds9gaArwc3SjPwQN/UM6CpIUHtTtxevLEYr7nI5iMIPBBrEcoKOJefxg==", - "optional": true, - "requires": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.352.0", - "@aws-sdk/config-resolver": "3.347.0", - "@aws-sdk/credential-provider-node": "3.352.0", - "@aws-sdk/fetch-http-handler": "3.347.0", - "@aws-sdk/hash-node": "3.347.0", - "@aws-sdk/invalid-dependency": "3.347.0", - "@aws-sdk/middleware-content-length": "3.347.0", - "@aws-sdk/middleware-endpoint": "3.347.0", - "@aws-sdk/middleware-host-header": "3.347.0", - "@aws-sdk/middleware-logger": "3.347.0", - "@aws-sdk/middleware-recursion-detection": "3.347.0", - "@aws-sdk/middleware-retry": "3.347.0", - "@aws-sdk/middleware-serde": "3.347.0", - "@aws-sdk/middleware-signing": "3.347.0", - "@aws-sdk/middleware-stack": "3.347.0", - "@aws-sdk/middleware-user-agent": "3.352.0", - "@aws-sdk/node-config-provider": "3.347.0", - "@aws-sdk/node-http-handler": "3.350.0", - "@aws-sdk/smithy-client": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/url-parser": "3.347.0", - "@aws-sdk/util-base64": "3.310.0", - "@aws-sdk/util-body-length-browser": "3.310.0", - "@aws-sdk/util-body-length-node": "3.310.0", - "@aws-sdk/util-defaults-mode-browser": "3.347.0", - "@aws-sdk/util-defaults-mode-node": "3.347.0", - "@aws-sdk/util-endpoints": "3.352.0", - "@aws-sdk/util-retry": "3.347.0", - "@aws-sdk/util-user-agent-browser": "3.347.0", - "@aws-sdk/util-user-agent-node": "3.347.0", - "@aws-sdk/util-utf8": "3.310.0", - "@smithy/protocol-http": "^1.0.1", - "@smithy/types": "^1.0.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/client-s3": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.352.0.tgz", - "integrity": "sha512-RUKXIIaNnSQE4FvLETuLglKAP2QOUn3dbzkLJYq37Pm0M/5rZhx5A7asov9jJDN+/vL/ae+O7pb2t4jpWqO75Q==", - "requires": { - "@aws-crypto/sha1-browser": "3.0.0", - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.352.0", - "@aws-sdk/config-resolver": "3.347.0", - "@aws-sdk/credential-provider-node": "3.352.0", - "@aws-sdk/eventstream-serde-browser": "3.347.0", - "@aws-sdk/eventstream-serde-config-resolver": "3.347.0", - "@aws-sdk/eventstream-serde-node": "3.347.0", - "@aws-sdk/fetch-http-handler": "3.347.0", - "@aws-sdk/hash-blob-browser": "3.347.0", - "@aws-sdk/hash-node": "3.347.0", - "@aws-sdk/hash-stream-node": "3.347.0", - "@aws-sdk/invalid-dependency": "3.347.0", - "@aws-sdk/md5-js": "3.347.0", - "@aws-sdk/middleware-bucket-endpoint": "3.347.0", - "@aws-sdk/middleware-content-length": "3.347.0", - "@aws-sdk/middleware-endpoint": "3.347.0", - "@aws-sdk/middleware-expect-continue": "3.347.0", - "@aws-sdk/middleware-flexible-checksums": "3.347.0", - "@aws-sdk/middleware-host-header": "3.347.0", - "@aws-sdk/middleware-location-constraint": "3.347.0", - "@aws-sdk/middleware-logger": "3.347.0", - "@aws-sdk/middleware-recursion-detection": "3.347.0", - "@aws-sdk/middleware-retry": "3.347.0", - "@aws-sdk/middleware-sdk-s3": "3.347.0", - "@aws-sdk/middleware-serde": "3.347.0", - "@aws-sdk/middleware-signing": "3.347.0", - "@aws-sdk/middleware-ssec": "3.347.0", - "@aws-sdk/middleware-stack": "3.347.0", - "@aws-sdk/middleware-user-agent": "3.352.0", - "@aws-sdk/node-config-provider": "3.347.0", - "@aws-sdk/node-http-handler": "3.350.0", - "@aws-sdk/signature-v4-multi-region": "3.347.0", - "@aws-sdk/smithy-client": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/url-parser": "3.347.0", - "@aws-sdk/util-base64": "3.310.0", - "@aws-sdk/util-body-length-browser": "3.310.0", - "@aws-sdk/util-body-length-node": "3.310.0", - "@aws-sdk/util-defaults-mode-browser": "3.347.0", - "@aws-sdk/util-defaults-mode-node": "3.347.0", - "@aws-sdk/util-endpoints": "3.352.0", - "@aws-sdk/util-retry": "3.347.0", - "@aws-sdk/util-stream-browser": "3.347.0", - "@aws-sdk/util-stream-node": "3.350.0", - "@aws-sdk/util-user-agent-browser": "3.347.0", - "@aws-sdk/util-user-agent-node": "3.347.0", - "@aws-sdk/util-utf8": "3.310.0", - "@aws-sdk/util-waiter": "3.347.0", - "@aws-sdk/xml-builder": "3.310.0", - "@smithy/protocol-http": "^1.0.1", - "@smithy/types": "^1.0.0", - "fast-xml-parser": "4.2.5", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/client-sso": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.352.0.tgz", - "integrity": "sha512-oeO36rvRvYbUlsgzYtLI2/BPwXdUK4KtYw+OFmirYeONUyX5uYx8kWXD66r3oXViIYMqhyHKN3fhkiFmFcVluQ==", - "requires": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/config-resolver": "3.347.0", - "@aws-sdk/fetch-http-handler": "3.347.0", - "@aws-sdk/hash-node": "3.347.0", - "@aws-sdk/invalid-dependency": "3.347.0", - "@aws-sdk/middleware-content-length": "3.347.0", - "@aws-sdk/middleware-endpoint": "3.347.0", - "@aws-sdk/middleware-host-header": "3.347.0", - "@aws-sdk/middleware-logger": "3.347.0", - "@aws-sdk/middleware-recursion-detection": "3.347.0", - "@aws-sdk/middleware-retry": "3.347.0", - "@aws-sdk/middleware-serde": "3.347.0", - "@aws-sdk/middleware-stack": "3.347.0", - "@aws-sdk/middleware-user-agent": "3.352.0", - "@aws-sdk/node-config-provider": "3.347.0", - "@aws-sdk/node-http-handler": "3.350.0", - "@aws-sdk/smithy-client": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/url-parser": "3.347.0", - "@aws-sdk/util-base64": "3.310.0", - "@aws-sdk/util-body-length-browser": "3.310.0", - "@aws-sdk/util-body-length-node": "3.310.0", - "@aws-sdk/util-defaults-mode-browser": "3.347.0", - "@aws-sdk/util-defaults-mode-node": "3.347.0", - "@aws-sdk/util-endpoints": "3.352.0", - "@aws-sdk/util-retry": "3.347.0", - "@aws-sdk/util-user-agent-browser": "3.347.0", - "@aws-sdk/util-user-agent-node": "3.347.0", - "@aws-sdk/util-utf8": "3.310.0", - "@smithy/protocol-http": "^1.0.1", - "@smithy/types": "^1.0.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/client-sts": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.352.0.tgz", - "integrity": "sha512-Lt7uSdwgOrwYx8S6Bhz76ewOeoJNFiPD+Q7v8S/mJK8T7HUE/houjomXC3UnFaJjcecjWv273zEqV67FgP5l5g==", - "requires": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/config-resolver": "3.347.0", - "@aws-sdk/credential-provider-node": "3.352.0", - "@aws-sdk/fetch-http-handler": "3.347.0", - "@aws-sdk/hash-node": "3.347.0", - "@aws-sdk/invalid-dependency": "3.347.0", - "@aws-sdk/middleware-content-length": "3.347.0", - "@aws-sdk/middleware-endpoint": "3.347.0", - "@aws-sdk/middleware-host-header": "3.347.0", - "@aws-sdk/middleware-logger": "3.347.0", - "@aws-sdk/middleware-recursion-detection": "3.347.0", - "@aws-sdk/middleware-retry": "3.347.0", - "@aws-sdk/middleware-sdk-sts": "3.347.0", - "@aws-sdk/middleware-serde": "3.347.0", - "@aws-sdk/middleware-signing": "3.347.0", - "@aws-sdk/middleware-stack": "3.347.0", - "@aws-sdk/middleware-user-agent": "3.352.0", - "@aws-sdk/node-config-provider": "3.347.0", - "@aws-sdk/node-http-handler": "3.350.0", - "@aws-sdk/smithy-client": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/url-parser": "3.347.0", - "@aws-sdk/util-base64": "3.310.0", - "@aws-sdk/util-body-length-browser": "3.310.0", - "@aws-sdk/util-body-length-node": "3.310.0", - "@aws-sdk/util-defaults-mode-browser": "3.347.0", - "@aws-sdk/util-defaults-mode-node": "3.347.0", - "@aws-sdk/util-endpoints": "3.352.0", - "@aws-sdk/util-retry": "3.347.0", - "@aws-sdk/util-user-agent-browser": "3.347.0", - "@aws-sdk/util-user-agent-node": "3.347.0", - "@aws-sdk/util-utf8": "3.310.0", - "@smithy/protocol-http": "^1.0.1", - "@smithy/types": "^1.0.0", - "fast-xml-parser": "4.2.5", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/config-resolver": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/config-resolver/-/config-resolver-3.347.0.tgz", - "integrity": "sha512-2ja+Sf/VnUO7IQ3nKbDQ5aumYKKJUaTm/BuVJ29wNho8wYHfuf7wHZV0pDTkB8RF5SH7IpHap7zpZAj39Iq+EA==", - "requires": { - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-config-provider": "3.310.0", - "@aws-sdk/util-middleware": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/credential-provider-cognito-identity": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.352.0.tgz", - "integrity": "sha512-395bdedGD0pangBT6dyyrTvtDRxr3lqbi8lfuJR/+7bpMIEJKVhF5D6IAgdjRDpASDRHUPhHuWzR3Qa9RHAcNA==", - "optional": true, - "requires": { - "@aws-sdk/client-cognito-identity": "3.352.0", - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/credential-provider-env": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.347.0.tgz", - "integrity": "sha512-UnEM+LKGpXKzw/1WvYEQsC6Wj9PupYZdQOE+e2Dgy2dqk/pVFy4WueRtFXYDT2B41ppv3drdXUuKZRIDVqIgNQ==", - "requires": { - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/credential-provider-imds": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-imds/-/credential-provider-imds-3.347.0.tgz", - "integrity": "sha512-7scCy/DCDRLIhlqTxff97LQWDnRwRXji3bxxMg+xWOTTaJe7PWx+etGSbBWaL42vsBHFShQjSLvJryEgoBktpw==", - "requires": { - "@aws-sdk/node-config-provider": "3.347.0", - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/url-parser": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/credential-provider-ini": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.352.0.tgz", - "integrity": "sha512-lnQUJznvOhI2er1u/OVf99/2JIyDH7W+6tfWNXEoVgEi4WXtdyZ+GpPNoZsmCtHB2Jwlsh51IxmYdCj6b6SdwQ==", - "requires": { - "@aws-sdk/credential-provider-env": "3.347.0", - "@aws-sdk/credential-provider-imds": "3.347.0", - "@aws-sdk/credential-provider-process": "3.347.0", - "@aws-sdk/credential-provider-sso": "3.352.0", - "@aws-sdk/credential-provider-web-identity": "3.347.0", - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/shared-ini-file-loader": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/credential-provider-node": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.352.0.tgz", - "integrity": "sha512-8UZ5EQpoqHCh+XSGq2CdhzHZyKLOwF1taDw5A/gmV4O5lAWL0AGs0cPIEUORJyggU6Hv43zZOpLgK6dMgWOLgA==", - "requires": { - "@aws-sdk/credential-provider-env": "3.347.0", - "@aws-sdk/credential-provider-imds": "3.347.0", - "@aws-sdk/credential-provider-ini": "3.352.0", - "@aws-sdk/credential-provider-process": "3.347.0", - "@aws-sdk/credential-provider-sso": "3.352.0", - "@aws-sdk/credential-provider-web-identity": "3.347.0", - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/shared-ini-file-loader": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/credential-provider-process": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.347.0.tgz", - "integrity": "sha512-yl1z4MsaBdXd4GQ2halIvYds23S67kElyOwz7g8kaQ4kHj+UoYWxz3JVW/DGusM6XmQ9/F67utBrUVA0uhQYyw==", - "requires": { - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/shared-ini-file-loader": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/credential-provider-sso": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.352.0.tgz", - "integrity": "sha512-YiooGNy9LYN1bFqKwO2wHC++1pYReiSqQDWBeluJfC3uZWpCyIUMdeYBR1X3XZDVtK6bl5KmhxldxJ3ntt/Q4w==", - "requires": { - "@aws-sdk/client-sso": "3.352.0", - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/shared-ini-file-loader": "3.347.0", - "@aws-sdk/token-providers": "3.352.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - }, - "dependencies": { - "@aws-sdk/client-sso-oidc": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.352.0.tgz", - "integrity": "sha512-PQdp0KOr478CaJNohASTgtt03W8Y/qINwsalLNguK01tWIGzellg2N3bA+IdyYXU8Oz3+Ab1oIJMKkUxtuNiGg==", - "requires": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/config-resolver": "3.347.0", - "@aws-sdk/fetch-http-handler": "3.347.0", - "@aws-sdk/hash-node": "3.347.0", - "@aws-sdk/invalid-dependency": "3.347.0", - "@aws-sdk/middleware-content-length": "3.347.0", - "@aws-sdk/middleware-endpoint": "3.347.0", - "@aws-sdk/middleware-host-header": "3.347.0", - "@aws-sdk/middleware-logger": "3.347.0", - "@aws-sdk/middleware-recursion-detection": "3.347.0", - "@aws-sdk/middleware-retry": "3.347.0", - "@aws-sdk/middleware-serde": "3.347.0", - "@aws-sdk/middleware-stack": "3.347.0", - "@aws-sdk/middleware-user-agent": "3.352.0", - "@aws-sdk/node-config-provider": "3.347.0", - "@aws-sdk/node-http-handler": "3.350.0", - "@aws-sdk/smithy-client": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/url-parser": "3.347.0", - "@aws-sdk/util-base64": "3.310.0", - "@aws-sdk/util-body-length-browser": "3.310.0", - "@aws-sdk/util-body-length-node": "3.310.0", - "@aws-sdk/util-defaults-mode-browser": "3.347.0", - "@aws-sdk/util-defaults-mode-node": "3.347.0", - "@aws-sdk/util-endpoints": "3.352.0", - "@aws-sdk/util-retry": "3.347.0", - "@aws-sdk/util-user-agent-browser": "3.347.0", - "@aws-sdk/util-user-agent-node": "3.347.0", - "@aws-sdk/util-utf8": "3.310.0", - "@smithy/protocol-http": "^1.0.1", - "@smithy/types": "^1.0.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/token-providers": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.352.0.tgz", - "integrity": "sha512-cmmAgieLP/aAl9WdPiBoaC0Abd6KncSLig/ElLPoNsADR10l3QgxQcVF3YMtdX0U0d917+/SeE1PdrPD2x15cw==", - "requires": { - "@aws-sdk/client-sso-oidc": "3.352.0", - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/shared-ini-file-loader": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - } - } - }, - "@aws-sdk/credential-provider-web-identity": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.347.0.tgz", - "integrity": "sha512-DxoTlVK8lXjS1zVphtz/Ab+jkN/IZor9d6pP2GjJHNoAIIzXfRwwj5C8vr4eTayx/5VJ7GRP91J8GJ2cKly8Qw==", - "requires": { - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/credential-providers": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.352.0.tgz", - "integrity": "sha512-hV6NO7+xzf3CPEsKZRsYflR05eNMvgVvOXFgQnOucUc85Kxt2XTSoH/HFtkolXDbxjA2Hku1pdaRG7qBzbiJHg==", - "optional": true, - "requires": { - "@aws-sdk/client-cognito-identity": "3.352.0", - "@aws-sdk/client-sso": "3.352.0", - "@aws-sdk/client-sts": "3.352.0", - "@aws-sdk/credential-provider-cognito-identity": "3.352.0", - "@aws-sdk/credential-provider-env": "3.347.0", - "@aws-sdk/credential-provider-imds": "3.347.0", - "@aws-sdk/credential-provider-ini": "3.352.0", - "@aws-sdk/credential-provider-node": "3.352.0", - "@aws-sdk/credential-provider-process": "3.347.0", - "@aws-sdk/credential-provider-sso": "3.352.0", - "@aws-sdk/credential-provider-web-identity": "3.347.0", - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/eventstream-codec": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-codec/-/eventstream-codec-3.347.0.tgz", - "integrity": "sha512-61q+SyspjsaQ4sdgjizMyRgVph2CiW4aAtfpoH69EJFJfTxTR/OqnZ9Jx/3YiYi0ksrvDenJddYodfWWJqD8/w==", - "requires": { - "@aws-crypto/crc32": "3.0.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-hex-encoding": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/eventstream-serde-browser": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-serde-browser/-/eventstream-serde-browser-3.347.0.tgz", - "integrity": "sha512-9BLVTHWgpiTo/hl+k7qt7E9iYu43zVwJN+4TEwA9ZZB3p12068t1Hay6HgCcgJC3+LWMtw/OhvypV6vQAG4UBg==", - "requires": { - "@aws-sdk/eventstream-serde-universal": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/eventstream-serde-config-resolver": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.347.0.tgz", - "integrity": "sha512-RcXQbNVq0PFmDqfn6+MnjCUWbbobcYVxpimaF6pMDav04o6Mcle+G2Hrefp5NlFr/lZbHW2eUKYsp1sXPaxVlQ==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/eventstream-serde-node": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-serde-node/-/eventstream-serde-node-3.347.0.tgz", - "integrity": "sha512-pgQCWH0PkHjcHs04JE7FoGAD3Ww45ffV8Op0MSLUhg9OpGa6EDoO3EOpWi9l/TALtH4f0KRV35PVyUyHJ/wEkA==", - "requires": { - "@aws-sdk/eventstream-serde-universal": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/eventstream-serde-universal": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-serde-universal/-/eventstream-serde-universal-3.347.0.tgz", - "integrity": "sha512-4wWj6bz6lOyDIO/dCCjwaLwRz648xzQQnf89R29sLoEqvAPP5XOB7HL+uFaQ/f5tPNh49gL6huNFSVwDm62n4Q==", - "requires": { - "@aws-sdk/eventstream-codec": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/fetch-http-handler": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/fetch-http-handler/-/fetch-http-handler-3.347.0.tgz", - "integrity": "sha512-sQ5P7ivY8//7wdxfA76LT1sF6V2Tyyz1qF6xXf9sihPN5Q1Y65c+SKpMzXyFSPqWZ82+SQQuDliYZouVyS6kQQ==", - "requires": { - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/querystring-builder": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-base64": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/hash-blob-browser": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/hash-blob-browser/-/hash-blob-browser-3.347.0.tgz", - "integrity": "sha512-RxgstIldLsdJKN5UHUwSI9PMiatr0xKmKxS4+tnWZ1/OOg6wuWqqpDpWdNOVSJSpxpUaP6kRrvG5Yo5ZevoTXw==", - "requires": { - "@aws-sdk/chunked-blob-reader": "3.310.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/hash-node": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/hash-node/-/hash-node-3.347.0.tgz", - "integrity": "sha512-96+ml/4EaUaVpzBdOLGOxdoXOjkPgkoJp/0i1fxOJEvl8wdAQSwc3IugVK9wZkCxy2DlENtgOe6DfIOhfffm/g==", - "requires": { - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-buffer-from": "3.310.0", - "@aws-sdk/util-utf8": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/hash-stream-node": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/hash-stream-node/-/hash-stream-node-3.347.0.tgz", - "integrity": "sha512-tOBfcvELyt1GVuAlQ4d0mvm3QxoSSmvhH15SWIubM9RP4JWytBVzaFAn/aC02DBAWyvp0acMZ5J+47mxrWJElg==", - "requires": { - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-utf8": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/invalid-dependency": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/invalid-dependency/-/invalid-dependency-3.347.0.tgz", - "integrity": "sha512-8imQcwLwqZ/wTJXZqzXT9pGLIksTRckhGLZaXT60tiBOPKuerTsus2L59UstLs5LP8TKaVZKFFSsjRIn9dQdmQ==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/is-array-buffer": { - "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.310.0.tgz", - "integrity": "sha512-urnbcCR+h9NWUnmOtet/s4ghvzsidFmspfhYaHAmSRdy9yDjdjBJMFjjsn85A1ODUktztm+cVncXjQ38WCMjMQ==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@aws-sdk/lib-storage": { - "version": "3.100.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.100.0.tgz", - "integrity": "sha512-IP8Y310+24FOI3bZqdx9mTef1fKUa5YFfMa+Zmfj4+cxMB5/5wrAc2MacyQdPshmdOCIfJ8Izikop6SqeEQqcg==", - "requires": { - "buffer": "5.6.0", - "events": "3.3.0", - "stream-browserify": "3.0.0", - "tslib": "^2.3.1" - }, - "dependencies": { - "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==", - "requires": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" - } - } - } - }, - "@aws-sdk/md5-js": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/md5-js/-/md5-js-3.347.0.tgz", - "integrity": "sha512-mChE+7DByTY9H4cQ6fnWp2x5jf8e6OZN+AdLp6WQ+W99z35zBeqBxVmgm8ziJwkMIrkSTv9j3Y7T9Ve3RIcSfg==", - "requires": { - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-utf8": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-bucket-endpoint": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.347.0.tgz", - "integrity": "sha512-i9n4ylkGmGvizVcTfN4L+oN10OCL2DKvyMa4cCAVE1TJrsnaE0g7IOOyJGUS8p5KJYQrKVR7kcsa2L1S0VeEcA==", - "requires": { - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-arn-parser": "3.310.0", - "@aws-sdk/util-config-provider": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-content-length": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-content-length/-/middleware-content-length-3.347.0.tgz", - "integrity": "sha512-i4qtWTDImMaDUtwKQPbaZpXsReiwiBomM1cWymCU4bhz81HL01oIxOxOBuiM+3NlDoCSPr3KI6txZSz/8cqXCQ==", - "requires": { - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-endpoint": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint/-/middleware-endpoint-3.347.0.tgz", - "integrity": "sha512-unF0c6dMaUL1ffU+37Ugty43DgMnzPWXr/Jup/8GbK5fzzWT5NQq6dj9KHPubMbWeEjQbmczvhv25JuJdK8gNQ==", - "requires": { - "@aws-sdk/middleware-serde": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/url-parser": "3.347.0", - "@aws-sdk/util-middleware": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-expect-continue": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.347.0.tgz", - "integrity": "sha512-95M1unD1ENL0tx35dfyenSfx0QuXBSKtOi/qJja6LfX5771C5fm5ZTOrsrzPFJvRg/wj8pCOVWRZk+d5+jvfOQ==", - "requires": { - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-flexible-checksums": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.347.0.tgz", - "integrity": "sha512-Pda7VMAIyeHw9nMp29rxdFft3EF4KP/tz/vLB6bqVoBNbLujo5rxn3SGOgStgIz7fuMLQQfoWIsmvxUm+Fp+Dw==", - "requires": { - "@aws-crypto/crc32": "3.0.0", - "@aws-crypto/crc32c": "3.0.0", - "@aws-sdk/is-array-buffer": "3.310.0", - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-utf8": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-host-header": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.347.0.tgz", - "integrity": "sha512-kpKmR9OvMlnReqp5sKcJkozbj1wmlblbVSbnQAIkzeQj2xD5dnVR3Nn2ogQKxSmU1Fv7dEroBtrruJ1o3fY38A==", - "requires": { - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-location-constraint": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.347.0.tgz", - "integrity": "sha512-x5fcEV7q8fQ0OmUO+cLhN5iPqGoLWtC3+aKHIfRRb2BpOO1khyc1FKzsIAdeQz2hfktq4j+WsrmcPvFKv51pSg==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-logger": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.347.0.tgz", - "integrity": "sha512-NYC+Id5UCkVn+3P1t/YtmHt75uED06vwaKyxDy0UmB2K66PZLVtwWbLpVWrhbroaw1bvUHYcRyQ9NIfnVcXQjA==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-recursion-detection": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.347.0.tgz", - "integrity": "sha512-qfnSvkFKCAMjMHR31NdsT0gv5Sq/ZHTUD4yQsSLpbVQ6iYAS834lrzXt41iyEHt57Y514uG7F/Xfvude3u4icQ==", - "requires": { - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-retry": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-retry/-/middleware-retry-3.347.0.tgz", - "integrity": "sha512-CpdM+8dCSbX96agy4FCzOfzDmhNnGBM/pxrgIVLm5nkYTLuXp/d7ubpFEUHULr+4hCd5wakHotMt7yO29NFaVw==", - "requires": { - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/service-error-classification": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-middleware": "3.347.0", - "@aws-sdk/util-retry": "3.347.0", - "tslib": "^2.5.0", - "uuid": "^8.3.2" - } - }, - "@aws-sdk/middleware-sdk-s3": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.347.0.tgz", - "integrity": "sha512-TLr92+HMvamrhJJ0VDhA/PiUh4rTNQz38B9dB9ikohTaRgm+duP+mRiIv16tNPZPGl8v82Thn7Ogk2qPByNDtg==", - "requires": { - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-arn-parser": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-sdk-sts": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sts/-/middleware-sdk-sts-3.347.0.tgz", - "integrity": "sha512-38LJ0bkIoVF3W97x6Jyyou72YV9Cfbml4OaDEdnrCOo0EssNZM5d7RhjMvQDwww7/3OBY/BzeOcZKfJlkYUXGw==", - "requires": { - "@aws-sdk/middleware-signing": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-serde": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-serde/-/middleware-serde-3.347.0.tgz", - "integrity": "sha512-x5Foi7jRbVJXDu9bHfyCbhYDH5pKK+31MmsSJ3k8rY8keXLBxm2XEEg/AIoV9/TUF9EeVvZ7F1/RmMpJnWQsEg==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-signing": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.347.0.tgz", - "integrity": "sha512-zVBF/4MGKnvhAE/J+oAL/VAehiyv+trs2dqSQXwHou9j8eA8Vm8HS2NdOwpkZQchIxTuwFlqSusDuPEdYFbvGw==", - "requires": { - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/signature-v4": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-middleware": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-ssec": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.347.0.tgz", - "integrity": "sha512-467VEi2elPmUGcHAgTmzhguZ3lwTpwK+3s+pk312uZtVsS9rP1MAknYhpS3ZvssiqBUVPx8m29cLcC6Tx5nOJg==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-stack": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.347.0.tgz", - "integrity": "sha512-Izidg4rqtYMcKuvn2UzgEpPLSmyd8ub9+LQ2oIzG3mpIzCBITq7wp40jN1iNkMg+X6KEnX9vdMJIYZsPYMCYuQ==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@aws-sdk/middleware-user-agent": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.352.0.tgz", - "integrity": "sha512-QGqblMTsVDqeomy22KPm9LUW8PHZXBA2Hjk9Hcw8U1uFS8IKYJrewInG3ae2+9FAcTyug4LFWDf8CRr9YH2B3Q==", - "requires": { - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-endpoints": "3.352.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/node-config-provider": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/node-config-provider/-/node-config-provider-3.347.0.tgz", - "integrity": "sha512-faU93d3+5uTTUcotGgMXF+sJVFjrKh+ufW+CzYKT4yUHammyaIab/IbTPWy2hIolcEGtuPeVoxXw8TXbkh/tuw==", - "requires": { - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/shared-ini-file-loader": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/node-http-handler": { - "version": "3.350.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/node-http-handler/-/node-http-handler-3.350.0.tgz", - "integrity": "sha512-oD96GAlmpzYilCdC8wwyURM5lNfNHZCjm/kxBkQulHKa2kRbIrnD9GfDqdCkWA5cTpjh1NzGLT4D6e6UFDjt9w==", - "requires": { - "@aws-sdk/abort-controller": "3.347.0", - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/querystring-builder": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/property-provider": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/property-provider/-/property-provider-3.347.0.tgz", - "integrity": "sha512-t3nJ8CYPLKAF2v9nIHOHOlF0CviQbTvbFc2L4a+A+EVd/rM4PzL3+3n8ZJsr0h7f6uD04+b5YRFgKgnaqLXlEg==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/protocol-http": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.347.0.tgz", - "integrity": "sha512-2YdBhc02Wvy03YjhGwUxF0UQgrPWEy8Iq75pfS42N+/0B/+eWX1aQgfjFxIpLg7YSjT5eKtYOQGlYd4MFTgj9g==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/querystring-builder": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.347.0.tgz", - "integrity": "sha512-phtKTe6FXoV02MoPkIVV6owXI8Mwr5IBN3bPoxhcPvJG2AjEmnetSIrhb8kwc4oNhlwfZwH6Jo5ARW/VEWbZtg==", - "requires": { - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-uri-escape": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/querystring-parser": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-parser/-/querystring-parser-3.347.0.tgz", - "integrity": "sha512-5VXOhfZz78T2W7SuXf2avfjKglx1VZgZgp9Zfhrt/Rq+MTu2D+PZc5zmJHhYigD7x83jLSLogpuInQpFMA9LgA==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/service-error-classification": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/service-error-classification/-/service-error-classification-3.347.0.tgz", - "integrity": "sha512-xZ3MqSY81Oy2gh5g0fCtooAbahqh9VhsF8vcKjVX8+XPbGC8y+kej82+MsMg4gYL8gRFB9u4hgYbNgIS6JTAvg==" - }, - "@aws-sdk/shared-ini-file-loader": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/shared-ini-file-loader/-/shared-ini-file-loader-3.347.0.tgz", - "integrity": "sha512-Xw+zAZQVLb+xMNHChXQ29tzzLqm3AEHsD8JJnlkeFjeMnWQtXdUfOARl5s8NzAppcKQNlVe2gPzjaKjoy2jz1Q==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/signature-v4": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.347.0.tgz", - "integrity": "sha512-58Uq1do+VsTHYkP11dTK+DF53fguoNNJL9rHRWhzP+OcYv3/mBMLoS2WPz/x9FO5mBg4ESFsug0I6mXbd36tjw==", - "requires": { - "@aws-sdk/eventstream-codec": "3.347.0", - "@aws-sdk/is-array-buffer": "3.310.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-hex-encoding": "3.310.0", - "@aws-sdk/util-middleware": "3.347.0", - "@aws-sdk/util-uri-escape": "3.310.0", - "@aws-sdk/util-utf8": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/signature-v4-multi-region": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.347.0.tgz", - "integrity": "sha512-838h7pbRCVYWlTl8W+r5+Z5ld7uoBObgAn7/RB1MQ4JjlkfLdN7emiITG6ueVL+7gWZNZc/4dXR/FJSzCgrkxQ==", - "requires": { - "@aws-sdk/protocol-http": "3.347.0", - "@aws-sdk/signature-v4": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/smithy-client": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.347.0.tgz", - "integrity": "sha512-PaGTDsJLGK0sTjA6YdYQzILRlPRN3uVFyqeBUkfltXssvUzkm8z2t1lz2H4VyJLAhwnG5ZuZTNEV/2mcWrU7JQ==", - "requires": { - "@aws-sdk/middleware-stack": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/types": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.347.0.tgz", - "integrity": "sha512-GkCMy79mdjU9OTIe5KT58fI/6uqdf8UmMdWqVHmFJ+UpEzOci7L/uw4sOXWo7xpPzLs6cJ7s5ouGZW4GRPmHFA==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@aws-sdk/url-parser": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/url-parser/-/url-parser-3.347.0.tgz", - "integrity": "sha512-lhrnVjxdV7hl+yCnJfDZOaVLSqKjxN20MIOiijRiqaWGLGEAiSqBreMhL89X1WKCifxAs4zZf9YB9SbdziRpAA==", - "requires": { - "@aws-sdk/querystring-parser": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-arn-parser": { - "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.310.0.tgz", - "integrity": "sha512-jL8509owp/xB9+Or0pvn3Fe+b94qfklc2yPowZZIFAkFcCSIdkIglz18cPDWnYAcy9JGewpMS1COXKIUhZkJsA==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-base64": { - "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-base64/-/util-base64-3.310.0.tgz", - "integrity": "sha512-v3+HBKQvqgdzcbL+pFswlx5HQsd9L6ZTlyPVL2LS9nNXnCcR3XgGz9jRskikRUuUvUXtkSG1J88GAOnJ/apTPg==", - "requires": { - "@aws-sdk/util-buffer-from": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-body-length-browser": { - "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-browser/-/util-body-length-browser-3.310.0.tgz", - "integrity": "sha512-sxsC3lPBGfpHtNTUoGXMQXLwjmR0zVpx0rSvzTPAuoVILVsp5AU/w5FphNPxD5OVIjNbZv9KsKTuvNTiZjDp9g==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-body-length-node": { - "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-body-length-node/-/util-body-length-node-3.310.0.tgz", - "integrity": "sha512-2tqGXdyKhyA6w4zz7UPoS8Ip+7sayOg9BwHNidiGm2ikbDxm1YrCfYXvCBdwaJxa4hJfRVz+aL9e+d3GqPI9pQ==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-buffer-from": { - "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-buffer-from/-/util-buffer-from-3.310.0.tgz", - "integrity": "sha512-i6LVeXFtGih5Zs8enLrt+ExXY92QV25jtEnTKHsmlFqFAuL3VBeod6boeMXkN2p9lbSVVQ1sAOOYZOHYbYkntw==", - "requires": { - "@aws-sdk/is-array-buffer": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-config-provider": { - "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-config-provider/-/util-config-provider-3.310.0.tgz", - "integrity": "sha512-xIBaYo8dwiojCw8vnUcIL4Z5tyfb1v3yjqyJKJWV/dqKUFOOS0U591plmXbM+M/QkXyML3ypon1f8+BoaDExrg==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-defaults-mode-browser": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-browser/-/util-defaults-mode-browser-3.347.0.tgz", - "integrity": "sha512-+JHFA4reWnW/nMWwrLKqL2Lm/biw/Dzi/Ix54DAkRZ08C462jMKVnUlzAI+TfxQE3YLm99EIa0G7jiEA+p81Qw==", - "requires": { - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/types": "3.347.0", - "bowser": "^2.11.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-defaults-mode-node": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-defaults-mode-node/-/util-defaults-mode-node-3.347.0.tgz", - "integrity": "sha512-A8BzIVhAAZE5WEukoAN2kYebzTc99ZgncbwOmgCCbvdaYlk5tzguR/s+uoT4G0JgQGol/4hAMuJEl7elNgU6RQ==", - "requires": { - "@aws-sdk/config-resolver": "3.347.0", - "@aws-sdk/credential-provider-imds": "3.347.0", - "@aws-sdk/node-config-provider": "3.347.0", - "@aws-sdk/property-provider": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-endpoints": { - "version": "3.352.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.352.0.tgz", - "integrity": "sha512-PjWMPdoIUWfBPgAWLyOrWFbdSS/3DJtc0OmFb/JrE8C8rKFYl+VGW5f1p0cVdRWiDR0xCGr0s67p8itAakVqjw==", - "requires": { - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-hex-encoding": { - "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.310.0.tgz", - "integrity": "sha512-sVN7mcCCDSJ67pI1ZMtk84SKGqyix6/0A1Ab163YKn+lFBQRMKexleZzpYzNGxYzmQS6VanP/cfU7NiLQOaSfA==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-locate-window": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.49.0.tgz", - "integrity": "sha512-ryw+t+quF1raaK0nXSplMiCVnahNLNgNDijZCFFkddGTMaCy+L4VRLYyNms3bgwt3G0BmVn9f3uyDWRSkn5sSg==", - "requires": { - "tslib": "^2.3.0" - } - }, - "@aws-sdk/util-middleware": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-middleware/-/util-middleware-3.347.0.tgz", - "integrity": "sha512-8owqUA3ePufeYTUvlzdJ7Z0miLorTwx+rNol5lourGQZ9JXsVMo23+yGA7nOlFuXSGkoKpMOtn6S0BT2bcfeiw==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-retry": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-retry/-/util-retry-3.347.0.tgz", - "integrity": "sha512-NxnQA0/FHFxriQAeEgBonA43Q9/VPFQa8cfJDuT2A1YZruMasgjcltoZszi1dvoIRWSZsFTW42eY2gdOd0nffQ==", - "requires": { - "@aws-sdk/service-error-classification": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-stream-browser": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-stream-browser/-/util-stream-browser-3.347.0.tgz", - "integrity": "sha512-pIbmzIJfyX26qG622uIESOmJSMGuBkhmNU7I98bzhYCet5ctC0ow9L5FZw9ljOE46P/HkEcsOhh+qTHyCXlCEQ==", - "requires": { - "@aws-sdk/fetch-http-handler": "3.347.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-base64": "3.310.0", - "@aws-sdk/util-hex-encoding": "3.310.0", - "@aws-sdk/util-utf8": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-stream-node": { - "version": "3.350.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-stream-node/-/util-stream-node-3.350.0.tgz", - "integrity": "sha512-qhcmYEAVMJPjCepog3WTFBaeP3XCkLBbUrM5/+LaB/FASKk+JeV8qBQyjYUd8EVb6Gsk7+y9SE3Tj+ChyHB4WA==", - "requires": { - "@aws-sdk/node-http-handler": "3.350.0", - "@aws-sdk/types": "3.347.0", - "@aws-sdk/util-buffer-from": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-uri-escape": { - "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.310.0.tgz", - "integrity": "sha512-drzt+aB2qo2LgtDoiy/3sVG8w63cgLkqFIa2NFlGpUgHFWTXkqtbgf4L5QdjRGKWhmZsnqkbtL7vkSWEcYDJ4Q==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-user-agent-browser": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.347.0.tgz", - "integrity": "sha512-ydxtsKVtQefgbk1Dku1q7pMkjDYThauG9/8mQkZUAVik55OUZw71Zzr3XO8J8RKvQG8lmhPXuAQ0FKAyycc0RA==", - "requires": { - "@aws-sdk/types": "3.347.0", - "bowser": "^2.11.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-user-agent-node": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.347.0.tgz", - "integrity": "sha512-6X0b9qGsbD1s80PmbaB6v1/ZtLfSx6fjRX8caM7NN0y/ObuLoX8LhYnW6WlB2f1+xb4EjaCNgpP/zCf98MXosw==", - "requires": { - "@aws-sdk/node-config-provider": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-utf8": { - "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8/-/util-utf8-3.310.0.tgz", - "integrity": "sha512-DnLfFT8uCO22uOJc0pt0DsSNau1GTisngBCDw8jQuWT5CqogMJu4b/uXmwEqfj8B3GX6Xsz8zOd6JpRlPftQoA==", - "requires": { - "@aws-sdk/util-buffer-from": "3.310.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/util-utf8-browser": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.49.0.tgz", - "integrity": "sha512-u9ZgAiTWX9yZFQ/ptlnVpYJ/rXF7aE2Wagar1IjhZrnxXbpVJvcX1EeRayxI1P5AAp2y2fiEKHZzX9ugTwOcEg==", - "requires": { - "tslib": "^2.3.0" - } - }, - "@aws-sdk/util-waiter": { - "version": "3.347.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-waiter/-/util-waiter-3.347.0.tgz", - "integrity": "sha512-3ze/0PkwkzUzLncukx93tZgGL0JX9NaP8DxTi6WzflnL/TEul5Z63PCruRNK0om17iZYAWKrf8q2mFoHYb4grA==", - "requires": { - "@aws-sdk/abort-controller": "3.347.0", - "@aws-sdk/types": "3.347.0", - "tslib": "^2.5.0" - } - }, - "@aws-sdk/xml-builder": { - "version": "3.310.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.310.0.tgz", - "integrity": "sha512-TqELu4mOuSIKQCqj63fGVs86Yh+vBx5nHRpWKNUNhB2nPTpfbziTs5c1X358be3peVWA4wPxW7Nt53KIg1tnNw==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@babel/code-frame": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz", - "integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==", - "dev": true, - "requires": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" - }, - "dependencies": { - "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==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "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==", - "dev": true - }, - "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==", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@babel/compat-data": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.0.tgz", - "integrity": "sha512-392byTlpGWXMv4FbyWw3sAZ/FrW/DrwqLGXpy0mbyNe9Taqv1mg9yON5/o0cnr8XYCkFTZbC1eV+c+LAROgrng==", - "dev": true - }, - "@babel/core": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.0.tgz", - "integrity": "sha512-x/5Ea+RO5MvF9ize5DeVICJoVrNv0Mi2RnIABrZEKYvPEpldXwauPkgvYA17cKa6WpU3LoYvYbuEMFtSNFsarA==", - "dev": true, - "requires": { - "@ampproject/remapping": "^2.0.0", - "@babel/code-frame": "^7.16.7", - "@babel/generator": "^7.17.0", - "@babel/helper-compilation-targets": "^7.16.7", - "@babel/helper-module-transforms": "^7.16.7", - "@babel/helpers": "^7.17.0", - "@babel/parser": "^7.17.0", - "@babel/template": "^7.16.7", - "@babel/traverse": "^7.17.0", - "@babel/types": "^7.17.0", - "convert-source-map": "^1.7.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.1.2", - "semver": "^6.3.0" - }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "@babel/generator": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.4.tgz", - "integrity": "sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==", - "dev": true, - "requires": { - "@babel/types": "^7.23.4", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" - } - }, - "@babel/helper-compilation-targets": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.16.7.tgz", - "integrity": "sha512-mGojBwIWcwGD6rfqgRXVlVYmPAv7eOpIemUG3dGnDdCY4Pae70ROij3XmfrH6Fa1h1aiDylpglbZyktfzyo/hA==", - "dev": true, - "requires": { - "@babel/compat-data": "^7.16.4", - "@babel/helper-validator-option": "^7.16.7", - "browserslist": "^4.17.5", - "semver": "^6.3.0" - }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "@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 - }, - "@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, - "requires": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - } - }, - "@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, - "requires": { - "@babel/types": "^7.22.5" - } - }, - "@babel/helper-module-imports": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", - "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", - "dev": true, - "requires": { - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-module-transforms": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.16.7.tgz", - "integrity": "sha512-gaqtLDxJEFCeQbYp9aLAefjhkKdjKcdh6DB7jniIGU3Pz52WAmP268zK0VgPz9hUNkMSYeH976K2/Y6yPadpng==", - "dev": true, - "requires": { - "@babel/helper-environment-visitor": "^7.16.7", - "@babel/helper-module-imports": "^7.16.7", - "@babel/helper-simple-access": "^7.16.7", - "@babel/helper-split-export-declaration": "^7.16.7", - "@babel/helper-validator-identifier": "^7.16.7", - "@babel/template": "^7.16.7", - "@babel/traverse": "^7.16.7", - "@babel/types": "^7.16.7" - } - }, - "@babel/helper-plugin-utils": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz", - "integrity": "sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==", - "dev": true - }, - "@babel/helper-simple-access": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.16.7.tgz", - "integrity": "sha512-ZIzHVyoeLMvXMN/vok/a4LWRy8G2v205mNP0XOuf9XRLyX5/u9CnVulUtDgUTama3lT+bf/UqucuZjqiGuTS1g==", - "dev": true, - "requires": { - "@babel/types": "^7.16.7" - } - }, - "@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, - "requires": { - "@babel/types": "^7.22.5" - } - }, - "@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", - "dev": true - }, - "@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 - }, - "@babel/helper-validator-option": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", - "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==", - "dev": true - }, - "@babel/helpers": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.0.tgz", - "integrity": "sha512-Xe/9NFxjPwELUvW2dsukcMZIp6XwPSbI4ojFBJuX5ramHuVE22SVcZIwqzdWo5uCgeTXW8qV97lMvSOjq+1+nQ==", - "dev": true, - "requires": { - "@babel/template": "^7.16.7", - "@babel/traverse": "^7.17.0", - "@babel/types": "^7.17.0" - } - }, - "@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" - }, - "dependencies": { - "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==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@babel/parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz", - "integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==", - "dev": true - }, - "@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.12.13" - } - }, - "@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.10.4" - } - }, - "@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-jsx": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz", - "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.18.6" - } - }, - "@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.10.4" - } - }, - "@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.10.4" - } - }, - "@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.14.5" - } - }, - "@babel/plugin-syntax-typescript": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.20.0.tgz", - "integrity": "sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.19.0" - } - }, - "@babel/runtime": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.6.tgz", - "integrity": "sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==", - "requires": { - "regenerator-runtime": "^0.13.11" - } - }, - "@babel/runtime-corejs3": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.17.0.tgz", - "integrity": "sha512-qeydncU80ravKzovVncW3EYaC1ji3GpntdPgNcJy9g7hHSY6KX+ne1cbV3ov7Zzm4F1z0+QreZPCuw1ynkmYNg==", - "dev": true, - "requires": { - "core-js-pure": "^3.20.2", - "regenerator-runtime": "^0.13.4" - } - }, - "@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" - } - }, - "@babel/traverse": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.4.tgz", - "integrity": "sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.23.4", - "@babel/generator": "^7.23.4", - "@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.23.4", - "@babel/types": "^7.23.4", - "debug": "^4.1.0", - "globals": "^11.1.0" - } - }, - "@babel/types": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz", - "integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==", - "dev": true, - "requires": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - } - }, - "@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true - }, - "@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==" - }, - "@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "dependencies": { - "@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "requires": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - } - } - }, - "@dabh/diagnostics": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.2.tgz", - "integrity": "sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q==", - "requires": { - "colorspace": "1.1.x", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, - "@esbuild/android-arm": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.10.tgz", - "integrity": "sha512-7YEBfZ5lSem9Tqpsz+tjbdsEshlO9j/REJrfv4DXgKTt1+/MHqGwbtlyxQuaSlMeUZLxUKBaX8wdzlTfHkmnLw==", - "dev": true, - "optional": true - }, - "@esbuild/android-arm64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.10.tgz", - "integrity": "sha512-ht1P9CmvrPF5yKDtyC+z43RczVs4rrHpRqrmIuoSvSdn44Fs1n6DGlpZKdK6rM83pFLbVaSUwle8IN+TPmkv7g==", - "dev": true, - "optional": true - }, - "@esbuild/android-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.10.tgz", - "integrity": "sha512-CYzrm+hTiY5QICji64aJ/xKdN70IK8XZ6iiyq0tZkd3tfnwwSWTYH1t3m6zyaaBxkuj40kxgMyj1km/NqdjQZA==", - "dev": true, - "optional": true - }, - "@esbuild/darwin-arm64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.10.tgz", - "integrity": "sha512-3HaGIowI+nMZlopqyW6+jxYr01KvNaLB5znXfbyyjuo4lE0VZfvFGcguIJapQeQMS4cX/NEispwOekJt3gr5Dg==", - "dev": true, - "optional": true - }, - "@esbuild/darwin-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.10.tgz", - "integrity": "sha512-J4MJzGchuCRG5n+B4EHpAMoJmBeAE1L3wGYDIN5oWNqX0tEr7VKOzw0ymSwpoeSpdCa030lagGUfnfhS7OvzrQ==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-arm64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.10.tgz", - "integrity": "sha512-ZkX40Z7qCbugeK4U5/gbzna/UQkM9d9LNV+Fro8r7HA7sRof5Rwxc46SsqeMvB5ZaR0b1/ITQ/8Y1NmV2F0fXQ==", - "dev": true, - "optional": true - }, - "@esbuild/freebsd-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.10.tgz", - "integrity": "sha512-0m0YX1IWSLG9hWh7tZa3kdAugFbZFFx9XrvfpaCMMvrswSTvUZypp0NFKriUurHpBA3xsHVE9Qb/0u2Bbi/otg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.10.tgz", - "integrity": "sha512-whRdrrl0X+9D6o5f0sTZtDM9s86Xt4wk1bf7ltx6iQqrIIOH+sre1yjpcCdrVXntQPCNw/G+XqsD4HuxeS+2QA==", - "dev": true, - "optional": true - }, - "@esbuild/linux-arm64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.10.tgz", - "integrity": "sha512-g1EZJR1/c+MmCgVwpdZdKi4QAJ8DCLP5uTgLWSAVd9wlqk9GMscaNMEViG3aE1wS+cNMzXXgdWiW/VX4J+5nTA==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ia32": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.10.tgz", - "integrity": "sha512-1vKYCjfv/bEwxngHERp7huYfJ4jJzldfxyfaF7hc3216xiDA62xbXJfRlradiMhGZbdNLj2WA1YwYFzs9IWNPw==", - "dev": true, - "optional": true - }, - "@esbuild/linux-loong64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.10.tgz", - "integrity": "sha512-mvwAr75q3Fgc/qz3K6sya3gBmJIYZCgcJ0s7XshpoqIAIBszzfXsqhpRrRdVFAyV1G9VUjj7VopL2HnAS8aHFA==", - "dev": true, - "optional": true - }, - "@esbuild/linux-mips64el": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.10.tgz", - "integrity": "sha512-XilKPgM2u1zR1YuvCsFQWl9Fc35BqSqktooumOY2zj7CSn5czJn279j9TE1JEqSqz88izJo7yE4x3LSf7oxHzg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-ppc64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.10.tgz", - "integrity": "sha512-kM4Rmh9l670SwjlGkIe7pYWezk8uxKHX4Lnn5jBZYBNlWpKMBCVfpAgAJqp5doLobhzF3l64VZVrmGeZ8+uKmQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-riscv64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.10.tgz", - "integrity": "sha512-r1m9ZMNJBtOvYYGQVXKy+WvWd0BPvSxMsVq8Hp4GzdMBQvfZRvRr5TtX/1RdN6Va8JMVQGpxqde3O+e8+khNJQ==", - "dev": true, - "optional": true - }, - "@esbuild/linux-s390x": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.10.tgz", - "integrity": "sha512-LsY7QvOLPw9WRJ+fU5pNB3qrSfA00u32ND5JVDrn/xG5hIQo3kvTxSlWFRP0NJ0+n6HmhPGG0Q4jtQsb6PFoyg==", - "dev": true, - "optional": true - }, - "@esbuild/linux-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.10.tgz", - "integrity": "sha512-zJUfJLebCYzBdIz/Z9vqwFjIA7iSlLCFvVi7glMgnu2MK7XYigwsonXshy9wP9S7szF+nmwrelNaP3WGanstEg==", - "dev": true, - "optional": true - }, - "@esbuild/netbsd-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.10.tgz", - "integrity": "sha512-lOMkailn4Ok9Vbp/q7uJfgicpDTbZFlXlnKT2DqC8uBijmm5oGtXAJy2ZZVo5hX7IOVXikV9LpCMj2U8cTguWA==", - "dev": true, - "optional": true - }, - "@esbuild/openbsd-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.10.tgz", - "integrity": "sha512-/VE0Kx6y7eekqZ+ZLU4AjMlB80ov9tEz4H067Y0STwnGOYL8CsNg4J+cCmBznk1tMpxMoUOf0AbWlb1d2Pkbig==", - "dev": true, - "optional": true - }, - "@esbuild/sunos-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.10.tgz", - "integrity": "sha512-ERNO0838OUm8HfUjjsEs71cLjLMu/xt6bhOlxcJ0/1MG3hNqCmbWaS+w/8nFLa0DDjbwZQuGKVtCUJliLmbVgg==", - "dev": true, - "optional": true - }, - "@esbuild/win32-arm64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.10.tgz", - "integrity": "sha512-fXv+L+Bw2AeK+XJHwDAQ9m3NRlNemG6Z6ijLwJAAVdu4cyoFbBWbEtyZzDeL+rpG2lWI51cXeMt70HA8g2MqIg==", - "dev": true, - "optional": true - }, - "@esbuild/win32-ia32": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.10.tgz", - "integrity": "sha512-3s+HADrOdCdGOi5lnh5DMQEzgbsFsd4w57L/eLKKjMnN0CN4AIEP0DCP3F3N14xnxh3ruNc32A0Na9zYe1Z/AQ==", - "dev": true, - "optional": true - }, - "@esbuild/win32-x64": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.10.tgz", - "integrity": "sha512-oP+zFUjYNaMNmjTwlFtWep85hvwUu19cZklB3QsBOcZSs6y7hmH4LNCJ7075bsqzYaNvZFXJlAVaQ2ApITDXtw==", - "dev": true, - "optional": true - }, - "@eslint/eslintrc": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.0.tgz", - "integrity": "sha512-7yfvXy6MWLgWSFsLhz5yH3iQ52St8cdUY6FoGieKkRDVxuxmrNuUetIuu6cmjNWwniUHiWXjxCr5tTXDrbYS5A==", - "dev": true, - "requires": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.4.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "dependencies": { - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "globals": { - "version": "13.19.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz", - "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - } - } - }, - "@faker-js/faker": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.1.0.tgz", - "integrity": "sha512-38DT60rumHfBYynif3lmtxMqMqmsOQIxQgEuPZxCk2yUYN0eqWpTACgxi0VpidvsJB8CRxCpvP7B3anK85FjtQ==", - "dev": true - }, - "@feathersjs/adapter-commons": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/adapter-commons/-/adapter-commons-5.0.12.tgz", - "integrity": "sha512-HP4330c2hhhiih1oFheKzS/YzBChBWvm4dDNhzcv+p4W7kmciclpyShGvDALZehe3R/8JHdxhkjB/3eo6zpn+w==", - "requires": { - "@feathersjs/commons": "^5.0.12", - "@feathersjs/errors": "^5.0.12", - "@feathersjs/feathers": "^5.0.12" - } - }, - "@feathersjs/adapter-tests": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/adapter-tests/-/adapter-tests-5.0.12.tgz", - "integrity": "sha512-D+CRrMlWSo+FuREWRrYfgZTiTSVDq8y12AAM+C8b2X54W2CzyvCu9JjxSbU3EjUF6Mg2p7cCrb+JhLZ6Y81I8A==", - "dev": true - }, - "@feathersjs/authentication": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/authentication/-/authentication-5.0.12.tgz", - "integrity": "sha512-eaxRGCPVkvZ6MAh50zDPdYmSofrFDJUHFgT4kaAzEc3+fUGpsriqjUBrrxSF88wWojR6ZHpZIzB8IMYD7oKs7Q==", - "requires": { - "@feathersjs/commons": "^5.0.12", - "@feathersjs/errors": "^5.0.12", - "@feathersjs/feathers": "^5.0.12", - "@feathersjs/hooks": "^0.8.1", - "@feathersjs/schema": "^5.0.12", - "@feathersjs/transport-commons": "^5.0.12", - "@types/jsonwebtoken": "^9.0.5", - "jsonwebtoken": "^9.0.2", - "lodash": "^4.17.21", - "long-timeout": "^0.1.1", - "uuid": "^9.0.1" - }, - "dependencies": { - "@feathersjs/schema": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/schema/-/schema-5.0.12.tgz", - "integrity": "sha512-xuiePDDeWPEH24KFzdbP3kSeshxpPa3XuVxylwtEZnBRAq9mN7AxQEMDalKAvjYWlfhbnJ2qGScMlav5BdWGYw==", - "requires": { - "@feathersjs/adapter-commons": "^5.0.12", - "@feathersjs/commons": "^5.0.12", - "@feathersjs/errors": "^5.0.12", - "@feathersjs/feathers": "^5.0.12", - "@feathersjs/hooks": "^0.8.1", - "@types/json-schema": "^7.0.15", - "ajv": "^8.12.0", - "ajv-formats": "^2.1.1", - "json-schema-to-ts": "^2.9.2" - } - }, - "@types/jsonwebtoken": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", - "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", - "requires": { - "@types/node": "*" - } - }, - "jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "requires": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - } - }, - "typescript": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", - "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", - "peer": true - }, - "uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" - } - } - }, - "@feathersjs/authentication-local": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/authentication-local/-/authentication-local-5.0.12.tgz", - "integrity": "sha512-JvMcz2JxPfuCur5NCTI5zrSLp8Vx15FBHu8izlUl31OuXHox0/pJ2TP/FZV8RFIsoo9MpyrWAByeDwNRhKe1zA==", - "requires": { - "@feathersjs/authentication": "^5.0.12", - "@feathersjs/commons": "^5.0.12", - "@feathersjs/errors": "^5.0.12", - "@feathersjs/feathers": "^5.0.12", - "bcryptjs": "^2.4.3", - "lodash": "^4.17.21" - } - }, - "@feathersjs/commons": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/commons/-/commons-5.0.12.tgz", - "integrity": "sha512-/6LiS4PLu60O39C91EXE7xzv04Ja+WupZ9UcR1p0qwp58OQRlE/60GWLzQ6XPEIVwELb7ylNgakMAq9D7Z9k8A==" - }, - "@feathersjs/configuration": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/configuration/-/configuration-5.0.12.tgz", - "integrity": "sha512-p5N+12AC3sBk+hz/RYcKRlbpzm5W9yo7ifTz9u/lAH6XrJ/lJqFlC1FCixzYE60tJxC4NAuhBFQDduo0V1vSMA==", - "requires": { - "@feathersjs/commons": "^5.0.12", - "@feathersjs/feathers": "^5.0.12", - "@feathersjs/schema": "^5.0.12", - "@types/config": "^3.3.3", - "config": "^3.3.9" - }, - "dependencies": { - "@feathersjs/schema": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/schema/-/schema-5.0.12.tgz", - "integrity": "sha512-xuiePDDeWPEH24KFzdbP3kSeshxpPa3XuVxylwtEZnBRAq9mN7AxQEMDalKAvjYWlfhbnJ2qGScMlav5BdWGYw==", - "requires": { - "@feathersjs/adapter-commons": "^5.0.12", - "@feathersjs/commons": "^5.0.12", - "@feathersjs/errors": "^5.0.12", - "@feathersjs/feathers": "^5.0.12", - "@feathersjs/hooks": "^0.8.1", - "@types/json-schema": "^7.0.15", - "ajv": "^8.12.0", - "ajv-formats": "^2.1.1", - "json-schema-to-ts": "^2.9.2" - } - }, - "typescript": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", - "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", - "peer": true - } - } - }, - "@feathersjs/errors": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/errors/-/errors-5.0.12.tgz", - "integrity": "sha512-WU3rDO/tlvXkwLeTG4rgcplxKkNQrirCs5MRWp5SH0pGmoNgoBmzVtq/LrGBuyQcr1TdEOaFASJpmQXx1+pcKw==" - }, - "@feathersjs/express": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/express/-/express-5.0.12.tgz", - "integrity": "sha512-8X6OXupTtbg5jHT0eEXvYxBSHomuJ6v3v8dMvShPfrrTq6+shVUycdWVvp3s/U133DPpz38TjDKWNy0IVKK26w==", - "requires": { - "@feathersjs/authentication": "^5.0.12", - "@feathersjs/commons": "^5.0.12", - "@feathersjs/errors": "^5.0.12", - "@feathersjs/feathers": "^5.0.12", - "@feathersjs/transport-commons": "^5.0.12", - "@types/compression": "^1.7.5", - "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", - "@types/express-serve-static-core": "^4.17.41", - "compression": "^1.7.4", - "cors": "^2.8.5", - "express": "^4.18.2" - } - }, - "@feathersjs/feathers": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/feathers/-/feathers-5.0.12.tgz", - "integrity": "sha512-m4rj6sFGMBc5fWmZRpphlXPmk2QodzTmKHw0Vb7npc/Q1pGz86NVY3KBwoIxJSahaF7AaaRofzYHooWaCEnOyw==", - "requires": { - "@feathersjs/commons": "^5.0.12", - "@feathersjs/hooks": "^0.8.1", - "events": "^3.3.0" - } - }, - "@feathersjs/hooks": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@feathersjs/hooks/-/hooks-0.8.1.tgz", - "integrity": "sha512-q/OGjm2BEhT9cHYYcMZR4YKX4lHyufBJmi5Dz+XRM5YqUuEg9MYtR45CWgDiC1klrd2srNSsdmGNVU1otL4+0Q==" - }, - "@feathersjs/transport-commons": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@feathersjs/transport-commons/-/transport-commons-5.0.12.tgz", - "integrity": "sha512-HduReTKT7VHM1MHk8FFp2UaTZtLIKRU87AtGn6RhHAqSKfX01sz1r7OIaB8vPJ/ZAYAqW9P+0UHQaQaA/XDaZQ==", - "requires": { - "@feathersjs/commons": "^5.0.12", - "@feathersjs/errors": "^5.0.12", - "@feathersjs/feathers": "^5.0.12", - "encodeurl": "^1.0.2", - "lodash": "^4.17.21" - } - }, - "@golevelup/nestjs-common": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@golevelup/nestjs-common/-/nestjs-common-2.0.0.tgz", - "integrity": "sha512-D9RLXgkqn9SDLnZ2VoMER9l/+g5CM9Z7sZXa+10+0rZs6yevMepoiWmMVsFoUXLzYG2GwfixHLExwUr3XBCHFw==", - "requires": { - "lodash": "^4.17.21", - "nanoid": "^3.3.6" - } - }, - "@golevelup/nestjs-discovery": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.0.tgz", - "integrity": "sha512-iyZLYip9rhVMR0C93vo860xmboRrD5g5F5iEOfpeblGvYSz8ymQrL9RAST7x/Fp3n+TAXSeOLzDIASt+rak68g==", - "requires": { - "lodash": "^4.17.21" - } - }, - "@golevelup/nestjs-modules": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@golevelup/nestjs-modules/-/nestjs-modules-0.7.0.tgz", - "integrity": "sha512-4WxGKubYx0IJF2rxL3S4SChKdl4ZDZPwCdSj6HxmmElXRyua/LlcwLH6NYquh4RRIkQGspDd5WpcMTBw3SxR5g==", - "requires": { - "lodash": "^4.17.21" - } - }, - "@golevelup/nestjs-rabbitmq": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@golevelup/nestjs-rabbitmq/-/nestjs-rabbitmq-4.0.0.tgz", - "integrity": "sha512-CQHRq/jyK3GlM7Lv4nVaqd+BJ53tZXsrOtO/8/OZh19i0YOcQxyRM7iDdtULeG8omJB5/aGMZNsbioLuupxoog==", - "requires": { - "@golevelup/nestjs-common": "^2.0.0", - "@golevelup/nestjs-discovery": "^4.0.0", - "@golevelup/nestjs-modules": "^0.7.0", - "amqp-connection-manager": "^3.0.0", - "amqplib": "^0.8.0", - "lodash": "^4.17.21" - } - }, - "@golevelup/ts-jest": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@golevelup/ts-jest/-/ts-jest-0.3.4.tgz", - "integrity": "sha512-UXas4+20gNmYfVR7DTJ9iRppy00o0ElqQrRQyn9cF3Gq90iM1Xx1lKvipTEe5NniHDD9FiMZNweOfB+9ebZRZA==", - "dev": true - }, - "@hapi/boom": { - "version": "9.1.4", - "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", - "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", - "dev": true, - "requires": { - "@hapi/hoek": "9.x.x" - } - }, - "@hapi/bourne": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.0.0.tgz", - "integrity": "sha512-WEezM1FWztfbzqIUbsDzFRVMxSoLy3HugVcux6KDDtTqzPsLE8NDRHfXvev66aH1i2oOKKar3/XDjbvh/OUBdg==", - "dev": true - }, - "@hapi/hoek": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.1.tgz", - "integrity": "sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw==", - "dev": true - }, - "@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "dev": true, - "requires": { - "@hapi/hoek": "^9.0.0" - } - }, - "@hapi/wreck": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@hapi/wreck/-/wreck-17.1.0.tgz", - "integrity": "sha512-nx6sFyfqOpJ+EFrHX+XWwJAxs3ju4iHdbB/bwR8yTNZOiYmuhA8eCe7lYPtYmb4j7vyK/SlbaQsmTtUrMvPEBw==", - "dev": true, - "requires": { - "@hapi/boom": "9.x.x", - "@hapi/bourne": "2.x.x", - "@hapi/hoek": "9.x.x" - } - }, - "@hendt/xml2json": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@hendt/xml2json/-/xml2json-1.0.3.tgz", - "integrity": "sha512-9BvcVYnHHS4QGyc1tfE1DBv5++i3DOF/w5hJOm4A5z6HfWd76nI1tBgVxj4LmtRFRRmQSHJm901qSBLXw10Obg==" - }, - "@hpi-schul-cloud/commons": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@hpi-schul-cloud/commons/-/commons-1.3.4.tgz", - "integrity": "sha512-RYUhxwSa5VauJUuKinfT9iuJo6rSdh72WP/JckpGuZxtoTDvxJyREkZHoZDLgY2dVQwCdKw28dRSLMMBLIYtYQ==", - "requires": { - "ajv": "^6.12.5", - "config": "^3.3.1", - "dot-object": "^2.1.4", - "dotenv": "^10.0.0", - "lodash": "^4.17.20" - }, - "dependencies": { - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - } - } - }, - "@httptoolkit/websocket-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@httptoolkit/websocket-stream/-/websocket-stream-6.0.0.tgz", - "integrity": "sha512-EC8m9JbhpGX2okfvLakqrmy4Le0VyNKR7b3IdvFZR/BfFO4ruh/XceBvXhCFHkykchnFxuOSlRwFiqNSXlwcGA==", - "optional": true, - "peer": true, - "requires": { - "@types/ws": "*", - "duplexify": "^3.5.1", - "inherits": "^2.0.1", - "isomorphic-ws": "^4.0.1", - "readable-stream": "^2.3.3", - "safe-buffer": "^5.1.2", - "ws": "*", - "xtend": "^4.0.0" - }, - "dependencies": { - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "optional": true, - "peer": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "optional": true, - "peer": true - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "optional": true, - "peer": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "@humanwhocodes/config-array": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", - "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", - "dev": true, - "requires": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.5" - } - }, - "@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true - }, - "@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true - }, - "@ioredis/commands": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", - "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", - "optional": true, - "peer": true - }, - "@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "requires": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "dependencies": { - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true - }, - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true - } - } - }, - "@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true - }, - "@jest-mock/express": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@jest-mock/express/-/express-1.4.5.tgz", - "integrity": "sha512-bERM1jnutyH7VMahdaOHAKy7lgX47zJ7+RTz2eMz0wlCttd9CkhsKFEyoWmJBSz/ow0nVj3lCuRqLem4QDYFkQ==", - "dev": true - }, - "@jest/console": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.2.1.tgz", - "integrity": "sha512-MF8Adcw+WPLZGBiNxn76DOuczG3BhODTcMlDCA4+cFi41OkaY/lyI0XUUhi73F88Y+7IHoGmD80pN5CtxQUdSw==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.2.1", - "jest-util": "^29.2.1", - "slash": "^3.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "@jest/core": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.2.2.tgz", - "integrity": "sha512-susVl8o2KYLcZhhkvSB+b7xX575CX3TmSvxfeDjpRko7KmT89rHkXj6XkDkNpSeFMBzIENw5qIchO9HC9Sem+A==", - "dev": true, - "requires": { - "@jest/console": "^29.2.1", - "@jest/reporters": "^29.2.2", - "@jest/test-result": "^29.2.1", - "@jest/transform": "^29.2.2", - "@jest/types": "^29.2.1", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.2.0", - "jest-config": "^29.2.2", - "jest-haste-map": "^29.2.1", - "jest-message-util": "^29.2.1", - "jest-regex-util": "^29.2.0", - "jest-resolve": "^29.2.2", - "jest-resolve-dependencies": "^29.2.2", - "jest-runner": "^29.2.2", - "jest-runtime": "^29.2.2", - "jest-snapshot": "^29.2.2", - "jest-util": "^29.2.1", - "jest-validate": "^29.2.2", - "jest-watcher": "^29.2.2", - "micromatch": "^4.0.4", - "pretty-format": "^29.2.1", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "dependencies": { - "@jest/transform": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.2.2.tgz", - "integrity": "sha512-aPe6rrletyuEIt2axxgdtxljmzH8O/nrov4byy6pDw9S8inIrTV+2PnjyP/oFHMSynzGxJ2s6OHowBNMXp/Jzg==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.2.1", - "@jridgewell/trace-mapping": "^0.3.15", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.2.1", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "jest-haste-map": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "fsevents": "^2.3.2", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "jest-worker": "^29.2.1", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - } - }, - "jest-regex-util": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", - "dev": true - }, - "jest-worker": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", - "dev": true, - "requires": { - "@types/node": "*", - "jest-util": "^29.2.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "dependencies": { - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - } - } - } - }, - "@jest/environment": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.2.2.tgz", - "integrity": "sha512-OWn+Vhu0I1yxuGBJEFFekMYc8aGBGrY4rt47SOh/IFaI+D7ZHCk7pKRiSoZ2/Ml7b0Ony3ydmEHRx/tEOC7H1A==", - "dev": true, - "requires": { - "@jest/fake-timers": "^29.2.2", - "@jest/types": "^29.2.1", - "@types/node": "*", - "jest-mock": "^29.2.2" - } - }, - "@jest/expect": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.2.2.tgz", - "integrity": "sha512-zwblIZnrIVt8z/SiEeJ7Q9wKKuB+/GS4yZe9zw7gMqfGf4C5hBLGrVyxu1SzDbVSqyMSlprKl3WL1r80cBNkgg==", - "dev": true, - "requires": { - "expect": "^29.2.2", - "jest-snapshot": "^29.2.2" - } - }, - "@jest/expect-utils": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.2.2.tgz", - "integrity": "sha512-vwnVmrVhTmGgQzyvcpze08br91OL61t9O0lJMDyb6Y/D8EKQ9V7rGUb/p7PDt0GPzK0zFYqXWFo4EO2legXmkg==", - "dev": true, - "requires": { - "jest-get-type": "^29.2.0" - }, - "dependencies": { - "jest-get-type": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", - "dev": true - } - } - }, - "@jest/fake-timers": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.2.2.tgz", - "integrity": "sha512-nqaW3y2aSyZDl7zQ7t1XogsxeavNpH6kkdq+EpXncIDvAkjvFD7hmhcIs1nWloengEWUoWqkqSA6MSbf9w6DgA==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "@sinonjs/fake-timers": "^9.1.2", - "@types/node": "*", - "jest-message-util": "^29.2.1", - "jest-mock": "^29.2.2", - "jest-util": "^29.2.1" - }, - "dependencies": { - "@sinonjs/fake-timers": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz", - "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==", - "dev": true, - "requires": { - "@sinonjs/commons": "^1.7.0" - } - } - } - }, - "@jest/globals": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.2.2.tgz", - "integrity": "sha512-/nt+5YMh65kYcfBhj38B3Hm0Trk4IsuMXNDGKE/swp36yydBWfz3OXkLqkSvoAtPW8IJMSJDFCbTM2oj5SNprw==", - "dev": true, - "requires": { - "@jest/environment": "^29.2.2", - "@jest/expect": "^29.2.2", - "@jest/types": "^29.2.1", - "jest-mock": "^29.2.2" - } - }, - "@jest/reporters": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.2.2.tgz", - "integrity": "sha512-AzjL2rl2zJC0njIzcooBvjA4sJjvdoq98sDuuNs4aNugtLPSQ+91nysGKRF0uY1to5k0MdGMdOBggUsPqvBcpA==", - "dev": true, - "requires": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.2.1", - "@jest/test-result": "^29.2.1", - "@jest/transform": "^29.2.2", - "@jest/types": "^29.2.1", - "@jridgewell/trace-mapping": "^0.3.15", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.2.1", - "jest-util": "^29.2.1", - "jest-worker": "^29.2.1", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "dependencies": { - "@jest/transform": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.2.2.tgz", - "integrity": "sha512-aPe6rrletyuEIt2axxgdtxljmzH8O/nrov4byy6pDw9S8inIrTV+2PnjyP/oFHMSynzGxJ2s6OHowBNMXp/Jzg==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.2.1", - "@jridgewell/trace-mapping": "^0.3.15", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.2.1", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.1" - } - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "jest-haste-map": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "fsevents": "^2.3.2", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "jest-worker": "^29.2.1", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - } - }, - "jest-regex-util": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", - "dev": true - }, - "jest-worker": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", - "dev": true, - "requires": { - "@types/node": "*", - "jest-util": "^29.2.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - } - }, - "write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - } - } - } - }, - "@jest/schemas": { - "version": "29.0.0", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.0.0.tgz", - "integrity": "sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA==", - "dev": true, - "requires": { - "@sinclair/typebox": "^0.24.1" - } - }, - "@jest/source-map": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.2.0.tgz", - "integrity": "sha512-1NX9/7zzI0nqa6+kgpSdKPK+WU1p+SJk3TloWZf5MzPbxri9UEeXX5bWZAPCzbQcyuAzubcdUHA7hcNznmRqWQ==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.15", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - } - }, - "@jest/test-result": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.2.1.tgz", - "integrity": "sha512-lS4+H+VkhbX6z64tZP7PAUwPqhwj3kbuEHcaLuaBuB+riyaX7oa1txe0tXgrFj5hRWvZKvqO7LZDlNWeJ7VTPA==", - "dev": true, - "requires": { - "@jest/console": "^29.2.1", - "@jest/types": "^29.2.1", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - } - }, - "@jest/test-sequencer": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.2.2.tgz", - "integrity": "sha512-Cuc1znc1pl4v9REgmmLf0jBd3Y65UXJpioGYtMr/JNpQEIGEzkmHhy6W6DLbSsXeUA13TDzymPv0ZGZ9jH3eIw==", - "dev": true, - "requires": { - "@jest/test-result": "^29.2.1", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.2.1", - "slash": "^3.0.0" - }, - "dependencies": { - "jest-haste-map": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "fsevents": "^2.3.2", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "jest-worker": "^29.2.1", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - } - }, - "jest-regex-util": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", - "dev": true - }, - "jest-worker": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", - "dev": true, - "requires": { - "@types/node": "*", - "jest-util": "^29.2.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - } - } - } - }, - "@jest/types": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.2.1.tgz", - "integrity": "sha512-O/QNDQODLnINEPAI0cl9U6zUIDXEWXt6IC1o2N2QENuos7hlGUIthlKyV4p6ki3TvXFX071blj8HUhgLGquPjw==", - "dev": true, - "requires": { - "@jest/schemas": "^29.0.0", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dev": true, - "requires": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true - }, - "@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true - }, - "@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", - "dev": true, - "requires": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true - }, - "@jridgewell/trace-mapping": { - "version": "0.3.17", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz", - "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==", - "dev": true, - "requires": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" - } - }, - "@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" - }, - "@keycloak/keycloak-admin-client": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-21.1.2.tgz", - "integrity": "sha512-YEsYZsTJTp6TQq0yX4u0uyztpqnTyeRJZP6Fke4ZkMaStB2l3nLGDdamg9lOAS9Z7xC11pt1yy7NNDEwtJVtHQ==", - "requires": { - "camelize-ts": "^2.5.0", - "lodash-es": "^4.17.21", - "url-join": "^5.0.0", - "url-template": "^3.1.0" - }, - "dependencies": { - "url-join": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", - "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==" - } - } - }, - "@lukeed/csprng": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", - "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==" - }, - "@lumieducation/h5p-server": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@lumieducation/h5p-server/-/h5p-server-9.2.0.tgz", - "integrity": "sha512-npW5hXyFikFS7LakT6O+4FQgJNHEAyEMRm9VTifyZcNuQ+lMWoz2gGbEuoT4PcTyaK+a1f6G8V8G3882fL0qKQ==", - "requires": { - "ajv": "^8.11.0", - "ajv-keywords": "^5.1.0", - "async-lock": "^1.3.1", - "axios": "^0.27.0", - "cache-manager": "^3.6.1", - "debug": "^4.3.4", - "flat": "^5.0.2", - "fs-extra": "^10.1.0", - "get-all-files": "^4.1.0", - "https-proxy-agent": "^5.0.1", - "image-size": "^1.0.1", - "jsonpath": "^1.1.1", - "merge": "^2.1.1", - "mime-types": "^2.1.35", - "nanoid": "^3.3.2", - "node-machine-id": "^1.1.12", - "promisepipe": "^3.0.0", - "qs": "^6.10.3", - "sanitize-html": "^2.7.0", - "stream-buffers": "^3.0.2", - "tmp-promise": "^3.0.3", - "upath": "^2.0.1", - "yauzl-promise": "^2.1.3", - "yazl": "^2.5.1" - }, - "dependencies": { - "axios": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", - "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", - "requires": { - "follow-redirects": "^1.14.9", - "form-data": "^4.0.0" - } - }, - "cache-manager": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-3.6.3.tgz", - "integrity": "sha512-dS4DnV6c6cQcVH5OxzIU1XZaACXwvVIiUPkFytnRmLOACuBGv3GQgRQ1RJGRRw4/9DF14ZK2RFlZu1TUgDniMg==", - "requires": { - "async": "3.2.3", - "lodash.clonedeep": "^4.5.0", - "lru-cache": "6.0.0" - } - }, - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "requires": { - "ms": "2.1.2" - } - } - } - }, - "@mikro-orm/core": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/@mikro-orm/core/-/core-5.5.3.tgz", - "integrity": "sha512-/iQ6YKDp8EfTYibAOTkvW44uIc3qk0N+VSsUqMtO3sjb5Y2C6B+Wz4E1Qjb+oSeZtvWZn469HMCiOTgdMl6KSw==", - "requires": { - "acorn-loose": "8.3.0", - "acorn-walk": "8.2.0", - "dotenv": "16.0.3", - "fs-extra": "10.1.0", - "globby": "11.0.4", - "mikro-orm": "^5.5.3", - "reflect-metadata": "0.1.13" - }, - "dependencies": { - "acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" - }, - "dotenv": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", - "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==" - } - } - }, - "@mikro-orm/mongodb": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/@mikro-orm/mongodb/-/mongodb-5.5.3.tgz", - "integrity": "sha512-SXWcxTuNhpC+O6GbkCYf1AFvIuK1wivkww3nSp2xjCry3h9XQ334yg0CnVGpzJ2tmU5q69M0FLyRQg7P6/QHvg==", - "requires": { - "bson": "^4.7.0", - "mongodb": "4.11.0" - } - }, - "@mikro-orm/nestjs": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@mikro-orm/nestjs/-/nestjs-5.2.1.tgz", - "integrity": "sha512-TrCdPsM7DApxrK3avBbijT6/6Er4TZhtiQ+qlMqtqva13vMCG4HiF2vIWGrKJbFukkLRuhOfZlES+KZ9Y1Lx2A==", - "requires": {} - }, - "@mongodb-js/saslprep": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", - "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", - "requires": { - "sparse-bitfield": "^3.0.3" - } - }, - "@nestjs/axios": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.0.tgz", - "integrity": "sha512-ULdH03jDWkS5dy9X69XbUVbhC+0pVnrRcj7bIK/ytTZ76w7CgvTZDJqsIyisg3kNOiljRW/4NIjSf3j6YGvl+g==", - "requires": {} - }, - "@nestjs/cache-manager": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.1.0.tgz", - "integrity": "sha512-9kep3a8Mq5cMuXN/anGhSYc0P48CRBXk5wyJJRBFxhNkCH8AIzZF4CASGVDIEMmm3OjVcEUHojjyJwCODS17Qw==", - "requires": {} - }, - "@nestjs/cli": { - "version": "10.1.17", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.1.17.tgz", - "integrity": "sha512-jUEnR2DgC15Op+IhcRWb6cyJrhec9CUQO+GtxCF2Dv9MwLcr4sTDq1UOkfs09HAhpuI8otgF2LoWGTlW3qRuqg==", - "dev": true, - "requires": { - "@angular-devkit/core": "16.2.0", - "@angular-devkit/schematics": "16.2.0", - "@angular-devkit/schematics-cli": "16.2.0", - "@nestjs/schematics": "^10.0.1", - "chalk": "4.1.2", - "chokidar": "3.5.3", - "cli-table3": "0.6.3", - "commander": "4.1.1", - "fork-ts-checker-webpack-plugin": "8.0.0", - "inquirer": "8.2.6", - "node-emoji": "1.11.0", - "ora": "5.4.1", - "os-name": "4.0.1", - "rimraf": "4.4.1", - "shelljs": "0.8.5", - "source-map-support": "0.5.21", - "tree-kill": "1.2.2", - "tsconfig-paths": "4.2.0", - "tsconfig-paths-webpack-plugin": "4.1.0", - "typescript": "5.1.6", - "webpack": "5.88.2", - "webpack-node-externals": "3.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true - }, - "glob": { - "version": "9.3.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", - "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "minimatch": "^8.0.2", - "minipass": "^4.2.4", - "path-scurry": "^1.6.1" - } - }, - "inquirer": { - "version": "8.2.6", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", - "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", - "dev": true, - "requires": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" - } - }, - "minimatch": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", - "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - }, - "minipass": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", - "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", - "dev": true - }, - "rimraf": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-4.4.1.tgz", - "integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==", - "dev": true, - "requires": { - "glob": "^9.2.0" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", - "dev": true - }, - "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==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - } - } - }, - "@nestjs/common": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.2.4.tgz", - "integrity": "sha512-3Lg4PUaSDucf14V8rPCH212NqrK09AJbY0NKqFsb4j5OIE+TuOzVZR/yjaJ8JNxH2hjskJNCZie0D/9tA2lzlA==", - "requires": { - "iterare": "1.2.1", - "tslib": "2.6.2", - "uid": "2.0.2" - } - }, - "@nestjs/config": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.0.1.tgz", - "integrity": "sha512-a98MMkDlgUlXTv9qtDbimYfXsuafn/YZOh/S35afutr0Qc5T6KzjyWP5VjxRkv26yI2JM0RhFruByFTM6ezwHA==", - "requires": { - "dotenv": "16.3.1", - "dotenv-expand": "10.0.0", - "lodash": "4.17.21", - "uuid": "9.0.0" - }, - "dependencies": { - "dotenv": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==" - }, - "uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" - } - } - }, - "@nestjs/core": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.2.4.tgz", - "integrity": "sha512-aWeii2l+3pNCc9kIRdLbXQMvrgSZD0jZgXOZv7bZwVf9mClMMi7TussLI4On12VbqVE7LE3gsNgRTwgQJlVC8g==", - "requires": { - "@nuxtjs/opencollective": "0.3.2", - "fast-safe-stringify": "2.1.1", - "iterare": "1.2.1", - "path-to-regexp": "3.2.0", - "tslib": "2.6.2", - "uid": "2.0.2" - } - }, - "@nestjs/jwt": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.1.1.tgz", - "integrity": "sha512-sISYylg8y1Mb7saxPx5Zh11i7v9JOh70CEC/rN6g43MrbFlJ57c1eYFrffxip1YAx3DmV4K67yXob3syKZMOew==", - "requires": { - "@types/jsonwebtoken": "9.0.2", - "jsonwebtoken": "9.0.0" - } - }, - "@nestjs/mapped-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.2.tgz", - "integrity": "sha512-V0izw6tWs6fTp9+KiiPUbGHWALy563Frn8X6Bm87ANLRuE46iuBMD5acKBDP5lKL/75QFvrzSJT7HkCbB0jTpg==", - "requires": {} - }, - "@nestjs/microservices": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-10.2.4.tgz", - "integrity": "sha512-GytBFj4onLveWDUm+aj7Ft4518yiRx3dfHwqBzYfekPFWIfzVHNGWQCZUSNpS/jMbTfbM2PAknkuhWFjV1811A==", - "requires": { - "iterare": "1.2.1", - "tslib": "2.6.2" - } - }, - "@nestjs/passport": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.1.tgz", - "integrity": "sha512-hS22LeNj0LByS9toBPkpKyZhyKAXoHACLS1EQrjbAJJEQjhocOskVGwcMwvMlz+ohN+VU804/nMF1Zlya4+TiQ==", - "requires": {} - }, - "@nestjs/platform-express": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.2.4.tgz", - "integrity": "sha512-E9F6WYo6bNwvTT0saJpkr8t4BJLbZRwrX5EKbtBRQqyRcw6NAvlKdacKzoo+Sompdre0IbF8AvNRFk4uLZTWqA==", - "requires": { - "body-parser": "1.20.2", - "cors": "2.8.5", - "express": "4.18.2", - "multer": "1.4.4-lts.1", - "tslib": "2.6.2" - } - }, - "@nestjs/platform-ws": { - "version": "10.2.7", - "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.2.7.tgz", - "integrity": "sha512-4H4AeCQgM29Dju+zQb70Jt0JgWhQssOB8mh9n9icsSJ4B/joa+X7OiBBSjn72HZelj0tvX1gal6PaAhEaOdmGQ==", - "requires": { - "tslib": "2.6.2", - "ws": "8.14.2" - }, - "dependencies": { - "ws": { - "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", - "requires": {} - } - } - }, - "@nestjs/schematics": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.2.tgz", - "integrity": "sha512-DaZZjymYoIfRqC5W62lnYXIIods1PDY6CGc8+IpRwyinzffjKxZ3DF3exu+mdyvllzkXo9DTXkoX4zOPSJHCkw==", - "dev": true, - "requires": { - "@angular-devkit/core": "16.1.8", - "@angular-devkit/schematics": "16.1.8", - "comment-json": "4.2.3", - "jsonc-parser": "3.2.0", - "pluralize": "8.0.0" - }, - "dependencies": { - "@angular-devkit/core": { - "version": "16.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-16.1.8.tgz", - "integrity": "sha512-dSRD/+bGanArIXkj+kaU1kDFleZeQMzmBiOXX+pK0Ah9/0Yn1VmY3RZh1zcX9vgIQXV+t7UPrTpOjaERMUtVGw==", - "dev": true, - "requires": { - "ajv": "8.12.0", - "ajv-formats": "2.1.1", - "jsonc-parser": "3.2.0", - "rxjs": "7.8.1", - "source-map": "0.7.4" - } - }, - "@angular-devkit/schematics": { - "version": "16.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-16.1.8.tgz", - "integrity": "sha512-6LyzMdFJs337RTxxkI2U1Ndw0CW5mMX/aXWl8d7cW2odiSrAg8IdlMqpc+AM8+CPfsB0FtS1aWkEZqJLT0jHOg==", - "dev": true, - "requires": { - "@angular-devkit/core": "16.1.8", - "jsonc-parser": "3.2.0", - "magic-string": "0.30.0", - "ora": "5.4.1", - "rxjs": "7.8.1" - } - }, - "magic-string": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", - "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", - "dev": true, - "requires": { - "@jridgewell/sourcemap-codec": "^1.4.13" - } - }, - "source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true - } - } - }, - "@nestjs/swagger": { - "version": "7.1.10", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.1.10.tgz", - "integrity": "sha512-qreCcxgHFyFX1mOfK36pxiziy4xoa/XcxC0h4Zr9yH54WuqMqO9aaNFhFyuQ1iyd/3YBVQB21Un4gQnh9iGm0w==", - "requires": { - "@nestjs/mapped-types": "2.0.2", - "js-yaml": "4.1.0", - "lodash": "4.17.21", - "path-to-regexp": "3.2.0", - "swagger-ui-dist": "5.4.2" - }, - "dependencies": { - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "requires": { - "argparse": "^2.0.1" - } - }, - "swagger-ui-dist": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.4.2.tgz", - "integrity": "sha512-vT5QxP/NOr9m4gLZl+SpavWI3M9Fdh30+Sdw9rEtZbkqNmNNEPhjXas2xTD9rsJYYdLzAiMfwXvtooWH3xbLJA==" - } - } - }, - "@nestjs/testing": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.2.4.tgz", - "integrity": "sha512-2qqymiuPbC41yCXXhtt4cL8AOcVNu13gBCT13A8roUUdcs4lmtg+H3oXKF/Gc/vlLv2RkSTNO+JuzxP1hydLPg==", - "dev": true, - "requires": { - "tslib": "2.6.2" - } - }, - "@nestjs/websockets": { - "version": "10.2.7", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.2.7.tgz", - "integrity": "sha512-NKJMubkwpUBsudbiyjuLZDT/W68K+fS/pe3vG5Ur8QoPn+fkI9SFCiQw27Cv4K0qVX2eGJ41yNmVfu61zGa4CQ==", - "requires": { - "iterare": "1.2.1", - "object-hash": "3.0.0", - "tslib": "2.6.2" - } - }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" - }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@nuxtjs/opencollective": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", - "integrity": "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==", - "requires": { - "chalk": "^4.1.0", - "consola": "^2.15.0", - "node-fetch": "^2.6.1" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "@panva/asn1.js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", - "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==" - }, - "@pkgr/utils": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz", - "integrity": "sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.3", - "is-glob": "^4.0.3", - "open": "^8.4.0", - "picocolors": "^1.0.0", - "tiny-glob": "^0.2.9", - "tslib": "^2.4.0" - } - }, - "@redis/bloom": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", - "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", - "requires": {} - }, - "@redis/client": { - "version": "1.5.12", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.12.tgz", - "integrity": "sha512-/ZjE18HRzMd80eXIIUIPcH81UoZpwulbo8FmbElrjPqH0QC0SeIKu1BOU49bO5trM5g895kAjhvalt5h77q+4A==", - "requires": { - "cluster-key-slot": "1.1.2", - "generic-pool": "3.9.0", - "yallist": "4.0.0" - } - }, - "@redis/graph": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", - "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", - "requires": {} - }, - "@redis/json": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.6.tgz", - "integrity": "sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw==", - "requires": {} - }, - "@redis/search": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.6.tgz", - "integrity": "sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw==", - "requires": {} - }, - "@redis/time-series": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.5.tgz", - "integrity": "sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==", - "requires": {} - }, - "@servie/events": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@servie/events/-/events-1.0.0.tgz", - "integrity": "sha512-sBSO19KzdrJCM3gdx6eIxV8M9Gxfgg6iDQmH5TIAGaUu+X9VDdsINXJOnoiZ1Kx3TrHdH4bt5UVglkjsEGBcvw==" - }, - "@sideway/address": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.3.tgz", - "integrity": "sha512-8ncEUtmnTsMmL7z1YPB47kPUq7LpKWJNFPsRzHiIajGC5uXlWGn+AmkYPcHNl8S4tcEGx+cnORnNYaw2wvL+LQ==", - "dev": true, - "requires": { - "@hapi/hoek": "^9.0.0" - } - }, - "@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "dev": true - }, - "@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "dev": true - }, - "@sinclair/typebox": { - "version": "0.24.51", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", - "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", - "dev": true - }, - "@sinonjs/commons": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", - "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", - "dev": true, - "requires": { - "type-detect": "4.0.8" - } - }, - "@sinonjs/fake-timers": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz", - "integrity": "sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==", - "dev": true, - "requires": { - "@sinonjs/commons": "^1.7.0" - } - }, - "@sinonjs/samsam": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.1.tgz", - "integrity": "sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA==", - "dev": true, - "requires": { - "@sinonjs/commons": "^1.6.0", - "lodash.get": "^4.4.2", - "type-detect": "^4.0.8" - } - }, - "@sinonjs/text-encoding": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", - "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", - "dev": true - }, - "@smithy/protocol-http": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-1.0.1.tgz", - "integrity": "sha512-9OrEn0WfOVtBNYJUjUAn9AOiJ4lzERCJJ/JeZs8E6yajTGxBaFRxUnNBHiNqoDJVg076hY36UmEnPx7xXrvUSg==", - "requires": { - "@smithy/types": "^1.0.0", - "tslib": "^2.5.0" - } - }, - "@smithy/types": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-1.0.0.tgz", - "integrity": "sha512-kc1m5wPBHQCTixwuaOh9vnak/iJm21DrSf9UK6yDE5S3mQQ4u11pqAUiKWnlrZnYkeLfAI9UEHj9OaMT1v5Umg==", - "requires": { - "tslib": "^2.5.0" - } - }, - "@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" - }, - "@tsconfig/node10": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true - }, - "@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true - }, - "@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true - }, - "@tsconfig/node16": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", - "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "dev": true - }, - "@types/adm-zip": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.0.tgz", - "integrity": "sha512-FCJBJq9ODsQZUNURo5ILAQueuA8WJhRvuihS3ke2iI25mJlfV2LK8jG2Qj2z2AWg8U0FtWWqBHVRetceLskSaw==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/amqplib": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@types/amqplib/-/amqplib-0.8.2.tgz", - "integrity": "sha512-p+TFLzo52f8UanB+Nq6gyUi65yecAcRY3nYowU6MPGFtaJvEDxcnFWrxssSTkF+ts1W3zyQDvgVICLQem5WxRA==", - "dev": true, - "requires": { - "@types/bluebird": "*", - "@types/node": "*" - } - }, - "@types/babel__core": { - "version": "7.1.18", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.18.tgz", - "integrity": "sha512-S7unDjm/C7z2A2R9NzfKCK1I+BAALDtxEmsJBwlB3EzNfb929ykjL++1CK9LO++EIp2fQrC8O+BwjKvz6UeDyQ==", - "dev": true, - "requires": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "@types/babel__generator": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", - "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", - "dev": true, - "requires": { - "@babel/types": "^7.0.0" - } - }, - "@types/babel__template": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", - "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", - "dev": true, - "requires": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "@types/babel__traverse": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.14.2.tgz", - "integrity": "sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA==", - "dev": true, - "requires": { - "@babel/types": "^7.3.0" - } - }, - "@types/bcryptjs": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.2.tgz", - "integrity": "sha512-LiMQ6EOPob/4yUL66SZzu6Yh77cbzJFYll+ZfaPiPPFswtIlA/Fs1MzdKYA7JApHU49zQTbJGX3PDmCpIdDBRQ==", - "dev": true - }, - "@types/bluebird": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.38.tgz", - "integrity": "sha512-yR/Kxc0dd4FfwtEoLZMoqJbM/VE/W7hXn/MIjb+axcwag0iFmSPK7OBUZq1YWLynJUoWQkfUrI7T0HDqGApNSg==", - "dev": true - }, - "@types/body-parser": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", - "requires": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "@types/bson": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.5.tgz", - "integrity": "sha512-vVLwMUqhYJSQ/WKcE60eFqcyuWse5fGH+NMAXHuKrUAPoryq3ATxk5o4bgYNtg5aOM4APVg7Hnb3ASqUYG0PKg==", - "requires": { - "@types/node": "*" - } - }, - "@types/busboy": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.0.tgz", - "integrity": "sha512-ncOOhwmyFDW76c/Tuvv9MA9VGYUCn8blzyWmzYELcNGDb0WXWLSmFi7hJq25YdRBYJrmMBB5jZZwUjlJe9HCjQ==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/chai": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.0.tgz", - "integrity": "sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==", - "dev": true - }, - "@types/clamscan": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/clamscan/-/clamscan-2.0.5.tgz", - "integrity": "sha512-bFqdscswqBia3yKEJZVVWELOVvWKHUR1dCmH4xshYwu0T9YSfZd35Q8Z9jYW0ygxqGlHjLXMb2/7C6CJITbDgg==", - "dev": true, - "requires": { - "@types/node": "*", - "axios": "^0.24.0" - }, - "dependencies": { - "axios": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", - "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", - "dev": true, - "requires": { - "follow-redirects": "^1.14.4" - } - } - } - }, - "@types/compression": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz", - "integrity": "sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==", - "requires": { - "@types/express": "*" - } - }, - "@types/config": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@types/config/-/config-3.3.3.tgz", - "integrity": "sha512-BB8DBAud88EgiAKlz8WQStzI771Kb6F3j4dioRJ4GD+tP4tzcZyMlz86aXuZT4s9hyesFORehMQE6eqtA5O+Vg==" - }, - "@types/connect": { - "version": "3.4.35", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", - "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", - "requires": { - "@types/node": "*" - } - }, - "@types/cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", - "dev": true - }, - "@types/cookiejar": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", - "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", - "dev": true - }, - "@types/cors": { - "version": "2.8.17", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", - "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", - "requires": { - "@types/node": "*" - } - }, - "@types/crypto-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.0.tgz", - "integrity": "sha512-DCFfy/vh2lG6qHSGezQ+Sn2Ulf/1Mx51dqOdmOKyW5nMK3maLlxeS3onC7r212OnBM2pBR95HkAmAjjF08YkxQ==", - "dev": true - }, - "@types/eslint": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", - "integrity": "sha512-GE44+DNEyxxh2Kc6ro/VkIj+9ma0pO0bwv9+uHSyBrikYOHr8zYcdPvnBOp1aw8s+CjRvuSx7CyWqRrNFQ59mA==", - "dev": true, - "requires": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "@types/eslint-scope": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.3.tgz", - "integrity": "sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g==", - "dev": true, - "requires": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "@types/eslint-visitor-keys": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", - "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", - "dev": true - }, - "@types/estree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", - "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", - "dev": true - }, - "@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "requires": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "@types/express-jwt": { - "version": "0.0.42", - "resolved": "https://registry.npmjs.org/@types/express-jwt/-/express-jwt-0.0.42.tgz", - "integrity": "sha512-WszgUddvM1t5dPpJ3LhWNH8kfNN8GPIBrAGxgIYXVCEGx6Bx4A036aAuf/r5WH9DIEdlmp7gHOYvSM6U87B0ag==", - "requires": { - "@types/express": "*", - "@types/express-unless": "*" - } - }, - "@types/express-serve-static-core": { - "version": "4.17.41", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz", - "integrity": "sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==", - "requires": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "@types/express-session": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.5.tgz", - "integrity": "sha512-l0DhkvNVfyUPEEis8fcwbd46VptfA/jmMwHfob2TfDMf3HyPLiB9mKD71LXhz5TMUobODXPD27zXSwtFQLHm+w==", - "dev": true, - "requires": { - "@types/express": "*" - } - }, - "@types/express-unless": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-0.5.3.tgz", - "integrity": "sha512-TyPLQaF6w8UlWdv4gj8i46B+INBVzURBNRahCozCSXfsK2VTlL1wNyTlMKw817VHygBtlcl5jfnPadlydr06Yw==", - "requires": { - "@types/express": "*" - } - }, - "@types/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", - "dev": true, - "requires": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, - "@types/gm": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@types/gm/-/gm-1.25.1.tgz", - "integrity": "sha512-WLqlPvjot5jxpt1AFxaWm0fgWZUBGXOPJC3ZrQgRpvpHYjwYbvr/4GwRzd0mXFfxzX+TrvXaow+/WbmWFHomlQ==", - "requires": { - "@types/node": "*" - } - }, - "@types/graceful-fs": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", - "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", - "dev": true - }, - "@types/istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", - "dev": true, - "requires": { - "@types/istanbul-lib-coverage": "*" - } - }, - "@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", - "dev": true, - "requires": { - "@types/istanbul-lib-report": "*" - } - }, - "@types/jest": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.2.1.tgz", - "integrity": "sha512-nKixEdnGDqFOZkMTF74avFNr3yRqB1ZJ6sRZv5/28D5x2oLN14KApv7F9mfDT/vUic0L3tRCsh3XWpWjtJisUQ==", - "dev": true, - "requires": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, - "@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" - }, - "@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true - }, - "@types/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-drE6uz7QBKq1fYqqoFKTDRdFCPHd5TCub75BM+D+cMx7NU9hUz7SESLfC2fSCXVFMO5Yj8sOWHuGqPgjc+fz0Q==", - "requires": { - "@types/node": "*" - } - }, - "@types/ldapjs": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-2.2.5.tgz", - "integrity": "sha512-Lv/nD6QDCmcT+V1vaTRnEKE8UgOilVv5pHcQuzkU1LcRe4mbHHuUo/KHi0LKrpdHhQY8FJzryF38fcVdeUIrzg==", - "requires": { - "@types/node": "*" - } - }, - "@types/lodash": { - "version": "4.14.196", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.196.tgz", - "integrity": "sha512-22y3o88f4a94mKljsZcanlNWPzO0uBsBdzLAngf2tp533LzZcQzb6+eZPJ+vCTt+bqF2XnvT9gejTLsAcJAJyQ==", - "dev": true - }, - "@types/mime": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", - "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" - }, - "@types/minimatch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", - "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", - "dev": true - }, - "@types/mongodb": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-4.0.7.tgz", - "integrity": "sha512-lPUYPpzA43baXqnd36cZ9xxorprybxXDzteVKCPAdp14ppHtFJHnXYvNpmBvtMUTb5fKXVv6sVbzo1LHkWhJlw==", - "dev": true, - "requires": { - "mongodb": "*" - } - }, - "@types/multer": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", - "integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==", - "requires": { - "@types/express": "*" - } - }, - "@types/node": { - "version": "16.18.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.11.tgz", - "integrity": "sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==" - }, - "@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", - "dev": true - }, - "@types/passport": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", - "integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==", - "dev": true, - "requires": { - "@types/express": "*" - } - }, - "@types/passport-jwt": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.6.tgz", - "integrity": "sha512-cmAAMIRTaEwpqxlrZyiEY9kdibk94gP5KTF8AT1Ra4rWNZYHNMreqhKUEeC5WJtuN5SJZjPQmV+XO2P5PlnvNQ==", - "dev": true, - "requires": { - "@types/express": "*", - "@types/jsonwebtoken": "*", - "@types/passport-strategy": "*" - } - }, - "@types/passport-local": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.34.tgz", - "integrity": "sha512-PSc07UdYx+jhadySxxIYWuv6sAnY5e+gesn/5lkPKfBeGuIYn9OPR+AAEDq73VRUh6NBTpvE/iPE62rzZUslog==", - "dev": true, - "requires": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-strategy": "*" - } - }, - "@types/passport-strategy": { - "version": "0.2.35", - "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", - "integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==", - "dev": true, - "requires": { - "@types/express": "*", - "@types/passport": "*" - } - }, - "@types/prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==", - "dev": true - }, - "@types/qs": { - "version": "6.9.10", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.10.tgz", - "integrity": "sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==" - }, - "@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" - }, - "@types/response-time": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@types/response-time/-/response-time-2.3.5.tgz", - "integrity": "sha512-4ANzp+I3K7sztFFAGPALWBvSl4ayaDSKzI2Bok+WNz+en2eB2Pvk6VCjR47PBXBWOkEg2r4uWpZOlXA5DNINOQ==", - "dev": true, - "requires": { - "@types/express": "*", - "@types/node": "*" - } - }, - "@types/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-F3OznnSLAUxFrCEu/L5PY8+ny8DtcFRjx7fZZ9bycvXRi3KPTRS9HOitGZwvPg0juRhXFWIeKX58cnX5YqLohQ==", - "dev": true, - "requires": { - "@types/glob": "*", - "@types/node": "*" - } - }, - "@types/sanitize-html": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.6.2.tgz", - "integrity": "sha512-7Lu2zMQnmHHQGKXVvCOhSziQMpa+R2hMHFefzbYoYMHeaXR0uXqNeOc3JeQQQ8/6Xa2Br/P1IQTLzV09xxAiUQ==", - "dev": true, - "requires": { - "htmlparser2": "^6.0.0" - } - }, - "@types/semver": { - "version": "7.3.13", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", - "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", - "dev": true - }, - "@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "requires": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "@types/serve-static": { - "version": "1.13.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", - "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", - "requires": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "@types/sinon": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.2.tgz", - "integrity": "sha512-BHn8Bpkapj8Wdfxvh2jWIUoaYB/9/XhsL0oOvBfRagJtKlSl9NWPcFOz2lRukI9szwGxFtYZCTejJSqsGDbdmw==", - "dev": true, - "requires": { - "@sinonjs/fake-timers": "^7.1.0" - } - }, - "@types/source-map-support": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/@types/source-map-support/-/source-map-support-0.5.4.tgz", - "integrity": "sha512-9zGujX1sOPg32XLyfgEB/0G9ZnrjthL/Iv1ZfuAjj8LEilHZEpQSQs1scpRXPhHzGYgWiLz9ldF1cI8JhL+yMw==", - "dev": true, - "requires": { - "source-map": "^0.6.0" - } - }, - "@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", - "dev": true - }, - "@types/superagent": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.16.tgz", - "integrity": "sha512-tLfnlJf6A5mB6ddqF159GqcDizfzbMUB1/DeT59/wBNqzRTNNKsaw79A/1TZ84X+f/EwWH8FeuSkjlCLyqS/zQ==", - "dev": true, - "requires": { - "@types/cookiejar": "*", - "@types/node": "*" - } - }, - "@types/supertest": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.12.tgz", - "integrity": "sha512-X3HPWTwXRerBZS7Mo1k6vMVR1Z6zmJcDVn5O/31whe0tnjE4te6ZJSJGq1RiqHPjzPdMTfjCFogDJmwng9xHaQ==", - "dev": true, - "requires": { - "@types/superagent": "*" - } - }, - "@types/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-dDZH/tXzwjutnuk4UacGgFRwV+JSLaXL1ikvidfJprkb7L9Nx1njcRHHmi3Dsvt7pgqqTEeucQuOrWHPFgzVHA==", - "dev": true - }, - "@types/tough-cookie": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-2.3.8.tgz", - "integrity": "sha512-7axfYN8SW9pWg78NgenHasSproWQee5rzyPVLC9HpaQSDgNArsnKJD88EaMfi4Pl48AyciO3agYCFqpHS1gLpg==" - }, - "@types/uuid": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", - "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==", - "dev": true - }, - "@types/validator": { - "version": "13.7.14", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz", - "integrity": "sha512-J6OAed6rhN6zyqL9Of6ZMamhlsOEU/poBVvbHr/dKOYKTeuYYMlDkMv+b6UUV0o2i0tw73cgyv/97WTWaUl0/g==" - }, - "@types/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==" - }, - "@types/whatwg-url": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", - "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", - "requires": { - "@types/node": "*", - "@types/webidl-conversions": "*" - } - }, - "@types/ws": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.2.2.tgz", - "integrity": "sha512-NOn5eIcgWLOo6qW8AcuLZ7G8PycXu0xTxxkS6Q18VWFxgPUSOwV0pBj2a/4viNZVu25i7RIB7GttdkAIUUXOOg==", - "optional": true, - "peer": true, - "requires": { - "@types/node": "*" - } - }, - "@types/xml2js": { - "version": "0.4.11", - "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.11.tgz", - "integrity": "sha512-JdigeAKmCyoJUiQljjr7tQG3if9NkqGUgwEUqBvV0N7LM4HyQk7UXCnusRa1lnvXAEYJ8mw8GtZWioagNztOwA==", - "requires": { - "@types/node": "*" - } - }, - "@types/yargs": { - "version": "17.0.13", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.13.tgz", - "integrity": "sha512-9sWaruZk2JGxIQU+IhI1fhPYRcQ0UuTNuKuCW9bR5fp7qi2Llf7WDzNa17Cy7TKnh3cdxDOiyTu6gaLS0eDatg==", - "dev": true, - "requires": { - "@types/yargs-parser": "*" - } - }, - "@types/yargs-parser": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", - "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", - "dev": true - }, - "@typescript-eslint/eslint-plugin": { - "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.47.1.tgz", - "integrity": "sha512-r4RZ2Jl9kcQN7K/dcOT+J7NAimbiis4sSM9spvWimsBvDegMhKLA5vri2jG19PmIPbDjPeWzfUPQ2hjEzA4Nmg==", - "dev": true, - "requires": { - "@typescript-eslint/scope-manager": "5.47.1", - "@typescript-eslint/type-utils": "5.47.1", - "@typescript-eslint/utils": "5.47.1", - "debug": "^4.3.4", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "regexpp": "^3.2.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - } - } - }, - "@typescript-eslint/experimental-utils": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.10.1.tgz", - "integrity": "sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw==", - "dev": true, - "requires": { - "@types/json-schema": "^7.0.3", - "@typescript-eslint/types": "3.10.1", - "@typescript-eslint/typescript-estree": "3.10.1", - "eslint-scope": "^5.0.0", - "eslint-utils": "^2.0.0" - }, - "dependencies": { - "@typescript-eslint/types": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz", - "integrity": "sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ==", - "dev": true - }, - "@typescript-eslint/typescript-estree": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz", - "integrity": "sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w==", - "dev": true, - "requires": { - "@typescript-eslint/types": "3.10.1", - "@typescript-eslint/visitor-keys": "3.10.1", - "debug": "^4.1.1", - "glob": "^7.1.6", - "is-glob": "^4.0.1", - "lodash": "^4.17.15", - "semver": "^7.3.2", - "tsutils": "^3.17.1" - } - }, - "@typescript-eslint/visitor-keys": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz", - "integrity": "sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^1.1.0" - } - }, - "eslint-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^1.1.0" - } - }, - "eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true - } - } - }, - "@typescript-eslint/parser": { - "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.47.1.tgz", - "integrity": "sha512-9Vb+KIv29r6GPu4EboWOnQM7T+UjpjXvjCPhNORlgm40a9Ia9bvaPJswvtae1gip2QEeVeGh6YquqAzEgoRAlw==", - "dev": true, - "requires": { - "@typescript-eslint/scope-manager": "5.47.1", - "@typescript-eslint/types": "5.47.1", - "@typescript-eslint/typescript-estree": "5.47.1", - "debug": "^4.3.4" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - } - } - }, - "@typescript-eslint/scope-manager": { - "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.47.1.tgz", - "integrity": "sha512-9hsFDsgUwrdOoW1D97Ewog7DYSHaq4WKuNs0LHF9RiCmqB0Z+XRR4Pf7u7u9z/8CciHuJ6yxNws1XznI3ddjEw==", - "dev": true, - "requires": { - "@typescript-eslint/types": "5.47.1", - "@typescript-eslint/visitor-keys": "5.47.1" - } - }, - "@typescript-eslint/type-utils": { - "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.47.1.tgz", - "integrity": "sha512-/UKOeo8ee80A7/GJA427oIrBi/Gd4osk/3auBUg4Rn9EahFpevVV1mUK8hjyQD5lHPqX397x6CwOk5WGh1E/1w==", - "dev": true, - "requires": { - "@typescript-eslint/typescript-estree": "5.47.1", - "@typescript-eslint/utils": "5.47.1", - "debug": "^4.3.4", - "tsutils": "^3.21.0" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - } - } - }, - "@typescript-eslint/types": { - "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.47.1.tgz", - "integrity": "sha512-CmALY9YWXEpwuu6377ybJBZdtSAnzXLSQcxLSqSQSbC7VfpMu/HLVdrnVJj7ycI138EHqocW02LPJErE35cE9A==", - "dev": true - }, - "@typescript-eslint/typescript-estree": { - "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.47.1.tgz", - "integrity": "sha512-4+ZhFSuISAvRi2xUszEj0xXbNTHceV9GbH9S8oAD2a/F9SW57aJNQVOCxG8GPfSWH/X4eOPdMEU2jYVuWKEpWA==", - "dev": true, - "requires": { - "@typescript-eslint/types": "5.47.1", - "@typescript-eslint/visitor-keys": "5.47.1", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "requires": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - } - } - } - }, - "@typescript-eslint/utils": { - "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.47.1.tgz", - "integrity": "sha512-l90SdwqfmkuIVaREZ2ykEfCezepCLxzWMo5gVfcJsJCaT4jHT+QjgSkYhs5BMQmWqE9k3AtIfk4g211z/sTMVw==", - "dev": true, - "requires": { - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.47.1", - "@typescript-eslint/types": "5.47.1", - "@typescript-eslint/typescript-estree": "5.47.1", - "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0", - "semver": "^7.3.7" - } - }, - "@typescript-eslint/visitor-keys": { - "version": "5.47.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.47.1.tgz", - "integrity": "sha512-rF3pmut2JCCjh6BLRhNKdYjULMb1brvoaiWDlHfLNVgmnZ0sBVJrs3SyaKE1XoDDnJuAx/hDQryHYmPUuNq0ig==", - "dev": true, - "requires": { - "@typescript-eslint/types": "5.47.1", - "eslint-visitor-keys": "^3.3.0" - } - }, - "@ungap/promise-all-settled": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", - "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", - "dev": true - }, - "@webassemblyjs/ast": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", - "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", - "dev": true, - "requires": { - "@webassemblyjs/helper-numbers": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6" - } - }, - "@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", - "dev": true - }, - "@webassemblyjs/helper-api-error": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", - "dev": true - }, - "@webassemblyjs/helper-buffer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", - "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", - "dev": true - }, - "@webassemblyjs/helper-numbers": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", - "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", - "dev": true, - "requires": { - "@webassemblyjs/floating-point-hex-parser": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", - "@xtuc/long": "4.2.2" - } - }, - "@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", - "dev": true - }, - "@webassemblyjs/helper-wasm-section": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", - "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6" - } - }, - "@webassemblyjs/ieee754": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", - "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", - "dev": true, - "requires": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "@webassemblyjs/leb128": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", - "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", - "dev": true, - "requires": { - "@xtuc/long": "4.2.2" - } - }, - "@webassemblyjs/utf8": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", - "dev": true - }, - "@webassemblyjs/wasm-edit": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", - "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-opt": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6", - "@webassemblyjs/wast-printer": "1.11.6" - } - }, - "@webassemblyjs/wasm-gen": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", - "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" - } - }, - "@webassemblyjs/wasm-opt": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", - "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6" - } - }, - "@webassemblyjs/wasm-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", - "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" - } - }, - "@webassemblyjs/wast-printer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", - "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", - "dev": true, - "requires": { - "@webassemblyjs/ast": "1.11.6", - "@xtuc/long": "4.2.2" - } - }, - "@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true - }, - "@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true - }, - "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 - }, - "accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "requires": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - } - }, - "acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true - }, - "acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} - }, - "acorn-loose": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.3.0.tgz", - "integrity": "sha512-75lAs9H19ldmW+fAbyqHdjgdCrz0pWGXKmnqFoh8PyVd1L2RIb4RzYrSjmopeqv3E1G3/Pimu6GgLlrGbrkF7w==", - "requires": { - "acorn": "^8.5.0" - }, - "dependencies": { - "acorn": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz", - "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==" - } - } - }, - "adm-zip": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.9.tgz", - "integrity": "sha512-s+3fXLkeeLjZ2kLjCBwQufpI5fuN+kIGBxu6530nVQZGVol0d7Y/M88/xw9HGGUcJjKf8LutN3VPRUBq6N7Ajg==" - }, - "agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "requires": { - "debug": "4" - } - }, - "aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "requires": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - } - }, - "ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "requires": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - } - }, - "ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "requires": { - "ajv": "^8.0.0" - } - }, - "ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "requires": { - "fast-deep-equal": "^3.1.3" - } - }, - "amqp-connection-manager": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/amqp-connection-manager/-/amqp-connection-manager-3.9.0.tgz", - "integrity": "sha512-ZKw9ckJKz40Lc2pC7DY0NVocpzPalMaCgv0sBn+N4er2QFAJul9pIiMOm/FsPHeCzB+FulV7PckOpmZvWvewGQ==", - "requires": { - "promise-breaker": "^5.0.0" - } - }, - "amqplib": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.8.0.tgz", - "integrity": "sha512-icU+a4kkq4Y1PS4NNi+YPDMwdlbFcZ1EZTQT2nigW3fvOb6AOgUQ9+Mk4ue0Zu5cBg/XpDzB40oH10ysrk2dmA==", - "requires": { - "bitsyntax": "~0.1.0", - "bluebird": "^3.7.2", - "buffer-more-ints": "~1.0.0", - "readable-stream": "1.x >=1.1.9", - "safe-buffer": "~5.2.1", - "url-parse": "~1.5.1" - } - }, - "ansi": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.1.tgz", - "integrity": "sha1-DELU+xcWDVqa8eSEus4cZpIsGyE=", - "optional": true, - "peer": true - }, - "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==", - "dev": true - }, - "ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "requires": { - "type-fest": "^0.21.3" - } - }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true - }, - "any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true - }, - "anymatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", - "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "dev": true, - "requires": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - } - }, - "append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" - }, - "append-transform": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", - "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", - "dev": true, - "requires": { - "default-require-extensions": "^3.0.0" - } - }, - "archy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", - "dev": true - }, - "are-we-there-yet": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.0.6.tgz", - "integrity": "sha1-otKMkxAqpsyWJFomy5VN4G7FPww=", - "optional": true, - "peer": true, - "requires": { - "delegates": "^1.0.0", - "readable-stream": "^2.0.0 || ^1.1.13" - } - }, - "arg": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", - "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==" - }, - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "requires": { - "sprintf-js": "~1.0.2" - } - }, - "args": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/args/-/args-5.0.1.tgz", - "integrity": "sha512-1kqmFCFsPffavQFGt8OxJdIcETti99kySRUPMpOhaGjL6mRJn8HFU1OxKY5bMqfZKUwTQc1mZkAjmGYaVOHFtQ==", - "requires": { - "camelcase": "5.0.0", - "chalk": "2.4.2", - "leven": "2.1.0", - "mri": "1.1.4" - }, - "dependencies": { - "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==", - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "aria-query": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.10.2", - "@babel/runtime-corejs3": "^7.10.2" - } - }, - "array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", - "requires": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" - } - }, - "array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" - }, - "array-includes": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", - "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "is-string": "^1.0.7" - } - }, - "array-parallel": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/array-parallel/-/array-parallel-0.1.3.tgz", - "integrity": "sha512-TDPTwSWW5E4oiFiKmz6RGJ/a80Y91GuLgUYuLd49+XBS75tYo8PNgaT2K/OxuQYqkoI852MDGBorg9OcUSTQ8w==" - }, - "array-series": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/array-series/-/array-series-0.1.5.tgz", - "integrity": "sha512-L0XlBwfx9QetHOsbLDrE/vh2t018w9462HM3iaFfxRiK83aJjAt/Ja3NMkOW7FICwWTlQBa3ZbL5FKhuQWkDrg==" - }, - "array-timsort": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", - "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", - "dev": true - }, - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==" - }, - "array.prototype.findlastindex": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", - "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.2.1" - } - }, - "array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - } - }, - "array.prototype.flatmap": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", - "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - } - }, - "arraybuffer.prototype.slice": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", - "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", - "requires": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", - "is-shared-array-buffer": "^1.0.2" - } - }, - "asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true - }, - "asn1": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", - "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" - }, - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" - }, - "assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true - }, - "ast-types-flow": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", - "integrity": "sha1-9wtzXGvKGlycItmCw+Oef+ujva0=", - "dev": true - }, - "astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true - }, - "async": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" - }, - "async-lock": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.0.tgz", - "integrity": "sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==" - }, - "async-mutex": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz", - "integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==", - "requires": { - "tslib": "^2.4.0" - } - }, - "asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" - }, - "available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==" - }, - "aws-crt": { - "version": "1.10.6", - "resolved": "https://registry.npmjs.org/aws-crt/-/aws-crt-1.10.6.tgz", - "integrity": "sha512-LCOFwFdUk3NJNUkm0YuiqU2jPtnC5OZYeLqIZyqVzDoSpK2wL7QAaA553v4YPA5xs3QU7jCmaDl6y3LjJTm4+w==", - "optional": true, - "peer": true, - "requires": { - "@httptoolkit/websocket-stream": "^6.0.0", - "axios": "^0.24.0", - "cmake-js": "6.3.0", - "crypto-js": "^4.0.0", - "fastestsmallesttextencoderdecoder": "^1.0.22", - "mqtt": "^4.3.4", - "tar": "^6.1.11", - "ws": "^7.5.5" - }, - "dependencies": { - "axios": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", - "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", - "optional": true, - "peer": true, - "requires": { - "follow-redirects": "^1.14.4" - } - } - } - }, - "aws-sdk": { - "version": "2.1375.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1375.0.tgz", - "integrity": "sha512-4JusqLa0+TJ4a2rfxuiPiaEHZVxVWDzREN8rAI4zhL+u4QbqGq95yfMh9v5QtSDkdNCAReA5DSSVXPOHbS80pA==", - "requires": { - "buffer": "4.9.2", - "events": "1.1.1", - "ieee754": "1.1.13", - "jmespath": "0.16.0", - "querystring": "0.2.0", - "sax": "1.2.1", - "url": "0.10.3", - "util": "^0.12.4", - "uuid": "8.0.0", - "xml2js": "0.5.0" - }, - "dependencies": { - "events": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", - "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" - }, - "uuid": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", - "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==" - }, - "xml2js": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", - "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", - "requires": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - } - } - } - }, - "aws-sdk-client-mock": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/aws-sdk-client-mock/-/aws-sdk-client-mock-0.5.6.tgz", - "integrity": "sha512-67C+6vlSMPhVGaDlUak3XVR/qvah4ENMMJMl+aWVnK42pBznQQuNvYzlg+OBeW+aBa6kOVDSAYstIqNfInIB/A==", - "dev": true, - "requires": { - "@types/sinon": "10.0.2", - "sinon": "^11.1.1", - "tslib": "^2.1.0" - } - }, - "aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" - }, - "aws4": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", - "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" - }, - "axe-core": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.6.1.tgz", - "integrity": "sha512-lCZN5XRuOnpG4bpMq8v0khrWtUOn+i8lZSb6wHZH56ZfbIEv6XwJV84AAueh9/zi7qPVJ/E4yz6fmsiyOmXR4w==", - "dev": true - }, - "axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", - "requires": { - "follow-redirects": "^1.15.0", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "axios-mock-adapter": { - "version": "1.21.2", - "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.21.2.tgz", - "integrity": "sha512-jzyNxU3JzB2XVhplZboUcF0YDs7xuExzoRSHXPHr+UQajaGmcTqvkkUADgkVI2WkGlpZ1zZlMVdcTMU0ejV8zQ==", - "requires": { - "fast-deep-equal": "^3.1.3", - "is-buffer": "^2.0.5" - } - }, - "axobject-query": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", - "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==", - "dev": true - }, - "babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - } - }, - "babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", - "dev": true, - "requires": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" - } - }, - "backoff": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", - "integrity": "sha1-9hbtqdPktmuMp/ynn2lXIsX44m8=", - "requires": { - "precond": "0.2" - } - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" - }, - "bbb-promise": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/bbb-promise/-/bbb-promise-1.2.0.tgz", - "integrity": "sha512-Ox+L3yDXiRk+Our/cEKndKkGEIJbWxa7cvgx5rWLZy9qrz33xd9nFTQnD4+C6KM+AFAhwWpLJ3iM8xDX8hAyxQ==", - "requires": { - "build-url": "^1.0.9", - "request": "^2.81.0", - "request-promise": "^4.2.0", - "sha1": "^1.1.1", - "xml2js-es6-promise": "^1.1.1" - } - }, - "bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "requires": { - "tweetnacl": "^0.14.3" - } - }, - "bcryptjs": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" - }, - "big-integer": { - "version": "1.6.51", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", - "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==", - "optional": true, - "peer": true - }, - "binary": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", - "integrity": "sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=", - "optional": true, - "peer": true, - "requires": { - "buffers": "~0.1.1", - "chainsaw": "~0.1.0" - } - }, - "binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true - }, - "bintrees": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz", - "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=" - }, - "bitsyntax": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/bitsyntax/-/bitsyntax-0.1.0.tgz", - "integrity": "sha512-ikAdCnrloKmFOugAfxWws89/fPc+nw0OOG1IzIE72uSOg/A3cYptKCjSUhDTuj7fhsJtzkzlv7l3b8PzRHLN0Q==", - "requires": { - "buffer-more-ints": "~1.0.0", - "debug": "~2.6.9", - "safe-buffer": "~5.1.2" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - } - } - }, - "bl": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", - "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", - "requires": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - }, - "dependencies": { - "readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" - }, - "body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "requires": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - }, - "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, - "on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "requires": { - "ee-first": "1.1.1" - } - }, - "raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "requires": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - } - }, - "statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" - } - } - }, - "boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" - }, - "bowser": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", - "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "requires": { - "fill-range": "^7.0.1" - } - }, - "browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, - "browserslist": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.19.1.tgz", - "integrity": "sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A==", - "dev": true, - "requires": { - "caniuse-lite": "^1.0.30001286", - "electron-to-chromium": "^1.4.17", - "escalade": "^3.1.1", - "node-releases": "^2.0.1", - "picocolors": "^1.0.0" - } - }, - "bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "requires": { - "fast-json-stable-stringify": "2.x" - } - }, - "bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "requires": { - "node-int64": "^0.4.0" - } - }, - "bson": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.0.tgz", - "integrity": "sha512-VrlEE4vuiO1WTpfof4VmaVolCVYkYTgB9iWgYNOrVlnifpME/06fhFRmONgBhClD5pFC1t9ZWqFUQEQAzY43bA==", - "requires": { - "buffer": "^5.6.0" - }, - "dependencies": { - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - } - } - }, - "buffer": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", - "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", - "requires": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - } - }, - "buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" - }, - "buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" - }, - "buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, - "buffer-indexof-polyfill": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", - "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", - "optional": true, - "peer": true - }, - "buffer-more-ints": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz", - "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==" - }, - "buffer-shims": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz", - "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E=", - "optional": true, - "peer": true - }, - "buffers": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", - "integrity": "sha1-skV5w77U1tOWru5tmorn9Ugqt7s=", - "optional": true, - "peer": true - }, - "build-url": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/build-url/-/build-url-1.3.3.tgz", - "integrity": "sha512-uSC8d+d4SlbXTu/9nBhwEKi33CE0KQgCvfy8QwyrrO5vCuXr9hN021ZBh8ip5vxPbMOrZiPwgqcupuhezxiP3g==" - }, - "bundle-require": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-3.1.2.tgz", - "integrity": "sha512-Of6l6JBAxiyQ5axFxUM6dYeP/W7X2Sozeo/4EYB9sJhL+dqL7TKjg+shwxp6jlu/6ZSERfsYtIpSJ1/x3XkAEA==", - "dev": true, - "requires": { - "load-tsconfig": "^0.2.0" - } - }, - "bunyan": { - "version": "1.8.15", - "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz", - "integrity": "sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==", - "requires": { - "dtrace-provider": "~0.8", - "moment": "^2.19.3", - "mv": "~2", - "safe-json-stringify": "~1" - } - }, - "busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "requires": { - "streamsearch": "^1.1.0" - } - }, - "byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", - "integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==" - }, - "bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" - }, - "cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true - }, - "cache-manager": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.3.1.tgz", - "integrity": "sha512-9HP6nc1ZqyZgcVEpy5XS2ns9MYE6cPEM6InA1wQhR6M7GviJzLH2NTFYnf3NEfRmLE351NCSkDo2VISX8dlG+w==", - "requires": { - "lodash.clonedeep": "^4.5.0", - "lru-cache": "^10.0.2", - "promise-coalesce": "^1.1.1" - }, - "dependencies": { - "lru-cache": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", - "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==" - } - } - }, - "cache-manager-redis-yet": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/cache-manager-redis-yet/-/cache-manager-redis-yet-4.1.2.tgz", - "integrity": "sha512-pM2K1ZlOv8gQpE1Z5mcDrfLj5CsNKVRiYua/SZ12j7LEDgfDeFVntI6JSgIw0siFSR/9P/FpG30scI3frHwibA==", - "requires": { - "@redis/bloom": "^1.2.0", - "@redis/client": "^1.5.8", - "@redis/graph": "^1.1.0", - "@redis/json": "^1.0.4", - "@redis/search": "^1.1.3", - "@redis/time-series": "^1.0.4", - "cache-manager": "^5.2.2", - "redis": "^4.6.7" - } - }, - "caching-transform": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", - "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", - "dev": true, - "requires": { - "hasha": "^5.0.0", - "make-dir": "^3.0.0", - "package-hash": "^4.0.0", - "write-file-atomic": "^3.0.0" - } - }, - "call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", - "requires": { - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" - } - }, - "call-me-maybe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", - "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms=" - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "camelcase": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz", - "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==" - }, - "camelize-ts": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/camelize-ts/-/camelize-ts-2.5.0.tgz", - "integrity": "sha512-ERaOJadw+ID9MuKGeTOF1kQOb/zZIv6Vkt44kFYZraiAFiZU6E3TwnJebp8jofsW/hDxME/U63lFdNKIkSqijw==" - }, - "caniuse-lite": { - "version": "1.0.30001309", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001309.tgz", - "integrity": "sha512-Pl8vfigmBXXq+/yUz1jUwULeq9xhMJznzdc/xwl4WclDAuebcTHVefpz8lE/bMI+UN7TOkSSe7B7RnZd6+dzjA==", - "dev": true - }, - "caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" - }, - "chai": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz", - "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==", - "dev": true, - "requires": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.2", - "deep-eql": "^3.0.1", - "get-func-name": "^2.0.0", - "loupe": "^2.3.1", - "pathval": "^1.1.1", - "type-detect": "^4.0.5" - } - }, - "chai-as-promised": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", - "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", - "dev": true, - "requires": { - "check-error": "^1.0.2" - } - }, - "chai-http": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/chai-http/-/chai-http-4.3.0.tgz", - "integrity": "sha512-zFTxlN7HLMv+7+SPXZdkd5wUlK+KxH6Q7bIEMiEx0FK3zuuMqL7cwICAQ0V1+yYRozBburYuxN1qZstgHpFZQg==", - "dev": true, - "requires": { - "@types/chai": "4", - "@types/superagent": "^3.8.3", - "cookiejar": "^2.1.1", - "is-ip": "^2.0.0", - "methods": "^1.1.2", - "qs": "^6.5.1", - "superagent": "^3.7.0" - }, - "dependencies": { - "@types/superagent": { - "version": "3.8.7", - "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-3.8.7.tgz", - "integrity": "sha512-9KhCkyXv268A2nZ1Wvu7rQWM+BmdYUVkycFeNnYrUL5Zwu7o8wPQ3wBfW59dDP+wuoxw0ww8YKgTNv8j/cgscA==", - "dev": true, - "requires": { - "@types/cookiejar": "*", - "@types/node": "*" - } - } - } - }, - "chainsaw": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", - "integrity": "sha1-XqtQsor+WAdNDVgpE4iCi15fvJg=", - "optional": true, - "peer": true, - "requires": { - "traverse": ">=0.3.0 <0.4" - }, - "dependencies": { - "traverse": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", - "integrity": "sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=", - "optional": true, - "peer": true - } - } - }, - "chalk": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.0.tgz", - "integrity": "sha512-/duVOqst+luxCQRKEo4bNxinsOQtMP80ZYm7mMqzuh5PociNL0PvmHFvREJ9ueYL2TxlHjBcmLCdmocx9Vg+IQ==" - }, - "char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true - }, - "chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true - }, - "charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" - }, - "check-error": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", - "dev": true - }, - "cheerio": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", - "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", - "requires": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "htmlparser2": "^8.0.1", - "parse5": "^7.0.0", - "parse5-htmlparser2-tree-adapter": "^7.0.0" - }, - "dependencies": { - "dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "requires": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - } - }, - "domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "requires": { - "domelementtype": "^2.3.0" - } - }, - "domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", - "requires": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - } - }, - "entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" - }, - "htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", - "requires": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" - } - } - } - }, - "cheerio-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "requires": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "dependencies": { - "dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "requires": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - } - }, - "domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "requires": { - "domelementtype": "^2.3.0" - } - }, - "domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", - "requires": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - } - }, - "entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" - } - } - }, - "chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "requires": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "fsevents": "~2.3.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - } - }, - "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "optional": true, - "peer": true - }, - "chrome-trace-event": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", - "dev": true - }, - "ci-info": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.3.0.tgz", - "integrity": "sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==", - "dev": true - }, - "cjs-module-lexer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", - "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", - "dev": true - }, - "clamscan": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.1.2.tgz", - "integrity": "sha512-pcovgLHcrg3l/mI51Kuk0kN++07pSZdBTskISw0UFvsm8UXda8oNCm0eLeODxFg85Mz+k+TtSS9+XPlriJ8/Fg==" - }, - "class-transformer": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz", - "integrity": "sha512-ETWD/H2TbWbKEi7m9N4Km5+cw1hNcqJSxlSYhsLsNjQzWWiZIYA1zafxpK9PwVfaZ6AqR5rrjPVUBGESm5tQUA==" - }, - "class-validator": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.0.tgz", - "integrity": "sha512-ct3ltplN8I9fOwUd8GrP8UQixwff129BkEtuWDKL5W45cQuLd19xqmTLu5ge78YDm/fdje6FMt0hGOhl0lii3A==", - "requires": { - "@types/validator": "^13.7.10", - "libphonenumber-js": "^1.10.14", - "validator": "^13.7.0" - } - }, - "clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true - }, - "cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "requires": { - "restore-cursor": "^3.1.0" - } - }, - "cli-spinners": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz", - "integrity": "sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==", - "dev": true - }, - "cli-table3": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", - "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==", - "dev": true, - "requires": { - "@colors/colors": "1.5.0", - "string-width": "^4.2.0" - } - }, - "cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", - "dev": true - }, - "client-oauth2": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/client-oauth2/-/client-oauth2-4.3.3.tgz", - "integrity": "sha512-k8AvUYJon0vv75ufoVo4nALYb/qwFFicO3I0+39C6xEdflqVtr+f9cy+0ZxAduoVSTfhP5DX2tY2XICAd5hy6Q==", - "requires": { - "popsicle": "^12.0.5", - "safe-buffer": "^5.2.0" - } - }, - "cliui": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", - "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", - "requires": { - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1", - "wrap-ansi": "^2.0.0" - }, - "dependencies": { - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "requires": { - "number-is-nan": "^1.0.0" - } - }, - "string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - } - }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "wrap-ansi": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", - "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", - "requires": { - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1" - } - } - } - }, - "cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" - }, - "cmake-js": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-6.3.0.tgz", - "integrity": "sha512-1uqTOmFt6BIqKlrX+39/aewU/JVhyZWDqwAL+6psToUwxj3yWPJiwxiZFmV0XdcoWmqGs7peZTxTbJtAcH8hxw==", - "optional": true, - "peer": true, - "requires": { - "axios": "^0.21.1", - "debug": "^4", - "fs-extra": "^5.0.0", - "is-iojs": "^1.0.1", - "lodash": "^4", - "memory-stream": "0", - "npmlog": "^1.2.0", - "rc": "^1.2.7", - "semver": "^5.0.3", - "splitargs": "0", - "tar": "^4", - "unzipper": "^0.8.13", - "url-join": "0", - "which": "^1.0.9", - "yargs": "^3.6.0" - }, - "dependencies": { - "axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", - "optional": true, - "peer": true, - "requires": { - "follow-redirects": "^1.14.0" - } - }, - "chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "optional": true, - "peer": true - }, - "fs-extra": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-5.0.0.tgz", - "integrity": "sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ==", - "optional": true, - "peer": true, - "requires": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } - }, - "fs-minipass": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", - "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", - "optional": true, - "peer": true, - "requires": { - "minipass": "^2.6.0" - } - }, - "jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", - "optional": true, - "peer": true, - "requires": { - "graceful-fs": "^4.1.6" - } - }, - "minipass": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", - "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", - "optional": true, - "peer": true, - "requires": { - "safe-buffer": "^5.1.2", - "yallist": "^3.0.0" - } - }, - "minizlib": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", - "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", - "optional": true, - "peer": true, - "requires": { - "minipass": "^2.9.0" - } - }, - "mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "optional": true, - "peer": true, - "requires": { - "minimist": "^1.2.5" - } - }, - "semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "optional": true, - "peer": true - }, - "tar": { - "version": "4.4.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz", - "integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==", - "optional": true, - "peer": true, - "requires": { - "chownr": "^1.1.4", - "fs-minipass": "^1.2.7", - "minipass": "^2.9.0", - "minizlib": "^1.3.3", - "mkdirp": "^0.5.5", - "safe-buffer": "^5.2.1", - "yallist": "^3.1.1" - } - }, - "universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "optional": true, - "peer": true - }, - "yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "optional": true, - "peer": true - } - } - }, - "co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", - "dev": true - }, - "code-point-at": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" - }, - "collect-v8-coverage": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", - "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==", - "dev": true - }, - "color": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", - "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", - "requires": { - "color-convert": "^1.9.3", - "color-string": "^1.6.0" - } - }, - "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==", - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" - }, - "color-string": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.0.tgz", - "integrity": "sha512-9Mrz2AQLefkH1UvASKj6v6hj/7eWgjnT/cVsR8CumieLoT+g900exWeNogqtweI8dxloXN9BDQTYro1oWu/5CQ==", - "requires": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" - }, - "colorspace": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", - "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", - "requires": { - "color": "^3.1.3", - "text-hex": "1.0.x" - } - }, - "combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "requires": { - "delayed-stream": "~1.0.0" - } - }, - "commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==" - }, - "comment-json": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.3.tgz", - "integrity": "sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw==", - "dev": true, - "requires": { - "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", - "esprima": "^4.0.1", - "has-own-prop": "^2.0.0", - "repeat-string": "^1.6.1" - } - }, - "commist": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/commist/-/commist-1.1.0.tgz", - "integrity": "sha512-rraC8NXWOEjhADbZe9QBNzLAN5Q3fsTPQtBV+fEVj6xKIgDgNiEVE6ZNfHpZOqfQ21YUzfVNUXLOEZquYvQPPg==", - "optional": true, - "peer": true, - "requires": { - "leven": "^2.1.0", - "minimist": "^1.1.0" - } - }, - "common-tags": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", - "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", - "dev": true - }, - "commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", - "dev": true - }, - "component-emitter": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", - "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", - "dev": true - }, - "compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "requires": { - "mime-db": ">= 1.43.0 < 2" - } - }, - "compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", - "requires": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", - "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", - "vary": "~1.1.2" - }, - "dependencies": { - "bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - } - } - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" - }, - "concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "optional": true, - "peer": true, - "requires": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - }, - "dependencies": { - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "optional": true, - "peer": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "optional": true, - "peer": true, - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "concurrently": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-6.5.1.tgz", - "integrity": "sha512-FlSwNpGjWQfRwPLXvJ/OgysbBxPkWpiVjy1042b0U7on7S7qwwMIILRj7WTN1mTgqa582bG6NFuScOoh6Zgdag==", - "requires": { - "chalk": "^4.1.0", - "date-fns": "^2.16.1", - "lodash": "^4.17.21", - "rxjs": "^6.6.3", - "spawn-command": "^0.0.2-1", - "supports-color": "^8.1.0", - "tree-kill": "^1.2.2", - "yargs": "^16.2.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "rxjs": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", - "requires": { - "tslib": "^1.9.0" - } - }, - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" - }, - "yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - } - } - } - }, - "config": { - "version": "3.3.9", - "resolved": "https://registry.npmjs.org/config/-/config-3.3.9.tgz", - "integrity": "sha512-G17nfe+cY7kR0wVpc49NCYvNtelm/pPy8czHoFkAgtV1lkmcp7DHtWCdDu+C9Z7gb2WVqa9Tm3uF9aKaPbCfhg==", - "requires": { - "json5": "^2.2.3" - } - }, - "confusing-browser-globals": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", - "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", - "dev": true - }, - "connect-redis": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-7.1.0.tgz", - "integrity": "sha512-UaqO1EirWjON2ENsyau7N5lbkrdYBpS6mYlXSeff/OYXsd6EGZ+SXSmNPoljL2PSua8fgjAEaldSA73PMZQ9Eg==", - "requires": {} - }, - "consola": { - "version": "2.15.3", - "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", - "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==" - }, - "content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "requires": { - "safe-buffer": "5.2.1" - } - }, - "content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" - }, - "convert-source-map": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", - "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.1" - }, - "dependencies": { - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - } - } - }, - "cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" - }, - "cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" - }, - "cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true - }, - "copyfiles": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", - "integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==", - "dev": true, - "requires": { - "glob": "^7.0.5", - "minimatch": "^3.0.3", - "mkdirp": "^1.0.4", - "noms": "0.0.0", - "through2": "^2.0.1", - "untildify": "^4.0.0", - "yargs": "^16.1.0" - }, - "dependencies": { - "cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true - }, - "yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - } - } - } - }, - "core-js-pure": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.21.0.tgz", - "integrity": "sha512-VaJUunCZLnxuDbo1rNOzwbet9E1K9joiXS5+DQMPtgxd24wfsZbJZMMfQLGYMlCUvSxLfsRUUhoOR2x28mFfeg==", - "dev": true - }, - "core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" - }, - "cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "requires": { - "object-assign": "^4", - "vary": "^1" - } - }, - "cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "dev": true, - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - } - }, - "create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true - }, - "cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "requires": { - "cross-spawn": "^7.0.1" - } - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "dependencies": { - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=" - }, - "crypto-js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" - }, - "css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", - "requires": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "dependencies": { - "dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "requires": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - } - }, - "domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "requires": { - "domelementtype": "^2.3.0" - } - }, - "domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", - "requires": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - } - }, - "entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" - } - } - }, - "css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" - }, - "daemon": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/daemon/-/daemon-1.1.0.tgz", - "integrity": "sha1-bFECyB2wvoVvyQCPwsk1s5iGSug=" - }, - "damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true - }, - "dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "requires": { - "assert-plus": "^1.0.0" - } - }, - "date-fns": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz", - "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==" - }, - "debug": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", - "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", - "requires": { - "ms": "2.1.2" - } - }, - "decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" - }, - "dedent": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", - "dev": true - }, - "deep-eql": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", - "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", - "dev": true, - "requires": { - "type-detect": "^4.0.0" - } - }, - "deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "optional": true, - "peer": true - }, - "deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" - }, - "deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" - }, - "default-require-extensions": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.0.tgz", - "integrity": "sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==", - "dev": true, - "requires": { - "strip-bom": "^4.0.0" - } - }, - "defaults": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", - "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", - "dev": true, - "requires": { - "clone": "^1.0.2" - }, - "dependencies": { - "clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", - "dev": true - } - } - }, - "define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", - "requires": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" - } - }, - "define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true - }, - "define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "requires": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - } - }, - "delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" - }, - "delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", - "optional": true, - "peer": true - }, - "denque": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", - "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==" - }, - "depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" - }, - "destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" - }, - "detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true - }, - "dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, - "requires": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", - "dev": true - }, - "dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "requires": { - "path-type": "^4.0.0" - } - }, - "disposable-email-domains": { - "version": "1.0.59", - "resolved": "https://registry.npmjs.org/disposable-email-domains/-/disposable-email-domains-1.0.59.tgz", - "integrity": "sha512-45NbOP1Oboaddf0pD5mGnT+1msEifY6VUcR9Msq4zBHk2EeGv9PxiwuoynIfdGID1BSFR3U3egPfMbERkqXxUQ==" - }, - "dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true - }, - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "dom-serializer": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz", - "integrity": "sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==", - "requires": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" - } - }, - "domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" - }, - "domhandler": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.0.tgz", - "integrity": "sha512-fC0aXNQXqKSFTr2wDNZDhsEYjCiYsDWl3D01kwt25hm1YIPyDGHvvi3rw+PLqHAl/m71MaiF7d5zvBr0p5UB2g==", - "requires": { - "domelementtype": "^2.2.0" - } - }, - "domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "requires": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" - } - }, - "dot-object": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/dot-object/-/dot-object-2.1.4.tgz", - "integrity": "sha512-7FXnyyCLFawNYJ+NhkqyP9Wd2yzuo+7n9pGiYpkmXCTYa8Ci2U0eUNDVg5OuO5Pm6aFXI2SWN8/N/w7SJWu1WA==", - "requires": { - "commander": "^4.0.0", - "glob": "^7.1.5" - }, - "dependencies": { - "commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==" - } - } - }, - "dotenv": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", - "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==" - }, - "dotenv-expand": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==" - }, - "dtrace-provider": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", - "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", - "optional": true, - "requires": { - "nan": "^2.14.0" - } - }, - "duplexer2": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", - "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", - "optional": true, - "peer": true, - "requires": { - "readable-stream": "^2.0.2" - }, - "dependencies": { - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "optional": true, - "peer": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "optional": true, - "peer": true - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "optional": true, - "peer": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "duplexify": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", - "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", - "optional": true, - "peer": true, - "requires": { - "end-of-stream": "^1.0.0", - "inherits": "^2.0.1", - "readable-stream": "^2.0.0", - "stream-shift": "^1.0.0" - }, - "dependencies": { - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "optional": true, - "peer": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "optional": true, - "peer": true - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "optional": true, - "peer": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "requires": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, - "ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" - }, - "electron-to-chromium": { - "version": "1.4.66", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.66.tgz", - "integrity": "sha512-f1RXFMsvwufWLwYUxTiP7HmjprKXrqEWHiQkjAYa9DJeVIlZk5v8gBGcaV+FhtXLly6C1OTVzQY+2UQrACiLlg==", - "dev": true - }, - "emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true - }, - "emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "enabled": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" - }, - "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" - }, - "end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "devOptional": true, - "requires": { - "once": "^1.4.0" - } - }, - "enhanced-resolve": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", - "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", - "dev": true, - "requires": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - } - }, - "enquirer": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", - "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", - "dev": true, - "requires": { - "ansi-colors": "^4.1.1" - } - }, - "entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==" - }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "requires": { - "is-arrayish": "^0.2.1" - } - }, - "es-abstract": { - "version": "1.22.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", - "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", - "requires": { - "array-buffer-byte-length": "^1.0.0", - "arraybuffer.prototype.slice": "^1.0.2", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.5", - "es-set-tostringtag": "^2.0.1", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.2", - "get-symbol-description": "^1.0.0", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", - "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.12", - "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "safe-array-concat": "^1.0.1", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.8", - "string.prototype.trimend": "^1.0.7", - "string.prototype.trimstart": "^1.0.7", - "typed-array-buffer": "^1.0.0", - "typed-array-byte-length": "^1.0.0", - "typed-array-byte-offset": "^1.0.0", - "typed-array-length": "^1.0.4", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.13" - } - }, - "es-module-lexer": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.0.tgz", - "integrity": "sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==", - "dev": true - }, - "es-set-tostringtag": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", - "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", - "requires": { - "get-intrinsic": "^1.2.2", - "has-tostringtag": "^1.0.0", - "hasown": "^2.0.0" - } - }, - "es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", - "dev": true, - "requires": { - "hasown": "^2.0.0" - } - }, - "es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "requires": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - } - }, - "es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true - }, - "es6-promisify": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-7.0.0.tgz", - "integrity": "sha512-ginqzK3J90Rd4/Yz7qRrqUeIpe3TwSXTPPZtPne7tGBPeAaQiU8qt4fpKApnxHcq1AwtUdHVg5P77x/yrggG8Q==" - }, - "esbuild": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.10.tgz", - "integrity": "sha512-n7V3v29IuZy5qgxx25TKJrEm0FHghAlS6QweUcyIgh/U0zYmQcvogWROitrTyZId1mHSkuhhuyEXtI9OXioq7A==", - "dev": true, - "requires": { - "@esbuild/android-arm": "0.17.10", - "@esbuild/android-arm64": "0.17.10", - "@esbuild/android-x64": "0.17.10", - "@esbuild/darwin-arm64": "0.17.10", - "@esbuild/darwin-x64": "0.17.10", - "@esbuild/freebsd-arm64": "0.17.10", - "@esbuild/freebsd-x64": "0.17.10", - "@esbuild/linux-arm": "0.17.10", - "@esbuild/linux-arm64": "0.17.10", - "@esbuild/linux-ia32": "0.17.10", - "@esbuild/linux-loong64": "0.17.10", - "@esbuild/linux-mips64el": "0.17.10", - "@esbuild/linux-ppc64": "0.17.10", - "@esbuild/linux-riscv64": "0.17.10", - "@esbuild/linux-s390x": "0.17.10", - "@esbuild/linux-x64": "0.17.10", - "@esbuild/netbsd-x64": "0.17.10", - "@esbuild/openbsd-x64": "0.17.10", - "@esbuild/sunos-x64": "0.17.10", - "@esbuild/win32-arm64": "0.17.10", - "@esbuild/win32-ia32": "0.17.10", - "@esbuild/win32-x64": "0.17.10" - } - }, - "esbuild-android-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz", - "integrity": "sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==", - "dev": true, - "optional": true - }, - "esbuild-android-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz", - "integrity": "sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==", - "dev": true, - "optional": true - }, - "esbuild-darwin-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz", - "integrity": "sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==", - "dev": true, - "optional": true - }, - "esbuild-darwin-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz", - "integrity": "sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz", - "integrity": "sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==", - "dev": true, - "optional": true - }, - "esbuild-freebsd-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz", - "integrity": "sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==", - "dev": true, - "optional": true - }, - "esbuild-linux-32": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz", - "integrity": "sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==", - "dev": true, - "optional": true - }, - "esbuild-linux-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz", - "integrity": "sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz", - "integrity": "sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==", - "dev": true, - "optional": true - }, - "esbuild-linux-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz", - "integrity": "sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==", - "dev": true, - "optional": true - }, - "esbuild-linux-mips64le": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz", - "integrity": "sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==", - "dev": true, - "optional": true - }, - "esbuild-linux-ppc64le": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz", - "integrity": "sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==", - "dev": true, - "optional": true - }, - "esbuild-linux-riscv64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz", - "integrity": "sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==", - "dev": true, - "optional": true - }, - "esbuild-linux-s390x": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz", - "integrity": "sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==", - "dev": true, - "optional": true - }, - "esbuild-netbsd-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz", - "integrity": "sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==", - "dev": true, - "optional": true - }, - "esbuild-openbsd-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz", - "integrity": "sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==", - "dev": true, - "optional": true - }, - "esbuild-plugin-d.ts": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/esbuild-plugin-d.ts/-/esbuild-plugin-d.ts-1.1.0.tgz", - "integrity": "sha512-3oSR3kUS4fNdKHLYLcST9YOfD2dULe7/UbXnrnu/mRybJYW+jZlYNgklb9Pt7osg6B1qwAYMyr2jTC+Ijj2YbQ==", - "dev": true, - "requires": { - "chalk": "4.x", - "jju": "^1.4.0", - "tmp": "^0.2.1", - "tsup": "^5.11.1" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", - "dev": true, - "requires": { - "rimraf": "^3.0.0" - } - } - } - }, - "esbuild-sunos-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz", - "integrity": "sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==", - "dev": true, - "optional": true - }, - "esbuild-windows-32": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz", - "integrity": "sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==", - "dev": true, - "optional": true - }, - "esbuild-windows-64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz", - "integrity": "sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==", - "dev": true, - "optional": true - }, - "esbuild-windows-arm64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz", - "integrity": "sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==", - "dev": true, - "optional": true - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" - }, - "escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" - }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" - }, - "escodegen": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", - "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", - "requires": { - "esprima": "^4.0.1", - "estraverse": "^4.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1", - "source-map": "~0.6.1" - }, - "dependencies": { - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" - }, - "levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "requires": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - } - }, - "optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "requires": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - } - }, - "prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==" - }, - "type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "requires": { - "prelude-ls": "~1.1.2" - } - } - } - }, - "eslint": { - "version": "8.30.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.30.0.tgz", - "integrity": "sha512-MGADB39QqYuzEGov+F/qb18r4i7DohCDOfatHaxI2iGlPuC65bwG2gxgO+7DkyL38dRFaRH7RaRAgU6JKL9rMQ==", - "dev": true, - "requires": { - "@eslint/eslintrc": "^1.4.0", - "@humanwhocodes/config-array": "^0.11.8", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.4.0", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "grapheme-splitter": "^1.0.4", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0" - }, - "dependencies": { - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - } - }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "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, - "requires": { - "is-glob": "^4.0.3" - } - }, - "globals": { - "version": "13.19.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz", - "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, - "js-sdsl": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", - "integrity": "sha512-dyBIzQBDkCqCu+0upx25Y2jGdbTGxE9fshMsCdK0ViOongpV+n5tXRcZY9v7CaVQ79AGS9KA1KHtojxiM7aXSQ==", - "dev": true - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - } - } - }, - "eslint-config-airbnb-base": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz", - "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", - "dev": true, - "requires": { - "confusing-browser-globals": "^1.0.10", - "object.assign": "^4.1.2", - "object.entries": "^1.1.5", - "semver": "^6.3.0" - }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "eslint-config-airbnb-typescript": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-17.0.0.tgz", - "integrity": "sha512-elNiuzD0kPAPTXjFWg+lE24nMdHMtuxgYoD30OyMD6yrW1AhFZPAg27VX7d3tzOErw+dgJTNWfRSDqEcXb4V0g==", - "dev": true, - "requires": { - "eslint-config-airbnb-base": "^15.0.0" - } - }, - "eslint-config-prettier": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz", - "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", - "dev": true, - "requires": {} - }, - "eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "requires": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "eslint-import-resolver-typescript": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.5.2.tgz", - "integrity": "sha512-zX4ebnnyXiykjhcBvKIf5TNvt8K7yX6bllTRZ14MiurKPjDpCAZujlszTdB8pcNXhZcOf+god4s9SjQa5GnytQ==", - "dev": true, - "requires": { - "debug": "^4.3.4", - "enhanced-resolve": "^5.10.0", - "get-tsconfig": "^4.2.0", - "globby": "^13.1.2", - "is-core-module": "^2.10.0", - "is-glob": "^4.0.3", - "synckit": "^0.8.4" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "globby": { - "version": "13.1.3", - "resolved": "https://registry.npmjs.org/globby/-/globby-13.1.3.tgz", - "integrity": "sha512-8krCNHXvlCgHDpegPzleMq07yMYTO2sXKASmZmquEYWEmCx6J5UTRbp5RwMJkTJGtcQ44YpiUYUiN0b9mzy8Bw==", - "dev": true, - "requires": { - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.11", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^4.0.0" - } - }, - "slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", - "dev": true - } - } - }, - "eslint-module-utils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", - "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", - "dev": true, - "requires": { - "debug": "^3.2.7" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "eslint-plugin-import": { - "version": "2.29.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", - "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", - "dev": true, - "requires": { - "array-includes": "^3.1.7", - "array.prototype.findlastindex": "^1.2.3", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.8.0", - "hasown": "^2.0.0", - "is-core-module": "^2.13.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.7", - "object.groupby": "^1.0.1", - "object.values": "^1.1.7", - "semver": "^6.3.1", - "tsconfig-paths": "^3.14.2" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "requires": { - "minimist": "^1.2.0" - } - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - }, - "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 - }, - "tsconfig-paths": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", - "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", - "dev": true, - "requires": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - } - } - }, - "eslint-plugin-jest": { - "version": "27.1.7", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.1.7.tgz", - "integrity": "sha512-0QVzf+og4YI1Qr3UoprkqqhezAZjFffdi62b0IurkCXMqPtRW84/UT4CKsYT80h/D82LA9avjO/80Ou1LdgbaQ==", - "dev": true, - "requires": { - "@typescript-eslint/utils": "^5.10.0" - } - }, - "eslint-plugin-jsx-a11y": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz", - "integrity": "sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==", - "dev": true, - "requires": { - "@babel/runtime": "^7.18.9", - "aria-query": "^4.2.2", - "array-includes": "^3.1.5", - "ast-types-flow": "^0.0.7", - "axe-core": "^4.4.3", - "axobject-query": "^2.2.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "has": "^1.0.3", - "jsx-ast-utils": "^3.3.2", - "language-tags": "^1.0.5", - "minimatch": "^3.1.2", - "semver": "^6.3.0" - }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "eslint-plugin-no-only-tests": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-no-only-tests/-/eslint-plugin-no-only-tests-3.1.0.tgz", - "integrity": "sha512-Lf4YW/bL6Un1R6A76pRZyE1dl1vr31G/ev8UzIc/geCgFWyrKil8hVjYqWVKGB/UIGmb6Slzs9T0wNezdSVegw==", - "dev": true - }, - "eslint-plugin-prettier": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz", - "integrity": "sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==", - "dev": true, - "requires": { - "prettier-linter-helpers": "^1.0.0" - } - }, - "eslint-plugin-promise": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz", - "integrity": "sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==", - "dev": true, - "requires": {} - }, - "eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "dependencies": { - "estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true - } - } - }, - "eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^2.0.0" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true - } - } - }, - "eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", - "dev": true - }, - "espree": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz", - "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==", - "dev": true, - "requires": { - "acorn": "^8.8.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.3.0" - }, - "dependencies": { - "acorn": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz", - "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==", - "dev": true - } - } - }, - "esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" - }, - "esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", - "dev": true, - "requires": { - "estraverse": "^5.1.0" - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - } - }, - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" - }, - "etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" - }, - "events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" - }, - "events-intercept": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/events-intercept/-/events-intercept-2.0.0.tgz", - "integrity": "sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==" - }, - "execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - } - }, - "exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true - }, - "exit-hook": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", - "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=" - }, - "expect": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.2.2.tgz", - "integrity": "sha512-hE09QerxZ5wXiOhqkXy5d2G9ar+EqOyifnCXCpMNu+vZ6DG9TJ6CO2c2kPDSLqERTTWrO7OZj8EkYHQqSd78Yw==", - "dev": true, - "requires": { - "@jest/expect-utils": "^29.2.2", - "jest-get-type": "^29.2.0", - "jest-matcher-utils": "^29.2.2", - "jest-message-util": "^29.2.1", - "jest-util": "^29.2.1" - }, - "dependencies": { - "jest-get-type": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", - "dev": true - } - } - }, - "express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", - "requires": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.1", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.5.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "dependencies": { - "body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "requires": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - } - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - }, - "finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "requires": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - } - }, - "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, - "on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "requires": { - "ee-first": "1.1.1" - } - }, - "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" - }, - "statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" - } - } - }, - "express-openapi-validator": { - "version": "4.13.8", - "resolved": "https://registry.npmjs.org/express-openapi-validator/-/express-openapi-validator-4.13.8.tgz", - "integrity": "sha512-89/sdkq+BKBuIyykaMl/vR9grFc3WFUPTjFo0THHbu+5g+q8rA7fKeoMfz+h84yOQIBcztmJ5ZJdk5uhEls31A==", - "requires": { - "@types/multer": "^1.4.7", - "ajv": "^6.12.6", - "content-type": "^1.0.4", - "json-schema-ref-parser": "^9.0.9", - "lodash.clonedeep": "^4.5.0", - "lodash.get": "^4.4.2", - "lodash.uniq": "^4.5.0", - "lodash.zipobject": "^4.1.3", - "media-typer": "^1.1.0", - "multer": "^1.4.5-lts.1", - "ono": "^7.1.3", - "path-to-regexp": "^6.2.0" - }, - "dependencies": { - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "requires": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "requires": { - "minimist": "^1.2.6" - } - }, - "multer": { - "version": "1.4.5-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", - "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", - "requires": { - "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" - } - }, - "path-to-regexp": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.0.tgz", - "integrity": "sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==" - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "express-session": { - "version": "1.17.3", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", - "integrity": "sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==", - "requires": { - "cookie": "0.4.2", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-headers": "~1.0.2", - "parseurl": "~1.3.3", - "safe-buffer": "5.2.1", - "uid-safe": "~2.1.5" - }, - "dependencies": { - "cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" - }, - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - } - } - }, - "extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "requires": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - } - }, - "extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "fast-diff": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", - "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", - "dev": true - }, - "fast-glob": { - "version": "3.2.11", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", - "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", - "requires": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - } - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" - }, - "fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" - }, - "fast-xml-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz", - "integrity": "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==", - "requires": { - "strnum": "^1.0.5" - } - }, - "fastestsmallesttextencoderdecoder": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", - "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==", - "optional": true, - "peer": true - }, - "fastq": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", - "requires": { - "reusify": "^1.0.4" - } - }, - "fb-watchman": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", - "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==", - "dev": true, - "requires": { - "bser": "2.1.1" - } - }, - "fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "requires": { - "pend": "~1.2.0" - } - }, - "feathers-hooks-common": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/feathers-hooks-common/-/feathers-hooks-common-8.1.1.tgz", - "integrity": "sha512-l33cpzRAjBnX/koyyCdeRY7dFPJBZVRKmN1fR1926S8p3gjS+gq5HE7wIipFcIPLDFKfwE9o2ZU5FlM2eCteXA==", - "requires": { - "@feathersjs/errors": "^5.0.8", - "ajv": "^6.12.6", - "debug": "^4.3.4", - "graphql": "^16.8.0", - "lodash": "^4.17.21", - "traverse": "^0.6.7" - }, - "dependencies": { - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "requires": { - "ms": "2.1.2" - } - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - } - } - }, - "feathers-swagger": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/feathers-swagger/-/feathers-swagger-3.0.0.tgz", - "integrity": "sha512-RBM3FZpV9teyPZA1TrsZ49an2q07LEFZUVMJeib2hEo2+H2nj3YM3wlm/kXePUZQPD5ugaWXwgnJJlADW/+w1A==", - "requires": { - "lodash": "^4.17.21" - } - }, - "fecha": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.1.tgz", - "integrity": "sha512-MMMQ0ludy/nBs1/o0zVOiKTpG7qMbonKUzjJgQFEuvq6INZ1OraKPRAWkBq5vlKLOUMpmNYG1JoN3oDPUQ9m3Q==" - }, - "figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "requires": { - "escape-string-regexp": "^1.0.5" - }, - "dependencies": { - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - } - } - }, - "file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "requires": { - "flat-cache": "^3.0.4" - } - }, - "file-type": { - "version": "18.5.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-18.5.0.tgz", - "integrity": "sha512-yvpl5U868+V6PqXHMmsESpg6unQ5GfnPssl4dxdJudBrr9qy7Fddt7EVX1VLlddFfe8Gj9N7goCZH22FXuSQXQ==", - "requires": { - "readable-web-to-node-stream": "^3.0.2", - "strtok3": "^7.0.0", - "token-types": "^5.0.1" - } - }, - "fill-keys": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", - "integrity": "sha1-mo+jb06K1jTjv2tPPIiCVRRS6yA=", - "dev": true, - "requires": { - "is-object": "~1.0.1", - "merge-descriptors": "~1.0.0" - } - }, - "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==", - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "dev": true, - "requires": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - } - }, - "fishery": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/fishery/-/fishery-2.2.2.tgz", - "integrity": "sha512-jeU0nDhPHJkupmjX+r9niKgVMTBDB8X+U/pktoGHAiWOSyNlMd0HhmqnjrpjUOCDPJYaSSu4Ze16h6dZOKSp2w==", - "dev": true, - "requires": { - "lodash.mergewith": "^4.6.2" - } - }, - "flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==" - }, - "flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "requires": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - } - }, - "flatted": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz", - "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==", - "dev": true - }, - "fn.name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" - }, - "follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" - }, - "for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "requires": { - "is-callable": "^1.1.3" - } - }, - "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, - "requires": { - "cross-spawn": "^7.0.0", - "signal-exit": "^3.0.2" - } - }, - "forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" - }, - "fork-ts-checker-webpack-plugin": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-8.0.0.tgz", - "integrity": "sha512-mX3qW3idpueT2klaQXBzrIM/pHw+T0B/V9KHEvNrqijTq9NFnMZU6oreVxDYcf33P8a5cW+67PjodNHthGnNVg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.16.7", - "chalk": "^4.1.2", - "chokidar": "^3.5.3", - "cosmiconfig": "^7.0.1", - "deepmerge": "^4.2.2", - "fs-extra": "^10.0.0", - "memfs": "^3.4.1", - "minimatch": "^3.0.4", - "node-abort-controller": "^3.0.1", - "schema-utils": "^3.1.1", - "semver": "^7.3.5", - "tapable": "^2.2.1" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "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==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, - "formidable": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", - "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==", - "dev": true - }, - "forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" - }, - "freeport": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/freeport/-/freeport-1.0.5.tgz", - "integrity": "sha1-JV6KuEFwwzuoXZkOghrl9KGpvF0=" - }, - "fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" - }, - "fromentries": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", - "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", - "dev": true - }, - "fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true - }, - "fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "requires": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - } - }, - "fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "optional": true, - "peer": true, - "requires": { - "minipass": "^3.0.0" - } - }, - "fs-monkey": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", - "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", - "dev": true - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" - }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, - "fstream": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", - "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", - "optional": true, - "peer": true, - "requires": { - "graceful-fs": "^4.1.2", - "inherits": "~2.0.0", - "mkdirp": ">=0.5 0", - "rimraf": "2" - }, - "dependencies": { - "mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "optional": true, - "peer": true, - "requires": { - "minimist": "^1.2.5" - } - }, - "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "optional": true, - "peer": true, - "requires": { - "glob": "^7.1.3" - } - } - } - }, - "function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" - }, - "function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" - } - }, - "functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", - "dev": true - }, - "functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==" - }, - "gauge": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-1.2.7.tgz", - "integrity": "sha1-6c7FSD09TuDvRLYKfZnkk14TbZM=", - "optional": true, - "peer": true, - "requires": { - "ansi": "^0.3.0", - "has-unicode": "^2.0.0", - "lodash.pad": "^4.1.0", - "lodash.padend": "^4.1.0", - "lodash.padstart": "^4.1.0" - } - }, - "generic-pool": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", - "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==" - }, - "gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true - }, - "get-all-files": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/get-all-files/-/get-all-files-4.1.0.tgz", - "integrity": "sha512-ZH0Sbr6VQLMCcjrWNWyK0Wii9Kfw7ALnaZL6/5tTYf2/9lMtTTx9jSVAU92D3HfH9K8veAIehq3PbCZA7yde2g==" - }, - "get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" - }, - "get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", - "dev": true - }, - "get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", - "requires": { - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - } - }, - "get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true - }, - "get-port": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", - "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", - "dev": true - }, - "get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true - }, - "get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - } - }, - "get-tsconfig": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.2.0.tgz", - "integrity": "sha512-X8u8fREiYOE6S8hLbq99PeykTDoLVnxvF4DjWKJmz9xy2nNRdUcV8ZN9tniJFeKyTU3qnC9lL8n4Chd6LmVKHg==", - "dev": true - }, - "getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "requires": { - "assert-plus": "^1.0.0" - } - }, - "glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "requires": { - "is-glob": "^4.0.1" - } - }, - "glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true - }, - "globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true - }, - "globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", - "requires": { - "define-properties": "^1.1.3" - } - }, - "globalyzer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", - "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", - "dev": true - }, - "globby": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz", - "integrity": "sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==", - "requires": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.1.1", - "ignore": "^5.1.4", - "merge2": "^1.3.0", - "slash": "^3.0.0" - } - }, - "globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true - }, - "gm": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/gm/-/gm-1.25.0.tgz", - "integrity": "sha512-4kKdWXTtgQ4biIo7hZA396HT062nDVVHPjQcurNZ3o/voYN+o5FUC5kOwuORbpExp3XbTJ3SU7iRipiIhQtovw==", - "requires": { - "array-parallel": "~0.1.3", - "array-series": "~0.1.5", - "cross-spawn": "^4.0.0", - "debug": "^3.1.0" - }, - "dependencies": { - "cross-spawn": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", - "integrity": "sha512-yAXz/pA1tD8Gtg2S98Ekf/sewp3Lcp3YoFKJ4Hkp5h5yLWnKVTDU0kwjKJ8NDCYcfTLfyGkzTikst+jWypT1iA==", - "requires": { - "lru-cache": "^4.0.1", - "which": "^1.2.9" - } - }, - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "requires": { - "ms": "^2.1.1" - } - }, - "lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "requires": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" - } - } - }, - "gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "requires": { - "get-intrinsic": "^1.1.3" - } - }, - "graceful-fs": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz", - "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==" - }, - "grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "dev": true - }, - "graphql": { - "version": "16.8.1", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", - "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==" - }, - "growl": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", - "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", - "dev": true - }, - "har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" - }, - "har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "requires": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - }, - "dependencies": { - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - } - } - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==" - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "has-own-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", - "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", - "dev": true - }, - "has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "requires": { - "get-intrinsic": "^1.1.1" - } - }, - "has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==" - }, - "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" - }, - "has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "requires": { - "has-symbols": "^1.0.2" - } - }, - "has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", - "optional": true, - "peer": true - }, - "hasha": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", - "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", - "dev": true, - "requires": { - "is-stream": "^2.0.0", - "type-fest": "^0.8.0" - }, - "dependencies": { - "type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true - } - } - }, - "hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", - "requires": { - "function-bind": "^1.1.2" - } - }, - "he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true - }, - "help-me": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-3.0.0.tgz", - "integrity": "sha512-hx73jClhyk910sidBB7ERlnhMlFsJJIBqSVMFDwPN8o2v9nmp5KgLq1Xz1Bf1fCMMZ6mPrX159iG0VLy/fPMtQ==", - "optional": true, - "peer": true, - "requires": { - "glob": "^7.1.6", - "readable-stream": "^3.6.0" - }, - "dependencies": { - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "optional": true, - "peer": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "optional": true, - "peer": true, - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "hexoid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", - "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", - "dev": true - }, - "hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" - }, - "html-entities": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.2.tgz", - "integrity": "sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ==" - }, - "html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, - "htmlparser2": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", - "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", - "requires": { - "domelementtype": "^2.0.1", - "domhandler": "^4.0.0", - "domutils": "^2.5.2", - "entities": "^2.0.0" - } - }, - "http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "requires": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - } - }, - "https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "requires": { - "agent-base": "6", - "debug": "4" - } - }, - "human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true - }, - "i18next": { - "version": "23.3.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.3.0.tgz", - "integrity": "sha512-xd/UzWT71zYudCT7qVn6tB4yUVuXAhgCorsowYgM2EOdc14WqQBp5P2wEsxgfiDgdLN5XwJvTbzxrMfoY/nxnw==", - "requires": { - "@babel/runtime": "^7.22.5" - } - }, - "i18next-fs-backend": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.1.5.tgz", - "integrity": "sha512-7fgSH8nVhXSBYPHR/W3tEXXhcnwHwNiND4Dfx9knzPzdsWTUTL/TdDVV+DY0dL0asHKLbdoJaXS4LdVW6R8MVQ==" - }, - "iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "requires": { - "safer-buffer": ">= 2.1.2 < 3" - } - }, - "ieee754": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", - "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" - }, - "ignore": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", - "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==" - }, - "ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha1-SMptcvbGo68Aqa1K5odr44ieKwk=", - "dev": true - }, - "image-size": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz", - "integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==", - "requires": { - "queue": "6.0.2" - } - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", - "dev": true, - "requires": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true - }, - "indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "optional": true, - "peer": true - }, - "inquirer": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", - "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", - "dev": true, - "requires": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.19", - "mute-stream": "0.0.8", - "run-async": "^2.4.0", - "rxjs": "^6.6.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "rxjs": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", - "dev": true, - "requires": { - "tslib": "^1.9.0" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - } - } - }, - "internal-slot": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", - "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", - "requires": { - "get-intrinsic": "^1.2.2", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" - } - }, - "interpret": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", - "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", - "dev": true - }, - "invert-kv": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", - "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=" - }, - "ioredis": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", - "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", - "optional": true, - "peer": true, - "requires": { - "@ioredis/commands": "^1.1.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "optional": true, - "peer": true, - "requires": { - "ms": "2.1.2" - } - }, - "denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "optional": true, - "peer": true - } - } - }, - "ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" - }, - "ip-regex": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", - "integrity": "sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=" - }, - "ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" - }, - "is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" - } - }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" - }, - "is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "requires": { - "has-bigints": "^1.0.1" - } - }, - "is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "requires": { - "binary-extensions": "^2.0.0" - } - }, - "is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==" - }, - "is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" - }, - "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==", - "requires": { - "hasown": "^2.0.0" - } - }, - "is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" - }, - "is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" - }, - "is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true - }, - "is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true - }, - "is-iojs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-iojs/-/is-iojs-1.1.0.tgz", - "integrity": "sha1-TBEDO11dlNbqs3dd7cm+fQCDJfE=", - "optional": true, - "peer": true - }, - "is-ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-2.0.0.tgz", - "integrity": "sha1-aO6gfooKCpTC0IDdZ0xzGrKkYas=", - "dev": true, - "requires": { - "ip-regex": "^2.0.0" - } - }, - "is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==" - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" - }, - "is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-object": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", - "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", - "dev": true - }, - "is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true - }, - "is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true - }, - "is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "requires": { - "call-bind": "^1.0.2" - } - }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" - }, - "is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "requires": { - "has-symbols": "^1.0.2" - } - }, - "is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", - "requires": { - "which-typed-array": "^1.1.11" - } - }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" - }, - "is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true - }, - "is-utf8": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", - "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=" - }, - "is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "requires": { - "call-bind": "^1.0.2" - } - }, - "is-windows": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", - "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", - "dev": true - }, - "is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "requires": { - "is-docker": "^2.0.0" - } - }, - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" - }, - "isomorphic-ws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", - "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", - "optional": true, - "peer": true, - "requires": {} - }, - "isomorphic.js": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", - "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==" - }, - "isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" - }, - "istanbul-lib-coverage": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", - "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", - "dev": true - }, - "istanbul-lib-hook": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", - "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", - "dev": true, - "requires": { - "append-transform": "^2.0.0" - } - }, - "istanbul-lib-instrument": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.1.0.tgz", - "integrity": "sha512-czwUz525rkOFDJxfKK6mYfIs9zBKILyrZQxjz3ABhjQXhbhFsSbo1HW/BFcsDnfJYJWA6thRR5/TUY2qs5W99Q==", - "dev": true, - "requires": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "istanbul-lib-processinfo": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz", - "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==", - "dev": true, - "requires": { - "archy": "^1.0.0", - "cross-spawn": "^7.0.0", - "istanbul-lib-coverage": "^3.0.0-alpha.1", - "make-dir": "^3.0.0", - "p-map": "^3.0.0", - "rimraf": "^3.0.0", - "uuid": "^3.3.3" - }, - "dependencies": { - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true - } - } - }, - "istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", - "dev": true, - "requires": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^3.0.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "requires": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - } - }, - "istanbul-reports": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.4.tgz", - "integrity": "sha512-r1/DshN4KSE7xWEknZLLLLDn5CJybV3nw01VTkp6D5jzLuELlcbudfj/eSQFvrKsJuTVCGnePO7ho82Nw9zzfw==", - "dev": true, - "requires": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - } - }, - "iterare": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", - "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==" - }, - "jest": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.2.2.tgz", - "integrity": "sha512-r+0zCN9kUqoON6IjDdjbrsWobXM/09Nd45kIPRD8kloaRh1z5ZCMdVsgLXGxmlL7UpAJsvCYOQNO+NjvG/gqiQ==", - "dev": true, - "requires": { - "@jest/core": "^29.2.2", - "@jest/types": "^29.2.1", - "import-local": "^3.0.2", - "jest-cli": "^29.2.2" - } - }, - "jest-changed-files": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.2.0.tgz", - "integrity": "sha512-qPVmLLyBmvF5HJrY7krDisx6Voi8DmlV3GZYX0aFNbaQsZeoz1hfxcCMbqDGuQCxU1dJy9eYc2xscE8QrCCYaA==", - "dev": true, - "requires": { - "execa": "^5.0.0", - "p-limit": "^3.1.0" - } - }, - "jest-circus": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.2.2.tgz", - "integrity": "sha512-upSdWxx+Mh4DV7oueuZndJ1NVdgtTsqM4YgywHEx05UMH5nxxA2Qu9T9T9XVuR021XxqSoaKvSmmpAbjwwwxMw==", - "dev": true, - "requires": { - "@jest/environment": "^29.2.2", - "@jest/expect": "^29.2.2", - "@jest/test-result": "^29.2.1", - "@jest/types": "^29.2.1", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^0.7.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.2.1", - "jest-matcher-utils": "^29.2.2", - "jest-message-util": "^29.2.1", - "jest-runtime": "^29.2.2", - "jest-snapshot": "^29.2.2", - "jest-util": "^29.2.1", - "p-limit": "^3.1.0", - "pretty-format": "^29.2.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "dependencies": { - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-cli": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.2.2.tgz", - "integrity": "sha512-R45ygnnb2CQOfd8rTPFR+/fls0d+1zXS6JPYTBBrnLPrhr58SSuPTiA5Tplv8/PXpz4zXR/AYNxmwIj6J6nrvg==", - "dev": true, - "requires": { - "@jest/core": "^29.2.2", - "@jest/test-result": "^29.2.1", - "@jest/types": "^29.2.1", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "import-local": "^3.0.2", - "jest-config": "^29.2.2", - "jest-util": "^29.2.1", - "jest-validate": "^29.2.2", - "prompts": "^2.0.1", - "yargs": "^17.3.1" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true - }, - "yargs": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.6.0.tgz", - "integrity": "sha512-8H/wTDqlSwoSnScvV2N/JHfLWOKuh5MVla9hqLjK3nsfyy6Y4kDSYSvkU5YCUEPOSnRXfIyx3Sq+B/IWudTo4g==", - "dev": true, - "requires": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.0.0" - } - }, - "yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true - } - } - }, - "jest-config": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.2.2.tgz", - "integrity": "sha512-Q0JX54a5g1lP63keRfKR8EuC7n7wwny2HoTRDb8cx78IwQOiaYUVZAdjViY3WcTxpR02rPUpvNVmZ1fkIlZPcw==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.2.2", - "@jest/types": "^29.2.1", - "babel-jest": "^29.2.2", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.2.2", - "jest-environment-node": "^29.2.2", - "jest-get-type": "^29.2.0", - "jest-regex-util": "^29.2.0", - "jest-resolve": "^29.2.2", - "jest-runner": "^29.2.2", - "jest-util": "^29.2.1", - "jest-validate": "^29.2.2", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.2.1", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "dependencies": { - "@jest/transform": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.2.2.tgz", - "integrity": "sha512-aPe6rrletyuEIt2axxgdtxljmzH8O/nrov4byy6pDw9S8inIrTV+2PnjyP/oFHMSynzGxJ2s6OHowBNMXp/Jzg==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.2.1", - "@jridgewell/trace-mapping": "^0.3.15", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.2.1", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.1" - } - }, - "babel-jest": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.2.2.tgz", - "integrity": "sha512-kkq2QSDIuvpgfoac3WZ1OOcHsQQDU5xYk2Ql7tLdJ8BVAYbefEXal+NfS45Y5LVZA7cxC8KYcQMObpCt1J025w==", - "dev": true, - "requires": { - "@jest/transform": "^29.2.2", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.2.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - } - }, - "babel-plugin-jest-hoist": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.2.0.tgz", - "integrity": "sha512-TnspP2WNiR3GLfCsUNHqeXw0RoQ2f9U5hQ5L3XFpwuO8htQmSrhh8qsB6vi5Yi8+kuynN1yjDjQsPfkebmB6ZA==", - "dev": true, - "requires": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - } - }, - "babel-preset-jest": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.2.0.tgz", - "integrity": "sha512-z9JmMJppMxNv8N7fNRHvhMg9cvIkMxQBXgFkane3yKVEvEOP+kB50lk8DFRvF9PGqbyXxlmebKWhuDORO8RgdA==", - "dev": true, - "requires": { - "babel-plugin-jest-hoist": "^29.2.0", - "babel-preset-current-node-syntax": "^1.0.0" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "jest-get-type": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", - "dev": true - }, - "jest-haste-map": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "fsevents": "^2.3.2", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "jest-worker": "^29.2.1", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - } - }, - "jest-regex-util": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", - "dev": true - }, - "jest-worker": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", - "dev": true, - "requires": { - "@types/node": "*", - "jest-util": "^29.2.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "dependencies": { - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - } - } - } - }, - "jest-docblock": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.2.0.tgz", - "integrity": "sha512-bkxUsxTgWQGbXV5IENmfiIuqZhJcyvF7tU4zJ/7ioTutdz4ToB5Yx6JOFBpgI+TphRY4lhOyCWGNH/QFQh5T6A==", - "dev": true, - "requires": { - "detect-newline": "^3.0.0" - } - }, - "jest-each": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.2.1.tgz", - "integrity": "sha512-sGP86H/CpWHMyK3qGIGFCgP6mt+o5tu9qG4+tobl0LNdgny0aitLXs9/EBacLy3Bwqy+v4uXClqJgASJWcruYw==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "chalk": "^4.0.0", - "jest-get-type": "^29.2.0", - "jest-util": "^29.2.1", - "pretty-format": "^29.2.1" - }, - "dependencies": { - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "jest-get-type": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-environment-node": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.2.2.tgz", - "integrity": "sha512-B7qDxQjkIakQf+YyrqV5dICNs7tlCO55WJ4OMSXsqz1lpI/0PmeuXdx2F7eU8rnPbRkUR/fItSSUh0jvE2y/tw==", - "dev": true, - "requires": { - "@jest/environment": "^29.2.2", - "@jest/fake-timers": "^29.2.2", - "@jest/types": "^29.2.1", - "@types/node": "*", - "jest-mock": "^29.2.2", - "jest-util": "^29.2.1" - } - }, - "jest-leak-detector": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.2.1.tgz", - "integrity": "sha512-1YvSqYoiurxKOJtySc+CGVmw/e1v4yNY27BjWTVzp0aTduQeA7pdieLiW05wTYG/twlKOp2xS/pWuikQEmklug==", - "dev": true, - "requires": { - "jest-get-type": "^29.2.0", - "pretty-format": "^29.2.1" - }, - "dependencies": { - "jest-get-type": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", - "dev": true - } - } - }, - "jest-matcher-utils": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.2.2.tgz", - "integrity": "sha512-4DkJ1sDPT+UX2MR7Y3od6KtvRi9Im1ZGLGgdLFLm4lPexbTaCgJW5NN3IOXlQHF7NSHY/VHhflQ+WoKtD/vyCw==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "jest-diff": "^29.2.1", - "jest-get-type": "^29.2.0", - "pretty-format": "^29.2.1" - }, - "dependencies": { - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "diff-sequences": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.2.0.tgz", - "integrity": "sha512-413SY5JpYeSBZxmenGEmCVQ8mCgtFJF0w9PROdaS6z987XC2Pd2GOKqOITLtMftmyFZqgtCOb/QA7/Z3ZXfzIw==", - "dev": true - }, - "jest-diff": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.2.1.tgz", - "integrity": "sha512-gfh/SMNlQmP3MOUgdzxPOd4XETDJifADpT937fN1iUGz+9DgOu2eUPHH25JDkLVcLwwqxv3GzVyK4VBUr9fjfA==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "diff-sequences": "^29.2.0", - "jest-get-type": "^29.2.0", - "pretty-format": "^29.2.1" - } - }, - "jest-get-type": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-message-util": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.2.1.tgz", - "integrity": "sha512-Dx5nEjw9V8C1/Yj10S/8ivA8F439VS8vTq1L7hEgwHFn9ovSKNpYW/kwNh7UglaEgXO42XxzKJB+2x0nSglFVw==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.2.1", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.2.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "dependencies": { - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-mock": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.2.2.tgz", - "integrity": "sha512-1leySQxNAnivvbcx0sCB37itu8f4OX2S/+gxLAV4Z62shT4r4dTG9tACDywUAEZoLSr36aYUTsVp3WKwWt4PMQ==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "@types/node": "*", - "jest-util": "^29.2.1" - } - }, - "jest-pnp-resolver": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", - "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "dev": true, - "requires": {} - }, - "jest-resolve": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.2.2.tgz", - "integrity": "sha512-3gaLpiC3kr14rJR3w7vWh0CBX2QAhfpfiQTwrFPvVrcHe5VUBtIXaR004aWE/X9B2CFrITOQAp5gxLONGrk6GA==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.2.1", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.2.1", - "jest-validate": "^29.2.2", - "resolve": "^1.20.0", - "resolve.exports": "^1.1.0", - "slash": "^3.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "jest-haste-map": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "fsevents": "^2.3.2", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "jest-worker": "^29.2.1", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - } - }, - "jest-regex-util": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", - "dev": true - }, - "jest-worker": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", - "dev": true, - "requires": { - "@types/node": "*", - "jest-util": "^29.2.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "dependencies": { - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-resolve-dependencies": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.2.2.tgz", - "integrity": "sha512-wWOmgbkbIC2NmFsq8Lb+3EkHuW5oZfctffTGvwsA4JcJ1IRk8b2tg+hz44f0lngvRTeHvp3Kyix9ACgudHH9aQ==", - "dev": true, - "requires": { - "jest-regex-util": "^29.2.0", - "jest-snapshot": "^29.2.2" - }, - "dependencies": { - "jest-regex-util": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", - "dev": true - } - } - }, - "jest-runner": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.2.2.tgz", - "integrity": "sha512-1CpUxXDrbsfy9Hr9/1zCUUhT813kGGK//58HeIw/t8fa/DmkecEwZSWlb1N/xDKXg3uCFHQp1GCvlSClfImMxg==", - "dev": true, - "requires": { - "@jest/console": "^29.2.1", - "@jest/environment": "^29.2.2", - "@jest/test-result": "^29.2.1", - "@jest/transform": "^29.2.2", - "@jest/types": "^29.2.1", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.2.0", - "jest-environment-node": "^29.2.2", - "jest-haste-map": "^29.2.1", - "jest-leak-detector": "^29.2.1", - "jest-message-util": "^29.2.1", - "jest-resolve": "^29.2.2", - "jest-runtime": "^29.2.2", - "jest-util": "^29.2.1", - "jest-watcher": "^29.2.2", - "jest-worker": "^29.2.1", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "dependencies": { - "@jest/transform": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.2.2.tgz", - "integrity": "sha512-aPe6rrletyuEIt2axxgdtxljmzH8O/nrov4byy6pDw9S8inIrTV+2PnjyP/oFHMSynzGxJ2s6OHowBNMXp/Jzg==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.2.1", - "@jridgewell/trace-mapping": "^0.3.15", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.2.1", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.1" - } - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "jest-haste-map": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "fsevents": "^2.3.2", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "jest-worker": "^29.2.1", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - } - }, - "jest-regex-util": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", - "dev": true - }, - "jest-worker": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", - "dev": true, - "requires": { - "@types/node": "*", - "jest-util": "^29.2.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - } - }, - "source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - } - } - } - }, - "jest-runtime": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.2.2.tgz", - "integrity": "sha512-TpR1V6zRdLynckKDIQaY41od4o0xWL+KOPUCZvJK2bu5P1UXhjobt5nJ2ICNeIxgyj9NGkO0aWgDqYPVhDNKjA==", - "dev": true, - "requires": { - "@jest/environment": "^29.2.2", - "@jest/fake-timers": "^29.2.2", - "@jest/globals": "^29.2.2", - "@jest/source-map": "^29.2.0", - "@jest/test-result": "^29.2.1", - "@jest/transform": "^29.2.2", - "@jest/types": "^29.2.1", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.2.1", - "jest-message-util": "^29.2.1", - "jest-mock": "^29.2.2", - "jest-regex-util": "^29.2.0", - "jest-resolve": "^29.2.2", - "jest-snapshot": "^29.2.2", - "jest-util": "^29.2.1", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "dependencies": { - "@jest/transform": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.2.2.tgz", - "integrity": "sha512-aPe6rrletyuEIt2axxgdtxljmzH8O/nrov4byy6pDw9S8inIrTV+2PnjyP/oFHMSynzGxJ2s6OHowBNMXp/Jzg==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.2.1", - "@jridgewell/trace-mapping": "^0.3.15", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.2.1", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.1" - } - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "jest-haste-map": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "fsevents": "^2.3.2", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "jest-worker": "^29.2.1", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - } - }, - "jest-regex-util": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", - "dev": true - }, - "jest-worker": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", - "dev": true, - "requires": { - "@types/node": "*", - "jest-util": "^29.2.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "dependencies": { - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - } - } - } - }, - "jest-snapshot": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.2.2.tgz", - "integrity": "sha512-GfKJrpZ5SMqhli3NJ+mOspDqtZfJBryGA8RIBxF+G+WbDoC7HCqKaeAss4Z/Sab6bAW11ffasx8/vGsj83jyjA==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/traverse": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.2.2", - "@jest/transform": "^29.2.2", - "@jest/types": "^29.2.1", - "@types/babel__traverse": "^7.0.6", - "@types/prettier": "^2.1.5", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.2.2", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.2.1", - "jest-get-type": "^29.2.0", - "jest-haste-map": "^29.2.1", - "jest-matcher-utils": "^29.2.2", - "jest-message-util": "^29.2.1", - "jest-util": "^29.2.1", - "natural-compare": "^1.4.0", - "pretty-format": "^29.2.1", - "semver": "^7.3.5" - }, - "dependencies": { - "@jest/transform": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.2.2.tgz", - "integrity": "sha512-aPe6rrletyuEIt2axxgdtxljmzH8O/nrov4byy6pDw9S8inIrTV+2PnjyP/oFHMSynzGxJ2s6OHowBNMXp/Jzg==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.2.1", - "@jridgewell/trace-mapping": "^0.3.15", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.2.1", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "diff-sequences": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.2.0.tgz", - "integrity": "sha512-413SY5JpYeSBZxmenGEmCVQ8mCgtFJF0w9PROdaS6z987XC2Pd2GOKqOITLtMftmyFZqgtCOb/QA7/Z3ZXfzIw==", - "dev": true - }, - "jest-diff": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.2.1.tgz", - "integrity": "sha512-gfh/SMNlQmP3MOUgdzxPOd4XETDJifADpT937fN1iUGz+9DgOu2eUPHH25JDkLVcLwwqxv3GzVyK4VBUr9fjfA==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "diff-sequences": "^29.2.0", - "jest-get-type": "^29.2.0", - "pretty-format": "^29.2.1" - } - }, - "jest-get-type": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", - "dev": true - }, - "jest-haste-map": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.2.1.tgz", - "integrity": "sha512-wF460rAFmYc6ARcCFNw4MbGYQjYkvjovb9GBT+W10Um8q5nHq98jD6fHZMDMO3tA56S8XnmNkM8GcA8diSZfnA==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "fsevents": "^2.3.2", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.2.0", - "jest-util": "^29.2.1", - "jest-worker": "^29.2.1", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - } - }, - "jest-regex-util": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.2.0.tgz", - "integrity": "sha512-6yXn0kg2JXzH30cr2NlThF+70iuO/3irbaB4mh5WyqNIvLLP+B6sFdluO1/1RJmslyh/f9osnefECflHvTbwVA==", - "dev": true - }, - "jest-worker": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.2.1.tgz", - "integrity": "sha512-ROHTZ+oj7sBrgtv46zZ84uWky71AoYi0vEV9CdEtc1FQunsoAGe5HbQmW76nI5QWdvECVPrSi1MCVUmizSavMg==", - "dev": true, - "requires": { - "@types/node": "*", - "jest-util": "^29.2.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "dependencies": { - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - } - } - } - }, - "jest-util": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.2.1.tgz", - "integrity": "sha512-P5VWDj25r7kj7kl4pN2rG/RN2c1TLfYYYZYULnS/35nFDjBai+hBeo3MDrYZS7p6IoY3YHZnt2vq4L6mKnLk0g==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-validate": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.2.2.tgz", - "integrity": "sha512-eJXATaKaSnOuxNfs8CLHgdABFgUrd0TtWS8QckiJ4L/QVDF4KVbZFBBOwCBZHOS0Rc5fOxqngXeGXE3nGQkpQA==", - "dev": true, - "requires": { - "@jest/types": "^29.2.1", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.2.0", - "leven": "^3.1.0", - "pretty-format": "^29.2.1" - }, - "dependencies": { - "camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - } - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "jest-get-type": { - "version": "29.2.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", - "integrity": "sha512-uXNJlg8hKFEnDgFsrCjznB+sTxdkuqiCL6zMgA75qEbAJjJYTs9XPrvDctrEig2GDow22T/LvHgO57iJhXB/UA==", - "dev": true - }, - "leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-watcher": { - "version": "29.2.2", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.2.2.tgz", - "integrity": "sha512-j2otfqh7mOvMgN2WlJ0n7gIx9XCMWntheYGlBK7+5g3b1Su13/UAK7pdKGyd4kDlrLwtH2QPvRv5oNIxWvsJ1w==", - "dev": true, - "requires": { - "@jest/test-result": "^29.2.1", - "@jest/types": "^29.2.1", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.2.1", - "string-length": "^4.0.1" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "requires": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - } - }, - "jju": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", - "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", - "dev": true - }, - "jmespath": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", - "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==" - }, - "joi": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.6.0.tgz", - "integrity": "sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw==", - "dev": true, - "requires": { - "@hapi/hoek": "^9.0.0", - "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.3", - "@sideway/formula": "^3.0.0", - "@sideway/pinpoint": "^2.0.0" - } - }, - "jose": { - "version": "1.28.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-1.28.2.tgz", - "integrity": "sha512-wWy51U2MXxYi3g8zk2lsQ8M6O1lartpkxuq1TYexzPKYLgHLZkCjklaATP36I5BUoWjF2sInB9U1Qf18fBZxNA==", - "requires": { - "@panva/asn1.js": "^1.0.0" - } - }, - "joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true - }, - "js-sdsl": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-2.1.4.tgz", - "integrity": "sha512-/Ew+CJWHNddr7sjwgxaVeIORIH4AMVC9dy0hPf540ZGMVgS9d3ajwuVdyhDt6/QUvT8ATjR3yuYBKsS79F+H4A==", - "optional": true, - "peer": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - } - }, - "jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" - }, - "jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" - }, - "json-schema-ref-parser": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz", - "integrity": "sha512-qcP2lmGy+JUoQJ4DOQeLaZDqH9qSkeGCK3suKWxJXS82dg728Mn3j97azDMaOUmJAN4uCq91LdPx4K7E8F1a7Q==", - "requires": { - "@apidevtools/json-schema-ref-parser": "9.0.9" - } - }, - "json-schema-to-ts": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-2.9.2.tgz", - "integrity": "sha512-h9WqLkTVpBbiaPb5OmeUpz/FBLS/kvIJw4oRCPiEisIu2WjMh+aai0QIY2LoOhRFx5r92taGLcerIrzxKBAP6g==", - "requires": { - "@babel/runtime": "^7.18.3", - "@types/json-schema": "^7.0.9", - "ts-algebra": "^1.2.0" - } - }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" - }, - "json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" - }, - "jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", - "dev": true - }, - "jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "requires": { - "graceful-fs": "^4.1.6", - "universalify": "^2.0.0" - } - }, - "jsonpath": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.1.1.tgz", - "integrity": "sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w==", - "requires": { - "esprima": "1.2.2", - "static-eval": "2.0.2", - "underscore": "1.12.1" - }, - "dependencies": { - "esprima": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.2.tgz", - "integrity": "sha512-+JpPZam9w5DuJ3Q67SqsMGtiHKENSMRVoxvArfJZK01/BfLEObtZ6orJa/MtoGNR/rfMgp5837T41PAmTwAv/A==" - } - } - }, - "jsonwebtoken": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", - "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", - "requires": { - "jws": "^3.2.2", - "lodash": "^4.17.21", - "ms": "^2.1.1", - "semver": "^7.3.8" - } - }, - "jsprim": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", - "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", - "requires": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - }, - "dependencies": { - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" - }, - "verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "requires": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - } - } - }, - "jsx-ast-utils": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", - "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", - "dev": true, - "requires": { - "array-includes": "^3.1.5", - "object.assign": "^4.1.3" - } - }, - "just-extend": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", - "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", - "dev": true - }, - "jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "requires": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "jwks-rsa": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-2.0.5.tgz", - "integrity": "sha512-fliHfsiBRzEU0nXzSvwnh0hynzGB0WihF+CinKbSRlaqRxbqqKf2xbBPgwc8mzf18/WgwlG8e5eTpfSTBcU4DQ==", - "requires": { - "@types/express-jwt": "0.0.42", - "debug": "^4.3.2", - "jose": "^2.0.5", - "limiter": "^1.1.5", - "lru-memoizer": "^2.1.4" - }, - "dependencies": { - "jose": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.6.tgz", - "integrity": "sha512-FVoPY7SflDodE4lknJmbAHSUjLCzE2H1F6MS0RYKMQ8SR+lNccpMf8R4eqkNYyyUjR5qZReOzZo5C5YiHOCjjg==", - "requires": { - "@panva/asn1.js": "^1.0.0" - } - } - } - }, - "jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "requires": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, - "jwt-decode": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", - "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==", - "dev": true - }, - "kareem": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", - "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==" - }, - "kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true - }, - "kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" - }, - "language-subtag-registry": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz", - "integrity": "sha512-L0IqwlIXjilBVVYKFT37X9Ih11Um5NEl9cbJIuU/SwP/zEEAbBPOnEeeuxVMf45ydWQRDQN3Nqc96OgbH1K+Pg==", - "dev": true - }, - "language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha1-0yHbxNowuovzAk4ED6XBRmH5GTo=", - "dev": true, - "requires": { - "language-subtag-registry": "~0.3.2" - } - }, - "lcid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", - "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", - "requires": { - "invert-kv": "^1.0.0" - } - }, - "ldap-filter": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.2.2.tgz", - "integrity": "sha1-8rhCvguG2jNSeYUFsx68rlkNd9A=", - "requires": { - "assert-plus": "0.1.5" - }, - "dependencies": { - "assert-plus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz", - "integrity": "sha1-7nQAlBMALYTOxyGcasgRgS5yMWA=" - } - } - }, - "ldapjs": { - "version": "git+ssh://git@github.com/hpi-schul-cloud/node-ldapjs.git#3d470ab339c203560ad9816158dfb5695fff36ab", - "from": "ldapjs@git://github.com/hpi-schul-cloud/node-ldapjs.git", - "requires": { - "asn1": "0.2.3", - "assert-plus": "^1.0.0", - "backoff": "^2.5.0", - "bunyan": "^1.8.3", - "dashdash": "^1.14.0", - "dtrace-provider": "~0.8", - "ldap-filter": "0.2.2", - "once": "^1.4.0", - "vasync": "^1.6.4", - "verror": "^1.8.1" - } - }, - "leven": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", - "integrity": "sha1-wuep93IJTe6dNCAq6KzORoeHVYA=" - }, - "levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "lib0": { - "version": "0.2.87", - "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.87.tgz", - "integrity": "sha512-TbB63XJixvNToW2IHWAFsCJj9tVnajmwjE14p69i51Rx8byOQd2IP4ourE8v4d7vhyO++nVm1sQk3ePslfbucg==", - "requires": { - "isomorphic.js": "^0.2.4" - } - }, - "libphonenumber-js": { - "version": "1.10.24", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.24.tgz", - "integrity": "sha512-3Dk8f5AmrcWqg+oHhmm9hwSTqpWHBdSqsHmjCJGroULFubi0+x7JEIGmRZCuL3TI8Tx39xaKqfnhsDQ4ALa/Nw==" - }, - "lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "dev": true - }, - "limiter": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", - "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" - }, - "lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "listenercount": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", - "integrity": "sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc=", - "optional": true, - "peer": true - }, - "load-json-file": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", - "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", - "requires": { - "graceful-fs": "^4.1.2", - "parse-json": "^2.2.0", - "pify": "^2.0.0", - "pinkie-promise": "^2.0.0", - "strip-bom": "^2.0.0" - }, - "dependencies": { - "parse-json": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", - "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", - "requires": { - "error-ex": "^1.2.0" - } - }, - "pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" - }, - "strip-bom": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", - "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", - "requires": { - "is-utf8": "^0.2.0" - } - } - } - }, - "load-tsconfig": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.3.tgz", - "integrity": "sha512-iyT2MXws+dc2Wi6o3grCFtGXpeMvHmJqS27sMPGtV2eUu4PeFnG+33I8BlFK1t1NWMjOpcx9bridn5yxLDX2gQ==", - "dev": true - }, - "loader-runner": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz", - "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==", - "dev": true - }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" - }, - "lodash.assign": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", - "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=" - }, - "lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" - }, - "lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "optional": true, - "peer": true - }, - "lodash.flattendeep": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", - "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", - "dev": true - }, - "lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" - }, - "lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" - }, - "lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "optional": true, - "peer": true - }, - "lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" - }, - "lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" - }, - "lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" - }, - "lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" - }, - "lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" - }, - "lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", - "dev": true - }, - "lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true - }, - "lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" - }, - "lodash.pad": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/lodash.pad/-/lodash.pad-4.5.1.tgz", - "integrity": "sha1-QzCUmoM6fI2iLMIPaibE1Z3runA=", - "optional": true, - "peer": true - }, - "lodash.padend": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", - "integrity": "sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=", - "optional": true, - "peer": true - }, - "lodash.padstart": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/lodash.padstart/-/lodash.padstart-4.6.1.tgz", - "integrity": "sha1-0uPuv/DZ05rVD1y9G1KnvOa7YRs=", - "optional": true, - "peer": true - }, - "lodash.set": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", - "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=", - "dev": true - }, - "lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "dev": true - }, - "lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true - }, - "lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" - }, - "lodash.zipobject": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/lodash.zipobject/-/lodash.zipobject-4.1.3.tgz", - "integrity": "sha1-s5n1q6j/YqdG9peb8gshT5ZNvvg=" - }, - "log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "requires": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "logform": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.4.0.tgz", - "integrity": "sha512-CPSJw4ftjf517EhXZGGvTHHkYobo7ZCc0kvwUoOYcjfR2UVrI66RHj8MCrfAdEitdmFqbu2BYdYs8FHHZSb6iw==", - "requires": { - "@colors/colors": "1.5.0", - "fecha": "^4.2.0", - "ms": "^2.1.1", - "safe-stable-stringify": "^2.3.1", - "triple-beam": "^1.3.0" - } - }, - "loglevel": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.0.tgz", - "integrity": "sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA==", - "dev": true - }, - "loglevel-colored-level-prefix": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/loglevel-colored-level-prefix/-/loglevel-colored-level-prefix-1.0.0.tgz", - "integrity": "sha1-akAhj9x64V/HbD0PPmdsRlOIYD4=", - "dev": true, - "requires": { - "chalk": "^1.1.3", - "loglevel": "^1.4.1" - }, - "dependencies": { - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true - }, - "ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", - "dev": true - }, - "chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "dev": true, - "requires": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - } - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dev": true, - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", - "dev": true - } - } - }, - "long-timeout": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", - "integrity": "sha1-lyHXiLR+C8taJMLivuGg2lXatRQ=" - }, - "loupe": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.3.tgz", - "integrity": "sha512-krIV4Cf1BIGIx2t1e6tucThhrBemUnIUjMtD2vN4mrMxnxpBvrcosBSpooqunBqP/hOEEV1w/Cr1YskGtqw5Jg==", - "dev": true, - "requires": { - "get-func-name": "^2.0.0" - } - }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "requires": { - "yallist": "^4.0.0" - } - }, - "lru-memoizer": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.1.4.tgz", - "integrity": "sha512-IXAq50s4qwrOBrXJklY+KhgZF+5y98PDaNo0gi/v2KQBFLyWr+JyFvijZXkGKjQj/h9c0OwoE+JZbwUXce76hQ==", - "requires": { - "lodash.clonedeep": "^4.5.0", - "lru-cache": "~4.0.0" - }, - "dependencies": { - "lru-cache": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", - "integrity": "sha1-HRdnnAac2l0ECZGgnbwsDbN35V4=", - "requires": { - "pseudomap": "^1.0.1", - "yallist": "^2.0.0" - } - }, - "yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" - } - } - }, - "macos-release": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz", - "integrity": "sha512-EIgv+QZ9r+814gjJj0Bt5vSLJLzswGmSUbUpbi9AIr/fsN2IWFBl2NucV9PAiek+U1STK468tEkxmVYUtuAN3g==", - "dev": true - }, - "magic-string": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.1.tgz", - "integrity": "sha512-mbVKXPmS0z0G4XqFDCTllmDQ6coZzn94aMlb0o/A4HEHJCKcanlDZwYJgwnkmgD3jyWhUgj9VsPrfd972yPffA==", - "dev": true, - "requires": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "dependencies": { - "@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==", - "dev": true - } - } - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "requires": { - "semver": "^6.0.0" - }, - "dependencies": { - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" - }, - "make-error-cause": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/make-error-cause/-/make-error-cause-2.3.0.tgz", - "integrity": "sha512-etgt+n4LlOkGSJbBTV9VROHA5R7ekIPS4vfh+bCAoJgRrJWdqJCBbpS3osRJ/HrT7R68MzMiY3L3sDJ/Fd8aBg==", - "requires": { - "make-error": "^1.3.5" - } - }, - "makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "requires": { - "tmpl": "1.0.5" - } - }, - "md5-file": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/md5-file/-/md5-file-5.0.0.tgz", - "integrity": "sha512-xbEFXCYVWrSx/gEKS1VPlg84h/4L20znVIulKw6kMfmBUAZNAnF00eczz9ICMl+/hjQGo5KSXRxbL/47X3rmMw==", - "dev": true - }, - "media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" - }, - "memfs": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.0.tgz", - "integrity": "sha512-yK6o8xVJlQerz57kvPROwTMgx5WtGwC2ZxDtOUsnGl49rHjYkfQoPNZPCKH73VdLE1BwBu/+Fx/NL8NYMUw2aA==", - "dev": true, - "requires": { - "fs-monkey": "^1.0.3" - } - }, - "memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" - }, - "memory-stream": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/memory-stream/-/memory-stream-0.0.3.tgz", - "integrity": "sha1-6+jdHDuLw4wOeUHp3dWuvmtN6D8=", - "optional": true, - "peer": true, - "requires": { - "readable-stream": "~1.0.26-2" - }, - "dependencies": { - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "optional": true, - "peer": true - }, - "readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", - "optional": true, - "peer": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - } - } - }, - "merge": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/merge/-/merge-2.1.1.tgz", - "integrity": "sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==" - }, - "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" - }, - "merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" - }, - "methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" - }, - "micromatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", - "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", - "requires": { - "braces": "^3.0.1", - "picomatch": "^2.2.3" - } - }, - "migrate-mongoose": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/migrate-mongoose/-/migrate-mongoose-4.0.0.tgz", - "integrity": "sha512-Zf4Jk+CvBZUrZx4q/vvYr2pRGYAo7RO4BJx/3aTAR9VhNa34/iV0Rhqj87Tflk0n14SgwZpqvixyJzEpmSAikg==", - "requires": { - "bluebird": "^3.3.3", - "colors": "^1.1.2", - "dotenv": "^8.0.0", - "inquirer": "^0.12.0", - "mkdirp": "^0.5.1", - "mongoose": "^5.6.3", - "yargs": "^4.8.1" - }, - "dependencies": { - "@types/mongodb": { - "version": "3.6.20", - "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.6.20.tgz", - "integrity": "sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ==", - "requires": { - "@types/bson": "*", - "@types/node": "*" - } - }, - "ansi-escapes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", - "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=" - }, - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" - }, - "ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" - }, - "bluebird": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", - "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==" - }, - "bson": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz", - "integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==" - }, - "camelcase": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", - "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=" - }, - "chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "requires": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - } - }, - "cli-cursor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", - "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", - "requires": { - "restore-cursor": "^1.0.1" - } - }, - "cli-width": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", - "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==" - }, - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "requires": { - "ms": "2.0.0" - }, - "dependencies": { - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - } - } - }, - "dotenv": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", - "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==" - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" - }, - "figures": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", - "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", - "requires": { - "escape-string-regexp": "^1.0.5", - "object-assign": "^4.1.0" - } - }, - "get-caller-file": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", - "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==" - }, - "has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "inquirer": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", - "integrity": "sha1-HvK/1jUE3wvHV4X/+MLEHfEvB34=", - "requires": { - "ansi-escapes": "^1.1.0", - "ansi-regex": "^2.0.0", - "chalk": "^1.0.0", - "cli-cursor": "^1.0.1", - "cli-width": "^2.0.0", - "figures": "^1.3.5", - "lodash": "^4.3.0", - "readline2": "^1.0.1", - "run-async": "^0.1.0", - "rx-lite": "^3.1.2", - "string-width": "^1.0.1", - "strip-ansi": "^3.0.0", - "through": "^2.3.6" - } - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "requires": { - "number-is-nan": "^1.0.0" - } - }, - "kareem": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.2.tgz", - "integrity": "sha512-STHz9P7X2L4Kwn72fA4rGyqyXdmrMSdxqHx9IXon/FXluXieaFA6KJ2upcHAHxQPQ0LeM/OjLrhFxifHewOALQ==" - }, - "mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "requires": { - "minimist": "^1.2.5" - } - }, - "mongodb": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz", - "integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==", - "requires": { - "bl": "^2.2.1", - "bson": "^1.1.4", - "denque": "^1.4.1", - "optional-require": "^1.1.8", - "safe-buffer": "^5.1.2", - "saslprep": "^1.0.0" - }, - "dependencies": { - "optional-require": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.8.tgz", - "integrity": "sha512-jq83qaUb0wNg9Krv1c5OQ+58EK+vHde6aBPzLvPPqJm89UQWsvSuFy9X/OSNJnFeSOKo7btE0n8Nl2+nE+z5nA==", - "requires": { - "require-at": "^1.0.6" - } - } - } - }, - "mongoose": { - "version": "5.13.21", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.13.21.tgz", - "integrity": "sha512-EvSrXrCBogenxY131qKasFcT1Pj+9Pg5AXj17vQ8S1mOEArK3CpOx965u1wTIrdnQ7DjFC+SRwPxNcqUjMAVyQ==", - "requires": { - "@types/bson": "1.x || 4.0.x", - "@types/mongodb": "^3.5.27", - "bson": "^1.1.4", - "kareem": "2.3.2", - "mongodb": "3.7.4", - "mongoose-legacy-pluralize": "1.0.2", - "mpath": "0.8.4", - "mquery": "3.2.5", - "ms": "2.1.2", - "optional-require": "1.0.x", - "regexp-clone": "1.0.0", - "safe-buffer": "5.2.1", - "sift": "13.5.2", - "sliced": "1.0.1" - } - }, - "mquery": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.5.tgz", - "integrity": "sha512-VjOKHHgU84wij7IUoZzFRU07IAxd5kWJaDmyUzQlbjHjyoeK5TNeeo8ZsFDtTYnSgpW6n/nMNIHvE3u8Lbrf4A==", - "requires": { - "bluebird": "3.5.1", - "debug": "3.1.0", - "regexp-clone": "^1.0.0", - "safe-buffer": "5.1.2", - "sliced": "1.0.1" - }, - "dependencies": { - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - } - } - }, - "onetime": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", - "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=" - }, - "restore-cursor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", - "integrity": "sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=", - "requires": { - "exit-hook": "^1.0.0", - "onetime": "^1.0.0" - } - }, - "run-async": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz", - "integrity": "sha1-yK1KXhEGYeQCp9IbUw4AnyX444k=", - "requires": { - "once": "^1.3.0" - } - }, - "sift": { - "version": "13.5.2", - "resolved": "https://registry.npmjs.org/sift/-/sift-13.5.2.tgz", - "integrity": "sha512-+gxdEOMA2J+AI+fVsCqeNn7Tgx3M9ZN9jdi95939l1IJ8cZsqS8sqpJyOkic2SJk+1+98Uwryt/gL6XDaV+UZA==" - }, - "string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - } - }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "requires": { - "ansi-regex": "^2.0.0" - } - }, - "supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" - }, - "window-size": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.2.0.tgz", - "integrity": "sha1-tDFbtCFKPXBY6+7okuE/ok2YsHU=" - }, - "yargs": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-4.8.1.tgz", - "integrity": "sha512-LqodLrnIDM3IFT+Hf/5sxBnEGECrfdC1uIbgZeJmESCSo4HoCAaKEus8MylXHAkdacGc0ye+Qa+dpkuom8uVYA==", - "requires": { - "cliui": "^3.2.0", - "decamelize": "^1.1.1", - "get-caller-file": "^1.0.1", - "lodash.assign": "^4.0.3", - "os-locale": "^1.4.0", - "read-pkg-up": "^1.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^1.0.1", - "set-blocking": "^2.0.0", - "string-width": "^1.0.1", - "which-module": "^1.0.0", - "window-size": "^0.2.0", - "y18n": "^3.2.1", - "yargs-parser": "^2.4.1" - } - }, - "yargs-parser": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-2.4.1.tgz", - "integrity": "sha512-9pIKIJhnI5tonzG6OnCFlz/yln8xHYcGl+pn3xR0Vzff0vzN1PbNRaelgfgRUwZ3s4i3jvxT9WhmUGL4whnasA==", - "requires": { - "camelcase": "^3.0.0", - "lodash.assign": "^4.0.6" - } - } - } - }, - "mikro-orm": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/mikro-orm/-/mikro-orm-5.9.3.tgz", - "integrity": "sha512-lLBWENtV7yUE5KraqJEMaaKDPotnab6i/uf+wOyjILxYPjaXivH+oq7g9U3WS7K1fLUpQlR+bdQTOExHLy1FtQ==" - }, - "mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" - }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "requires": { - "mime-db": "1.52.0" - } - }, - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", - "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==" - }, - "minipass": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.6.tgz", - "integrity": "sha512-rty5kpw9/z8SX9dmxblFA6edItUmwJgMeYDZRrwlIVN27i8gysGbznJwUggw2V/FVqFSDdWy040ZPS811DYAqQ==", - "optional": true, - "peer": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "optional": true, - "peer": true, - "requires": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - } - }, - "mixwith": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/mixwith/-/mixwith-0.1.1.tgz", - "integrity": "sha1-yJlZGMW2H7/amtN3qFfNR3UFQcA=" - }, - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "devOptional": true - }, - "mocha": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.2.2.tgz", - "integrity": "sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g==", - "dev": true, - "requires": { - "@ungap/promise-all-settled": "1.1.2", - "ansi-colors": "4.1.1", - "browser-stdout": "1.3.1", - "chokidar": "3.5.3", - "debug": "4.3.3", - "diff": "5.0.0", - "escape-string-regexp": "4.0.0", - "find-up": "5.0.0", - "glob": "7.2.0", - "growl": "1.10.5", - "he": "1.2.0", - "js-yaml": "4.1.0", - "log-symbols": "4.1.0", - "minimatch": "4.2.1", - "ms": "2.1.3", - "nanoid": "3.3.1", - "serialize-javascript": "6.0.0", - "strip-json-comments": "3.1.1", - "supports-color": "8.1.1", - "which": "2.0.2", - "workerpool": "6.2.0", - "yargs": "16.2.0", - "yargs-parser": "20.2.4", - "yargs-unparser": "2.0.0" - }, - "dependencies": { - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "minimatch": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-4.2.1.tgz", - "integrity": "sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "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 - }, - "nanoid": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz", - "integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==", - "dev": true - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true - }, - "yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "requires": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - } - } - } - }, - "mockery": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mockery/-/mockery-2.1.0.tgz", - "integrity": "sha512-9VkOmxKlWXoDO/h1jDZaS4lH33aWfRiJiNT/tKj+8OGzrcFDLo8d0syGdbsc3Bc4GvRXPb+NMMvojotmuGJTvA==", - "dev": true - }, - "module-not-found-error": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", - "integrity": "sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA=", - "dev": true - }, - "moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" - }, - "mongodb": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.11.0.tgz", - "integrity": "sha512-9l9n4Nk2BYZzljW3vHah3Z0rfS5npKw6ktnkmFgTcnzaXH1DRm3pDl6VMHu84EVb1lzmSaJC4OzWZqTkB5i2wg==", - "requires": { - "@aws-sdk/credential-providers": "^3.186.0", - "bson": "^4.7.0", - "denque": "^2.1.0", - "mongodb-connection-string-url": "^2.5.4", - "saslprep": "^1.0.3", - "socks": "^2.7.1" - }, - "dependencies": { - "denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==" - } - } - }, - "mongodb-connection-string-url": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", - "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", - "requires": { - "@types/whatwg-url": "^8.2.1", - "whatwg-url": "^11.0.0" - } - }, - "mongodb-memory-server-core": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-8.10.2.tgz", - "integrity": "sha512-ro4k1eGcjk6p8214wFpv31dsB4eaBUMRr9WYLBcQDbmzCkM7ARn6vsJhlrKWH8eoayLZf0X6557j013t/Ld8aA==", - "dev": true, - "requires": { - "@types/tmp": "^0.2.3", - "async-mutex": "^0.3.2", - "camelcase": "^6.3.0", - "debug": "^4.3.4", - "find-cache-dir": "^3.3.2", - "get-port": "^5.1.1", - "https-proxy-agent": "^5.0.1", - "md5-file": "^5.0.0", - "mongodb": "~4.11.0", - "new-find-package-json": "^2.0.0", - "semver": "^7.3.8", - "tar-stream": "^2.1.4", - "tmp": "^0.2.1", - "tslib": "^2.4.1", - "uuid": "^8.3.1", - "yauzl": "^2.10.0" - }, - "dependencies": { - "async-mutex": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz", - "integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==", - "dev": true, - "requires": { - "tslib": "^2.3.1" - } - }, - "camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true - }, - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", - "dev": true, - "requires": { - "rimraf": "^3.0.0" - } - } - } - }, - "mongodb-memory-server-global-4.4": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-global-4.4/-/mongodb-memory-server-global-4.4-8.10.2.tgz", - "integrity": "sha512-gRtF8LAkXJMJkrSRhLoVEy87cs0y4B3BLa3OIIDf6Hn669Jm68e5Lg1cL0yjIc+mx0rPFCm9IhjZ0aO/miyhSQ==", - "dev": true, - "requires": { - "mongodb-memory-server-core": "8.10.2", - "tslib": "^2.4.1" - } - }, - "mongodb-uri": { - "version": "0.9.7", - "resolved": "https://registry.npmjs.org/mongodb-uri/-/mongodb-uri-0.9.7.tgz", - "integrity": "sha1-D3ca0W9IOuZfQoeWlCjp+8SqYYE=" - }, - "mongoose": { - "version": "6.12.3", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.12.3.tgz", - "integrity": "sha512-MNJymaaXali7w7rHBxVUoQ3HzHHMk/7I/+yeeoSa4rUzdjZwIWQznBNvVgc0A8ghuJwsuIkb5LyLV6gSjGjWyQ==", - "requires": { - "bson": "^4.7.2", - "kareem": "2.5.1", - "mongodb": "4.17.1", - "mpath": "0.9.0", - "mquery": "4.0.3", - "ms": "2.1.3", - "sift": "16.0.1" - }, - "dependencies": { - "bson": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz", - "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==", - "requires": { - "buffer": "^5.6.0" - } - }, - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "mongodb": { - "version": "4.17.1", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.17.1.tgz", - "integrity": "sha512-MBuyYiPUPRTqfH2dV0ya4dcr2E5N52ocBuZ8Sgg/M030nGF78v855B3Z27mZJnp8PxjnUquEnAtjOsphgMZOlQ==", - "requires": { - "@aws-sdk/credential-providers": "^3.186.0", - "@mongodb-js/saslprep": "^1.1.0", - "bson": "^4.7.2", - "mongodb-connection-string-url": "^2.6.0", - "socks": "^2.7.1" - } - }, - "mpath": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==" - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - } - } - }, - "mongoose-delete": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/mongoose-delete/-/mongoose-delete-0.5.4.tgz", - "integrity": "sha512-zKkcWFEwTneVG4Oiy+qJRJPFCiGlqOJToHeU3a3meJybWsHCnWxdHkGEkFWNY5cmYEv5av3WwByOhPlQ6I+FmQ==", - "requires": {} - }, - "mongoose-id-validator": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/mongoose-id-validator/-/mongoose-id-validator-0.6.0.tgz", - "integrity": "sha512-y3b3/PkmaiMKSbKB8tsEEGUjgCgKQGpD2Ood7jaVEob3V2HWgnmNKCgiSQUpEtQuDU0lUnLJQ5JE9PH1Bytziw==", - "requires": { - "clone": "^1.0.2", - "traverse": "^0.6.6" - }, - "dependencies": { - "clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" - } - } - }, - "mongoose-lean-virtuals": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/mongoose-lean-virtuals/-/mongoose-lean-virtuals-0.8.1.tgz", - "integrity": "sha512-XbL6V1Yg5mMtiCPZ0nsoq/55qhxOfdo/LX8cGTe5scTkk2BIzkOBFED+s8mLxWK0o0yVxigSTiF+tAdBp89QfQ==", - "requires": { - "array.prototype.flat": "1.2.3", - "mpath": "^0.8.4" - }, - "dependencies": { - "array.prototype.flat": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz", - "integrity": "sha512-gBlRZV0VSmfPIeWfuuy56XZMvbVfbEUnOXUvt3F/eUUUSyzlgLxhEX4YAEpxNAogRGehPSnfXyPtYyKAhkzQhQ==", - "requires": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.0-next.1" - } - } - } - }, - "mongoose-legacy-pluralize": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz", - "integrity": "sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ==", - "requires": {} - }, - "mongoose-shortid-nodeps": { - "version": "git+ssh://git@github.com/leeroybrun/mongoose-shortid-nodeps.git#3f6afe8f95504e0e7f1c59001b8a1dbcc164dfbb", - "from": "mongoose-shortid-nodeps@git://github.com/leeroybrun/mongoose-shortid-nodeps.git", - "requires": {} - }, - "moodle-client": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/moodle-client/-/moodle-client-0.5.2.tgz", - "integrity": "sha512-3ETg3cp5O0ASXn3YgVzQ8M7MkxOVgn5Mjp2O7nrUkGzLF6jA3OwWowIzhr/yHx+gLiANErM/+xulspFAJhQMhw==", - "requires": { - "bluebird": "^3.5.2", - "request": "^2.88.0", - "request-promise": "^4.2.2" - } - }, - "mpath": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.8.4.tgz", - "integrity": "sha512-DTxNZomBcTWlrMW76jy1wvV37X/cNNxPW1y2Jzd4DZkAaC5ZGsm8bfGfNOthcDuRJujXLqiuS6o3Tpy0JEoh7g==" - }, - "mqtt": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-4.3.5.tgz", - "integrity": "sha512-l29WGHAc0EayK1cjb6moozc+rlgK6YRCPbP3zB1CrJw84Bjk4kG9EJCXojdn4r29lA80SCqxRKq1QJ87+Xevng==", - "optional": true, - "peer": true, - "requires": { - "commist": "^1.0.0", - "concat-stream": "^2.0.0", - "debug": "^4.1.1", - "duplexify": "^4.1.1", - "help-me": "^3.0.0", - "inherits": "^2.0.3", - "lru-cache": "^6.0.0", - "minimist": "^1.2.5", - "mqtt-packet": "^6.8.0", - "number-allocator": "^1.0.9", - "pump": "^3.0.0", - "readable-stream": "^3.6.0", - "reinterval": "^1.1.0", - "rfdc": "^1.3.0", - "split2": "^3.1.0", - "ws": "^7.5.5", - "xtend": "^4.0.2" - }, - "dependencies": { - "duplexify": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", - "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", - "optional": true, - "peer": true, - "requires": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.0" - } - }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "optional": true, - "peer": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "optional": true, - "peer": true, - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "mqtt-packet": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-6.10.0.tgz", - "integrity": "sha512-ja8+mFKIHdB1Tpl6vac+sktqy3gA8t9Mduom1BA75cI+R9AHnZOiaBQwpGiWnaVJLDGRdNhQmFaAqd7tkKSMGA==", - "optional": true, - "peer": true, - "requires": { - "bl": "^4.0.2", - "debug": "^4.1.1", - "process-nextick-args": "^2.0.1" - }, - "dependencies": { - "bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "optional": true, - "peer": true, - "requires": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "optional": true, - "peer": true, - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "optional": true, - "peer": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "optional": true, - "peer": true, - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "mquery": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz", - "integrity": "sha512-J5heI+P08I6VJ2Ky3+33IpCdAvlYGTSUjwTPxkAr8i8EoduPMBX2OY/wa3IKZIQl7MU4SbFk8ndgSKyB/cl1zA==", - "requires": { - "debug": "4.x" - } - }, - "mri": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", - "integrity": "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==" - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "multer": { - "version": "1.4.4-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", - "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", - "requires": { - "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" - }, - "dependencies": { - "concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "requires": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "requires": { - "minimist": "^1.2.6" - } - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true - }, - "mv": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", - "integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=", - "optional": true, - "requires": { - "mkdirp": "~0.5.1", - "ncp": "~2.0.0", - "rimraf": "~2.4.0" - }, - "dependencies": { - "glob": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", - "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=", - "optional": true, - "requires": { - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "2 || 3", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "optional": true, - "requires": { - "minimist": "^1.2.5" - } - }, - "rimraf": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", - "integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=", - "optional": true, - "requires": { - "glob": "^6.0.1" - } - } - } - }, - "mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "requires": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "nan": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", - "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==", - "optional": true - }, - "nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==" - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "natural-compare-lite": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", - "dev": true - }, - "ncp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", - "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=", - "optional": true - }, - "negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" - }, - "neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true - }, - "nest-winston": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/nest-winston/-/nest-winston-1.9.4.tgz", - "integrity": "sha512-ilEmHuuYSAI6aMNR120fLBl42EdY13QI9WRggHdEizt9M7qZlmXJwpbemVWKW/tqRmULjSx/otKNQ3GMQbfoUQ==", - "requires": { - "fast-safe-stringify": "^2.1.1" - } - }, - "nestjs-console": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nestjs-console/-/nestjs-console-9.0.0.tgz", - "integrity": "sha512-5t9r0E9WHei2aCxPTPrtSq0h1rQBsO8TWok9HEwuRDpT3OEZJfA5FF0LaJDTD0DkusDq+GufSK1pZpgs21EeKQ==", - "requires": { - "commander": "^11.0.0" - }, - "dependencies": { - "commander": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz", - "integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==" - } - } - }, - "new-find-package-json": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", - "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", - "dev": true, - "requires": { - "debug": "^4.3.4" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - } - } - }, - "nice-try": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "dev": true - }, - "nise": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.1.tgz", - "integrity": "sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==", - "dev": true, - "requires": { - "@sinonjs/commons": "^1.8.3", - "@sinonjs/fake-timers": ">=5", - "@sinonjs/text-encoding": "^0.7.1", - "just-extend": "^4.0.2", - "path-to-regexp": "^1.7.0" - }, - "dependencies": { - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true - }, - "path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", - "dev": true, - "requires": { - "isarray": "0.0.1" - } - } - } - }, - "nock": { - "version": "13.2.4", - "resolved": "https://registry.npmjs.org/nock/-/nock-13.2.4.tgz", - "integrity": "sha512-8GPznwxcPNCH/h8B+XZcKjYPXnUV5clOKCjAqyjsiqA++MpNx9E9+t8YPp0MbThO+KauRo7aZJ1WuIZmOrT2Ug==", - "dev": true, - "requires": { - "debug": "^4.1.0", - "json-stringify-safe": "^5.0.1", - "lodash.set": "^4.3.2", - "propagate": "^2.0.0" - } - }, - "node-abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "dev": true - }, - "node-emoji": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", - "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", - "dev": true, - "requires": { - "lodash": "^4.17.21" - } - }, - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "requires": { - "whatwg-url": "^5.0.0" - }, - "dependencies": { - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" - }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - } - } - }, - "node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=", - "dev": true - }, - "node-machine-id": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", - "integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==" - }, - "node-preload": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", - "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", - "dev": true, - "requires": { - "process-on-spawn": "^1.0.0" - } - }, - "node-releases": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz", - "integrity": "sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==", - "dev": true - }, - "nodemon": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz", - "integrity": "sha512-Km2mWHKKY5GzRg6i1j5OxOHQtuvVsgskLfigG25yTtbyfRGn/GNvIbRyOf1PSCKJ2aT/58TiuUsuOU5UToVViw==", - "dev": true, - "requires": { - "chokidar": "^3.5.2", - "debug": "^3.2.7", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", - "pstree.remy": "^1.1.8", - "semver": "^5.7.1", - "simple-update-notifier": "^1.0.7", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "noms": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", - "integrity": "sha1-2o69nzr51nYJGbJ9nNyAkqczKFk=", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "readable-stream": "~1.0.31" - }, - "dependencies": { - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true - }, - "readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - } - } - }, - "nopt": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha1-bd0hvSoxQXuScn3Vhfim83YI6+4=", - "dev": true, - "requires": { - "abbrev": "1" - } - }, - "normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "requires": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - }, - "dependencies": { - "semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==" - } - } - }, - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true - }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "requires": { - "path-key": "^3.0.0" - } - }, - "npmlog": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-1.2.1.tgz", - "integrity": "sha1-KOe+YZYJtT960d0wChDWTXFiaLY=", - "optional": true, - "peer": true, - "requires": { - "ansi": "~0.3.0", - "are-we-there-yet": "~1.0.0", - "gauge": "~1.2.0" - } - }, - "nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "requires": { - "boolbase": "^1.0.0" - } - }, - "number-allocator": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.9.tgz", - "integrity": "sha512-sIIF0dZKMs3roPUD7rLreH8H3x47QKV9dHZ+PeSnH24gL0CxKxz/823woGZC0hLBSb2Ar/rOOeHiNbnPBum/Mw==", - "optional": true, - "peer": true, - "requires": { - "debug": "^4.3.1", - "js-sdsl": "^2.1.2" - } - }, - "number-is-nan": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" - }, - "nyc": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", - "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==", - "dev": true, - "requires": { - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "caching-transform": "^4.0.0", - "convert-source-map": "^1.7.0", - "decamelize": "^1.2.0", - "find-cache-dir": "^3.2.0", - "find-up": "^4.1.0", - "foreground-child": "^2.0.0", - "get-package-type": "^0.1.0", - "glob": "^7.1.6", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-hook": "^3.0.0", - "istanbul-lib-instrument": "^4.0.0", - "istanbul-lib-processinfo": "^2.0.2", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.0.2", - "make-dir": "^3.0.0", - "node-preload": "^0.2.1", - "p-map": "^3.0.0", - "process-on-spawn": "^1.0.0", - "resolve-from": "^5.0.0", - "rimraf": "^3.0.0", - "signal-exit": "^3.0.2", - "spawn-wrap": "^2.0.0", - "test-exclude": "^6.0.0", - "yargs": "^15.0.2" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "istanbul-lib-instrument": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", - "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", - "dev": true, - "requires": { - "@babel/core": "^7.7.5", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.0.0", - "semver": "^6.3.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true - }, - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - }, - "which-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", - "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", - "dev": true - }, - "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==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - } - }, - "y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true - }, - "yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "dev": true, - "requires": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - } - }, - "yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "dev": true, - "requires": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - } - } - } - }, - "oauth-1.0a": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", - "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==" - }, - "oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" - }, - "object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" - }, - "object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==" - }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" - }, - "object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - } - }, - "object.entries": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz", - "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.1" - } - }, - "object.fromentries": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", - "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" - } - }, - "object.groupby": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", - "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1" - } - }, - "object.values": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", - "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" - } - }, - "on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "requires": { - "wrappy": "1" - } - }, - "one-time": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", - "requires": { - "fn.name": "1.x.x" - } - }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "requires": { - "mimic-fn": "^2.1.0" - } - }, - "ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-9jnfVriq7uJM4o5ganUY54ntUm+5EK21EGaQ5NWnkWg3zz5ywbbonlBguRcnmF1/HDiIe3zxNxXcO1YPBmPcQQ==", - "requires": { - "@jsdevtools/ono": "7.1.3" - } - }, - "open": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz", - "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==", - "dev": true, - "requires": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - } - }, - "open-graph-scraper": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/open-graph-scraper/-/open-graph-scraper-6.2.2.tgz", - "integrity": "sha512-cQO0c0HF9ZMhSoIEOKMyxbSYwKn6qWBDEdQeCvZnAVwKCxSWj2DV8AwC1J4JCiwZbn/C4grGCJXpvmlAyTXrBg==", - "requires": { - "chardet": "^1.6.0", - "cheerio": "^1.0.0-rc.12", - "undici": "^5.22.1", - "validator": "^13.9.0" - }, - "dependencies": { - "chardet": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-1.6.0.tgz", - "integrity": "sha512-+QOTw3otC4+FxdjK9RopGpNOglADbr4WPFi0SonkO99JbpkTPbMxmdm4NenhF5Zs+4gPXLI1+y2uazws5TMe8w==" - } - } - }, - "optional-require": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz", - "integrity": "sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA==" - }, - "optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, - "requires": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" - } - }, - "ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "requires": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "requires": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "os-locale": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", - "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", - "requires": { - "lcid": "^1.0.0" - } - }, - "os-name": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/os-name/-/os-name-4.0.1.tgz", - "integrity": "sha512-xl9MAoU97MH1Xt5K9ERft2YfCAoaO6msy1OBA0ozxEC0x0TmIoE6K3QvgJMMZA9yKGLmHXNY/YZoDbiGDj4zYw==", - "dev": true, - "requires": { - "macos-release": "^2.5.0", - "windows-release": "^4.0.0" - } - }, - "os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", - "dev": true - }, - "p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-map": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", - "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", - "dev": true, - "requires": { - "aggregate-error": "^3.0.0" - } - }, - "package-hash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", - "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.15", - "hasha": "^5.0.0", - "lodash.flattendeep": "^4.4.0", - "release-zalgo": "^1.0.0" - } - }, - "papaparse": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.1.tgz", - "integrity": "sha512-Dbt2yjLJrCwH2sRqKFFJaN5XgIASO9YOFeFP8rIBRG2Ain8mqk5r1M6DkfvqEVozVcz3r3HaUGw253hA1nLIcA==" - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "requires": { - "callsites": "^3.0.0" - } - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, - "parse-srcset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", - "integrity": "sha1-8r0iH2zJcKk42IVWq8WJyqqiveE=" - }, - "parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "requires": { - "entities": "^4.4.0" - }, - "dependencies": { - "entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" - } - } - }, - "parse5-htmlparser2-tree-adapter": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", - "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", - "requires": { - "domhandler": "^5.0.2", - "parse5": "^7.0.0" - }, - "dependencies": { - "domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "requires": { - "domelementtype": "^2.3.0" - } - } - } - }, - "parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" - }, - "passport": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", - "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", - "requires": { - "passport-strategy": "1.x.x", - "pause": "0.0.1", - "utils-merge": "^1.0.1" - } - }, - "passport-custom": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", - "integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==", - "requires": { - "passport-strategy": "1.x.x" - } - }, - "passport-headerapikey": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/passport-headerapikey/-/passport-headerapikey-1.2.2.tgz", - "integrity": "sha512-4BvVJRrWsNJPrd3UoZfcnnl4zvUWYKEtfYkoDsaOKBsrWHYmzTApCjs7qUbncOLexE9ul0IRiYBFfBG0y9IVQA==", - "requires": { - "lodash": "^4.17.15", - "passport-strategy": "^1.0.0" - } - }, - "passport-jwt": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", - "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", - "requires": { - "jsonwebtoken": "^9.0.0", - "passport-strategy": "^1.0.0" - } - }, - "passport-local": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", - "integrity": "sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4=", - "requires": { - "passport-strategy": "1.x.x" - } - }, - "passport-strategy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", - "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", - "dev": true, - "requires": { - "lru-cache": "^9.1.1 || ^10.0.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "dependencies": { - "lru-cache": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz", - "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==", - "dev": true - }, - "minipass": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.3.tgz", - "integrity": "sha512-LhbbwCfz3vsb12j/WkWQPZfKTsgqIe1Nf/ti1pKjYESGLHIVjWU96G9/ljLH4F9mWNVhlQOm0VySdAWzf05dpg==", - "dev": true - } - } - }, - "path-to-regexp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" - }, - "pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true - }, - "pause": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", - "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" - }, - "peek-readable": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.0.0.tgz", - "integrity": "sha512-YtCKvLUOvwtMGmrniQPdO7MwPjgkFBtFIrmfSbYmYuq3tKDV/mcfAhBth1+C3ru7uXIZasc/pHnb+YDYNkkj4A==" - }, - "pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" - }, - "performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" - }, - "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" - }, - "pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==" - }, - "pinkie": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" - }, - "pinkie-promise": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", - "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", - "requires": { - "pinkie": "^2.0.0" - } - }, - "pirates": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", - "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", - "dev": true - }, - "pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "requires": { - "find-up": "^4.0.0" - }, - "dependencies": { - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - } - }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - } - } - }, - "pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true - }, - "popsicle": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/popsicle/-/popsicle-12.1.0.tgz", - "integrity": "sha512-muNC/cIrWhfR6HqqhHazkxjob3eyECBe8uZYSQ/N5vixNAgssacVleerXnE8Are5fspR0a+d2qWaBR1g7RYlmw==", - "requires": { - "popsicle-content-encoding": "^1.0.0", - "popsicle-cookie-jar": "^1.0.0", - "popsicle-redirects": "^1.1.0", - "popsicle-transport-http": "^1.0.8", - "popsicle-transport-xhr": "^2.0.0", - "popsicle-user-agent": "^1.0.0", - "servie": "^4.3.3", - "throwback": "^4.1.0" - } - }, - "popsicle-content-encoding": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/popsicle-content-encoding/-/popsicle-content-encoding-1.0.0.tgz", - "integrity": "sha512-4Df+vTfM8wCCJVTzPujiI6eOl3SiWQkcZg0AMrOkD1enMXsF3glIkFUZGvour1Sj7jOWCsNSEhBxpbbhclHhzw==", - "requires": {} - }, - "popsicle-cookie-jar": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/popsicle-cookie-jar/-/popsicle-cookie-jar-1.0.0.tgz", - "integrity": "sha512-vrlOGvNVELko0+J8NpGC5lHWDGrk8LQJq9nwAMIVEVBfN1Lib3BLxAaLRGDTuUnvl45j5N9dT2H85PULz6IjjQ==", - "requires": { - "@types/tough-cookie": "^2.3.5", - "tough-cookie": "^3.0.1" - }, - "dependencies": { - "tough-cookie": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz", - "integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==", - "requires": { - "ip-regex": "^2.1.0", - "psl": "^1.1.28", - "punycode": "^2.1.1" - } - } - } - }, - "popsicle-redirects": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/popsicle-redirects/-/popsicle-redirects-1.1.0.tgz", - "integrity": "sha512-XCpzVjVk7tty+IJnSdqWevmOr1n8HNDhL86v7mZ6T1JIIf2KGybxUk9mm7ZFOhWMkGB0e8XkacHip7BV8AQWQA==", - "requires": {} - }, - "popsicle-transport-http": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/popsicle-transport-http/-/popsicle-transport-http-1.2.1.tgz", - "integrity": "sha512-i5r3IGHkGiBDm1oPFvOfEeSGWR0lQJcsdTqwvvDjXqcTHYJJi4iSi3ecXIttDiTBoBtRAFAE9nF91fspQr63FQ==", - "requires": { - "make-error-cause": "^2.2.0" - } - }, - "popsicle-transport-xhr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/popsicle-transport-xhr/-/popsicle-transport-xhr-2.0.0.tgz", - "integrity": "sha512-5Sbud4Widngf1dodJE5cjEYXkzEUIl8CzyYRYR57t6vpy9a9KPGQX6KBKdPjmBZlR5A06pOBXuJnVr23l27rtA==", - "requires": {} - }, - "popsicle-user-agent": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/popsicle-user-agent/-/popsicle-user-agent-1.0.0.tgz", - "integrity": "sha512-epKaq3TTfTzXcxBxjpoKYMcTTcAX8Rykus6QZu77XNhJuRHSRxMd+JJrbX/3PFI0opFGSN0BabbAYCbGxbu0mA==", - "requires": {} - }, - "postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", - "requires": { - "nanoid": "^3.3.4", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - } - }, - "postcss-load-config": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", - "dev": true, - "requires": { - "lilconfig": "^2.0.5", - "yaml": "^1.10.2" - } - }, - "precond": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", - "integrity": "sha1-qpWRvKokkj8eD0hJ0kD0fvwQdaw=" - }, - "prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true - }, - "prettier": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz", - "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==", - "dev": true - }, - "prettier-eslint": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/prettier-eslint/-/prettier-eslint-12.0.0.tgz", - "integrity": "sha512-N8SGGQwAosISXTNl1E57sBbtnqUGlyRWjcfIUxyD3HF4ynehA9GZ8IfJgiep/OfYvCof/JEpy9ZqSl250Wia7A==", - "dev": true, - "requires": { - "@typescript-eslint/parser": "^3.0.0", - "common-tags": "^1.4.0", - "dlv": "^1.1.0", - "eslint": "^7.9.0", - "indent-string": "^4.0.0", - "lodash.merge": "^4.6.0", - "loglevel-colored-level-prefix": "^1.0.0", - "prettier": "^2.0.0", - "pretty-format": "^23.0.1", - "require-relative": "^0.8.7", - "typescript": "^3.9.3", - "vue-eslint-parser": "~7.1.0" - }, - "dependencies": { - "@babel/code-frame": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", - "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", - "dev": true, - "requires": { - "@babel/highlight": "^7.10.4" - } - }, - "@eslint/eslintrc": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", - "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", - "dev": true, - "requires": { - "ajv": "^6.12.4", - "debug": "^4.1.1", - "espree": "^7.3.0", - "globals": "^13.9.0", - "ignore": "^4.0.6", - "import-fresh": "^3.2.1", - "js-yaml": "^3.13.1", - "minimatch": "^3.0.4", - "strip-json-comments": "^3.1.1" - } - }, - "@humanwhocodes/config-array": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", - "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", - "dev": true, - "requires": { - "@humanwhocodes/object-schema": "^1.2.0", - "debug": "^4.1.1", - "minimatch": "^3.0.4" - } - }, - "@typescript-eslint/parser": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-3.10.1.tgz", - "integrity": "sha512-Ug1RcWcrJP02hmtaXVS3axPPTTPnZjupqhgj+NnZ6BCkwSImWk/283347+x9wN+lqOdK9Eo3vsyiyDHgsmiEJw==", - "dev": true, - "requires": { - "@types/eslint-visitor-keys": "^1.0.0", - "@typescript-eslint/experimental-utils": "3.10.1", - "@typescript-eslint/types": "3.10.1", - "@typescript-eslint/typescript-estree": "3.10.1", - "eslint-visitor-keys": "^1.1.0" - } - }, - "@typescript-eslint/types": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz", - "integrity": "sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ==", - "dev": true - }, - "@typescript-eslint/typescript-estree": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz", - "integrity": "sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w==", - "dev": true, - "requires": { - "@typescript-eslint/types": "3.10.1", - "@typescript-eslint/visitor-keys": "3.10.1", - "debug": "^4.1.1", - "glob": "^7.1.6", - "is-glob": "^4.0.1", - "lodash": "^4.17.15", - "semver": "^7.3.2", - "tsutils": "^3.17.1" - } - }, - "@typescript-eslint/visitor-keys": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz", - "integrity": "sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^1.1.0" - } - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-regex": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", - "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", - "dev": true - }, - "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==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - } - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "eslint": { - "version": "7.32.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", - "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", - "dev": true, - "requires": { - "@babel/code-frame": "7.12.11", - "@eslint/eslintrc": "^0.4.3", - "@humanwhocodes/config-array": "^0.5.0", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.0.1", - "doctrine": "^3.0.0", - "enquirer": "^2.3.5", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^5.1.1", - "eslint-utils": "^2.1.0", - "eslint-visitor-keys": "^2.0.0", - "espree": "^7.3.1", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.1.2", - "globals": "^13.6.0", - "ignore": "^4.0.6", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "js-yaml": "^3.13.1", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.0.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "progress": "^2.0.0", - "regexpp": "^3.1.0", - "semver": "^7.2.1", - "strip-ansi": "^6.0.0", - "strip-json-comments": "^3.1.0", - "table": "^6.0.9", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true - } - } - }, - "eslint-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^1.1.0" - } - }, - "eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true - }, - "espree": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", - "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", - "dev": true, - "requires": { - "acorn": "^7.4.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^1.3.0" - } - }, - "globals": { - "version": "13.19.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz", - "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, - "ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "pretty-format": { - "version": "23.6.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", - "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", - "dev": true, - "requires": { - "ansi-regex": "^3.0.0", - "ansi-styles": "^3.2.0" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - }, - "typescript": { - "version": "3.9.10", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz", - "integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==", - "dev": true - } - } - }, - "prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "requires": { - "fast-diff": "^1.1.2" - } - }, - "pretty-format": { - "version": "29.2.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.2.1.tgz", - "integrity": "sha512-Y41Sa4aLCtKAXvwuIpTvcFBkyeYp2gdFWzXGA+ZNES3VwURIB165XO/z7CjETwzCCS53MjW/rLMyyqEnTtaOfA==", - "dev": true, - "requires": { - "@jest/schemas": "^29.0.0", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - } - }, - "process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" - }, - "process-on-spawn": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", - "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", - "dev": true, - "requires": { - "fromentries": "^1.2.0" - } - }, - "progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true - }, - "prom-client": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-13.2.0.tgz", - "integrity": "sha512-wGr5mlNNdRNzEhRYXgboUU2LxHWIojxscJKmtG3R8f4/KiWqyYgXTLHs0+Ted7tG3zFT7pgHJbtomzZ1L0ARaQ==", - "requires": { - "tdigest": "^0.1.1" - } - }, - "promise-breaker": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/promise-breaker/-/promise-breaker-5.0.0.tgz", - "integrity": "sha512-mgsWQuG4kJ1dtO6e/QlNDLFtMkMzzecsC69aI5hlLEjGHFNpHrvGhFi4LiK5jg2SMQj74/diH+wZliL9LpGsyA==" - }, - "promise-coalesce": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.1.tgz", - "integrity": "sha512-k7+VaIwZc5dRfSF6RELqRY1+LCmcCkrnuNV9HzIpA6iwRHKke+j9yb0LBTTHQ2RRgf6AlMl9TntuTzcgV/BZwg==" - }, - "promisepipe": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/promisepipe/-/promisepipe-3.0.0.tgz", - "integrity": "sha512-V6TbZDJ/ZswevgkDNpGt/YqNCiZP9ASfgU+p83uJE6NrGtvSGoOcHLiDCqkMs2+yg7F5qHdLV8d0aS8O26G/KA==" - }, - "prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "requires": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - } - }, - "propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", - "dev": true - }, - "proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "requires": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - } - }, - "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, - "proxyquire": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", - "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", - "dev": true, - "requires": { - "fill-keys": "^1.0.2", - "module-not-found-error": "^1.0.1", - "resolve": "^1.11.1" - } - }, - "pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" - }, - "psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" - }, - "pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true - }, - "pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "devOptional": true, - "requires": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" - }, - "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "requires": { - "side-channel": "^1.0.4" - } - }, - "querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" - }, - "querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" - }, - "queue": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", - "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", - "requires": { - "inherits": "~2.0.3" - } - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" - }, - "random-bytes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", - "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==" - }, - "randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "requires": { - "safe-buffer": "^5.1.0" - } - }, - "range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" - }, - "raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", - "requires": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "dependencies": { - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - }, - "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - } - }, - "statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" - } - } - }, - "rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "optional": true, - "peer": true, - "requires": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "dependencies": { - "strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", - "optional": true, - "peer": true - } - } - }, - "react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, - "read-chunk": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-3.2.0.tgz", - "integrity": "sha512-CEjy9LCzhmD7nUpJ1oVOE6s/hBkejlcJEgLQHVnQznOSilOPb+kpKktlLfFDK3/WP43+F80xkUTM2VOkYoSYvQ==", - "requires": { - "pify": "^4.0.1", - "with-open-file": "^0.1.6" - } - }, - "read-pkg": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", - "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", - "requires": { - "load-json-file": "^1.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^1.0.0" - }, - "dependencies": { - "path-type": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", - "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", - "requires": { - "graceful-fs": "^4.1.2", - "pify": "^2.0.0", - "pinkie-promise": "^2.0.0" - } - }, - "pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" - } - } - }, - "read-pkg-up": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", - "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", - "requires": { - "find-up": "^1.0.0", - "read-pkg": "^1.0.0" - }, - "dependencies": { - "find-up": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", - "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", - "requires": { - "path-exists": "^2.0.0", - "pinkie-promise": "^2.0.0" - } - }, - "path-exists": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", - "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", - "requires": { - "pinkie-promise": "^2.0.0" - } - } - } - }, - "readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - }, - "dependencies": { - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" - } - } - }, - "readable-web-to-node-stream": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", - "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", - "requires": { - "readable-stream": "^3.6.0" - }, - "dependencies": { - "readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "requires": { - "picomatch": "^2.2.1" - } - }, - "readline2": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz", - "integrity": "sha1-QQWWCP/BVHV7cV2ZidGZ/783LjU=", - "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "mute-stream": "0.0.5" - }, - "dependencies": { - "is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "requires": { - "number-is-nan": "^1.0.0" - } - }, - "mute-stream": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz", - "integrity": "sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA=" - } - } - }, - "rechoir": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", - "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", - "dev": true, - "requires": { - "resolve": "^1.1.6" - } - }, - "redis": { - "version": "4.6.11", - "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.11.tgz", - "integrity": "sha512-kg1Lt4NZLYkAjPOj/WcyIGWfZfnyfKo1Wg9YKVSlzhFwxpFIl3LYI8BWy1Ab963LLDsTz2+OwdsesHKljB3WMQ==", - "requires": { - "@redis/bloom": "1.2.0", - "@redis/client": "1.5.12", - "@redis/graph": "1.1.1", - "@redis/json": "1.0.6", - "@redis/search": "1.1.6", - "@redis/time-series": "1.0.5" - } - }, - "redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "optional": true, - "peer": true - }, - "redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "optional": true, - "peer": true, - "requires": { - "redis-errors": "^1.0.0" - } - }, - "reflect-metadata": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", - "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" - }, - "regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" - }, - "regexp-clone": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz", - "integrity": "sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw==" - }, - "regexp.prototype.flags": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", - "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "set-function-name": "^2.0.0" - } - }, - "regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true - }, - "reinterval": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reinterval/-/reinterval-1.1.0.tgz", - "integrity": "sha1-M2Hs+jymwYKDOA3Qu5VG85D17Oc=", - "optional": true, - "peer": true - }, - "release-zalgo": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", - "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", - "dev": true, - "requires": { - "es6-error": "^4.0.1" - } - }, - "repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "dev": true - }, - "request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "requires": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - }, - "dependencies": { - "form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - } - }, - "qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==" - }, - "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "requires": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - } - }, - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" - } - } - }, - "request-promise": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.6.tgz", - "integrity": "sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==", - "requires": { - "bluebird": "^3.5.0", - "request-promise-core": "1.1.4", - "stealthy-require": "^1.1.1", - "tough-cookie": "^2.3.3" - }, - "dependencies": { - "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "requires": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - } - } - } - }, - "request-promise-core": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", - "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", - "requires": { - "lodash": "^4.17.19" - } - }, - "request-promise-native": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.9.tgz", - "integrity": "sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==", - "requires": { - "request-promise-core": "1.1.4", - "stealthy-require": "^1.1.1", - "tough-cookie": "^2.3.3" - }, - "dependencies": { - "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "requires": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - } - } - } - }, - "require-at": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/require-at/-/require-at-1.0.6.tgz", - "integrity": "sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==" - }, - "require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" - }, - "require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" - }, - "require-main-filename": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", - "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=" - }, - "require-relative": { - "version": "0.8.7", - "resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz", - "integrity": "sha1-eZlTn8ngR6N5KPoZb44VY9q9Nt4=", - "dev": true - }, - "requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" - }, - "resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "requires": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "requires": { - "resolve-from": "^5.0.0" - }, - "dependencies": { - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true - } - } - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - }, - "resolve.exports": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz", - "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==", - "dev": true - }, - "response-time": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/response-time/-/response-time-2.3.2.tgz", - "integrity": "sha512-MUIDaDQf+CVqflfTdQ5yam+aYCkXj1PY8fjlPDQ6ppxJlmgZb864pHtA750mayywNg8tx4rS7qH9JXd/OF+3gw==", - "requires": { - "depd": "~1.1.0", - "on-headers": "~1.0.1" - } - }, - "restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "requires": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - } - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" - }, - "rewire": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/rewire/-/rewire-5.0.0.tgz", - "integrity": "sha512-1zfitNyp9RH5UDyGGLe9/1N0bMlPQ0WrX0Tmg11kMHBpqwPJI4gfPpP7YngFyLbFmhXh19SToAG0sKKEFcOIJA==", - "dev": true, - "requires": { - "eslint": "^6.8.0" - }, - "dependencies": { - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "dev": true - }, - "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==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "astral-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", - "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", - "dev": true - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", - "dev": true, - "requires": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - }, - "dependencies": { - "semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true - } - } - }, - "emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true - }, - "eslint": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.8.0.tgz", - "integrity": "sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "ajv": "^6.10.0", - "chalk": "^2.1.0", - "cross-spawn": "^6.0.5", - "debug": "^4.0.1", - "doctrine": "^3.0.0", - "eslint-scope": "^5.0.0", - "eslint-utils": "^1.4.3", - "eslint-visitor-keys": "^1.1.0", - "espree": "^6.1.2", - "esquery": "^1.0.1", - "esutils": "^2.0.2", - "file-entry-cache": "^5.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^5.0.0", - "globals": "^12.1.0", - "ignore": "^4.0.6", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "inquirer": "^7.0.0", - "is-glob": "^4.0.0", - "js-yaml": "^3.13.1", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.3.0", - "lodash": "^4.17.14", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.1", - "natural-compare": "^1.4.0", - "optionator": "^0.8.3", - "progress": "^2.0.0", - "regexpp": "^2.0.1", - "semver": "^6.1.2", - "strip-ansi": "^5.2.0", - "strip-json-comments": "^3.0.1", - "table": "^5.2.3", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - } - }, - "eslint-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", - "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^1.1.0" - } - }, - "eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true - }, - "espree": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-6.2.1.tgz", - "integrity": "sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==", - "dev": true, - "requires": { - "acorn": "^7.1.1", - "acorn-jsx": "^5.2.0", - "eslint-visitor-keys": "^1.1.0" - } - }, - "file-entry-cache": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", - "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==", - "dev": true, - "requires": { - "flat-cache": "^2.0.1" - } - }, - "flat-cache": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", - "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", - "dev": true, - "requires": { - "flatted": "^2.0.0", - "rimraf": "2.6.3", - "write": "1.0.3" - } - }, - "flatted": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz", - "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", - "dev": true - }, - "globals": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", - "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==", - "dev": true, - "requires": { - "type-fest": "^0.8.1" - } - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "ignore": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", - "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - } - }, - "mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "dev": true, - "requires": { - "minimist": "^1.2.5" - } - }, - "optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "requires": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - } - }, - "path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", - "dev": true - }, - "prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", - "dev": true - }, - "regexpp": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", - "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", - "dev": true - }, - "rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - }, - "shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", - "dev": true, - "requires": { - "shebang-regex": "^1.0.0" - } - }, - "shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", - "dev": true - }, - "slice-ansi": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", - "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.0", - "astral-regex": "^1.0.0", - "is-fullwidth-code-point": "^2.0.0" - } - }, - "string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "dev": true, - "requires": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - } - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "requires": { - "ansi-regex": "^4.1.0" - } - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - }, - "table": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", - "integrity": "sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==", - "dev": true, - "requires": { - "ajv": "^6.10.2", - "lodash": "^4.17.14", - "slice-ansi": "^2.1.0", - "string-width": "^3.0.0" - } - }, - "type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", - "dev": true, - "requires": { - "prelude-ls": "~1.1.2" - } - }, - "type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true - } - } - }, - "rfdc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", - "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", - "optional": true, - "peer": true - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "requires": { - "glob": "^7.1.3" - } - }, - "rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", - "dev": true, - "requires": { - "fsevents": "~2.3.2" - } - }, - "rss-parser": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", - "integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==", - "requires": { - "entities": "^2.0.3", - "xml2js": "^0.5.0" - }, - "dependencies": { - "xml2js": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", - "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", - "requires": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - } - } - } - }, - "run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "rx-lite": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz", - "integrity": "sha1-Gc5QLKVyZl87ZHsQk5+X/RYV8QI=" - }, - "rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "requires": { - "tslib": "^2.1.0" - } - }, - "safe-array-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", - "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "has-symbols": "^1.0.3", - "isarray": "^2.0.5" - }, - "dependencies": { - "isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" - } - } - }, - "safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "safe-json-stringify": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", - "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", - "optional": true - }, - "safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "is-regex": "^1.1.4" - } - }, - "safe-stable-stringify": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz", - "integrity": "sha512-kYBSfT+troD9cDA85VDnHZ1rpHC50O0g1e6WlGHVCz/g+JS+9WKLj+XwFYyR8UbrZN8ll9HUpDAAddY58MGisg==" - }, - "safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "sanitize-html": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.7.0.tgz", - "integrity": "sha512-jfQelabOn5voO7FAfnQF7v+jsA6z9zC/O4ec0z3E35XPEtHYJT/OdUziVWlKW4irCr2kXaQAyXTXDHWAibg1tA==", - "requires": { - "deepmerge": "^4.2.2", - "escape-string-regexp": "^4.0.0", - "htmlparser2": "^6.0.0", - "is-plain-object": "^5.0.0", - "parse-srcset": "^1.0.2", - "postcss": "^8.3.11" - }, - "dependencies": { - "is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" - } - } - }, - "saslprep": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", - "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==", - "optional": true, - "requires": { - "sparse-bitfield": "^3.0.3" - } - }, - "sax": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", - "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" - }, - "schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "requires": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "dependencies": { - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "requires": {} - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - } - } - }, - "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "requires": { - "lru-cache": "^6.0.0" - } - }, - "send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "requires": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - }, - "dependencies": { - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - } - } - }, - "depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" - }, - "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - } - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "requires": { - "ee-first": "1.1.1" - } - }, - "statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" - } - } - }, - "serialize-javascript": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", - "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", - "dev": true, - "requires": { - "randombytes": "^2.1.0" - } - }, - "serve-favicon": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.5.0.tgz", - "integrity": "sha1-k10kDN/g9YBTB/3+ln2IlCosvPA=", - "requires": { - "etag": "~1.8.1", - "fresh": "0.5.2", - "ms": "2.1.1", - "parseurl": "~1.3.2", - "safe-buffer": "5.1.1" - }, - "dependencies": { - "ms": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", - "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" - }, - "safe-buffer": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", - "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" - } - } - }, - "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==", - "requires": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - } - }, - "service": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/service/-/service-0.1.4.tgz", - "integrity": "sha1-0Kuf+8K51Yda+LAd7DYzl4RSW0Q=", - "requires": { - "daemon": ">=0.3.0" - } - }, - "servie": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/servie/-/servie-4.3.3.tgz", - "integrity": "sha512-b0IrY3b1gVMsWvJppCf19g1p3JSnS0hQi6xu4Hi40CIhf0Lx8pQHcvBL+xunShpmOiQzg1NOia812NAWdSaShw==", - "requires": { - "@servie/events": "^1.0.0", - "byte-length": "^1.0.2", - "ts-expect": "^1.1.0" - } - }, - "set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" - }, - "set-function-length": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", - "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", - "requires": { - "define-data-property": "^1.1.1", - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" - } - }, - "set-function-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", - "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", - "requires": { - "define-data-property": "^1.0.1", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.0" - } - }, - "setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", - "optional": true, - "peer": true - }, - "setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "sha1": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/sha1/-/sha1-1.1.1.tgz", - "integrity": "sha1-rdqnqTFo85PxnrKxUJFhjicA+Eg=", - "requires": { - "charenc": ">= 0.0.1", - "crypt": ">= 0.0.1" - } - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" - }, - "shelljs": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", - "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", - "dev": true, - "requires": { - "glob": "^7.0.0", - "interpret": "^1.0.0", - "rechoir": "^0.6.2" - } - }, - "shx": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/shx/-/shx-0.3.4.tgz", - "integrity": "sha512-N6A9MLVqjxZYcVn8hLmtneQWIJtp8IKzMP4eMnx+nqkvXoqinUPCbUFLp2UcWTEIUONhlk0ewxr/jaVGlc+J+g==", - "dev": true, - "requires": { - "minimist": "^1.2.3", - "shelljs": "^0.8.5" - } - }, - "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - } - }, - "sift": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", - "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" - }, - "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 - }, - "simple-oauth2": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/simple-oauth2/-/simple-oauth2-4.3.0.tgz", - "integrity": "sha512-gjLIfy7M7WZSf3k5IZCQfEozbQwmW80zR9YMH4ph/WWG6S4U6sGhPujz8X6Hj6sZ8l7acSAxiyM4tF0vIN+E+A==", - "dev": true, - "requires": { - "@hapi/hoek": "^9.0.4", - "@hapi/wreck": "^17.0.0", - "debug": "^4.1.1", - "joi": "^17.3.0" - } - }, - "simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", - "requires": { - "is-arrayish": "^0.3.1" - }, - "dependencies": { - "is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" - } - } - }, - "simple-update-notifier": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.0.7.tgz", - "integrity": "sha512-BBKgR84BJQJm6WjWFMHgLVuo61FBDSj1z/xSFUIozqO6wO7ii0JxCqlIud7Enr/+LhlbNI0whErq96P2qHNWew==", - "dev": true, - "requires": { - "semver": "~7.0.0" - }, - "dependencies": { - "semver": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", - "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", - "dev": true - } - } - }, - "sinon": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-11.1.2.tgz", - "integrity": "sha512-59237HChms4kg7/sXhiRcUzdSkKuydDeTiamT/jesUVHshBgL8XAmhgFo0GfK6RruMDM/iRSij1EybmMog9cJw==", - "dev": true, - "requires": { - "@sinonjs/commons": "^1.8.3", - "@sinonjs/fake-timers": "^7.1.2", - "@sinonjs/samsam": "^6.0.2", - "diff": "^5.0.0", - "nise": "^5.1.0", - "supports-color": "^7.2.0" - }, - "dependencies": { - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "sinon-chai": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-3.7.0.tgz", - "integrity": "sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g==", - "dev": true, - "requires": {} - }, - "sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==" - }, - "slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "requires": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - } - } - }, - "sliced": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", - "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==" - }, - "smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" - }, - "socketio-file-upload": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/socketio-file-upload/-/socketio-file-upload-0.7.3.tgz", - "integrity": "sha512-JUvzi8Vvp2+GBfQtOehPSfecetZOo4g1JTl6+zmKhPiljn+z09lLL8zYeX4AJVSNpmRkGJnypbHkiPjPSkk5UA==" - }, - "socks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", - "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", - "requires": { - "ip": "^2.0.0", - "smart-buffer": "^4.2.0" - } - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - }, - "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" - }, - "source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", - "requires": { - "memory-pager": "^1.0.2" - } - }, - "spawn-command": { - "version": "0.0.2-1", - "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz", - "integrity": "sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A=" - }, - "spawn-wrap": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", - "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", - "dev": true, - "requires": { - "foreground-child": "^2.0.0", - "is-windows": "^1.0.2", - "make-dir": "^3.0.0", - "rimraf": "^3.0.0", - "signal-exit": "^3.0.2", - "which": "^2.0.1" - }, - "dependencies": { - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "spdx-correct": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", - "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", - "requires": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==" - }, - "spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "requires": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "spdx-license-ids": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz", - "integrity": "sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g==" - }, - "split2": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", - "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", - "optional": true, - "peer": true, - "requires": { - "readable-stream": "^3.0.0" - }, - "dependencies": { - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "optional": true, - "peer": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "optional": true, - "peer": true, - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "splitargs": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/splitargs/-/splitargs-0.0.7.tgz", - "integrity": "sha1-/p965lc3GzOxDLgNoUPPgknPazs=", - "optional": true, - "peer": true - }, - "sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true - }, - "sshpk": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", - "integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==", - "requires": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - } - }, - "stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" - }, - "stack-utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz", - "integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==", - "dev": true, - "requires": { - "escape-string-regexp": "^2.0.0" - }, - "dependencies": { - "escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true - } - } - }, - "standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "optional": true, - "peer": true - }, - "static-eval": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", - "integrity": "sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg==", - "requires": { - "escodegen": "^1.8.1" - } - }, - "stealthy-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=" - }, - "stream-browserify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", - "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", - "requires": { - "inherits": "~2.0.4", - "readable-stream": "^3.5.0" - }, - "dependencies": { - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "stream-buffers": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.2.tgz", - "integrity": "sha512-DQi1h8VEBA/lURbSwFtEHnSTb9s2/pwLEaFuNhXwy1Dx3Sa0lOuYT2yNUr4/j2fs8oCAMANtrZ5OrPZtyVs3MQ==" - }, - "stream-shift": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", - "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", - "optional": true, - "peer": true - }, - "streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" - }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" - }, - "string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "requires": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - } - }, - "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==", - "requires": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "dependencies": { - "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==" - } - } - }, - "string.prototype.trim": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", - "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" - } - }, - "string.prototype.trimend": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", - "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" - } - }, - "string.prototype.trimstart": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", - "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "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==" - }, - "strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true - }, - "strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true - }, - "strnum": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", - "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" - }, - "strtok3": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-7.0.0.tgz", - "integrity": "sha512-pQ+V+nYQdC5H3Q7qBZAz/MO6lwGhoC2gOAjuouGf/VO0m7vQRh8QNMl2Uf6SwAtzZ9bOw3UIeBukEGNJl5dtXQ==", - "requires": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^5.0.0" - } - }, - "sucrase": { - "version": "3.29.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.29.0.tgz", - "integrity": "sha512-bZPAuGA5SdFHuzqIhTAqt9fvNEo9rESqXIG3oiKdF8K4UmkQxC4KlNL3lVyAErXp+mPvUqZ5l13qx6TrDIGf3A==", - "dev": true, - "requires": { - "commander": "^4.0.0", - "glob": "7.1.6", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "dependencies": { - "commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true - }, - "glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } - } - }, - "superagent": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", - "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", - "dev": true, - "requires": { - "component-emitter": "^1.2.0", - "cookiejar": "^2.1.0", - "debug": "^3.1.0", - "extend": "^3.0.0", - "form-data": "^2.3.1", - "formidable": "^1.2.0", - "methods": "^1.1.1", - "mime": "^1.4.1", - "qs": "^6.5.1", - "readable-stream": "^2.3.5" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "form-data": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", - "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", - "dev": true, - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - } - }, - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "supertest": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.2.tgz", - "integrity": "sha512-mSmbW/sPpBU6K8w8189ZiHdc62zMe7dCHpC2ktS9tc0/d2DN0FaxNbDJJNFknZD4jCrGJpxkiFoVyemvKgOdwA==", - "dev": true, - "requires": { - "methods": "^1.1.2", - "superagent": "^8.0.3" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "formidable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.1.tgz", - "integrity": "sha512-0EcS9wCFEzLvfiks7omJ+SiYJAiD+TzK4Pcw1UlUoGnhUxDcMKjt0P7x8wEb0u6OHu8Nb98WG3nxtlF5C7bvUQ==", - "dev": true, - "requires": { - "dezalgo": "^1.0.4", - "hexoid": "^1.0.0", - "once": "^1.4.0", - "qs": "^6.11.0" - } - }, - "mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true - }, - "superagent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.5.tgz", - "integrity": "sha512-lQVE0Praz7nHiSaJLKBM/cZyi7J0E4io8tWnGSBdBrqAzhzrjQ/F5iGP9Zr29CJC8N5zYdhG2kKaNcB6dKxp7g==", - "dev": true, - "requires": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.3", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.0.1", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.11.0", - "semver": "^7.3.8" - } - } - } - }, - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "requires": { - "has-flag": "^4.0.0" - } - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" - }, - "swagger-ui-dist": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.18.2.tgz", - "integrity": "sha512-oVBoBl9Dg+VJw8uRWDxlyUyHoNEDC0c1ysT6+Boy6CTgr2rUcLcfPon4RvxgS2/taNW6O0+US+Z/dlAsWFjOAQ==" - }, - "swagger-ui-express": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz", - "integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==", - "requires": { - "swagger-ui-dist": ">=4.1.3" - } - }, - "symbol-observable": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", - "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", - "dev": true - }, - "synckit": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.4.tgz", - "integrity": "sha512-Dn2ZkzMdSX827QbowGbU/4yjWuvNaCoScLLoMo/yKbu+P4GBR6cRGKZH27k6a9bRzdqcyd1DE96pQtQ6uNkmyw==", - "dev": true, - "requires": { - "@pkgr/utils": "^2.3.1", - "tslib": "^2.4.0" - } - }, - "table": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz", - "integrity": "sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA==", - "dev": true, - "requires": { - "ajv": "^8.0.1", - "lodash.truncate": "^4.4.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - } - }, - "tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true - }, - "tar": { - "version": "6.1.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", - "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", - "optional": true, - "peer": true, - "requires": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^3.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - } - }, - "tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "requires": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "dependencies": { - "bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "requires": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "requires": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "tdigest": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz", - "integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=", - "requires": { - "bintrees": "1.0.1" - } - }, - "terser": { - "version": "5.19.4", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.4.tgz", - "integrity": "sha512-6p1DjHeuluwxDXcuT9VR8p64klWJKo1ILiy19s6C9+0Bh2+NWTX6nD9EPppiER4ICkHDVB1RkVpin/YW2nQn/g==", - "dev": true, - "requires": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "dependencies": { - "acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "dev": true - }, - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - } - } - }, - "terser-webpack-plugin": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", - "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.17", - "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.16.8" - }, - "dependencies": { - "serialize-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", - "dev": true, - "requires": { - "randombytes": "^2.1.0" - } - } - } - }, - "test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "requires": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - } - }, - "text-hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" - }, - "text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "requires": { - "any-promise": "^1.0.0" - } - }, - "thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "requires": { - "thenify": ">= 3.1.0 < 4" - } - }, - "through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" - }, - "through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, - "requires": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - }, - "dependencies": { - "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, - "throwback": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/throwback/-/throwback-4.1.0.tgz", - "integrity": "sha512-dLFe8bU8SeH0xeqeKL7BNo8XoPC/o91nz9/ooeplZPiso+DZukhoyZcSz9TFnUNScm+cA9qjU1m1853M6sPOng==" - }, - "tiny-async-pool": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.2.0.tgz", - "integrity": "sha512-PY/OiSenYGBU3c1nTuP1HLKRkhKFDXsAibYI5GeHbHw2WVpt6OFzAPIRP94dGnS66Jhrkheim2CHAXUNI4XwMg==", - "requires": { - "semver": "^5.5.0", - "yaassertion": "^1.0.0" - }, - "dependencies": { - "semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==" - } - } - }, - "tiny-glob": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", - "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", - "dev": true, - "requires": { - "globalyzer": "0.1.0", - "globrex": "^0.1.2" - } - }, - "tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "requires": { - "os-tmpdir": "~1.0.2" - } - }, - "tmp-promise": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", - "requires": { - "tmp": "^0.2.0" - }, - "dependencies": { - "tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", - "requires": { - "rimraf": "^3.0.0" - } - } - } - }, - "tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true - }, - "to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", - "dev": true - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "requires": { - "is-number": "^7.0.0" - } - }, - "toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" - }, - "token-types": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-5.0.1.tgz", - "integrity": "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==", - "requires": { - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "dependencies": { - "ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" - } - } - }, - "touch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", - "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", - "dev": true, - "requires": { - "nopt": "~1.0.10" - } - }, - "tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "requires": { - "punycode": "^2.1.1" - } - }, - "traverse": { - "version": "0.6.7", - "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.7.tgz", - "integrity": "sha512-/y956gpUo9ZNCb99YjxG7OaslxZWHfCHAUUfshwqOXmxUIvqLjVO581BT+gM59+QV9tFe6/CGG53tsA1Y7RSdg==" - }, - "tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==" - }, - "triple-beam": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", - "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" - }, - "ts-algebra": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-1.2.2.tgz", - "integrity": "sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA==" - }, - "ts-expect": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-expect/-/ts-expect-1.3.0.tgz", - "integrity": "sha512-e4g0EJtAjk64xgnFPD6kTBUtpnMVzDrMb12N1YZV0VvSlhnVT3SGxiYTLdGy8Q5cYHOIC/FAHmZ10eGrAguicQ==" - }, - "ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true - }, - "ts-jest": { - "version": "29.0.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.0.5.tgz", - "integrity": "sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==", - "dev": true, - "requires": { - "bs-logger": "0.x", - "fast-json-stable-stringify": "2.x", - "jest-util": "^29.0.0", - "json5": "^2.2.3", - "lodash.memoize": "4.x", - "make-error": "1.x", - "semver": "7.x", - "yargs-parser": "^21.0.1" - }, - "dependencies": { - "yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true - } - } - }, - "ts-loader": { - "version": "9.4.2", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.4.2.tgz", - "integrity": "sha512-OmlC4WVmFv5I0PpaxYb+qGeGOdm5giHU7HwDDUjw59emP2UYMHy9fFSDcYgSNoH8sXcj4hGCSEhlDZ9ULeDraA==", - "dev": true, - "requires": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.0.0", - "micromatch": "^4.0.0", - "semver": "^7.3.4" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "dev": true, - "requires": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "dependencies": { - "acorn": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz", - "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==", - "dev": true - }, - "acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dev": true - }, - "arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true - }, - "diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true - } - } - }, - "tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, - "requires": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "dependencies": { - "strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", - "dev": true - } - } - }, - "tsconfig-paths-webpack-plugin": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.1.0.tgz", - "integrity": "sha512-xWFISjviPydmtmgeUAuXp4N1fky+VCtfhOkDUFIv5ea7p4wuTomI4QTrXvFBX2S4jZsmyTSrStQl+E+4w+RzxA==", - "dev": true, - "requires": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.7.0", - "tsconfig-paths": "^4.1.2" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - }, - "tsup": { - "version": "5.12.9", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-5.12.9.tgz", - "integrity": "sha512-dUpuouWZYe40lLufo64qEhDpIDsWhRbr2expv5dHEMjwqeKJS2aXA/FPqs1dxO4T6mBojo7rvo3jP9NNzaKyDg==", - "dev": true, - "requires": { - "bundle-require": "^3.0.2", - "cac": "^6.7.12", - "chokidar": "^3.5.1", - "debug": "^4.3.1", - "esbuild": "^0.14.25", - "execa": "^5.0.0", - "globby": "^11.0.3", - "joycon": "^3.0.1", - "postcss-load-config": "^3.0.1", - "resolve-from": "^5.0.0", - "rollup": "^2.74.1", - "source-map": "0.8.0-beta.0", - "sucrase": "^3.20.3", - "tree-kill": "^1.2.2" - }, - "dependencies": { - "@esbuild/linux-loong64": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz", - "integrity": "sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==", - "dev": true, - "optional": true - }, - "esbuild": { - "version": "0.14.54", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.54.tgz", - "integrity": "sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==", - "dev": true, - "requires": { - "@esbuild/linux-loong64": "0.14.54", - "esbuild-android-64": "0.14.54", - "esbuild-android-arm64": "0.14.54", - "esbuild-darwin-64": "0.14.54", - "esbuild-darwin-arm64": "0.14.54", - "esbuild-freebsd-64": "0.14.54", - "esbuild-freebsd-arm64": "0.14.54", - "esbuild-linux-32": "0.14.54", - "esbuild-linux-64": "0.14.54", - "esbuild-linux-arm": "0.14.54", - "esbuild-linux-arm64": "0.14.54", - "esbuild-linux-mips64le": "0.14.54", - "esbuild-linux-ppc64le": "0.14.54", - "esbuild-linux-riscv64": "0.14.54", - "esbuild-linux-s390x": "0.14.54", - "esbuild-netbsd-64": "0.14.54", - "esbuild-openbsd-64": "0.14.54", - "esbuild-sunos-64": "0.14.54", - "esbuild-windows-32": "0.14.54", - "esbuild-windows-64": "0.14.54", - "esbuild-windows-arm64": "0.14.54" - } - }, - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true - }, - "source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "dev": true, - "requires": { - "whatwg-url": "^7.0.0" - } - }, - "tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true - }, - "whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "dev": true, - "requires": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - } - } - }, - "tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "requires": { - "tslib": "^1.8.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - } - } - }, - "tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" - }, - "type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1" - } - }, - "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 - }, - "type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true - }, - "type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "requires": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "dependencies": { - "media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" - } - } - }, - "typed-array-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", - "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" - } - }, - "typed-array-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", - "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", - "requires": { - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" - } - }, - "typed-array-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", - "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", - "requires": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" - } - }, - "typed-array-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", - "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", - "requires": { - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" - } - }, - "typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" - }, - "typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dev": true, - "requires": { - "is-typedarray": "^1.0.0" - } - }, - "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true - }, - "uid": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", - "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", - "requires": { - "@lukeed/csprng": "^1.0.0" - } - }, - "uid-safe": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", - "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", - "requires": { - "random-bytes": "~1.0.0" - } - }, - "unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "requires": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - } - }, - "undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true - }, - "underscore": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", - "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" - }, - "undici": { - "version": "5.25.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.25.2.tgz", - "integrity": "sha512-tch8RbCfn1UUH1PeVCXva4V8gDpGAud/w0WubD6sHC46vYQ3KDxL+xv1A2UxK0N6jrVedutuPHxe1XIoqerwMw==", - "requires": { - "busboy": "^1.6.0" - } - }, - "universal-analytics": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/universal-analytics/-/universal-analytics-0.5.3.tgz", - "integrity": "sha512-HXSMyIcf2XTvwZ6ZZQLfxfViRm/yTGoRgDeTbojtq6rezeyKB0sTBcKH2fhddnteAHRcHiKgr/ACpbgjGOC6RQ==", - "requires": { - "debug": "^4.3.1", - "uuid": "^8.0.0" - } - }, - "universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" - }, - "unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" - }, - "untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true - }, - "unzipper": { - "version": "0.8.14", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.8.14.tgz", - "integrity": "sha512-8rFtE7EP5ssOwGpN2dt1Q4njl0N1hUXJ7sSPz0leU2hRdq6+pra57z4YPBlVqm40vcgv6ooKZEAx48fMTv9x4w==", - "optional": true, - "peer": true, - "requires": { - "big-integer": "^1.6.17", - "binary": "~0.3.0", - "bluebird": "~3.4.1", - "buffer-indexof-polyfill": "~1.0.0", - "duplexer2": "~0.1.4", - "fstream": "~1.0.10", - "listenercount": "~1.0.1", - "readable-stream": "~2.1.5", - "setimmediate": "~1.0.4" - }, - "dependencies": { - "bluebird": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", - "integrity": "sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM=", - "optional": true, - "peer": true - }, - "process-nextick-args": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", - "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", - "optional": true, - "peer": true - }, - "readable-stream": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz", - "integrity": "sha1-ZvqLcg4UOLNkaB8q0aY8YYRIydA=", - "optional": true, - "peer": true, - "requires": { - "buffer-shims": "^1.0.0", - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "~1.0.0", - "process-nextick-args": "~1.0.6", - "string_decoder": "~0.10.x", - "util-deprecate": "~1.0.1" - } - } - } - }, - "upath": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", - "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==" - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "requires": { - "punycode": "^2.1.0" - } - }, - "url": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", - "requires": { - "punycode": "1.3.2", - "querystring": "0.2.0" - }, - "dependencies": { - "punycode": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" - } - } - }, - "url-join": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-0.0.1.tgz", - "integrity": "sha1-HbSK1CLTQCRpqH99l73r/k+x48g=", - "optional": true, - "peer": true - }, - "url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "requires": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, - "url-template": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/url-template/-/url-template-3.1.0.tgz", - "integrity": "sha512-vB/eHWttzhN+NZzk9FcQB2h1cSEgb7zDYyvyxPhw02LYw7YqIzO+w1AqkcKvZ51gPH8o4+nyiWve/xuQqMdJZw==" - }, - "urlsafe-base64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/urlsafe-base64/-/urlsafe-base64-1.0.0.tgz", - "integrity": "sha1-I/iQaabGL0bPOh07ABac77kL4MY=" - }, - "util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "requires": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" - }, - "utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" - }, - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" - }, - "v8-compile-cache": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true - }, - "v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true - }, - "v8-to-istanbul": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", - "integrity": "sha512-74Y4LqY74kLE6IFyIjPtkSTWzUZmj8tdHT9Ii/26dvQ6K9Dl2NbEfj0XgU2sHCtKgt5VupqhlO/5aWuqS+IY1w==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^1.6.0" - } - }, - "validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "requires": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "validator": { - "version": "13.11.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", - "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==" - }, - "vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" - }, - "vasync": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/vasync/-/vasync-1.6.4.tgz", - "integrity": "sha1-3+k2Fq0OeugBszKp2Iv8XNyOHR8=", - "requires": { - "verror": "1.6.0" - }, - "dependencies": { - "extsprintf": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz", - "integrity": "sha1-WtlGwi9bMrp/jNdCZxHG6KP8JSk=" - }, - "verror": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.6.0.tgz", - "integrity": "sha1-fROyex+swuLakEBetepuW90lLqU=", - "requires": { - "extsprintf": "1.2.0" - } - } - } - }, - "verror": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", - "requires": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "dependencies": { - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" - } - } - }, - "vue-eslint-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-7.1.1.tgz", - "integrity": "sha512-8FdXi0gieEwh1IprIBafpiJWcApwrU+l2FEj8c1HtHFdNXMd0+2jUSjBVmcQYohf/E72irwAXEXLga6TQcB3FA==", - "dev": true, - "requires": { - "debug": "^4.1.1", - "eslint-scope": "^5.0.0", - "eslint-visitor-keys": "^1.1.0", - "espree": "^6.2.1", - "esquery": "^1.0.1", - "lodash": "^4.17.15" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true - }, - "espree": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-6.2.1.tgz", - "integrity": "sha512-ysCxRQY3WaXJz9tdbWOwuWr5Y/XrPTGX9Kiz3yoUXwW0VZ4w30HTkQLaGx/+ttFjF8i+ACbArnB4ce68a9m5hw==", - "dev": true, - "requires": { - "acorn": "^7.1.1", - "acorn-jsx": "^5.2.0", - "eslint-visitor-keys": "^1.1.0" - } - } - } - }, - "walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "requires": { - "makeerror": "1.0.12" - } - }, - "watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", - "dev": true, - "requires": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - } - }, - "wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", - "dev": true, - "requires": { - "defaults": "^1.0.3" - } - }, - "webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" - }, - "webpack": { - "version": "5.88.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", - "integrity": "sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==", - "dev": true, - "requires": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^1.0.0", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", - "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", - "browserslist": "^4.14.5", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.7", - "watchpack": "^2.4.0", - "webpack-sources": "^3.2.3" - }, - "dependencies": { - "acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "dev": true - }, - "acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", - "dev": true, - "requires": {} - } - } - }, - "webpack-node-externals": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", - "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", - "dev": true - }, - "webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true - }, - "whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "requires": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - } - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "requires": { - "isexe": "^2.0.0" - } - }, - "which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "requires": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - } - }, - "which-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", - "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=" - }, - "which-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", - "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", - "requires": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.4", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" - } - }, - "window-size": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz", - "integrity": "sha1-+OGqHuWlPsW/FR/6CXQqatdpeHY=", - "optional": true, - "peer": true - }, - "windows-release": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-4.0.0.tgz", - "integrity": "sha512-OxmV4wzDKB1x7AZaZgXMVsdJ1qER1ed83ZrTYd5Bwq2HfJVg3DJS8nqlAG4sMoJ7mu8cuRmLEYyU13BKwctRAg==", - "dev": true, - "requires": { - "execa": "^4.0.2" - }, - "dependencies": { - "execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - } - }, - "get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "requires": { - "pump": "^3.0.0" - } - }, - "human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true - } - } - }, - "winston": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.8.2.tgz", - "integrity": "sha512-MsE1gRx1m5jdTTO9Ld/vND4krP2To+lgDoMEHGGa4HIlAUyXJtfc7CxQcGXVyz2IBpw5hbFkj2b/AtUdQwyRew==", - "requires": { - "@colors/colors": "1.5.0", - "@dabh/diagnostics": "^2.0.2", - "async": "^3.2.3", - "is-stream": "^2.0.0", - "logform": "^2.4.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.5.0" - }, - "dependencies": { - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "winston-transport": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.5.0.tgz", - "integrity": "sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q==", - "requires": { - "logform": "^2.3.2", - "readable-stream": "^3.6.0", - "triple-beam": "^1.3.0" - }, - "dependencies": { - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "requires": { - "safe-buffer": "~5.2.0" - } - } - } - }, - "with-open-file": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/with-open-file/-/with-open-file-0.1.7.tgz", - "integrity": "sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==", - "requires": { - "p-finally": "^1.0.0", - "p-try": "^2.1.0", - "pify": "^4.0.1" - }, - "dependencies": { - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" - } - } - }, - "word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" - }, - "workerpool": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.0.tgz", - "integrity": "sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==", - "dev": true - }, - "wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "requires": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - } - } - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" - }, - "write": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", - "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", - "dev": true, - "requires": { - "mkdirp": "^0.5.1" - }, - "dependencies": { - "mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "dev": true, - "requires": { - "minimist": "^1.2.5" - } - } - } - }, - "write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } - }, - "ws": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", - "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", - "requires": {} - }, - "xml2js": { - "version": "0.4.23", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", - "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", - "requires": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - } - }, - "xml2js-es6-promise": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/xml2js-es6-promise/-/xml2js-es6-promise-1.1.1.tgz", - "integrity": "sha1-zVaI2dY0TmfJCPceWwo9iNEo6aI=", - "requires": { - "xml2js": "^0.4.16" - } - }, - "xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" - }, - "xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" - }, - "y-mongodb-provider": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/y-mongodb-provider/-/y-mongodb-provider-0.1.8.tgz", - "integrity": "sha512-yV+rtS9nBEMqb6fG6sqyWNpMGzmTYe7hPiwWwWyrzK4frjMnkrQvJvyUiWjZI7eFFSKYzxYHucGEFA0j3QJEgA==", - "requires": { - "lib0": "^0.2.85", - "mongodb": "^6.1.0" - }, - "dependencies": { - "bson": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.1.0.tgz", - "integrity": "sha512-yiQ3KxvpVoRpx1oD1uPz4Jit9tAVTJgjdmjDKtUErkOoL9VNoF8Dd58qtAOL5E40exx2jvAT9sqdRSK/r+SHlA==" - }, - "mongodb": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.1.0.tgz", - "integrity": "sha512-AvzNY0zMkpothZ5mJAaIo2bGDjlJQqqAbn9fvtVgwIIUPEfdrqGxqNjjbuKyrgQxg2EvCmfWdjq+4uj96c0YPw==", - "requires": { - "@mongodb-js/saslprep": "^1.1.0", - "bson": "^6.1.0", - "mongodb-connection-string-url": "^2.6.0" - } - } - } - }, - "y-protocols": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", - "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", - "requires": { - "lib0": "^0.2.85" - } - }, - "y18n": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", - "integrity": "sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==" - }, - "yaassertion": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/yaassertion/-/yaassertion-1.0.2.tgz", - "integrity": "sha512-sBoJBg5vTr3lOpRX0yFD+tz7wv/l2UPMFthag4HGTMPrypBRKerjjS8jiEnNMjcAEtPXjbHiKE0UwRR1W1GXBg==" - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true - }, - "yargs": { - "version": "3.32.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz", - "integrity": "sha1-AwiOnr+edWtpdRYR0qXvWRSCyZU=", - "optional": true, - "peer": true, - "requires": { - "camelcase": "^2.0.1", - "cliui": "^3.0.3", - "decamelize": "^1.1.1", - "os-locale": "^1.4.0", - "string-width": "^1.0.1", - "window-size": "^0.1.4", - "y18n": "^3.2.0" - }, - "dependencies": { - "ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "optional": true, - "peer": true - }, - "camelcase": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", - "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=", - "optional": true, - "peer": true - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "optional": true, - "peer": true, - "requires": { - "number-is-nan": "^1.0.0" - } - }, - "string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "optional": true, - "peer": true, - "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - } - }, - "strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "optional": true, - "peer": true, - "requires": { - "ansi-regex": "^2.0.0" - } - } - } - }, - "yargs-parser": { - "version": "20.2.4", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", - "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==" - }, - "yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "requires": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "dependencies": { - "camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true - }, - "decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true - } - } - }, - "yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "requires": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "yauzl-clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/yauzl-clone/-/yauzl-clone-1.0.4.tgz", - "integrity": "sha512-igM2RRCf3k8TvZoxR2oguuw4z1xasOnA31joCqHIyLkeWrvAc2Jgay5ISQ2ZplinkoGaJ6orCz56Ey456c5ESA==", - "requires": { - "events-intercept": "^2.0.0" - } - }, - "yauzl-promise": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yauzl-promise/-/yauzl-promise-2.1.3.tgz", - "integrity": "sha512-A1pf6fzh6eYkK0L4Qp7g9jzJSDrM6nN0bOn5T0IbY4Yo3w+YkWlHFkJP7mzknMXjqusHFHlKsK2N+4OLsK2MRA==", - "requires": { - "yauzl": "^2.9.1", - "yauzl-clone": "^1.0.4" - } - }, - "yazl": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", - "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", - "requires": { - "buffer-crc32": "~0.2.3" - } - }, - "yjs": { - "version": "13.6.8", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.8.tgz", - "integrity": "sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==", - "requires": { - "lib0": "^0.2.74" - } - }, - "yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true - }, - "yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" - } } } diff --git a/package.json b/package.json index 656edf9a2d4..84e5b3c220b 100644 --- a/package.json +++ b/package.json @@ -20,14 +20,22 @@ "node": "18", "npm": ">=9" }, + "mikro-orm": { + "useTsNode": true, + "configPaths": [ + "./apps/server/src/config/mikro-orm-cli.config.ts", + "./dist/server/config/mikro-orm-cli.config.js" + ] + }, "scripts": { "lint-fix": "eslint . --fix --ignore-path .gitignore", "lint": "eslint . --ignore-path .gitignore", "test": "npm run nest:test && npm run feathers:test", - "feathers:test": "cross-env NODE_ENV=test npm run setup && npm run nest:build && npm run coverage", - "feathers:test-inspect": "cross-env NODE_ENV=test npm run setup && npm run mocha-inspect", - "setup": "npm run setup:db", - "setup:db": "npm run nest:start:console -- database seed", + "feathers:test": "cross-env NODE_ENV=test npm run setup:db:seed && npm run nest:build && npm run coverage", + "feathers:test-inspect": "cross-env NODE_ENV=test npm run setup:db:seed && npm run mocha-inspect", + "setup": "command removed - check npm run setup:db:seed instead!", + "setup:db": "DEPRECATED - check npm run setup:db:seed instead!", + "setup:db:seed": "npm run nest:start:console -- database seed", "setup:idm:seed": "npm run nest:start:console -- idm seed", "setup:idm:configure": "npm run nest:start:console -- idm configure", "setup:idm": "npm run setup:idm:seed && npm run setup:idm:configure", @@ -42,12 +50,10 @@ "mocha-watch": "cross-env NODE_ENV=test mocha", "mocha-inspect": "cross-env NODE_ENV=test mocha --inspect --no-timeout --exit", "mocha-metrics": "cross-env NODE_ENV=test mocha \"test/routes/*.metrics.js\" --exclude \"{test,src}/**/*.test.{js,ts}\" --no-timeout --exit", - "migration": "migrate --config ./config/migrate.js", - "migration-sync": "migrate list --autosync --config ./config/migrate.js", - "migration-list": "migrate list --config ./config/migrate.js", - "migration-prune": "migrate prune --autosync --config ./config/migrate.js", - "migration-persist": "npm run nest:start:console -- database export --collection migrations --override", - "migrate-etherpads": "node ./migrate-etherpads.js", + "migration:up": "npm run nest:start:console -- database migration --up", + "migration:down": "npm run nest:start:console -- database migration --down", + "migration:pending": "npm run nest:start:console -- database migration --pending", + "migration:persisted": "npm run nest:start:console -- database export --collection migrations --override", "nest:prebuild": "rimraf dist", "nest:build": "nest build", "nest:build:all": "npm run nest:build", @@ -88,6 +94,12 @@ "nest:start:deletion-console": "nest start deletion-console --", "nest:start:deletion-console:dev": "nest start deletion-console --watch --", "nest:start:deletion-console:debug": "nest start deletion-console --debug --watch --", + "nest:start:idp-console": "nest start idp-console --", + "nest:start:idp-console:dev": "nest start idp-console --watch --", + "nest:start:idp-console:debug": "nest start idp-console --debug --watch --", + "nest:start:tldraw-console": "nest start tldraw-console --", + "nest:start:tldraw-console:dev": "nest start tldraw-console --watch --", + "nest:start:tldraw-console:debug": "nest start tldraw-console --debug --watch --", "nest:test": "npm run nest:test:cov && npm run nest:lint", "nest:test:all": "jest", "nest:test:unit": "jest \"^((?!\\.api\\.spec\\.ts).)*\\.spec\\.ts$\"", @@ -115,25 +127,29 @@ "@golevelup/nestjs-rabbitmq": "^4.0.0", "@hendt/xml2json": "^1.0.3", "@hpi-schul-cloud/commons": "^1.3.4", - "@keycloak/keycloak-admin-client": "^21.1.2", + "@keycloak/keycloak-admin-client": "^23.0.6", "@lumieducation/h5p-server": "^9.2.0", - "@mikro-orm/core": "^5.5.3", - "@mikro-orm/mongodb": "^5.5.3", + "@mikro-orm/cli": "^5.6.16", + "@mikro-orm/core": "^5.6.16", + "@mikro-orm/migrations-mongodb": "^5.6.16", + "@mikro-orm/mongodb": "^5.6.16", "@mikro-orm/nestjs": "^5.2.1", "@nestjs/axios": "^3.0.0", "@nestjs/cache-manager": "^2.1.0", "@nestjs/common": "^10.2.4", "@nestjs/config": "^3.0.1", "@nestjs/core": "^10.2.4", + "@nestjs/cqrs": "^10.2.7", "@nestjs/jwt": "^10.1.1", "@nestjs/microservices": "^10.2.4", "@nestjs/passport": "^10.0.1", "@nestjs/platform-express": "^10.2.4", - "@nestjs/platform-ws": "^10.2.4", + "@nestjs/platform-ws": "^10.3.0", "@nestjs/swagger": "^7.1.10", - "@nestjs/websockets": "^10.2.4", + "@nestjs/websockets": "^10.3.0", "@types/gm": "^1.25.1", "@types/ldapjs": "^2.2.5", + "@types/pdfmake": "^0.2.8", "@types/xml2js": "^0.4.11", "adm-zip": "^0.5.9", "ajv": "^8.8.2", @@ -151,7 +167,7 @@ "body-parser": "^1.15.2", "bson": "^4.6.0", "busboy": "^1.6.0", - "cache-manager": "^5.3.1", + "cache-manager": "^5.4.0", "cache-manager-redis-yet": "^4.1.2", "chalk": "^5.0.0", "clamscan": "^2.1.2", @@ -161,7 +177,6 @@ "commander": "^8.1.0", "compression": "^1.6.2", "concurrently": "^6.0.0", - "connect-redis": "^7.1.0", "cors": "^2.8.1", "cross-env": "^7.0.0", "crypto-js": "^4.2.0", @@ -178,12 +193,13 @@ "html-entities": "^2.3.2", "i18next": "^23.3.0", "i18next-fs-backend": "^2.1.5", + "ioredis": "^5.3.2", "jose": "^1.28.1", + "jsdom": "^23.2.0", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^2.0.5", "ldapjs": "git://github.com/hpi-schul-cloud/node-ldapjs.git", "lodash": "^4.17.19", - "migrate-mongoose": "^4.0.0", "mixwith": "^0.1.1", "moment": "^2.19.2", "mongodb-uri": "^0.9.7", @@ -205,10 +221,10 @@ "passport-headerapikey": "^1.2.2", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "pdfmake": "^0.2.9", "prom-client": "^13.1.0", "qs": "^6.9.7", "read-chunk": "^3.0.0", - "redis": "^4.6.11", "reflect-metadata": "^0.1.13", "request-promise-core": "^1.1.4", "request-promise-native": "^1.0.3", @@ -229,10 +245,9 @@ "urlsafe-base64": "^1.0.0", "uuid": "^8.3.0", "winston": "^3.8.2", - "ws": "^7.5.7", - "y-mongodb-provider": "^0.1.7", - "y-protocols": "^1.0.5", - "yjs": "^13.6.7" + "ws": "^8.16.0", + "y-protocols": "^1.0.6", + "yjs": "^13.6.11" }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", @@ -254,7 +269,6 @@ "@types/express-session": "^1.17.5", "@types/jest": "^29.2.1", "@types/lodash": "^4.14.196", - "@types/mongodb": "^4.0.7", "@types/node": "^16.18.11", "@types/passport-jwt": "^3.0.5", "@types/passport-local": "^1.0.33", @@ -264,6 +278,7 @@ "@types/source-map-support": "^0.5.3", "@types/supertest": "^2.0.12", "@types/uuid": "^8.3.4", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^5.47.1", "@typescript-eslint/parser": "^5.47.1", "@typescript-eslint/typescript-estree": "^5.47.1", diff --git a/scripts/copy-legacy-tool-to-ctl.js b/scripts/copy-legacy-tool-to-ctl.js index 60a59f7559c..659379501ba 100644 --- a/scripts/copy-legacy-tool-to-ctl.js +++ b/scripts/copy-legacy-tool-to-ctl.js @@ -90,7 +90,6 @@ const ExternalTool = mongoose.model( config_skipConsent: Boolean, config_key: String, config_secret: String, - config_resource_link_id: String, config_lti_message_type: { type: String, enum: ['basic-lti-launch-request', 'LtiResourceLinkRequest', 'LtiDeepLinkingRequest'], @@ -243,7 +242,6 @@ function toolConfigMapper(ltiToolTemplate) { config_type: 'lti11', config_key: ltiToolTemplate.key, config_secret: ltiToolTemplate.secret, - config_ressource_link_id: ltiToolTemplate.ressource_link_id, config_lti_message_type: ltiToolTemplate.lti_message_type, config_privacy_permission: ltiToolTemplate.privacy_permission || 'anonymous', }; diff --git a/migrate-etherpads.js b/scripts/migrate-etherpads.js similarity index 98% rename from migrate-etherpads.js rename to scripts/migrate-etherpads.js index 98be8cd8be4..8ab173d6bce 100644 --- a/migrate-etherpads.js +++ b/scripts/migrate-etherpads.js @@ -1,9 +1,9 @@ #!/usr/bin/env node const arg = require('arg'); -const appPromise = require('./src/app'); +const appPromise = require('../src/app'); const { Configuration } = require('@hpi-schul-cloud/commons'); -const etherpadClient = require('./src/services/etherpad/utils/EtherpadClient.js'); +const etherpadClient = require('../src/services/etherpad/utils/EtherpadClient.js'); const { randomBytes } = require('crypto'); const { ObjectId } = require('mongodb'); const fs = require('fs').promises; diff --git a/sonar-project.properties b/sonar-project.properties index 82334f8ff5d..c4a3ed12529 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,8 +3,8 @@ sonar.projectKey=hpi-schul-cloud_schulcloud-server sonar.sources=. sonar.tests=. sonar.test.inclusions=**/*.spec.ts -sonar.exclusions=**/*.js,jest.config.ts,globalSetup.ts,globalTeardown.ts,**/*.app.ts,**/seed-data/*.ts -sonar.coverage.exclusions=**/board-management.uc.ts,**/*.module.ts,**/*.factory.ts +sonar.exclusions=**/*.js,jest.config.ts,globalSetup.ts,globalTeardown.ts,**/*.app.ts,**/seed-data/*.ts,**/migrations/mikro-orm/*.ts +sonar.coverage.exclusions=**/board-management.uc.ts,**/*.module.ts,**/*.factory.ts,**/migrations/mikro-orm/*.ts sonar.cpd.exclusions=**/controller/dto/*.ts sonar.javascript.lcov.reportPaths=merged-lcov.info sonar.typescript.tsconfigPaths=tsconfig.json,src/apps/server/tsconfig.app.json diff --git a/src/services/activation/docs/openapi.yaml b/src/services/activation/docs/openapi.yaml index 7b97aaf7eb9..af900207c5f 100644 --- a/src/services/activation/docs/openapi.yaml +++ b/src/services/activation/docs/openapi.yaml @@ -4,7 +4,7 @@ info: title: HPI Schul-Cloud Activation Service API description: This is the API specification for the HPI Schul-Cloud Activation service. - + contact: name: support email: info@dbildungscloud.de @@ -315,31 +315,6 @@ paths: tags: - activation security: [] - /activation/eMailAddress: - post: - parameters: [] - responses: - '201': - description: created - content: - application/json: - schema: - $ref: '#/components/schemas/eMailAddress' - '401': - description: not authenticated - '500': - description: general error - description: Creates a new resource with data. - summary: '' - tags: - - activation - security: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/eMailAddress' '/activation/eMailAddress/{id}': put: parameters: @@ -373,7 +348,7 @@ paths: application/json: schema: $ref: '#/components/schemas/eMailAddress' - + openapi: 3.0.2 tags: - name: activation diff --git a/src/services/activation/services/eMailAddress/index.js b/src/services/activation/services/eMailAddress/index.js index 620e7b37092..8db1c7f0fd3 100644 --- a/src/services/activation/services/eMailAddress/index.js +++ b/src/services/activation/services/eMailAddress/index.js @@ -15,15 +15,11 @@ const { const { STATE, - KEYWORDS: { E_MAIL_ADDRESS }, sendMail, - getUser, deleteEntry, - createEntry, setEntryState, createActivationLink, Mail, - BadRequest, Forbidden, GeneralError, customErrorMessages, @@ -84,21 +80,6 @@ const mail = async (ref, type, user, entry) => { * this service can be used to create an job to change the email/username. */ class EMailAddressActivationService { - /** - * create job - */ - async create(data, params) { - if (!data || !data.email || !data.password) throw new BadRequest('Missing information'); - const user = await getUser(this.app, params.account.userId); - - // create new entry - const entry = await createEntry(this, params.account.userId, E_MAIL_ADDRESS, data.email); - - // send email - await mail(this, 'activationLinkMail', user, entry); - return { success: true }; - } - async update(id, data, params) { const { entry, user } = data; const account = await this.app.service('nest-account-service').findByUserId(user._id); diff --git a/src/services/config/index.js b/src/services/config/index.js index 751d6d41eeb..0041a396230 100644 --- a/src/services/config/index.js +++ b/src/services/config/index.js @@ -2,14 +2,10 @@ const { static: staticContent } = require('@feathersjs/express'); const path = require('path'); const { ConfigService, configServiceHooks } = require('./configService'); -const { PublicAppConfigService, publicAppConfigServiceHooks } = require('./publicAppConfigService'); module.exports = (app) => { app.use('/config/api', staticContent(path.join(__dirname, '/docs'))); - app.use('/config/app/public', new PublicAppConfigService()); - app.service('/config/app/public').hooks(publicAppConfigServiceHooks); - app.use('/config', new ConfigService()); app.service('/config').hooks(configServiceHooks); }; diff --git a/src/services/config/publicAppConfigService.js b/src/services/config/publicAppConfigService.js deleted file mode 100644 index 35b0c353b98..00000000000 --- a/src/services/config/publicAppConfigService.js +++ /dev/null @@ -1,97 +0,0 @@ -const { Configuration } = require('@hpi-schul-cloud/commons'); - -const publicAppConfigServiceHooks = { - before: { - all: [], - find: [], - }, - after: { - all: [], - find: [], - }, -}; - -const exposedVars = [ - 'ADMIN_TABLES_DISPLAY_CONSENT_COLUMN', - 'ALERT_STATUS_URL', - 'FEATURE_ES_COLLECTIONS_ENABLED', - 'FEATURE_EXTENSIONS_ENABLED', - 'FEATURE_TEAMS_ENABLED', - 'FEATURE_LERNSTORE_ENABLED', - 'FEATURE_ADMIN_TOGGLE_STUDENT_LERNSTORE_VIEW_ENABLED', - 'TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE', - 'TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT', - 'TEACHER_STUDENT_VISIBILITY__IS_VISIBLE', - 'FEATURE_SCHOOL_POLICY_ENABLED_NEW', - 'FEATURE_SCHOOL_TERMS_OF_USE_ENABLED', - 'FEATURE_NEXBOARD_COPY_ENABLED', - 'FEATURE_VIDEOCONFERENCE_ENABLED', - 'ROCKETCHAT_SERVICE_ENABLED', - 'LERNSTORE_MODE', - 'I18N__AVAILABLE_LANGUAGES', - 'I18N__DEFAULT_LANGUAGE', - 'I18N__DEFAULT_TIMEZONE', - 'I18N__FALLBACK_LANGUAGE', - 'JWT_SHOW_TIMEOUT_WARNING_SECONDS', - 'JWT_TIMEOUT_SECONDS', - 'NOT_AUTHENTICATED_REDIRECT_URL', - 'DOCUMENT_BASE_DIR', - 'SC_THEME', - 'SC_TITLE', - 'FEATURE_COLUMN_BOARD_ENABLED', - 'FEATURE_COLUMN_BOARD_SUBMISSIONS_ENABLED', - 'FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED', - 'FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED', - 'FEATURE_COURSE_SHARE', - 'FEATURE_COURSE_SHARE_NEW', - 'FEATURE_LOGIN_LINK_ENABLED', - 'FEATURE_LESSON_SHARE', - 'FEATURE_TASK_SHARE', - 'FEATURE_USER_MIGRATION_ENABLED', - 'FEATURE_COPY_SERVICE_ENABLED', - 'ACCESSIBILITY_REPORT_EMAIL', - 'GHOST_BASE_URL', - 'FEATURE_CONSENT_NECESSARY', - 'FEATURE_IMSCC_COURSE_EXPORT_ENABLED', - 'FEATURE_SCHOOL_SANIS_USER_MIGRATION_ENABLED', - 'FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED', - 'FEATURE_NEW_SCHOOL_ADMINISTRATION_PAGE_AS_DEFAULT_ENABLED', - 'MIGRATION_END_GRACE_PERIOD_MS', - 'FEATURE_CTL_TOOLS_TAB_ENABLED', - 'FEATURE_LTI_TOOLS_TAB_ENABLED', - 'FILES_STORAGE__MAX_FILE_SIZE', - 'FEATURE_SHOW_OUTDATED_USERS', - 'FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION', - 'FEATURE_CTL_CONTEXT_CONFIGURATION_ENABLED', - 'FEATURE_SHOW_NEW_CLASS_VIEW_ENABLED', - 'FEATURE_TLDRAW_ENABLED', - 'FEATURE_CTL_TOOLS_COPY_ENABLED', -]; - -/** - * This service is for the env variables to sync between server and client. - * These env variables must be public and there must be no secret values. - */ -class PublicAppConfigService { - setup(app) { - this.app = app; - } - - find() { - // TODO: add true/false for getRedisClient() as check for this.$axios.$post("/accounts/jwtTimer"); - const envs = {}; - exposedVars.forEach((varName) => { - if (Configuration.has(varName)) { - envs[varName] = Configuration.get(varName); - } - }); - - return Promise.resolve(envs); - } -} - -module.exports = { - exposedVars, - publicAppConfigServiceHooks, - PublicAppConfigService, -}; diff --git a/src/services/content/hooks/materials.js b/src/services/content/hooks/materials.js index b0ca018e3d6..7e7b65dd5fc 100644 --- a/src/services/content/hooks/materials.js +++ b/src/services/content/hooks/materials.js @@ -13,6 +13,7 @@ const { getScopePermissions } = require('../../helpers/scopePermissions/hooks/ch * @returns {Boolean} true if conditions are met, false otherwise */ const hasMaterialAccess = async (context, id, permissions) => { + // @deprecated - to be replaced with nest lessons service const lessons = await context.app.service('lessons').find({ query: { materialIds: id, @@ -47,13 +48,15 @@ const hasMaterialAccess = async (context, id, permissions) => { * @throws {Forbidden} Forbidden if not all permissions are granted on the course(s) * @returns {Context} hook context */ -const checkAssociatedCoursePermission = (...permissions) => async (context) => { - const access = await hasMaterialAccess(context, context.id, permissions); - if (!access) { - throw new Forbidden('No permision to access this material'); - } - return context; -}; +const checkAssociatedCoursePermission = + (...permissions) => + async (context) => { + const access = await hasMaterialAccess(context, context.id, permissions); + if (!access) { + throw new Forbidden('No permision to access this material'); + } + return context; + }; /** * After-hook to filter results of a find query to materials of courses the requesting user @@ -63,22 +66,24 @@ const checkAssociatedCoursePermission = (...permissions) => async (context) => { * @param {...String} permissions list of permissions necessary for this hook to resolve * @returns {Context} hook context */ -const checkAssociatedCoursePermissionForSearchResult = (...permissions) => async (context) => { - const results = context.result.data ? context.result.data : context.result; - const filteredResults = []; - for (const result of results) { - if (await hasMaterialAccess(context, result._id, permissions)) { - filteredResults.push(result); +const checkAssociatedCoursePermissionForSearchResult = + (...permissions) => + async (context) => { + const results = context.result.data ? context.result.data : context.result; + const filteredResults = []; + for (const result of results) { + if (await hasMaterialAccess(context, result._id, permissions)) { + filteredResults.push(result); + } } - } - if (context.result.data) { - context.result.data = filteredResults; - context.result.total = filteredResults.length; - } else { - context.result = filteredResults; - } - return context; -}; + if (context.result.data) { + context.result.data = filteredResults; + context.result.total = filteredResults.length; + } else { + context.result = filteredResults; + } + return context; + }; exports.before = { all: [authenticate('jwt')], diff --git a/src/services/fileStorage/proxy-service.js b/src/services/fileStorage/proxy-service.js index a33f261e540..eaaefbb4071 100644 --- a/src/services/fileStorage/proxy-service.js +++ b/src/services/fileStorage/proxy-service.js @@ -438,6 +438,7 @@ const signedUrlService = { flatFileName: fileObject.storageFileName, localFileName: query.name || fileObject.name, download, + bucket: fileObject.bucket, }) ) .then((res) => ({ diff --git a/src/services/fileStorage/strategies/awsS3.js b/src/services/fileStorage/strategies/awsS3.js index f19fde23349..c618d549749 100644 --- a/src/services/fileStorage/strategies/awsS3.js +++ b/src/services/fileStorage/strategies/awsS3.js @@ -462,7 +462,7 @@ class AWSS3Strategy extends AbstractFileStorageStrategy { }); } - getSignedUrl({ userId, flatFileName, localFileName, download, action = 'getObject' }) { + getSignedUrl({ userId, flatFileName, localFileName, download, action = 'getObject', bucket = undefined }) { if (!userId || !flatFileName) { return Promise.reject(new BadRequest('Missing parameters by getSignedUrl.', { userId, flatFileName })); } @@ -478,7 +478,7 @@ class AWSS3Strategy extends AbstractFileStorageStrategy { return createAWSObject(result.schoolId).then((awsObject) => { const params = { - Bucket: awsObject.bucket, + Bucket: bucket || awsObject.bucket, Key: flatFileName, Expires: Configuration.get('STORAGE_SIGNED_URL_EXPIRE'), }; diff --git a/src/services/fileStorage/utils/filePermissionHelper.js b/src/services/fileStorage/utils/filePermissionHelper.js index 332409445ff..0acc1735631 100644 --- a/src/services/fileStorage/utils/filePermissionHelper.js +++ b/src/services/fileStorage/utils/filePermissionHelper.js @@ -2,14 +2,12 @@ const { NotFound } = require('../../../errors'); const { FileModel } = require('../model'); const { userModel } = require('../../user/model'); const RoleModel = require('../../role/model'); -const { sortRoles } = require('../../role/utils/rolesHelper'); const { equal: equalIds } = require('../../../helper/compare').ObjectId; const getFile = (id) => FileModel.findOne({ _id: id }).populate('owner').lean().exec(); const checkTeamPermission = async ({ user, file, permission }) => { let teamRoles; - let sortedTeamRoles; const roleIndex = {}; try { @@ -17,7 +15,6 @@ const checkTeamPermission = async ({ user, file, permission }) => { teamRoles.forEach((role) => { roleIndex[role._id] = role; }); - sortedTeamRoles = sortRoles(teamRoles); } catch (error) { return Promise.reject(error); } @@ -34,17 +31,7 @@ const checkTeamPermission = async ({ user, file, permission }) => { rolesToTest = rolesToTest.concat(roleIndex[roleId].roles || []); } - // deprecated: author check via file.permissions[0]?.refId is deprecated and will be removed in the next release - const { role: creatorRole } = file.owner.userIds.find((_) => - equalIds(_.userId, file.creator || file.permissions[0]?.refId) - ); - - const findRole = (roleId) => (roles) => roles.findIndex((r) => equalIds(r._id, roleId)) > -1; - - const userPos = sortedTeamRoles.findIndex(findRole(role)); - const creatorPos = sortedTeamRoles.findIndex(findRole(creatorRole)); - - if (userPos > creatorPos || rolePermissions[permission]) { + if (rolePermissions[permission]) { resolve(true); } reject(); diff --git a/src/services/homework/docs/openapi.yaml b/src/services/homework/docs/openapi.yaml index 835add95307..23e317dd2b2 100644 --- a/src/services/homework/docs/openapi.yaml +++ b/src/services/homework/docs/openapi.yaml @@ -188,32 +188,6 @@ paths: application/json: schema: $ref: '#/components/schemas/homework' - delete: - parameters: - - in: path - name: _id - description: ID of homework to remove - schema: - type: integer - required: true - responses: - '200': - description: success - content: - application/json: - schema: - $ref: '#/components/schemas/homework' - '401': - description: not authenticated - '404': - description: not found - '500': - description: general error - description: Removes the resource with id. - summary: '' - tags: - - homework - security: [] /submissions: get: parameters: diff --git a/src/services/homework/hooks/index.js b/src/services/homework/hooks/index.js index dbd7e9d5d23..489d5e0f011 100644 --- a/src/services/homework/hooks/index.js +++ b/src/services/homework/hooks/index.js @@ -1,8 +1,7 @@ const { authenticate } = require('@feathersjs/authentication'); const { iff, isProvider, disallow } = require('feathers-hooks-common'); -const { Forbidden, GeneralError, NotFound, NotAuthenticated } = require('../../../errors'); -const logger = require('../../../logger'); +const { Forbidden, GeneralError, NotFound } = require('../../../errors'); const globalHooks = require('../../../hooks'); const { equal: equalIds, isValid: isValidId } = require('../../../helper/compare').ObjectId; @@ -293,6 +292,7 @@ const hasCreatePermission = async (context) => { } if (data.lessonId) { + // @deprecated - use nest endpoint instead to get lesson const lesson = await context.app.service('lessons').get(data.lessonId); if (!(data.courseId && equalIds(lesson.courseId, data.courseId))) { throw new NotFound('lesson not found. did you forget to pass the correct course?'); @@ -300,33 +300,6 @@ const hasCreatePermission = async (context) => { } context.data = data; }; -const restrictHomeworkDeletion = async (context) => { - // expect authenticated user - const { userId } = context.params.account; - if (isValidId(userId) !== true) throw new NotAuthenticated('missing a valid authenticated user id', { userId }); - // expect homeworkId given - const homeworkId = context.id; - if (isValidId(homeworkId) !== true) throw new NotFound('missing a valid homework id', { homeworkId }); - - // expect homework to be deleted to exist - const homeworkWithPopulatedCourse = await context.app - .service('homework') - .get(homeworkId, { query: { $populate: ['courseId'] } }); - - if (homeworkWithPopulatedCourse === null) throw new NotFound(); - - if (hasHomeworkPermission(userId, homeworkWithPopulatedCourse)) return context; - - throw new Forbidden('homework deletion failed', { homeworkId, userId }); -}; - -const logDeletionAttempt = (context) => { - logger.alert(`user ${context.params.account.userId} tries to delete homework ${context.id}`); -}; - -const logDeletionPermit = (context) => { - logger.alert(`user ${context.params.account.userId} permitted to delete homework ${context.id}`); -}; const addLessonInfoToSingle = async (hook, data) => { const { lessonId } = data; @@ -334,6 +307,7 @@ const addLessonInfoToSingle = async (hook, data) => { return Promise.resolve(data); } + // @deprecated - use nest endpoint instead to get lesson const lesson = await hook.app.service('lessons').get(lessonId); if (lesson) { data.lessonName = lesson.name; @@ -357,36 +331,30 @@ const addLessonInfo = async (hook) => { return Promise.resolve(hook); }; -exports.before = () => ({ - all: [authenticate('jwt')], - find: [ - iff(isProvider('external'), [ - globalHooks.hasPermission('HOMEWORK_VIEW'), - globalHooks.mapPaginationQuery.bind(this), - hasViewPermissionBefore, - ]), - globalHooks.addCollation, - ], - get: [iff(isProvider('external'), [globalHooks.hasPermission('HOMEWORK_VIEW'), hasViewPermissionBefore])], - create: [iff(isProvider('external'), globalHooks.hasPermission('HOMEWORK_CREATE'), hasCreatePermission)], - update: [iff(isProvider('external'), disallow())], - patch: [ - iff(isProvider('external'), [ - globalHooks.hasPermission('HOMEWORK_EDIT'), - globalHooks.permitGroupOperation, - hasPatchPermission, - ]), - ], - remove: [ - iff(isProvider('external'), [ - globalHooks.hasPermission('HOMEWORK_CREATE'), - globalHooks.permitGroupOperation, - logDeletionAttempt, - restrictHomeworkDeletion, - logDeletionPermit, - ]), - ], -}); +exports.before = () => { + return { + all: [authenticate('jwt')], + find: [ + iff(isProvider('external'), [ + globalHooks.hasPermission('HOMEWORK_VIEW'), + globalHooks.mapPaginationQuery.bind(this), + hasViewPermissionBefore, + ]), + globalHooks.addCollation, + ], + get: [iff(isProvider('external'), [globalHooks.hasPermission('HOMEWORK_VIEW'), hasViewPermissionBefore])], + create: [iff(isProvider('external'), globalHooks.hasPermission('HOMEWORK_CREATE'), hasCreatePermission)], + update: [iff(isProvider('external'), disallow())], + patch: [ + iff(isProvider('external'), [ + globalHooks.hasPermission('HOMEWORK_EDIT'), + globalHooks.permitGroupOperation, + hasPatchPermission, + ]), + ], + remove: [disallow()], + }; +}; exports.after = { all: [], @@ -395,5 +363,6 @@ exports.after = { create: [], update: [], patch: [], + // TODO use nest-endpoint instead remove: [], }; diff --git a/src/services/ldap/strategies/general.js b/src/services/ldap/strategies/general.js index d6974544eb3..e95a5487feb 100644 --- a/src/services/ldap/strategies/general.js +++ b/src/services/ldap/strategies/general.js @@ -90,19 +90,18 @@ class GeneralLDAPStrategy extends AbstractLDAPStrategy { return; } } else { - if (obj[userAttributeNameMapping.role] === roleAttributeNameMapping.roleStudent) { + const userRole = obj[userAttributeNameMapping.role]; + if (!userRole || userRole === roleAttributeNameMapping.roleNoSc) { + return; + } + if (userRole === roleAttributeNameMapping.roleStudent) { roles.push('student'); } - splittedTeacherRoles.forEach((role) => { - if (obj[userAttributeNameMapping.role].includes(role)) { - roles.push('teacher'); - } - }); - if (obj[userAttributeNameMapping.role] === roleAttributeNameMapping.roleAdmin) { - roles.push('administrator'); + if (splittedTeacherRoles.includes(userRole)) { + roles.push('teacher'); } - if (obj[userAttributeNameMapping.role] === roleAttributeNameMapping.roleNoSc) { - return; + if (userRole === roleAttributeNameMapping.roleAdmin) { + roles.push('administrator'); } } diff --git a/src/services/lesson/docs/openapi.yaml b/src/services/lesson/docs/openapi.yaml index e6be994fbbd..79263463b72 100644 --- a/src/services/lesson/docs/openapi.yaml +++ b/src/services/lesson/docs/openapi.yaml @@ -422,4 +422,4 @@ paths: openapi: 3.0.2 tags: - name: lessons - description: A lessons service. + description: A lessons service. \ No newline at end of file diff --git a/src/services/lesson/hooks/addMaterial.js b/src/services/lesson/hooks/addMaterial.js index 07957fa38db..59767ac7c82 100644 --- a/src/services/lesson/hooks/addMaterial.js +++ b/src/services/lesson/hooks/addMaterial.js @@ -11,7 +11,7 @@ const addLessonToParams = async (context) => { if (!ObjectId.isValid(lessonId)) { throw new BadRequest(`Invalid lessonId: "${lessonId}"`); } - + // @deprecated - use nest endpoint instead to get lesson const lesson = await context.app.service('lessons').get(lessonId); context.params.lesson = lesson; @@ -64,14 +64,16 @@ const validateData = async (context) => { }; module.exports = { - before: () => ({ - all: [authenticate('jwt')], - create: [ - validateData, - addLessonToParams, - iff(isProvider('external'), restrictToUsersCoursesLessons), - // checks permission for COURSE and TOPIC for creation - checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_EDIT', 'TOPIC_EDIT', true), - ], - }), + before: () => { + return { + all: [authenticate('jwt')], + create: [ + validateData, + addLessonToParams, + iff(isProvider('external'), restrictToUsersCoursesLessons), + // checks permission for COURSE and TOPIC for creation + checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_EDIT', 'TOPIC_EDIT', true), + ], + }; + }, }; diff --git a/src/services/lesson/hooks/index.js b/src/services/lesson/hooks/index.js index bcd8b2db2ce..36b5d422164 100644 --- a/src/services/lesson/hooks/index.js +++ b/src/services/lesson/hooks/index.js @@ -3,7 +3,7 @@ const { Configuration } = require('@hpi-schul-cloud/commons'); const { nanoid } = require('nanoid'); const { iff, isProvider } = require('feathers-hooks-common'); -const { NotFound, BadRequest, Forbidden } = require('../../../errors'); +const { NotFound, BadRequest } = require('../../../errors'); const { equal } = require('../../../helper/compare').ObjectId; const { injectUserId, @@ -184,6 +184,7 @@ const restrictToUsersCoursesLessons = async (context) => { if (context.params.query.shareToken) return context; ({ courseId, courseGroupId } = context.params.query); } else { + // @deprecated - use nest endpoint instead to get lesson const lesson = await context.app.service('lessons').get(context.id); ({ courseId, courseGroupId } = lesson); } @@ -204,19 +205,6 @@ const restrictToUsersCoursesLessons = async (context) => { return context; }; -const restrictToUsersDraftLessons = async (context) => { - const user = await context.app.service('users').get(context.params.account.userId, { query: { $populate: 'roles' } }); - const userIsStudent = user.roles.filter((u) => u.name === 'student').length > 0; - const lesson = await context.app.service('lessons').get(context.id); - const isDraft = lesson.hidden; - - if (isDraft && userIsStudent) { - throw new Forbidden(`You don't have permission.`); - } - - return context; -}; - const populateWhitelist = { materialIds: [ '_id', @@ -245,16 +233,18 @@ const populateWhitelist = { exports.before = () => { return { all: [authenticate('jwt'), mapUsers], + // @deprecated - use nest endpoint instead to get lesson find: [ hasPermission('TOPIC_VIEW'), iff(isProvider('external'), validateLessonFind), iff(isProvider('external'), getRestrictPopulatesHook(populateWhitelist)), iff(isProvider('external'), restrictToUsersCoursesLessons), ], + // @deprecated - use nest endpoint instead to get lesson get: [ hasPermission('TOPIC_VIEW'), iff(isProvider('external'), getRestrictPopulatesHook(populateWhitelist)), - iff(isProvider('external'), restrictToUsersCoursesLessons, restrictToUsersDraftLessons), + iff(isProvider('external'), restrictToUsersCoursesLessons), ], create: [ checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_CREATE', 'TOPIC_CREATE', true), @@ -267,7 +257,7 @@ exports.before = () => { iff(isProvider('external'), preventPopulate), permitGroupOperation, ifNotLocal(checkCorrectCourseOrTeamId), - iff(isProvider('external'), restrictToUsersCoursesLessons, restrictToUsersDraftLessons), + iff(isProvider('external'), restrictToUsersCoursesLessons), checkIfCourseGroupLesson.bind(this, 'COURSEGROUP_EDIT', 'TOPIC_EDIT', false), ], patch: [ diff --git a/src/services/rocketChat/services/rocketChatChannel.js b/src/services/rocketChat/services/rocketChatChannel.js index 451e170fabb..bd74e4cd8fc 100644 --- a/src/services/rocketChat/services/rocketChatChannel.js +++ b/src/services/rocketChat/services/rocketChatChannel.js @@ -136,7 +136,7 @@ class RocketChatChannel { try { let channel = await channelModel.findOne({ teamId }); if (!channel) { - channel = await this.createChannel(teamId).then(() => channelModel.findOne({ teamId })); + channel = await this.createChannel(teamId); } // tis this even the right place to check this? if (params && params.account && params.account.userId) { diff --git a/src/services/school/docs/openapi.yaml b/src/services/school/docs/openapi.yaml index e91a426563b..20d6e65c039 100644 --- a/src/services/school/docs/openapi.yaml +++ b/src/services/school/docs/openapi.yaml @@ -110,48 +110,6 @@ components: skip: type: integer paths: - /schoolsList: - get: - parameters: - - description: Number of results to return - in: query - name: $limit - schema: - type: integer - - description: Number of results to skip - in: query - name: $skip - schema: - type: integer - - description: Property to sort results - in: query - name: $sort - style: deepObject - schema: - type: object - - description: Query parameters to filter - in: query - name: filter - style: form - explode: true - schema: - $ref: '#/components/schemas/schools' - responses: - '200': - description: success - content: - application/json: - schema: - $ref: '#/components/schemas/schools_list' - '401': - description: not authenticated - '500': - description: general error - description: Retrieves a list of all schools. - summary: '' - tags: - - schoolsList - security: [ ] /schools: get: parameters: diff --git a/src/services/school/hooks/publicSchools.hooks.js b/src/services/school/hooks/publicSchools.hooks.js deleted file mode 100644 index ba0522e2a0c..00000000000 --- a/src/services/school/hooks/publicSchools.hooks.js +++ /dev/null @@ -1,21 +0,0 @@ -const { disallow } = require('feathers-hooks-common'); - -exports.before = { - all: [], - find: [], - get: [disallow()], - create: [disallow()], - update: [disallow()], - patch: [disallow()], - remove: [disallow()], -}; - -exports.after = { - all: [], - find: [], - get: [], - create: [], - update: [], - patch: [], - remove: [], -}; diff --git a/src/services/school/index.js b/src/services/school/index.js index b897ce13440..f762506daa7 100644 --- a/src/services/school/index.js +++ b/src/services/school/index.js @@ -5,11 +5,9 @@ const path = require('path'); const schoolModels = require('./model'); const hooks = require('./hooks'); -const publicSchoolsHooks = require('./hooks/publicSchools.hooks'); const schoolGroupHooks = require('./hooks/schoolGroup.hooks'); const { SchoolMaintenanceService } = require('./maintenance'); const { HandlePermissions, handlePermissionsHooks } = require('./services/permissions'); -const { SchoolsListService } = require('./services/schoolsList'); module.exports = function schoolServices() { const app = this; @@ -31,21 +29,6 @@ module.exports = function schoolServices() { const schoolService = app.service('/schools'); schoolService.hooks(hooks); - // public endpoint, called from login - app.use('/schoolsList/api', staticContent(path.join(__dirname, './docs/openapi.yaml'))); - app.use( - '/schoolsList', - new SchoolsListService({ - Model: schoolModels.schoolModel, - paginate: { - default: 2, - max: 3, - }, - }) - ); - const schoolsListService = app.service('schoolsList'); - schoolsListService.hooks(publicSchoolsHooks); - app.use('/schools/:schoolId/maintenance', new SchoolMaintenanceService()); /* schoolGroup service */ diff --git a/src/services/school/logic/year.js b/src/services/school/logic/year.js index d23fb0ca27f..489013f7188 100644 --- a/src/services/school/logic/year.js +++ b/src/services/school/logic/year.js @@ -7,6 +7,7 @@ class SchoolYearFacade { /** retrieves custom year for given year id * @param yearId ObjectId */ + // This code regarding custom years does not work. See the ticket for removal: https://ticketsystem.dbildungscloud.de/browse/BC-6029. const customYearsOf = (yearId) => (school.customYears || []).filter((year) => String(year._id) === String(yearId)); /** overrides year values with custom year values if they have been defined */ const generateSchoolYears = () => diff --git a/src/services/school/services/schoolsList.js b/src/services/school/services/schoolsList.js deleted file mode 100644 index 66fa5e65799..00000000000 --- a/src/services/school/services/schoolsList.js +++ /dev/null @@ -1,30 +0,0 @@ -const { schoolModel } = require('../model'); - -class SchoolsListService { - constructor(options) { - this.options = options || {}; - this.docs = {}; - } - - async find() { - const schoolQuery = { - purpose: { $ne: 'expert' }, - }; - const systemsQuery = { - path: 'systems', - select: '_id type alias oauthConfig.provider', - match: { - $or: [{ type: { $ne: 'ldap' } }, { 'ldapConfig.active': { $eq: true } }], - }, - }; - return schoolModel.find(schoolQuery).populate(systemsQuery).select(['name', 'systems']).sort('name').lean().exec(); - } - - setup(app) { - this.app = app; - } -} - -module.exports = { - SchoolsListService, -}; diff --git a/src/services/sync/repo/user.repo.js b/src/services/sync/repo/user.repo.js index 38351857120..6dd412fc576 100644 --- a/src/services/sync/repo/user.repo.js +++ b/src/services/sync/repo/user.repo.js @@ -65,14 +65,16 @@ const checkUpdate = async (email, userId) => { if (users.length === 0) { return; } - if (!equalIds(users[0]._id, userId)) { - const userExistsInSchool = users[0].schoolId; + const foundUserByMail = users[0]; + if (!equalIds(foundUserByMail._id, userId)) { + const userExistsInSchool = foundUserByMail.schoolId; throw new BadRequest( `User cannot be updated. User and email don't match. User with the same email already exists in school ${userExistsInSchool}`, { userId, existsInSchool: userExistsInSchool, + foundUserByMail: foundUserByMail._id, } ); } diff --git a/src/services/sync/strategies/LDAPSyncer.js b/src/services/sync/strategies/LDAPSyncer.js index b3976c4b26d..68c4e158502 100644 --- a/src/services/sync/strategies/LDAPSyncer.js +++ b/src/services/sync/strategies/LDAPSyncer.js @@ -61,7 +61,7 @@ class LDAPSyncer extends Syncer { await this.sendLdapUsers(ldapUsers, school.ldapSchoolIdentifier); } catch (err) { syncLogger.error('Error while syncing process Ldap Users', { error: err, syncId: this.syncId }); - this.stats.errors.push(err); + this.stats.errors.push(err.stack ? err.stack : err); } } diff --git a/src/services/sync/strategies/TSP/SchoolChange.js b/src/services/sync/strategies/TSP/SchoolChange.js index 465a99e5ffc..1aac37c04bd 100644 --- a/src/services/sync/strategies/TSP/SchoolChange.js +++ b/src/services/sync/strategies/TSP/SchoolChange.js @@ -29,7 +29,7 @@ const deleteUser = (app, user) => { return Promise.all([ userService.remove({ _id: user._id }), accountService.deleteByUserId(user._id.toString()), - teamService.deleteUserDataFromTeams(user._id.toString()), + teamService.deleteUserData(user._id.toString()), ]); }; diff --git a/src/services/sync/strategies/consumerActions/UserAction.js b/src/services/sync/strategies/consumerActions/UserAction.js index ae2c4628c39..c9397e9d260 100644 --- a/src/services/sync/strategies/consumerActions/UserAction.js +++ b/src/services/sync/strategies/consumerActions/UserAction.js @@ -36,9 +36,9 @@ class UserAction extends BaseConsumerAction { } if ( - migratedSchool?.userLoginMigration && + migratedSchool.userLoginMigration && !migratedSchool.userLoginMigration.closedAt && - migratedSchool?.features?.includes(SCHOOL_FEATURES.ENABLE_LDAP_SYNC_DURING_MIGRATION) + migratedSchool.features?.includes(SCHOOL_FEATURES.ENABLE_LDAP_SYNC_DURING_MIGRATION) ) { school = migratedSchool; } else { @@ -64,7 +64,7 @@ class UserAction extends BaseConsumerAction { } // create migration user when the ldapId is not existing on a real user - if (school.inUserMigration === true && !foundUser) { + if (school.inUserMigration === true && !foundUser && !school.userLoginMigration) { await this.createImportUser(user, school); return; } diff --git a/src/services/system/hooks/index.js b/src/services/system/hooks/index.js index 25d3952f84f..907906595e9 100644 --- a/src/services/system/hooks/index.js +++ b/src/services/system/hooks/index.js @@ -38,8 +38,13 @@ const removeSystemFromSchool = async (context) => { const patchquery = { $pull: { systems: system._id }, }; + if (system.type === 'ldap') { - patchquery.$unset = { ldapSchoolIdentifier: '', ldapLastSync: '' }; + if (school.systems.length <= 1) { + patchquery.$unset = { ldapSchoolIdentifier: '', ldapLastSync: '' }; + } else { + patchquery.$unset = { ldapLastSync: '' }; + } } await context.app.service('schools').patch(school._id, patchquery); diff --git a/src/services/user-group/hooks/courses.js b/src/services/user-group/hooks/courses.js index 62c1121a0db..aa50cc52a78 100644 --- a/src/services/user-group/hooks/courses.js +++ b/src/services/user-group/hooks/courses.js @@ -1,12 +1,12 @@ const _ = require('lodash'); const { Configuration } = require('@hpi-schul-cloud/commons/lib'); -const { service } = require('../../../utils/feathers-mongoose'); +const moment = require('moment'); -const { BadRequest } = require('../../../errors'); +const { BadRequest, Forbidden } = require('../../../errors'); const globalHooks = require('../../../hooks'); const ClassModel = require('../model').classModel; const CourseModel = require('../model').courseModel; -const { equal: equalIds } = require('../../../helper/compare').ObjectId; +const { equal: equalIds, toString: toStringId } = require('../../../helper/compare').ObjectId; const restrictToCurrentSchool = globalHooks.ifNotLocal(globalHooks.restrictToCurrentSchool); const restrictToUsersOwnCourses = globalHooks.ifNotLocal(globalHooks.restrictToUsersOwnCourses); @@ -16,6 +16,32 @@ const { checkScopePermissions } = require('../../helpers/scopePermissions/hooks' * adds all students to a course when a class is added to the course * @param hook - contains created/patched object and request body */ +const splitClassIdsInGroupsAndClasses = async (hook) => { + if (!Configuration.get('FEATURE_GROUPS_IN_COURSE_ENABLED')) { + return; + } + + const { app } = hook; + const requestBody = hook.data; + + if ((requestBody.classIds || []).length > 0) { + const groups = await Promise.allSettled( + requestBody.classIds.map((classId) => app.service('nest-group-service').findById(classId)) + ).then(async (promiseResults) => { + const successfullPromises = promiseResults.filter((result) => result.status === 'fulfilled'); + const foundGroups = successfullPromises.map((result) => result.value); + + return foundGroups; + }); + + let classes = await Promise.all(requestBody.classIds.map((classId) => ClassModel.findById(classId).exec())); + classes = classes.filter((clazz) => clazz !== null); + + requestBody.groupIds = groups.map((group) => group.id); + requestBody.classIds = classes.map((clazz) => clazz._id); + } +}; + const addWholeClassToCourse = async (hook) => { const { app } = hook; const requestBody = hook.data; @@ -127,6 +153,43 @@ const deleteWholeClassFromCourse = (hook) => { }); }; +const compareIdArr = (arr1, arr2) => { + if (arr1.length !== arr2.length) { + return false; + } + return arr1.every((element) => arr2.includes(toStringId(element))); +}; + +const restrictChangesToSyncedCourse = async (hook) => { + const { app } = hook; + const courseId = hook.id; + const course = await app.service('courses').get(courseId); + + if (course.syncedWithGroup) { + const dateFormat = 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'; + const courseStartDate = course.startDate ? moment.utc(course.startDate).format(dateFormat) : undefined; + const courseUntilDate = course.untilDate ? moment.utc(course.untilDate).format(dateFormat) : undefined; + + if ( + compareIdArr(course.classIds, hook.data.classIds) && + compareIdArr(course.groupIds, hook.data.groupIds) && + compareIdArr(course.substitutionIds, hook.data.substitutionIds) && + compareIdArr(course.teacherIds, hook.data.teacherIds) && + courseStartDate === hook.data.startDate && + courseUntilDate === hook.data.untilDate + ) { + return hook; + } + throw new Forbidden("The course doesn't match the synchronized course"); + } + return hook; +}; + +const removeColumnBoard = async (context) => { + const courseId = context.id; + await context.app.service('nest-column-board-service').deleteByCourseId(courseId); +}; + /** * remove all substitution teacher which are also teachers * @param hook - contains and request body @@ -183,10 +246,13 @@ const restrictChangesToArchivedCourse = async (context) => { }; module.exports = { + splitClassIdsInGroupsAndClasses, addWholeClassToCourse, deleteWholeClassFromCourse, + removeColumnBoard, removeSubstitutionDuplicates, courseInviteHook, patchPermissionHook, restrictChangesToArchivedCourse, + restrictChangesToSyncedCourse, }; diff --git a/src/services/user-group/hooks/index.js b/src/services/user-group/hooks/index.js index 588bea6bbdd..7ffde55e72d 100644 --- a/src/services/user-group/hooks/index.js +++ b/src/services/user-group/hooks/index.js @@ -5,8 +5,10 @@ const restrictToCurrentSchool = globalHooks.ifNotLocal(globalHooks.restrictToCur const restrictToUsersOwnCourses = globalHooks.ifNotLocal(globalHooks.restrictToUsersOwnCourses); const { + splitClassIdsInGroupsAndClasses, addWholeClassToCourse, deleteWholeClassFromCourse, + removeColumnBoard, courseInviteHook, patchPermissionHook, restrictChangesToArchivedCourse, @@ -25,6 +27,7 @@ exports.before = { create: [ globalHooks.injectUserId, globalHooks.hasPermission('COURSE_CREATE'), + splitClassIdsInGroupsAndClasses, removeSubstitutionDuplicates, restrictToCurrentSchool, ], @@ -39,6 +42,7 @@ exports.before = { restrictToCurrentSchool, restrictChangesToArchivedCourse, globalHooks.permitGroupOperation, + splitClassIdsInGroupsAndClasses, removeSubstitutionDuplicates, deleteWholeClassFromCourse, ], @@ -63,5 +67,5 @@ exports.after = { create: [addWholeClassToCourse], update: [], patch: [addWholeClassToCourse], - remove: [], + remove: [removeColumnBoard], }; diff --git a/src/services/user-group/model.js b/src/services/user-group/model.js index 2bdf688b2c3..d05fb217050 100644 --- a/src/services/user-group/model.js +++ b/src/services/user-group/model.js @@ -61,6 +61,7 @@ const courseSchema = getUserGroupSchema({ // optional information if this course is a copy from other isCopyFrom: { type: Schema.Types.ObjectId, default: null }, features: [{ type: String, enum: Object.values(COURSE_FEATURES) }], + syncedWithGroup: { type: Schema.Types.ObjectId }, ...externalSourceSchema, }); diff --git a/src/services/user-group/services/courses.js b/src/services/user-group/services/courses.js index 8fde89d05d2..7f773999f60 100644 --- a/src/services/user-group/services/courses.js +++ b/src/services/user-group/services/courses.js @@ -21,12 +21,15 @@ const restrictToCurrentSchoolIfNotLocal = ifNotLocal(restrictToCurrentSchool); const restrictToUsersOwnCoursesIfNotLocal = ifNotLocal(restrictToUsersOwnCourses); const { + splitClassIdsInGroupsAndClasses, addWholeClassToCourse, deleteWholeClassFromCourse, + removeColumnBoard, courseInviteHook, patchPermissionHook, restrictChangesToArchivedCourse, removeSubstitutionDuplicates, + restrictChangesToSyncedCourse, } = require('../hooks/courses'); const { checkScopePermissions } = require('../../helpers/scopePermissions/hooks'); @@ -93,6 +96,7 @@ const courseHooks = { create: [ injectUserId, hasPermission('COURSE_CREATE'), + splitClassIdsInGroupsAndClasses, removeSubstitutionDuplicates, restrictToCurrentSchoolIfNotLocal, iff(isProvider('external'), preventPopulate), @@ -109,6 +113,8 @@ const courseHooks = { restrictToCurrentSchoolIfNotLocal, restrictChangesToArchivedCourse, permitGroupOperation, + restrictChangesToSyncedCourse, + splitClassIdsInGroupsAndClasses, removeSubstitutionDuplicates, deleteWholeClassFromCourse, iff(isProvider('external'), preventPopulate), @@ -134,7 +140,7 @@ const courseHooks = { create: [addWholeClassToCourse], update: [], patch: [addWholeClassToCourse], - remove: [], + remove: [removeColumnBoard], }, }; diff --git a/src/services/user/docs/openapi.yaml b/src/services/user/docs/openapi.yaml index 832967b305e..b92af5254a0 100644 --- a/src/services/user/docs/openapi.yaml +++ b/src/services/user/docs/openapi.yaml @@ -4,7 +4,7 @@ info: title: Schul-Cloud User Service API description: This is the API specification for the Schul-Cloud User service. - + contact: name: support email: info@dbildungscloud.de @@ -1000,84 +1000,6 @@ paths: schema: $ref: '#/components/schemas/firstLogin' /users/admin/students: - get: - parameters: - - description: Number of results to return - in: query - name: $limit - schema: - type: integer - - description: Number of results to skip - in: query - name: $skip - schema: - type: integer - - description: Property to sort results - in: query - name: $sort - schema: - type: object - - description: Regex to search for in firstName, lastName, and emails. - in: query - name: searchQuery - schema: - type: string - - description: Query parameters to filter - in: query - name: firstName - schema: - type: string - - description: Query parameters to filter - in: query - name: lastName - schema: - type: string - - description: Query parameters to filter - in: query - name: createdAt - schema: - anyOf: - - type: string - - type: object - - description: Query parameters to filter - in: query - name: consentStatus - schema: - anyOf: - - type: string - - type: object - - description: Array of user ids to filter - in: query - name: users - schema: - anyOf: - - type: string - - type: array - - type: object - - description: Array of classes to filter - in: query - name: classes - schema: - anyOf: - - type: string - - type: array - - type: object - responses: - '200': - description: success - content: - application/json: - schema: - $ref: '#/components/schemas/admin_list' - '401': - description: not authenticated - '500': - description: general error - description: Retrieves a list of all resources from the service. - summary: '' - tags: - - users - security: [] post: parameters: [] responses: @@ -1103,32 +1025,6 @@ paths: schema: $ref: '#/components/schemas/userAdmin' '/users/admin/students/{id}': - get: - parameters: - - in: path - name: id - description: ID of admin to return - schema: - type: string - required: true - responses: - '200': - description: success - content: - application/json: - schema: - $ref: '#/components/schemas/userAdmin' - '401': - description: not authenticated - '404': - description: not found - '500': - description: general error - description: Retrieves a single resource with the given id from the service. - summary: '' - tags: - - users - security: [] put: parameters: - in: path @@ -1220,84 +1116,6 @@ paths: - users security: [] /users/admin/teachers: - get: - parameters: - - description: Number of results to return - in: query - name: $limit - schema: - type: integer - - description: Number of results to skip - in: query - name: $skip - schema: - type: integer - - description: Property to sort results - in: query - name: $sort - schema: - type: object - - description: Regex to search for in firstName, lastName, and emails. - in: query - name: searchQuery - schema: - type: string - - description: Query parameters to filter - in: query - name: firstName - schema: - type: string - - description: Query parameters to filter - in: query - name: lastName - schema: - type: string - - description: Query parameters to filter - in: query - name: createdAt - schema: - anyOf: - - type: string - - type: object - - description: Query parameters to filter - in: query - name: consentStatus - schema: - anyOf: - - type: string - - type: object - - description: Array of user ids to filter - in: query - name: users - schema: - anyOf: - - type: string - - type: array - - type: object - - description: Array of classes to filter - in: query - name: classes - schema: - anyOf: - - type: string - - type: array - - type: object - responses: - '200': - description: success - content: - application/json: - schema: - $ref: '#/components/schemas/admin_list' - '401': - description: not authenticated - '500': - description: general error - description: Retrieves a list of all resources from the service. - summary: '' - tags: - - users - security: [] post: parameters: [] responses: @@ -1323,64 +1141,6 @@ paths: schema: $ref: '#/components/schemas/userAdmin' '/users/admin/teachers/{id}': - get: - parameters: - - in: path - name: id - description: ID of admin to return - schema: - type: string - required: true - responses: - '200': - description: success - content: - application/json: - schema: - $ref: '#/components/schemas/userAdmin' - '401': - description: not authenticated - '404': - description: not found - '500': - description: general error - description: Retrieves a single resource with the given id from the service. - summary: '' - tags: - - users - security: [] - put: - parameters: - - in: path - name: id - description: ID of admin to update - schema: - type: string - required: true - responses: - '200': - description: success - content: - application/json: - schema: - $ref: '#/components/schemas/userAdmin' - '401': - description: not authenticated - '404': - description: not found - '500': - description: general error - description: Updates the resource identified by id using data. - summary: '' - tags: - - users - security: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/userAdmin' patch: parameters: - in: path diff --git a/src/services/user/services/AdminUsers.js b/src/services/user/services/AdminUsers.js index 260dc01d133..6a04282c6e2 100644 --- a/src/services/user/services/AdminUsers.js +++ b/src/services/user/services/AdminUsers.js @@ -3,13 +3,8 @@ /* eslint-disable no-underscore-dangle */ const { authenticate } = require('@feathersjs/authentication').hooks; const moment = require('moment'); -const { v4: uuidv4 } = require('uuid'); -const { Configuration } = require('@hpi-schul-cloud/commons'); -const { Forbidden, BadRequest, GeneralError } = require('../../../errors'); -const logger = require('../../../logger'); -const { createMultiDocumentAggregation } = require('../utils/aggregations'); -const { splitForSearchIndexes } = require('../../../utils/search'); +const { Forbidden, BadRequest } = require('../../../errors'); const { hasSchoolPermission, blockDisposableEmail, transformToDataTransferObject } = require('../../../hooks'); const { equal: equalIds } = require('../../../helper/compare').ObjectId; const { validateParams, parseRequestQuery } = require('../hooks/adminUsers.hooks'); @@ -21,29 +16,6 @@ const { userModel } = require('../model'); const getCurrentUserInfo = (id) => userModel.findById(id).select('schoolId').lean().exec(); -const getCurrentYearId = (ref, schoolId) => - ref.app - .service('schools') - .get(schoolId, { - query: { $select: ['currentYear'] }, - }) - .then(({ currentYear }) => currentYear._id.toString()); - -const setSearchParametesIfExist = (clientQuery, query) => { - if (clientQuery.searchQuery && clientQuery.searchQuery.trim().length !== 0) { - const amountOfSearchWords = clientQuery.searchQuery.split(' ').length; - const searchQueryElements = splitForSearchIndexes(clientQuery.searchQuery.trim()); - query.searchQuery = `${clientQuery.searchQuery} ${searchQueryElements.join(' ')}`; - // increase gate by searched word, to get better results - query.searchFilterGate = searchQueryElements.length * 2 + amountOfSearchWords; - // recreating sort here, to set searchQuery as first (main) parameter of sorting - query.sort = { - ...query.sort, - searchQuery: 1, - }; - } -}; - class AdminUsers { constructor(roleName) { this.roleName = roleName; @@ -59,108 +31,6 @@ class AdminUsers { } } - async find(params) { - return this.getUsers(undefined, params); - } - - async get(id, params) { - return this.getUsers(id, params); - } - - async getUsers(_id, params) { - // integration test did not get the role in the setup - // so here is a workaround set it at first call - await this.setRole(); - - try { - const { query: clientQuery = {}, account } = params; - const currentUserId = account.userId.toString(); - - // fetch base data - const { schoolId } = await getCurrentUserInfo(currentUserId); - const schoolYearId = await getCurrentYearId(this, schoolId); - - const query = { - schoolId, - roles: this.role._id, - schoolYearId, - sort: clientQuery.$sort || clientQuery.sort, - select: [ - 'consentStatus', - 'consent', - 'classes', - 'firstName', - 'lastName', - 'email', - 'createdAt', - 'importHash', - 'birthday', - 'preferences.registrationMailSend', - 'lastLoginSystemChange', - 'outdatedSince', - ], - skip: clientQuery.$skip || clientQuery.skip, - limit: clientQuery.$limit || clientQuery.limit, - }; - if (_id) { - query._id = _id; - } else if (clientQuery.users) { - query._id = clientQuery.users; - // If the number of users exceeds 20, the underlying parsing library - // will convert the array to an object with the index as the key. - // To continue working with it, we convert it here back to the array form. - // See the documentation for further infos: https://github.com/ljharb/qs#parsing-arrays - if (typeof query._id === 'object') query._id = Object.values(query._id); - } - if (clientQuery.consentStatus) query.consentStatus = clientQuery.consentStatus; - if (clientQuery.classes) query.classes = clientQuery.classes; - if (clientQuery.firstName) query.firstName = clientQuery.firstName; - if (clientQuery.lastName) query.lastName = clientQuery.lastName; - setSearchParametesIfExist(clientQuery, query); - - const dateQueries = ['createdAt', 'outdatedSince', 'lastLoginSystemChange']; - for (const dateQuery of dateQueries) { - if (clientQuery[dateQuery]) { - if (typeof clientQuery[dateQuery] === 'object') { - for (const [key, value] of Object.entries(clientQuery[dateQuery])) { - if (['$gt', '$gte', '$lt', '$lte'].includes(key)) { - clientQuery[dateQuery][key] = new Date(value); - } - } - query[dateQuery] = clientQuery[dateQuery]; - } else { - query[dateQuery] = new Date(clientQuery[dateQuery]); - } - } - } - - return new Promise((resolve, reject) => - userModel - .aggregate(createMultiDocumentAggregation(query)) - .option({ - collation: { locale: 'de', caseLevel: true }, - }) - .exec((err, res) => { - if (err) reject(err); - else resolve(res[0] || {}); - }) - ); - } catch (err) { - if ((err || {}).code === 403) { - throw new Forbidden('You have not the permission to execute this.', err); - } - if (err && err.code >= 500) { - const uuid = uuidv4(); - logger.error(uuid, err); - if (Configuration.get('NODE_ENV') !== 'production') { - throw err; - } - throw new GeneralError(uuid); - } - throw err; - } - } - async create(_data, _params) { const currentUserId = _params.account.userId.toString(); const { schoolId } = await getCurrentUserInfo(currentUserId); @@ -301,17 +171,9 @@ class AdminUsers { } } -const formatBirthdayOfUsers = ({ result: { data: users } }) => { - users.forEach((user) => { - if (user.birthday) user.birthday = moment(user.birthday).format('DD.MM.YYYY'); - }); -}; - const adminHookGenerator = (kind) => ({ before: { all: [authenticate('jwt')], - find: [hasSchoolPermission(`${kind}_LIST`)], - get: [hasSchoolPermission(`${kind}_LIST`)], create: [hasSchoolPermission(`${kind}_CREATE`), blockDisposableEmail('email')], update: [hasSchoolPermission(`${kind}_EDIT`), protectImmutableAttributes, blockDisposableEmail('email')], patch: [hasSchoolPermission(`${kind}_EDIT`), protectImmutableAttributes, blockDisposableEmail('email')], @@ -319,7 +181,6 @@ const adminHookGenerator = (kind) => ({ }, after: { all: [transformToDataTransferObject], - find: [formatBirthdayOfUsers], create: [sendRegistrationLink], }, }); diff --git a/src/services/user/services/userService.js b/src/services/user/services/userService.js index 823a10b64e3..9b7d98e0bbe 100644 --- a/src/services/user/services/userService.js +++ b/src/services/user/services/userService.js @@ -89,6 +89,7 @@ const userService = new UserService({ const populateWhitelist = { roles: ['_id', 'name', 'permissions', 'roles'], + schoolId: ['_id', 'name'], }; const userHooks = { diff --git a/src/utils/rabbitmq.js b/src/utils/rabbitmq.js index 4aef8dbda50..350241251cf 100644 --- a/src/utils/rabbitmq.js +++ b/src/utils/rabbitmq.js @@ -44,6 +44,8 @@ class Channel { try { const conn = await getConnection(); this.channel = await conn.createChannel(); + // the default is 0, 0 means get absolutely everything, internet claims this is limited by rabbitmq by 2000, which basically defeats the purpose of using separate processes + await this.channel.prefetch(Configuration.get('LEGACY_RABBITMQ_GLOBAL_PREFETCH_COUNT'), true); await this.channel.assertQueue(this.queue, this.queueOptions); this.channel.on('close', () => { logger.warning('RabbitMQ channel was closed.'); diff --git a/src/utils/redis.js b/src/utils/redis.js index 2cdaaa0a7fc..550a728415e 100644 --- a/src/utils/redis.js +++ b/src/utils/redis.js @@ -1,20 +1,22 @@ const { promisify } = require('util'); -const redis = require('redis'); +const Redis = require('ioredis'); const { Configuration } = require('@hpi-schul-cloud/commons'); const { GeneralError } = require('../errors'); +const logger = require('../logger'); let redisClient = false; async function initializeRedisClient() { if (Configuration.has('REDIS_URI')) { try { - redisClient = redis.createClient({ - url: Configuration.get('REDIS_URI'), - // Legacy mode is needed for compatibility with v4, see https://github.com/redis/node-redis/blob/HEAD/docs/v3-to-v4.md#legacy-mode - legacyMode: true, + redisClient = new Redis(Configuration.get('REDIS_URI')); + + // The error event must be handled, otherwise the app crashes on redis connection errors. + // This is due to basic NodeJS behavior: https://nodejs.org/api/events.html#error-events + redisClient.on('error', (err) => { + logger.error('Redis client error', err); }); - await redisClient.connect(); } catch (err) { throw new GeneralError('Redis connection failed!', err); } diff --git a/test/app.hooks.redis.test.js b/test/app.hooks.redis.test.js index f52dc5bb0e4..7acfbf7511b 100644 --- a/test/app.hooks.redis.test.js +++ b/test/app.hooks.redis.test.js @@ -32,7 +32,7 @@ describe('handleAutoLogout hook', () => { warnOnUnregistered: false, useCleanCache: true, }); - mockery.registerMock('redis', redisMock); + mockery.registerMock('ioredis', redisMock); mockery.registerMock('@hpi-schul-cloud/commons', commons); delete require.cache[require.resolve('../src/utils/redis')]; diff --git a/test/routes/whitelist.js b/test/routes/whitelist.js index 1e0e61e31ae..b982a89d26d 100644 --- a/test/routes/whitelist.js +++ b/test/routes/whitelist.js @@ -10,7 +10,6 @@ const whitelistNoJwt = { 'oauth2/baseUrl': { get: 200 }, registrationlink: { post: 201 }, roster: { get: 200 }, - schoolsList: { get: 200 }, 'tools/link': { post: 404 }, years: { get: 200 }, 'system_info/haproxy': { get: 200 }, @@ -20,7 +19,6 @@ const whitelistNoJwt = { const whitelistInvalidJwt = { ...whitelistNoJwt, - schoolsList: { get: 401 }, years: { get: 401 }, gradeLevels: { get: 401 }, }; diff --git a/test/services/account/services/jwtTimerService.test.js b/test/services/account/services/jwtTimerService.test.js index 50a9b083b39..453ff4b5c6f 100644 --- a/test/services/account/services/jwtTimerService.test.js +++ b/test/services/account/services/jwtTimerService.test.js @@ -40,7 +40,7 @@ describe('jwtTimer service', () => { delete require.cache[require.resolve('../../../../src/utils/redis')]; delete require.cache[require.resolve('../../../../src/services/account/services/jwtTimerService')]; - mockery.registerMock('redis', redisMock); + mockery.registerMock('ioredis', redisMock); mockery.registerMock('@hpi-schul-cloud/commons', commons); /* eslint-disable global-require */ redisHelper = require('../../../../src/utils/redis'); @@ -100,7 +100,7 @@ describe('jwtTimer service', () => { warnOnUnregistered: false, useCleanCache: true, }); - mockery.registerMock('redis', redisMock); + mockery.registerMock('ioredis', redisMock); delete require.cache[require.resolve('../../../../src/utils/redis')]; /* eslint-disable global-require */ diff --git a/test/services/activation/services/eMailAddress/index.test.js b/test/services/activation/services/eMailAddress/index.test.js index bf5e617ed70..2a25e8b983b 100644 --- a/test/services/activation/services/eMailAddress/index.test.js +++ b/test/services/activation/services/eMailAddress/index.test.js @@ -41,30 +41,4 @@ describe('activation/services/eMailAddress EMailAdresseActivationService', () => it('registered the activation service', () => { expect(activationService).to.not.be.undefined; }); - - it('create entry', async () => { - const user = await createTestUser({ roles: ['student'] }); - const password = 'password123'; - const credentials = { username: user.email, password }; - await createTestAccount(credentials, 'local', user); - - const data = { - email: mockData.email, - repeatEmail: mockData.email, - password, - }; - - const nestMailService = { send: sinon.spy() }; - app.services['nest-mail'] = nestMailService; - - const res = await activationService.create(data, { account: { userId: user._id } }); - expect(nestMailService.send.calledOnce).to.equal(true); - expect(res.success).to.be.true; - - const entries = await util.getEntriesByUserId(app, user._id); - expect(entries).to.have.lengthOf(1); - expect(entries[0].quarantinedObject).to.be.equal(data.email); - expect(entries[0].keyword).to.be.equal(mockData.keyword); - expect(entries[0].userId.toString()).to.be.equal(user._id.toString()); - }); }); diff --git a/test/services/authentication/hooks/index.test.js b/test/services/authentication/hooks/index.test.js index 97ed2d41c56..593f0bd1007 100644 --- a/test/services/authentication/hooks/index.test.js +++ b/test/services/authentication/hooks/index.test.js @@ -32,7 +32,7 @@ describe('authentication hooks', () => { warnOnUnregistered: false, useCleanCache: true, }); - mockery.registerMock('redis', redisMock); + mockery.registerMock('ioredis', redisMock); mockery.registerMock('@hpi-schul-cloud/commons', commons); delete require.cache[require.resolve('../../../../src/app')]; diff --git a/test/services/config/hooks/services/publicAppConfigService.test.js b/test/services/config/hooks/services/publicAppConfigService.test.js deleted file mode 100644 index ad72a45496a..00000000000 --- a/test/services/config/hooks/services/publicAppConfigService.test.js +++ /dev/null @@ -1,36 +0,0 @@ -const { expect } = require('chai'); -const { Configuration } = require('@hpi-schul-cloud/commons'); -const appPromise = require('../../../../../src/app'); -const { exposedVars } = require('../../../../../src/services/config/publicAppConfigService'); - -describe('PublicAppConfigService', () => { - let app; - let server; - let configService; - - before(async () => { - app = await appPromise(); - configService = app.service('/config/app/public'); - server = await app.listen(0); - }); - - after(async () => { - await server.close(); - }); - - it('is properly registered', () => { - expect(configService).to.not.equal(undefined); - }); - - it('returns the right environment variables', async () => { - const serviceEnvs = await configService.find(); - const testEnvs = {}; - // eslint-disable-next-line no-return-assign - exposedVars.forEach((env) => { - if (Configuration.has(env)) { - testEnvs[env] = Configuration.get(env); - } - }); - expect(serviceEnvs).to.eql(testEnvs); - }); -}); diff --git a/test/services/courses/services/courses.test.js b/test/services/courses/services/courses.test.js index 82aced56e70..63f22cb8a8d 100644 --- a/test/services/courses/services/courses.test.js +++ b/test/services/courses/services/courses.test.js @@ -61,15 +61,15 @@ describe('course service', () => { } }); - it('teacher can DELETE course', async () => { - const teacher = await testObjects.createTestUser({ roles: ['teacher'] }); - const course = await testObjects.createTestCourse({ name: 'course', teacherIds: [teacher._id] }); - const params = await testObjects.generateRequestParamsFromUser(teacher); - params.query = {}; + // it('teacher can DELETE course', async () => { + // const teacher = await testObjects.createTestUser({ roles: ['teacher'] }); + // const course = await testObjects.createTestCourse({ name: 'course', teacherIds: [teacher._id] }); + // const params = await testObjects.generateRequestParamsFromUser(teacher); + // params.query = {}; - const result = await courseService.remove(course._id, params); - expect(result).to.not.be.undefined; - }); + // const result = await courseService.remove(course._id, params); + // expect(result).to.not.be.undefined; + // }); it('substitution teacher can not DELETE course', async () => { try { diff --git a/test/services/helpers/services/accounts.js b/test/services/helpers/services/accounts.js index c4c9b90162f..522c9724709 100644 --- a/test/services/helpers/services/accounts.js +++ b/test/services/helpers/services/accounts.js @@ -23,7 +23,9 @@ const createTestAccount = (appPromise) => async (accountParameters, system, user }; const createdAccount = await accountService.save(accountDto); createdaccountsIds.push(createdAccount.id.toString()); - return createdAccount; + return { + ...createdAccount.getProps(), + }; }; const cleanup = (appPromise) => async () => { diff --git a/test/services/homework/index.test.js b/test/services/homework/index.test.js index cfc478ce807..2f31f990e89 100644 --- a/test/services/homework/index.test.js +++ b/test/services/homework/index.test.js @@ -2,13 +2,13 @@ const assert = require('assert'); const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); const appPromise = require('../../../src/app'); -const { NotAuthenticated, NotFound } = require('../../../src/errors'); const testObjects = require('../helpers/testObjects')(appPromise()); chai.use(chaiAsPromised); const { expect } = chai; const { setupNestServices, closeNestServices } = require('../../utils/setup.nest.services'); +const { NotAuthenticated } = require('../../../src/errors'); describe('homework service', () => { let app; @@ -212,80 +212,11 @@ describe('homework service', () => { }); describe('DELETE', async () => { - it('should remove a users private task', async () => { - const { user, homework } = await setupPrivateHomework(); - const params = await testObjects.generateRequestParamsFromUser(user); - params.query = {}; - const result = await homeworkService.remove(homework._id, params); - expect(result._id).to.deep.equal(homework._id); - expect(homeworkService.get(homework._id, params)).to.be.rejectedWith(NotFound); - }); - it('should not allow to remove other users private tasks outside courses', async () => { - const { homework } = await setupPrivateHomework(); - - const otherTeacher = await testObjects.createTestUser({ roles: ['teacher'] }); - const otherStudent = await testObjects.createTestUser({ roles: ['student'] }); - - let params; - params = await testObjects.generateRequestParamsFromUser(otherTeacher); - params.query = {}; - expect(homeworkService.remove(homework._id, params)).to.be.rejectedWith(NotAuthenticated); - - params = await testObjects.generateRequestParamsFromUser(otherStudent); - params.query = {}; - expect(homeworkService.remove(homework._id, params)).to.be.rejectedWith(NotAuthenticated); - }); - it('should not allow homework removal by course student', async () => { - const { student, homework } = await setupHomeworkWithCourse(); - const params = await testObjects.generateRequestParamsFromUser(student); - params.query = {}; - expect(homeworkService.remove(homework._id, params)).to.be.rejectedWith(NotAuthenticated); - }); - it('should not allow homework removal by any student', async () => { - const { homework } = await setupHomeworkWithCourse(); - const otherStudent = await testObjects.createTestUser({ roles: ['student'] }); - const params = await testObjects.generateRequestParamsFromUser(otherStudent); - params.query = {}; - expect(homeworkService.remove(homework._id, params)).to.be.rejectedWith(NotAuthenticated); - }); - it('should not allow homework removal by any teacher', async () => { - const { homework } = await setupHomeworkWithCourse(); - const otherTeacher = await testObjects.createTestUser({ roles: ['teacher'] }); - const params = await testObjects.generateRequestParamsFromUser(otherTeacher); - params.query = {}; - expect(homeworkService.remove(homework._id, params)).to.be.rejectedWith(NotAuthenticated); - }); - it('should allow homework removal by course teacher', async () => { - const { teacher, homework } = await setupHomeworkWithCourse(); + it('should not allow homework removal', async () => { + const { homework, teacher } = await setupHomeworkWithCourse(); const params = await testObjects.generateRequestParamsFromUser(teacher); params.query = {}; - const result = await homeworkService.remove(homework._id, params); - expect(result._id).to.deep.equal(homework._id); - expect(homeworkService.get(homework._id, params)).to.be.rejectedWith(NotFound); - }); - it('should allow homework removal by course substitute teacher', async () => { - const { substitutionTeacher, homework } = await setupHomeworkWithCourse(); - const params = await testObjects.generateRequestParamsFromUser(substitutionTeacher); - params.query = {}; - const result = await homeworkService.remove(homework._id, params); - expect(result._id).to.deep.equal(homework._id); - expect(homeworkService.get(homework._id, params)).to.be.rejectedWith(NotFound); - }); - it('should allow teacher to remove private tasks in his course', async () => { - const { teacher, homework } = await setupHomeworkWithCourse({ asPrivate: true }); - const params = await testObjects.generateRequestParamsFromUser(teacher); - params.query = {}; - const result = await homeworkService.remove(homework._id, params); - expect(result._id).to.deep.equal(homework._id); - expect(homeworkService.get(homework._id, params)).to.be.rejectedWith(NotFound); - }); - it('should allow substitution teacher to remove private tasks in his course', async () => { - const { teacher, homework } = await setupHomeworkWithCourse({ asPrivate: true }); - const params = await testObjects.generateRequestParamsFromUser(teacher); - params.query = {}; - const result = await homeworkService.remove(homework._id, params); - expect(result._id).to.deep.equal(homework._id); - expect(homeworkService.get(homework._id, params)).to.be.rejectedWith(NotFound); + expect(homeworkService.remove(homework._id, params)).to.be.rejectedWith(NotAuthenticated); }); }); diff --git a/test/services/ldap/strategies/general.test.js b/test/services/ldap/strategies/general.test.js index 7328811f892..0c08eadef21 100644 --- a/test/services/ldap/strategies/general.test.js +++ b/test/services/ldap/strategies/general.test.js @@ -6,6 +6,8 @@ const appPromise = require('../../../../src/app'); const AbstractLDAPStrategy = require('../../../../src/services/ldap/strategies/interface'); const GeneralLDAPStrategy = require('../../../../src/services/ldap/strategies/general'); +const teacherRole1 = 'cn=ROLE_TEACHER,ou=roles,o=school0,dc=de,dc=example,dc=org'; +const teacherRole2 = 'cn=OTHER_TEACHERS,ou=roles,o=school0,dc=de,dc=example,dc=org'; const mockLDAPConfig = { url: 'ldaps://foo.bar:636', rootPath: 'o=school0,dc=de,dc=example,dc=org', @@ -23,8 +25,7 @@ const mockLDAPConfig = { }, roleAttributeNameMapping: { roleStudent: 'cn=ROLE_STUDENT,ou=roles,o=school0,dc=de,dc=example,dc=org', - roleTeacher: - 'cn=ROLE_TEACHER,ou=roles,o=school0,dc=de,dc=example,dc=org;;cn=OTHER_TEACHERS,ou=roles,o=school0,dc=de,dc=example,dc=org', + roleTeacher: `${teacherRole1};;${teacherRole2}`, roleAdmin: 'cn=ROLE_ADMIN,ou=roles,o=school0,dc=de,dc=example,dc=org', }, classAttributeNameMapping: { @@ -202,25 +203,57 @@ describe('GeneralLDAPStrategy', () => { expect(teacher2.roles).to.include('teacher'); }); - it('should assign roles based on specific group memberships for non-group role type', async () => { - const ldapConfig = { - ...mockLDAPConfig, - providerOptions: { ...mockLDAPConfig.providerOptions, roleType: 'non-group' }, - }; - app.unuse('/ldap'); - app.use('/ldap', { - setup: () => {}, - get: () => {}, - searchCollection: sinon.fake.resolves([ - createLDAPUserResult({ role: mockLDAPConfig.providerOptions.roleAttributeNameMapping.roleStudent }), - createLDAPUserResult({ role: mockLDAPConfig.providerOptions.roleAttributeNameMapping.roleTeacher }), - createLDAPUserResult({ role: mockLDAPConfig.providerOptions.roleAttributeNameMapping.roleAdmin }), - ]), + describe('non-group role type', () => { + it('should assign roles based on specific group memberships', async () => { + const ldapConfig = { + ...mockLDAPConfig, + providerOptions: { ...mockLDAPConfig.providerOptions, roleType: 'non-group' }, + }; + app.unuse('/ldap'); + app.use('/ldap', { + setup: () => {}, + get: () => {}, + searchCollection: sinon.fake.resolves([ + createLDAPUserResult({ role: mockLDAPConfig.providerOptions.roleAttributeNameMapping.roleStudent }), + createLDAPUserResult({ role: teacherRole1 }), + createLDAPUserResult({ role: teacherRole2 }), + createLDAPUserResult({ role: mockLDAPConfig.providerOptions.roleAttributeNameMapping.roleAdmin }), + ]), + }); + const [student, teacher1, teacher2, admin] = await new GeneralLDAPStrategy(app, ldapConfig).getUsers(); + expect(student.roles).to.include('student'); + expect(teacher1.roles).to.include('teacher'); + expect(teacher2.roles).to.include('teacher'); + expect(admin.roles).to.include('administrator'); + }); + + it('should skip ldap users with missing userAttributeNameMapping.role', async () => { + const ldapConfig = { + ...mockLDAPConfig, + providerOptions: { + ...mockLDAPConfig.providerOptions, + roleType: 'text', + userAttributeNameMapping: { ...mockLDAPConfig.providerOptions.userAttributeNameMapping, role: 'vcEXtype' }, + roleAttributeNameMapping: { + ...mockLDAPConfig.providerOptions.roleAttributeNameMapping, + roleStudent: '311', + }, + }, + }; + app.unuse('/ldap'); + app.use('/ldap', { + setup: () => {}, + get: () => {}, + searchCollection: sinon.fake.resolves([ + createLDAPUserResult({ vcEXtype: '311' }), + createLDAPUserResult({ vcEXtype: undefined }), + ]), + }); + + const result = await new GeneralLDAPStrategy(app, ldapConfig).getUsers(); + + expect(result.length).to.equal(1); }); - const [student, teacher, admin] = await new GeneralLDAPStrategy(app, ldapConfig).getUsers(); - expect(student.roles).to.include('student'); - expect(teacher.roles).to.include('teacher'); - expect(admin.roles).to.include('administrator'); }); it('should assign name defaults if entities lack first names', async () => { diff --git a/test/services/school/index.test.js b/test/services/school/index.test.js index f1817289b53..12d6c2cc44e 100644 --- a/test/services/school/index.test.js +++ b/test/services/school/index.test.js @@ -628,51 +628,3 @@ describe('years service', () => { assert.ok(app.service('gradeLevels')); }); }); - -describe('schoolsList service', () => { - let app; - let server; - let schoolsListService; - - before(async () => { - app = await appPromise(); - server = await app.listen(); - schoolsListService = app.service('schoolsList'); - }); - - after(async () => { - await testObjects.cleanup(); - await server.close(); - }); - - it('registered the schoolsList services', () => { - assert.ok(schoolsListService); - }); - - describe('find', () => { - before('load data and set samples', async () => { - await createSchool(); - }); - - it('can be accessed unautorized', async () => { - const params = { - provider: 'rest', - headers: { - authorization: undefined, - }, - account: undefined, - query: {}, - }; - const result = await schoolsListService.find(params); - expect(result.length).gt(0); - }); - - it('should return only certain fields', async () => { - const schoolsList = await schoolsListService.find(); - const fields = ['name', '_id', 'systems']; - schoolsList.forEach((school) => { - expect(Object.keys(school).every((field) => fields.includes(field))).to.be.true; - }); - }); - }); -}); diff --git a/test/services/school/services/permissions.test.js b/test/services/school/services/permissions.test.js index 1709d3bc138..e565f576c4f 100644 --- a/test/services/school/services/permissions.test.js +++ b/test/services/school/services/permissions.test.js @@ -32,8 +32,6 @@ describe('permissons service', () => { app.unuse('schools'); app.unuse('schools/api'); app.unuse('/schools/:schoolId/maintenance'); - app.unuse('schoolsList'); - app.unuse('schoolsList/api'); app.unuse('schoolGroup'); app.unuse('gradeLevels'); app.unuse('/school/teacher/studentvisibility'); diff --git a/test/services/system/index.test.js b/test/services/system/index.test.js index 985e5886851..47fcae2da18 100644 --- a/test/services/system/index.test.js +++ b/test/services/system/index.test.js @@ -512,6 +512,31 @@ describe('systemId service', () => { expect(usersSchoolUpdated.ldapSchoolIdentifier).to.be.undefined; }); + it('REMOVE should not remove ldapschoolidentifier from school, if another system exists', async () => { + const usersSystem = await testObjects.createTestSystem({ + type: 'ldap', + ldapConfig: { + provider: 'general', + }, + }); + const oauthSystem = await testObjects.createTestSystem({ + type: 'oauth', + }); + const usersSchool = await testObjects.createTestSchool({ + systems: [usersSystem._id, oauthSystem._id], + ldapSchoolIdentifier: 'someidentifier', + }); + + const user = await testObjects.createTestUser({ roles: ['administrator'], schoolId: [usersSchool._id] }); + const params = await testObjects.generateRequestParamsFromUser(user); + + await app.service('systems').remove(usersSystem._id, params); + + const usersSchoolUpdated = await app.service('schools').get(usersSchool._id, params); + + expect(usersSchoolUpdated.ldapSchoolIdentifier).to.not.be.undefined; + }); + it('REMOVE should remove ldapLastSync from school if ldap system is removed', async () => { const usersSystem = await testObjects.createTestSystem({ type: 'ldap', diff --git a/test/services/user/services/AdminUsers.integration.test.js b/test/services/user/services/AdminUsers.integration.test.js index 1380e03bd80..21e507321f8 100644 --- a/test/services/user/services/AdminUsers.integration.test.js +++ b/test/services/user/services/AdminUsers.integration.test.js @@ -78,69 +78,4 @@ describe('admin users integration tests', () => { expect(response.error).to.not.be.undefined; expect(response.error.status).to.equal(400); }); - - it('FIND basic request', async () => { - const { _id: schoolId } = await testObjects.createTestSchool(); - const [adminUser] = await Promise.all([ - testObjects.createTestUser({ roles: ['administrator'], schoolId }), - testObjects.createTestUser({ roles: ['student'], schoolId }), - testObjects.createTestUser({ roles: ['student'], schoolId }), - testObjects.createTestUser({ roles: ['student'], schoolId }), - ]); - - const params = await testObjects.generateRequestParamsFromUser(adminUser); - params.query = { - $limit: 25, - $skip: 0, - $sort: { - email: 1, - }, - }; - - const result = await app.service('/users/admin/students').find(params); - expect(result).to.not.be.undefined; - expect(result.data).to.be.an('array').of.length(3); - }); - - it('FIND request with searchQuery', async () => { - const { _id: schoolId } = await testObjects.createTestSchool(); - const [adminUser] = await Promise.all([ - testObjects.createTestUser({ roles: ['administrator'], schoolId }), - testObjects.createTestUser({ roles: ['student'], firstName: 'Hannes', schoolId }), - testObjects.createTestUser({ roles: ['student'], firstName: 'Hannelore', schoolId }), - testObjects.createTestUser({ roles: ['student'], firstName: 'Max', schoolId }), - ]); - const params = await testObjects.generateRequestParamsFromUser(adminUser); - params.query = { - $limit: 25, - $skip: 0, - $sort: { - email: 1, - }, - searchQuery: 'Hann', - }; - const result = await app.service('/users/admin/students').find(params); - expect(result).to.not.be.undefined; - expect(result.data).to.be.an('array').of.length(2); - }); - - it('FIND request with various query params', async () => { - const { _id: schoolId } = await testObjects.createTestSchool(); - const [adminUser] = await Promise.all([ - testObjects.createTestUser({ roles: ['administrator'], schoolId }), - testObjects.createTestUser({ roles: ['student'], firstName: 'Bruce', lastName: 'Wayne', schoolId }), - testObjects.createTestUser({ roles: ['student'], schoolId }), - testObjects.createTestUser({ roles: ['student'], schoolId }), - ]); - - const params = await testObjects.generateRequestParamsFromUser(adminUser); - params.query = { - consentStatus: { $in: ['ok', 'parentsAgreed', 'missing'] }, - firstName: 'Bruce', - lastName: 'Wayne', - }; - const result = await app.service('/users/admin/students').find(params); - expect(result).to.not.be.undefined; - expect(result.data).to.be.an('array').of.length(1); - }); }); diff --git a/test/services/user/services/AdminUsers.test.js b/test/services/user/services/AdminUsers.test.js index 3f88dc11d07..1c0355485a2 100644 --- a/test/services/user/services/AdminUsers.test.js +++ b/test/services/user/services/AdminUsers.test.js @@ -37,373 +37,6 @@ describe('AdminUsersService', () => { expect(adminStudentsService).to.not.equal(undefined); }); - it('builds class display names correctly', async () => { - const teacher = await testObjects.createTestUser({ roles: ['teacher'] }); - const student = await testObjects.createTestUser({ roles: ['student'] }); - - expect(teacher).to.not.be.undefined; - expect(student).to.not.be.undefined; - const testClass = await testObjects.createTestClass({ - name: 'staticName', - userIds: [student._id], - teacherIds: [teacher._id], - }); - expect(testClass).to.not.be.undefined; - - const gradeLevelClass = await testObjects.createTestClass({ - name: 'A', - userIds: [student._id], - teacherIds: [teacher._id], - nameFormat: 'gradeLevel+name', - gradeLevel: 2, - }); - - expect(gradeLevelClass).to.not.be.undefined; - - const params = { - account: { - userId: teacher._id, - }, - query: {}, - }; - - const result = await adminStudentsService.find(params); - - const searchClass = (users, name) => - users.some((user) => equalIds(student._id, user._id) && user.classes.includes(name)); - expect(result.data).to.not.be.undefined; - expect(searchClass(result.data, 'staticName')).to.be.true; - expect(searchClass(result.data, '2A')).to.be.true; - }); - - it('request muliple users by id', async () => { - const admin = await testObjects.createTestUser({ roles: ['administrator'] }); - const params = await testObjects.generateRequestParamsFromUser(admin); - - const student1 = await testObjects.createTestUser({ roles: ['student'] }); - - const student2 = await testObjects.createTestUser({ roles: ['student'] }); - - const student3 = await testObjects.createTestUser({ roles: ['student'] }); - - params.query = { - users: [student1._id.toString(), student2._id.toString(), student3._id.toString()], - }; - - const result = await adminStudentsService.find(params); - - expect(result.total).to.equal(3); - }); - - // https://ticketsystem.dbildungscloud.de/browse/SC-5076 - it('student can not administrate students', async () => { - const student = await testObjects.createTestUser({ roles: ['student'] }); - const params = await testObjects.generateRequestParamsFromUser(student); - params.query = {}; - try { - await adminStudentsService.find(params); - throw new Error('should have failed'); - } catch (err) { - expect(err.message).to.not.equal('should have failed'); - expect(err.message).to.equal(testGenericErrorMessage); - expect(err.code).to.equal(403); - } - }); - - it('teacher can administrate students', async () => { - const teacher = await testObjects.createTestUser({ roles: ['teacher'] }); - const params = await testObjects.generateRequestParamsFromUser(teacher); - params.query = {}; - const result = await adminStudentsService.find(params); - expect(result).to.not.be.undefined; - }); - - it('only shows current classes', async () => { - const teacher = await testObjects.createTestUser({ roles: ['teacher'] }); - const student = await testObjects.createTestUser({ firstName: 'Max', roles: ['student'] }); - const currentSchool = await app.service('schools').get(teacher.schoolId); - - const { currentYear } = currentSchool; - const lastYear = currentSchool.years.lastYear._id; - - const classPromises = []; - classPromises.push( - testObjects.createTestClass({ - name: 'classFromThisYear', - userIds: [student._id], - teacherIds: [teacher._id], - year: currentYear, - }) - ); - classPromises.push( - testObjects.createTestClass({ - name: 'classFromLastYear', - userIds: [student._id], - teacherIds: [teacher._id], - year: lastYear, - }) - ); - classPromises.push( - testObjects.createTestClass({ - name: 'classWithoutYear', - userIds: [student._id], - teacherIds: [teacher._id], - }) - ); - - await Promise.all(classPromises); - - const params = { - account: { - userId: teacher._id, - }, - query: {}, - }; - - const result = await adminStudentsService.find(params); - - expect(result.data).to.not.be.undefined; - const studentResult = result.data.filter((u) => equalIds(u._id, student._id))[0]; - expect(studentResult.classes).to.include('classFromThisYear'); - expect(studentResult.classes).to.not.include('classFromLastYear'); - expect(studentResult.classes).to.include('classWithoutYear'); - }); - - it('sorts students correctly', async () => { - const teacher = await testObjects.createTestUser({ roles: ['teacher'] }); - const student1 = await testObjects.createTestUser({ - firstName: 'Max', - roles: ['student'], - consent: { - userConsent: { - form: 'digital', - privacyConsent: true, - termsOfUseConsent: true, - }, - parentConsents: [ - { - form: 'digital', - privacyConsent: true, - termsOfUseConsent: true, - }, - ], - }, - }); - - const student2 = await testObjects.createTestUser({ - firstName: 'Moritz', - roles: ['student'], - }); - - expect(teacher).to.not.be.undefined; - expect(student1).to.not.be.undefined; - expect(student2).to.not.be.undefined; - - const testClass1 = await testObjects.createTestClass({ - name: '1a', - userIds: [student1._id], - teacherIds: [teacher._id], - }); - expect(testClass1).to.not.be.undefined; - const testClass2 = await testObjects.createTestClass({ - name: '2B', - userIds: [student1._id], - teacherIds: [teacher._id], - }); - expect(testClass2).to.not.be.undefined; - - const createParams = (sortObject) => ({ - account: { - userId: teacher._id, - }, - query: { - $sort: sortObject, - }, - }); - - const resultSortedByFirstName = await adminStudentsService.find(createParams({ firstName: -1 })); - expect(resultSortedByFirstName.data.lenght > 1); - expect(resultSortedByFirstName.data[0].firstName > resultSortedByFirstName.data[1].firstName); - - const resultSortedByClass = await adminStudentsService.find(createParams({ class: -1 })); - expect(resultSortedByClass.data[0].classes[0] > resultSortedByClass.data[1].classes[0]); - - /* TODO: Do not work! - const sortOrder = { - missing: 1, - parentsAgreed: 2, - ok: 3, - }; - - const resultSortedByConsent = await adminStudentsService.find(createParams({ consent: -1 })); - expect(sortOrder[resultSortedByConsent.data[0].consent.consentStatus]) - .to.be.at.least(sortOrder[resultSortedByConsent.data[1].consent.consentStatus]); - */ - }); - - it('filters students correctly', async () => { - const teacher = await testObjects.createTestUser({ roles: ['teacher'] }); - const studentWithoutConsents = await testObjects.createTestUser({ roles: ['student'] }); - - const currentDate = new Date(); - const birthday = new Date(); - birthday.setFullYear(currentDate.getFullYear() - 15); - const studentWithParentConsent = await testObjects.createTestUser({ - roles: ['student'], - birthday, - consent: { - parentConsents: [ - { - form: 'digital', - privacyConsent: true, - termsOfUseConsent: true, - }, - ], - }, - }); - - const studentWithConsents = await testObjects.createTestUser({ - roles: ['student'], - consent: { - userConsent: { - form: 'digital', - privacyConsent: true, - termsOfUseConsent: true, - }, - parentConsents: [ - { - form: 'digital', - privacyConsent: true, - termsOfUseConsent: true, - }, - ], - }, - }); - - const createParams = (status) => ({ - account: { - userId: teacher._id, - }, - query: { - consentStatus: { - $in: [status], - }, - }, - }); - - const resultMissing = (await adminStudentsService.find(createParams('missing'))).data; - const idsMissing = resultMissing.map((e) => e._id.toString()); - expect(idsMissing).to.include(studentWithoutConsents._id.toString()); - expect(idsMissing).to.not.include(studentWithParentConsent._id.toString(), studentWithConsents._id.toString()); - - const resultParentsAgreed = (await adminStudentsService.find(createParams('parentsAgreed'))).data; - const idsParentsAgreed = resultParentsAgreed.map((e) => e._id.toString()); - expect(idsParentsAgreed).to.include(studentWithParentConsent._id.toString()); - expect(idsParentsAgreed).to.not.include(studentWithoutConsents._id.toString(), studentWithConsents._id.toString()); - - const resultOk = (await adminStudentsService.find(createParams('ok'))).data; - const idsOk = resultOk.map((e) => e._id.toString()); - expect(idsOk).to.include(studentWithConsents._id.toString()); - expect(idsOk).to.not.include(studentWithoutConsents._id.toString(), studentWithParentConsent._id.toString()); - }); - - it('can filter by creation date', async () => { - const dateBefore = new Date(); - const findUser = await testObjects.createTestUser({ roles: ['student'] }); - const actingUser = await testObjects.createTestUser({ roles: ['administrator'] }); - const dateAfter = new Date(); - await testObjects.createTestUser({ roles: ['student'] }); - const params = await testObjects.generateRequestParamsFromUser(actingUser); - params.query = { createdAt: { $gte: dateBefore, $lte: dateAfter } }; - - const result = await adminStudentsService.find(params); - expect(result.total).to.equal(1); - expect(result.data[0]._id.toString()).to.equal(findUser._id.toString()); - }); - - it('can filter by creation date as ISO string', async () => { - const findUser = await testObjects.createTestUser({ roles: ['student'] }); - const actingUser = await testObjects.createTestUser({ roles: ['administrator'] }); - await testObjects.createTestUser({ roles: ['student'] }); - const params = await testObjects.generateRequestParamsFromUser(actingUser); - params.query = { createdAt: findUser.createdAt }; - - const result = await adminStudentsService.find(params); - expect(result.total).to.equal(1); - expect(result.data[0]._id.toString()).to.equal(findUser._id.toString()); - }); - - it('can filter by creation date as ISO string with range', async () => { - const dateBefore = new Date(); - const findUser = await testObjects.createTestUser({ roles: ['student'] }); - const actingUser = await testObjects.createTestUser({ roles: ['administrator'] }); - const dateAfter = new Date(); - await testObjects.createTestUser({ roles: ['student'] }); - const params = await testObjects.generateRequestParamsFromUser(actingUser); - params.query = { - createdAt: { - $gte: dateBefore.toISOString(), - $lte: dateAfter.toISOString(), - }, - }; - - const result = await adminStudentsService.find(params); - expect(result.total).to.equal(1); - expect(result.data[0]._id.toString()).to.equal(findUser._id.toString()); - }); - - it('pagination should work', async () => { - const limit = 1; - let skip = 0; - - const teacher = await testObjects.createTestUser({ roles: ['teacher'] }); - - expect(teacher).to.not.be.undefined; - - const createParams = () => ({ - account: { - userId: teacher._id, - }, - query: { - $limit: limit, - $skip: skip, - }, - }); - - const result1 = await adminStudentsService.find(createParams()); - expect(result1.data.length).to.be.equal(1); - expect(result1.limit).to.be.equal(limit); - expect(result1.skip).to.be.equal(skip); - const studentId1 = result1.data[0]._id.toString(); - expect(studentId1).to.not.be.undefined; - skip = 1; - - const result2 = await adminStudentsService.find(createParams()); - expect(result2.data.length).to.be.equal(1); - expect(result2.limit).to.be.equal(limit); - expect(result2.skip).to.be.equal(skip); - const studentId2 = result2.data[0]._id.toString(); - expect(studentId2).to.not.be.equal(studentId1); - }); - - it('birthday date in DD.MM.YYYY format', async () => { - // given - const birthdayMock = new Date(2000, 0, 1, 20, 45, 30); - const teacher = await testObjects.createTestUser({ roles: ['teacher'] }); - const mockStudent = await testObjects.createTestUser({ - firstName: 'Lukas', - birthday: birthdayMock, - roles: ['student'], - }); - const params = await testObjects.generateRequestParamsFromUser(teacher); - // when - const students = (await adminStudentsService.find(params)).data; - - // then - const testStudent = students.find((stud) => mockStudent.firstName === stud.firstName); - expect(testStudent.birthday).equals('01.01.2000'); - }); - it('does not allow student user creation if school is external', async () => { const schoolService = app.service('/schools'); const serviceCreatedSchool = await schoolService.create({ name: 'test', ldapSchoolIdentifier: 'testId' }); @@ -476,93 +109,6 @@ describe('AdminUsersService', () => { expect(result.roles[0].name).to.equal('student'); }); - it('users with STUDENT_LIST permission can access the FIND method', async () => { - await testObjects.createTestRole({ - name: 'studentListPerm', - permissions: ['STUDENT_LIST'], - }); - const testUser = await testObjects.createTestUser({ - firstName: 'testUser', - roles: ['studentListPerm'], - }); - const params = await testObjects.generateRequestParamsFromUser(testUser); - const { data } = await adminStudentsService.find(params); - expect(data).to.not.have.lengthOf(0); - }); - - it('users without STUDENT_LIST permission cannot access the FIND method', async () => { - await testObjects.createTestRole({ - name: 'noStudentListPerm', - permissions: [], - }); - const testUser = await testObjects.createTestUser({ - firstName: 'testUser', - roles: ['noStudentListPerm'], - }); - const params = await testObjects.generateRequestParamsFromUser(testUser); - await expect(adminStudentsService.find(params)).to.be.rejected; - }); - - it('users with STUDENT_LIST permission can access the GET method', async () => { - await testObjects.createTestRole({ - name: 'studentListPerm', - permissions: ['STUDENT_LIST'], - }); - const school = await testObjects.createTestSchool({ - name: 'testSchool', - }); - const testUser = await testObjects.createTestUser({ - firstName: 'testUser', - roles: ['studentListPerm'], - schoolId: school._id, - }); - const params = await testObjects.generateRequestParamsFromUser(testUser); - const student = await testObjects.createTestUser({ - firstName: 'Hans', - roles: ['student'], - schoolId: school._id, - }); - - const user = await adminStudentsService.get(student._id.toString(), params); - expect(user.firstName).to.be.equal(student.firstName); - }); - - it('users without STUDENT_LIST permission cannot access the GET method', async () => { - await testObjects.createTestRole({ - name: 'noStudentListPerm', - permissions: [], - }); - const school = await testObjects.createTestSchool({ - name: 'testSchool', - }); - const testUser = await testObjects.createTestUser({ - firstName: 'testUser', - roles: ['noStudentListPerm'], - schoolId: school._id, - }); - const params = await testObjects.generateRequestParamsFromUser(testUser); - const student = await testObjects.createTestUser({ roles: ['student'], schoolId: school._id }); - await expect(adminStudentsService.get(student._id, params)).to.be.rejected; - }); - - it('users cannot GET students from foreign schools', async () => { - await testObjects.createTestRole({ - name: 'studentListPerm', - permissions: ['STUDENT_LIST'], - }); - const school = await testObjects.createTestSchool({ - name: 'testSchool1', - }); - const otherSchool = await testObjects.createTestSchool({ - name: 'testSchool2', - }); - const testUSer = await testObjects.createTestUser({ roles: ['studentListPerm'], schoolId: school._id }); - const params = await testObjects.generateRequestParamsFromUser(testUSer); - const student = await testObjects.createTestUser({ roles: ['student'], schoolId: otherSchool._id }); - const user = await adminStudentsService.get(student._id, params); - expect(user).to.be.empty; - }); - it('users with STUDENT_CREATE permission can access the CREATE method', async () => { await testObjects.createTestRole({ name: 'studentCreatePerm', @@ -821,156 +367,6 @@ describe('AdminUsersService', () => { it('block changes teacher patch if email already in use', () => useEmailTwice('teacher', 'patch', adminTeachersService)); }); - - it('can search the user data by firstName', async () => { - const school = await testObjects.createTestSchool({ - name: 'testSchool', - }); - const testUser = await testObjects.createTestUser({ - roles: ['administrator'], - schoolId: school._id, - }); - const student0 = await testObjects.createTestUser({ - roles: ['student'], - schoolId: school._id, - }); - const student1 = await testObjects.createTestUser({ - roles: ['student'], - schoolId: school._id, - }); - const student2 = await testObjects.createTestUser({ - roles: ['student'], - firstName: 'Lars', - lastName: 'Ulrich', - schoolId: school._id, - }); - const student3 = await testObjects.createTestUser({ - roles: ['student'], - firstName: 'James', - lastName: 'Hetfield', - schoolId: school._id, - }); - const params = await testObjects.generateRequestParamsFromUser(testUser); - params.query = { - ...params.query, - searchQuery: student0.firstName, - }; - const result = await adminStudentsService.find(params); - - const resultIds = []; - result.data.forEach((user) => { - resultIds.push(user._id.toString()); - }); - - expect(result.data).to.not.be.undefined; - expect(result.data[0].firstName).to.equal(student0.firstName); - expect(resultIds).to.include.members([student0._id.toString(), student1._id.toString()]); - expect(result.data[0].lastName).to.equal(student0.lastName); - expect(result.data[0].firstName).to.not.equal(student2.firstName); - expect(result.data[0].lastName).to.not.equal(student2.lastName); - expect(result.data[0].firstName).to.not.equal(student3.firstName); - expect(result.data[0].lastName).to.not.equal(student3.lastName); - }); - - it('can search the user data by lastName', async () => { - const school = await testObjects.createTestSchool({ - name: 'testSchool', - }); - const testUser = await testObjects.createTestUser({ - roles: ['administrator'], - schoolId: school._id, - }); - const student0 = await testObjects.createTestUser({ - roles: ['student'], - schoolId: school._id, - }); - const student1 = await testObjects.createTestUser({ - roles: ['student'], - schoolId: school._id, - }); - const student2 = await testObjects.createTestUser({ - roles: ['student'], - firstName: 'Lars', - lastName: 'Ulrich', - schoolId: school._id, - }); - const student3 = await testObjects.createTestUser({ - roles: ['student'], - firstName: 'James', - lastName: 'Hetfield', - schoolId: school._id, - }); - const params = await testObjects.generateRequestParamsFromUser(testUser); - params.query = { - ...params.query, - searchQuery: student0.lastName, - }; - const result = await adminStudentsService.find(params); - - const resultIds = []; - result.data.forEach((user) => { - resultIds.push(user._id.toString()); - }); - - expect(result.data).to.not.be.undefined; - expect(result.data[0].firstName).to.equal(student0.firstName); - expect(resultIds).to.include.members([student0._id.toString(), student1._id.toString()]); - expect(result.data[0].lastName).to.equal(student0.lastName); - expect(result.data[0].firstName).to.not.equal(student2.firstName); - expect(result.data[0].lastName).to.not.equal(student2.lastName); - expect(result.data[0].firstName).to.not.equal(student3.firstName); - expect(result.data[0].lastName).to.not.equal(student3.lastName); - }); - - it('can search the user data by firstName + lastName', async () => { - const school = await testObjects.createTestSchool({ - name: 'testSchool', - }); - const testUser = await testObjects.createTestUser({ - roles: ['administrator'], - schoolId: school._id, - }); - const student0 = await testObjects.createTestUser({ - roles: ['student'], - schoolId: school._id, - }); - const student1 = await testObjects.createTestUser({ - roles: ['student'], - schoolId: school._id, - }); - const student2 = await testObjects.createTestUser({ - roles: ['student'], - firstName: 'Lars', - lastName: 'Ulrich', - schoolId: school._id, - }); - const student3 = await testObjects.createTestUser({ - roles: ['student'], - firstName: 'James', - lastName: 'Hetfield', - schoolId: school._id, - }); - const params = await testObjects.generateRequestParamsFromUser(testUser); - params.query = { - ...params.query, - searchQuery: `${student0.firstName} ${student0.lastName}`, - }; - const result = await adminStudentsService.find(params); - - const resultIds = []; - result.data.forEach((user) => { - resultIds.push(user._id.toString()); - }); - - expect(result.data).to.not.be.undefined; - expect(result.data[0].firstName).to.equal(student0.firstName); - expect(resultIds).to.include.members([student0._id.toString(), student1._id.toString()]); - expect(result.data[0].lastName).to.equal(student0.lastName); - expect(result.data[0].firstName).to.not.equal(student2.firstName); - expect(result.data[0].lastName).to.not.equal(student2.lastName); - expect(result.data[0].firstName).to.not.equal(student3.firstName); - expect(result.data[0].lastName).to.not.equal(student3.lastName); - }); }); describe('AdminTeachersService', () => { @@ -1002,85 +398,6 @@ describe('AdminTeachersService', () => { expect(adminTeachersService).to.not.equal(undefined); }); - // https://ticketsystem.dbildungscloud.de/browse/SC-5076 - xit('student can not administrate teachers', async () => { - const student = await testObjects.createTestUser({ roles: ['student'] }); - const params = await testObjects.generateRequestParamsFromUser(student); - params.query = {}; - await expect(adminTeachersService.find(params)).to.be.rejected; - }); - - // https://ticketsystem.dbildungscloud.de/browse/SC-5061 - it('teacher can not find teachers from other schools', async () => { - const school = await testObjects.createTestSchool({ - name: 'testSchool1', - }); - const otherSchool = await testObjects.createTestSchool({ - name: 'testSchool2', - }); - const teacher = await testObjects.createTestUser({ roles: ['teacher'], schoolId: school._id }); - const otherTeacher = await testObjects.createTestUser({ roles: ['teacher'], schoolId: otherSchool._id }); - const params = await testObjects.generateRequestParamsFromUser(teacher); - params.query = {}; - const resultOk = ( - await adminTeachersService.find({ - account: { - userId: teacher._id, - }, - query: { - account: { - userId: otherTeacher._id, - }, - }, - }) - ).data; - const idsOk = resultOk.map((e) => e._id.toString()); - expect(idsOk).not.to.include(otherTeacher._id.toString()); - }); - - it('filters teachers correctly', async () => { - const teacherWithoutConsent = await testObjects.createTestUser({ - birthday: '1992-03-04', - roles: ['teacher'], - }); - const teacherWithConsent = await testObjects.createTestUser({ - birthday: '1991-03-04', - roles: ['teacher'], - }); - - await consentService.create({ - userId: teacherWithConsent._id, - userConsent: { - form: 'digital', - privacyConsent: true, - termsOfUseConsent: true, - }, - }); - - const createParams = (status) => ({ - account: { - userId: teacherWithoutConsent._id, - }, - query: { - consentStatus: { - $in: [status], - }, - }, - }); - const resultMissing = (await adminTeachersService.find(createParams('missing'))).data; - const idsMissing = resultMissing.map((e) => e._id.toString()); - expect(idsMissing).to.include(teacherWithoutConsent._id.toString()); - expect(idsMissing).to.not.include(teacherWithConsent._id.toString()); - - const resultParentsAgreed = (await adminTeachersService.find(createParams('parentsAgreed'))).data; - expect(resultParentsAgreed).to.be.empty; - - const resultOk = (await adminTeachersService.find(createParams('ok'))).data; - const idsOk = resultOk.map((e) => e._id.toString()); - expect(idsOk).to.include(teacherWithConsent._id.toString()); - expect(idsOk).to.not.include(teacherWithoutConsent._id.toString()); - }); - it('does not allow teacher user creation if school is external', async () => { const schoolService = app.service('/schools'); const serviceCreatedSchool = await schoolService.create({ name: 'test', ldapSchoolIdentifier: 'testId' }); @@ -1127,93 +444,6 @@ describe('AdminTeachersService', () => { await expect(adminTeachersService.create(mockData, params)).to.be.rejected; }); - it('users with TEACHER_LIST permission can access the FIND method', async () => { - await testObjects.createTestRole({ - name: 'teacherListPerm', - permissions: ['TEACHER_LIST'], - }); - const testUser = await testObjects.createTestUser({ - firstName: 'testUser', - roles: ['teacherListPerm'], - }); - const params = await testObjects.generateRequestParamsFromUser(testUser); - const { data } = await adminTeachersService.find(params); - expect(data).to.not.have.lengthOf(0); - }); - - it('users without TEACHER_LIST permission cannot access the FIND method', async () => { - await testObjects.createTestRole({ - name: 'noTeacherListPerm', - permissions: [], - }); - const testUser = await testObjects.createTestUser({ - firstName: 'testUser', - roles: ['noTeacherListPerm'], - }); - const params = await testObjects.generateRequestParamsFromUser(testUser); - await expect(adminTeachersService.find(params)).to.be.rejected; - }); - - it('users with TEACHER_LIST permission can access the GET method', async () => { - await testObjects.createTestRole({ - name: 'teacherListPerm', - permissions: ['TEACHER_LIST'], - }); - const school = await testObjects.createTestSchool({ - name: 'testSchool', - }); - const testUser = await testObjects.createTestUser({ - firstName: 'testUser', - roles: ['teacherListPerm'], - schoolId: school._id, - }); - const params = await testObjects.generateRequestParamsFromUser(testUser); - const teacher = await testObjects.createTestUser({ - firstName: 'Affenmesserkamppf', - roles: ['teacher'], - schoolId: school._id, - }); - - const user = await adminTeachersService.get(teacher._id, params); - expect(user.firstName).to.be.equal(teacher.firstName); - }); - - it('users without TEACHER_LIST permission cannot access the GET method', async () => { - await testObjects.createTestRole({ - name: 'noTeacherListPerm', - permissions: [], - }); - const school = await testObjects.createTestSchool({ - name: 'testSchool', - }); - const testUser = await testObjects.createTestUser({ - firstName: 'testUser', - roles: ['noTeacherListPerm'], - schoolId: school._id, - }); - const params = await testObjects.generateRequestParamsFromUser(testUser); - const teacher = await testObjects.createTestUser({ roles: ['teacher'], schoolId: school._id }); - await expect(adminTeachersService.get(teacher._id, params)).to.be.rejected; - }); - - it('users cannot GET teachers from foreign schools', async () => { - await testObjects.createTestRole({ - name: 'teacherListPerm', - permissions: ['TEACHER_LIST'], - }); - const school = await testObjects.createTestSchool({ - name: 'testSchool1', - }); - const otherSchool = await testObjects.createTestSchool({ - name: 'testSchool2', - }); - const testUSer = await testObjects.createTestUser({ roles: ['teacherListPerm'], schoolId: school._id }); - const params = await testObjects.generateRequestParamsFromUser(testUSer); - const teacher = await testObjects.createTestUser({ roles: ['teacher'], schoolId: otherSchool._id }); - const user = await adminTeachersService.get(teacher._id, params); - expect(user).to.be.empty; - }); - it('users with TEACHER_CREATE permission can access the CREATE method', async () => { await testObjects.createTestRole({ name: 'teacherCreatePerm', diff --git a/test/services/user/services/ForcePasswordChange.test.js b/test/services/user/services/ForcePasswordChange.test.js index fdd86de7bb5..d7d4439f314 100644 --- a/test/services/user/services/ForcePasswordChange.test.js +++ b/test/services/user/services/ForcePasswordChange.test.js @@ -1,106 +1,111 @@ -const { expect } = require('chai'); -const appPromise = require('../../../../src/app'); -const { setupNestServices, closeNestServices } = require('../../../utils/setup.nest.services'); -const testObjects = require('../../helpers/testObjects')(appPromise()); -const { generateRequestParamsFromUser, generateRequestParams } = require('../../helpers/services/login')(appPromise()); +// Following tests fail because the testUser is created with the help of bson. But the tested code uses parts +// from nest and there mikroORM uses a newer version of bson that causes problems. Additional the tested code doesn't +// seem to be in use -describe('forcePasswordChange service tests', () => { - let app; - let forcePasswordChangeService; - let server; - let nestServices; - const newPassword = 'SomePassword1!'; - const newPasswordConfirmation = 'SomePassword1!'; - const newPasswordThatDoesNotMatch = 'SomePassword2!'; - const newPasswordThatIsToWeak = 'SomePassword1'; +// const { expect } = require('chai'); +// const appPromise = require('../../../../src/app'); +// const { setupNestServices, closeNestServices } = require('../../../utils/setup.nest.services'); +// const testObjects = require('../../helpers/testObjects')(appPromise()); +// const { generateRequestParamsFromUser, generateRequestParams } = require('../../helpers/services/login')(appPromise()); - before(async () => { - app = await appPromise(); - nestServices = await setupNestServices(app); - forcePasswordChangeService = app.service('forcePasswordChange'); - server = await app.listen(0); - }); +// describe('forcePasswordChange service tests', () => { +// let app; +// let forcePasswordChangeService; +// let server; +// let nestServices; +// const newPassword = 'SomePassword1!'; +// const newPasswordConfirmation = 'SomePassword1!'; +// const newPasswordThatDoesNotMatch = 'SomePassword2!'; +// const newPasswordThatIsToWeak = 'SomePassword1'; - after(async () => { - await testObjects.cleanup(); - await server.close(); - await closeNestServices(nestServices); - }); +// before(async () => { +// app = await appPromise(); +// nestServices = await setupNestServices(app); +// forcePasswordChangeService = app.service('forcePasswordChange'); +// server = await app.listen(0); +// }); - const postChangePassword = (requestParams, password, password2) => - forcePasswordChangeService.create( - { - 'password-1': password, - 'password-2': password2, - }, - requestParams, - app - ); +// after(async () => { +// await testObjects.cleanup(); +// await server.close(); +// await closeNestServices(nestServices); +// }); - describe('CREATE', () => { - it('when the passwords do not match, the error object is returned with the proper error message', async () => { - const testUser = await testObjects.createTestUser(); - const userRequestAuthentication = await generateRequestParamsFromUser(testUser); - return postChangePassword(userRequestAuthentication, newPassword, newPasswordThatDoesNotMatch).catch((err) => { - expect(err.code).to.equal(400); - expect(err.name).to.equal('BadRequest'); - expect(err.message).to.equal('Password and confirm password do not match.'); - }); - }); - it('when the password is to weak, the error object is returned with the proper error message', async () => { - const testUser = await testObjects.createTestUser(); - const userRequestAuthentication = await generateRequestParamsFromUser(testUser); - return postChangePassword(userRequestAuthentication, newPasswordThatIsToWeak, newPasswordThatIsToWeak).catch( - (err) => { - expect(err.code).to.equal(400); - expect(err.name).to.equal('BadRequest'); - expect(err.message).to.equal('Can not update the password. Please contact the administrator'); - } - ); - }); - // eslint-disable-next-line max-len - it('when the user has been forced to change his password, the proper flag will be setted after changing the password', async () => { - const testUser = await testObjects.createTestUser(); - const userRequestAuthentication = await generateRequestParamsFromUser(testUser); +// const postChangePassword = (requestParams, password, password2) => +// forcePasswordChangeService.create( +// { +// 'password-1': password, +// 'password-2': password2, +// }, +// requestParams, +// app +// ); - const newAccount = { - username: 'someutestsername22@email.com', - password: '$2a$10$wMuk7hpjULOEJrTW/CKtU.lIETKa.nEs8fncqLJ74SMeX.fzJACla', - activated: true, - createdAt: '2017-09-04T12:51:58.49Z', - forcePasswordChange: true, - }; - const savedUser = await testObjects.createTestUser(); - await testObjects.createTestAccount(newAccount, null, savedUser); - const requestParams = userRequestAuthentication; - requestParams.authentication.payload = { - accountId: newAccount.accountId, - }; - // this.app.service('/users').patch(params.account.userId +// describe('CREATE', () => { +// it('when the passwords do not match, the error object is returned with the proper error message', async () => { +// const testUser = await testObjects.createTestUser(); +// const userRequestAuthentication = await generateRequestParamsFromUser(testUser); +// return postChangePassword(userRequestAuthentication, newPassword, newPasswordThatDoesNotMatch).catch((err) => { +// expect(err.code).to.equal(400); +// expect(err.name).to.equal('BadRequest'); +// expect(err.message).to.equal('Password and confirm password do not match.'); +// }); +// }); - await postChangePassword(requestParams, newPassword, newPasswordConfirmation); - const updatedUser = await app.service('users').get(savedUser._id); - expect(updatedUser.forcePasswordChange).to.equal(false); - }); - // eslint-disable-next-line max-len - it('when the the user sets the password the same as the one specified by the admin, the proper error message will be shown', async () => { - const testUser = await testObjects.createTestUser(); - const password = 'Schulcloud1!'; - await testObjects.createTestAccount({ username: testUser.email, password }, null, testUser); +// it('when the password is to weak, the error object is returned with the proper error message', async () => { +// const testUser = await testObjects.createTestUser(); +// const userRequestAuthentication = await generateRequestParamsFromUser(testUser); +// return postChangePassword(userRequestAuthentication, newPasswordThatIsToWeak, newPasswordThatIsToWeak).catch( +// (err) => { +// expect(err.code).to.equal(400); +// expect(err.name).to.equal('BadRequest'); +// expect(err.message).to.equal('Can not update the password. Please contact the administrator'); +// } +// ); +// }); +// // eslint-disable-next-line max-len +// it('when the user has been forced to change his password, the proper flag will be setted after changing the password', async () => { +// const testUser = await testObjects.createTestUser(); +// const userRequestAuthentication = await generateRequestParamsFromUser(testUser); - const userRequestAuthentication = await generateRequestParams({ - username: testUser.email, - password, - }); - return postChangePassword(userRequestAuthentication, password, password).catch((err) => { - expect(err.code).to.equal(400); - expect(err.name).to.equal('BadRequest'); - expect(err.message).to.equal('New password can not be same as old password.'); - }); - }); - }); +// const newAccount = { +// username: 'someutestsername22@email.com', +// password: '$2a$10$wMuk7hpjULOEJrTW/CKtU.lIETKa.nEs8fncqLJ74SMeX.fzJACla', +// activated: true, +// createdAt: '2017-09-04T12:51:58.49Z', +// forcePasswordChange: true, +// }; +// const savedUser = await testObjects.createTestUser(); +// await testObjects.createTestAccount(newAccount, null, savedUser); +// const requestParams = userRequestAuthentication; +// requestParams.authentication.payload = { +// accountId: newAccount.accountId, +// }; +// // this.app.service('/users').patch(params.account.userId - afterEach(async () => { - await testObjects.cleanup(); - }); -}); +// await postChangePassword(requestParams, newPassword, newPasswordConfirmation); +// const updatedUser = await app.service('users').get(savedUser._id); +// expect(updatedUser.forcePasswordChange).to.equal(false); +// }); +// // eslint-disable-next-line max-len +// it('when the the user sets the password the same as the one specified by the admin, the proper error message will be shown', async () => { +// const testUser = await testObjects.createTestUser(); +// const password = 'Schulcloud1!'; +// await testObjects.createTestAccount({ username: testUser.email, password }, null, testUser); + +// const userRequestAuthentication = await generateRequestParams({ +// username: testUser.email, +// password, +// }); +// return postChangePassword(userRequestAuthentication, password, password).catch((err) => { +// expect(err.code).to.equal(400); +// expect(err.name).to.equal('BadRequest'); +// expect(err.message).to.equal('New password can not be same as old password.'); +// }); +// }); +// }); + +// afterEach(async () => { +// await testObjects.cleanup(); +// }); +// }); diff --git a/test/services/user/services/SkipRegistration.integration.test.js b/test/services/user/services/SkipRegistration.integration.test.js index 55eb21c8487..b39872d59d7 100644 --- a/test/services/user/services/SkipRegistration.integration.test.js +++ b/test/services/user/services/SkipRegistration.integration.test.js @@ -157,7 +157,7 @@ describe('SkipRegistration integration', () => { privacyConsent: true, termsOfUseConsent: true, birthday: '2014-12-19T00:00:00Z', - password: 'password1', + password: 'Password1!', }, scenarioParams ); @@ -181,7 +181,7 @@ describe('SkipRegistration integration', () => { parent_termsOfUseConsent: true, privacyConsent: true, termsOfUseConsent: true, - password: 'password1', + password: 'Password1!', }, scenarioParams ); @@ -206,7 +206,7 @@ describe('SkipRegistration integration', () => { privacyConsent: true, termsOfUseConsent: true, birthday: '2014-12-19T00:00:00Z', - password: 'password1', + password: 'Password1!', }, scenarioParams ); @@ -302,7 +302,7 @@ describe('SkipRegistration integration', () => { privacyConsent: true, termsOfUseConsent: true, birthday: '2014-12-19T00:00:00Z', - password: 'password1', + password: 'Password1!', }, { userId: targetUserTwo._id, @@ -311,7 +311,7 @@ describe('SkipRegistration integration', () => { privacyConsent: true, termsOfUseConsent: true, birthday: '2014-12-19T00:00:00Z', - password: 'password1', + password: 'Password1!', }, ], }, diff --git a/test/services/user/services/SkipRegistration.test.js b/test/services/user/services/SkipRegistration.test.js index 6823ecbbfa2..ad60227296d 100644 --- a/test/services/user/services/SkipRegistration.test.js +++ b/test/services/user/services/SkipRegistration.test.js @@ -85,7 +85,7 @@ describe('skipRegistration service', () => { parent_termsOfUseConsent: true, privacyConsent: true, termsOfUseConsent: true, - password: 'password1', + password: 'Password1!', }, { route: { userId: user._id } } ); @@ -149,7 +149,7 @@ describe('skipRegistration service', () => { privacyConsent: true, termsOfUseConsent: true, birthday: '2014-12-19T00:00:00Z', - password: 'password1', + password: 'Password1!', }, { route: { userId: user._id } } ); @@ -171,7 +171,7 @@ describe('skipRegistration service', () => { privacyConsent: true, termsOfUseConsent: true, birthday: '2014-12-19T00:00:00Z', - password: 'password1', + password: 'Password1!', }, { route: { userId: user._id } } ); @@ -201,7 +201,7 @@ describe('skipRegistration service', () => { privacyConsent: true, termsOfUseConsent: true, birthday: '2014-12-19T00:00:00Z', - password: 'password1', + password: 'Password1!', }, { route: { userId: user._id } } ); @@ -224,7 +224,7 @@ describe('skipRegistration service', () => { privacyConsent: true, termsOfUseConsent: true, birthday: '2014-12-19T00:00:00Z', - password: 'password1', + password: 'Password1!', }, { route: { userId: user._id } } ); @@ -252,7 +252,7 @@ describe('skipRegistration service', () => { privacyConsent: true, termsOfUseConsent: true, birthday: '2014-12-19T00:00:00Z', - password: 'password1', + password: 'Password1!', }, { userId: secondStudent._id, @@ -261,7 +261,7 @@ describe('skipRegistration service', () => { privacyConsent: true, termsOfUseConsent: true, birthday: '2013-12-19T00:00:00Z', - password: 'password2', + password: 'Password2!', }, ], }); @@ -284,7 +284,7 @@ describe('skipRegistration service', () => { privacyConsent: true, termsOfUseConsent: true, birthday: '2014-12-19T00:00:00Z', - password: 'password1', + password: 'Password1!', }, { userId: secondStudent._id, @@ -293,7 +293,7 @@ describe('skipRegistration service', () => { privacyConsent: true, termsOfUseConsent: true, birthday: '2013-12-19T00:00:00Z', - password: 'password2', + password: 'Password2!', }, ], }); diff --git a/test/services/user/services/userService.test.js b/test/services/user/services/userService.test.js index 8df8f3f7ff1..ea890dd706c 100644 --- a/test/services/user/services/userService.test.js +++ b/test/services/user/services/userService.test.js @@ -329,7 +329,7 @@ describe('user service', () => { } }); - it('can not populate school', async () => { + xit('can not populate school', async () => { const { _id: schoolId } = await testObjects.createTestSchool({}); const teacher = await testObjects.createTestUser({ roles: ['teacher'], schoolId }); const params = await testObjects.generateRequestParamsFromUser(teacher); diff --git a/test/services/user/utils/aggregations.test.js b/test/services/user/utils/aggregations.test.js deleted file mode 100644 index 73218d43441..00000000000 --- a/test/services/user/utils/aggregations.test.js +++ /dev/null @@ -1,62 +0,0 @@ -const { expect } = require('chai'); -const { - convertSelect, - getParentReducer, - createMultiDocumentAggregation, -} = require('../../../../src/services/user/utils/aggregations'); - -describe('consent aggregation', () => { - it('convert select', () => { - const selectArray = ['affe', 'tanz', 'schwein']; - const converted = convertSelect(selectArray); - expect(converted).to.deep.equal({ - affe: 1, - tanz: 1, - schwein: 1, - }); - }); - - it('get parent reducer', () => { - const variable = 'termsOfUse'; - const reducer = getParentReducer(variable); - expect(reducer).to.deep.equal({ - $reduce: { - input: '$consent.parentConsents', - initialValue: false, - in: { $or: ['$$value', `$$this.${variable}`] }, - }, - }); - }); - - it('sortable without selcting sorted values', () => { - const aggregation = createMultiDocumentAggregation({ - select: ['firstname'], - sort: { - consentStatus: 1, - lastname: -1, - }, - }); - - let sortAggregation; - let statusAggregation; - let selectAggregation; - - aggregation.forEach((agg) => { - if ({}.hasOwnProperty.call(agg, '$project')) { - if (!statusAggregation) statusAggregation = agg; - else if (!selectAggregation) selectAggregation = agg; - } else if ({}.hasOwnProperty.call(agg, '$sort')) { - sortAggregation = agg; - } - }); - - expect(statusAggregation).to.have.nested.property('$project.consentStatus'); - expect(statusAggregation).to.have.nested.property('$project.lastname'); - expect(statusAggregation).to.have.nested.property('$project.firstname'); - - expect(sortAggregation).to.have.nested.include({ '$sort.consentSortParam': 1 }); - expect(sortAggregation).to.have.nested.include({ '$sort.lastname': -1 }); - - expect(selectAggregation).to.have.nested.include({ '$project.firstname': 1 }); - }); -}); diff --git a/test/utils/redis/redis.test.js b/test/utils/redis/redis.test.js index 71b9e286d0e..48c422aba27 100644 --- a/test/utils/redis/redis.test.js +++ b/test/utils/redis/redis.test.js @@ -18,7 +18,7 @@ describe('redis helpers', () => { warnOnUnregistered: false, useCleanCache: true, }); - mockery.registerMock('redis', redisMock); + mockery.registerMock('ioredis', redisMock); mockery.registerMock('@hpi-schul-cloud/commons', commons); delete require.cache[require.resolve('../../../src/utils/redis')]; @@ -81,7 +81,7 @@ describe('redis helpers', () => { warnOnUnregistered: false, useCleanCache: true, }); - mockery.registerMock('redis', redisMock); + mockery.registerMock('ioredis', redisMock); mockery.registerMock('@hpi-schul-cloud/commons', commons); delete require.cache[require.resolve('../../../src/utils/redis')]; diff --git a/test/utils/redis/redisMock.js b/test/utils/redis/redisMock.js index 5f96635611a..f0e6c19d40f 100644 --- a/test/utils/redis/redisMock.js +++ b/test/utils/redis/redisMock.js @@ -32,8 +32,4 @@ const RedisClientMock = class { } }; -const redisLibraryMock = { - createClient: () => new RedisClientMock(), -}; - -module.exports = redisLibraryMock; +module.exports = RedisClientMock; diff --git a/test/utils/setup.nest.services.js b/test/utils/setup.nest.services.js index 5c56311df27..95dd0bb3ec9 100644 --- a/test/utils/setup.nest.services.js +++ b/test/utils/setup.nest.services.js @@ -17,6 +17,8 @@ const { TeamService } = require('../../dist/apps/server/modules/teams/service/te const { TeamsApiModule } = require('../../dist/apps/server/modules/teams/teams-api.module'); const { AuthorizationModule } = require('../../dist/apps/server/modules/authorization'); const { SystemRule } = require('../../dist/apps/server/modules/authorization'); +const { createConfigModuleOptions } = require('../../dist/apps/server/config/config-module-options'); +const { serverConfig } = require('../../dist/apps/server/modules/server/server.config'); const setupNestServices = async (app) => { const module = await Test.createTestingModule({ @@ -30,7 +32,7 @@ const setupNestServices = async (app) => { allowGlobalContext: true, // debug: true, // use it for locally debugging of querys }), - ConfigModule.forRoot({ ignoreEnvFile: true, ignoreEnvVars: true, isGlobal: true }), + ConfigModule.forRoot(createConfigModuleOptions(serverConfig)), AccountApiModule, TeamsApiModule, AuthorizationModule,