diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..981ee236 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,24 @@ +{ + "dockerComposeFile": ["../docker-compose.yml", "../docker-compose.dev.yml"], + "service": "backend", + "workspaceFolder": "/app", + "shutdownAction": "stopCompose", + "customizations": { + "vscode": { + "extensions": [ + "ajmnz.prisma-import", + "donjayamanne.git-extension-pack", + "AndrewButson.vscode-openapi-viewer" + ] + } + }, + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": true, + "configureZshAsDefaultShell": true, + "installOhMyZsh": true, + "installOhMyZshConfig": true + } + }, + "postCreateCommand": "bash .devcontainer/setup.sh" +} \ No newline at end of file diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh new file mode 100755 index 00000000..9547ff10 --- /dev/null +++ b/.devcontainer/setup.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +pnpm prisma migrate deploy + +# put customize content in customize.sh, which won't +# be tracked by git +# remember to grant execute permission to the script +customize_file=".devcontainer/customize.sh" +if [ -f "$customize_file" ]; then + echo "executing $customize_file" + bash "$customize_file" + echo "done" +else + echo "no customize file found, done" +fi \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..76add878 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..920779c0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text eol=lf +src/materials/resources/* binary +src/avatars/resources/* binary +test/resources/* binary diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..16ad12db --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: 'docker' + directory: '/' + schedule: + interval: 'weekly' + + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'weekly' + + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'weekly' + + - package-ecosystem: 'devcontainers' + directory: '/' + schedule: + interval: 'weekly' diff --git a/.github/workflows/build-docker-prod.yml b/.github/workflows/build-docker-prod.yml new file mode 100644 index 00000000..eeec27f4 --- /dev/null +++ b/.github/workflows/build-docker-prod.yml @@ -0,0 +1,49 @@ +name: Build and Push Product Docker Image + +on: + push: + branches: + - main + - dev + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: SageSeekerSociety/cheese-backend + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to docker registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + target: prod + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/build-test-docker-dev.yml b/.github/workflows/build-test-docker-dev.yml new file mode 100644 index 00000000..2df75a7b --- /dev/null +++ b/.github/workflows/build-test-docker-dev.yml @@ -0,0 +1,111 @@ +name: Build and Test Dev Docker + +on: + push: + branches: + - dev + pull_request: + branches: + - dev + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: SageSeekerSociety/cheese-backend-dev + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to docker registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build + uses: docker/build-push-action@v6 + with: + context: . + target: dev + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + load: true + + - name: Create env file + run: cp sample.env .env + + - name: set backend image tag + run: echo "BACKEND_DEV_VERSION=${{ steps.meta.outputs.version }}" >> "$GITHUB_ENV" + + - name: Start compose + run: docker compose -f docker-compose.yml -f docker-compose.dev.yml up --no-build -d + + # See: https://remarkablemark.org/blog/2022/05/12/github-actions-postgresql-increase-max-connections-and-shared-buffers/ + # See: https://stackoverflow.com/questions/70673766/how-to-increase-max-connection-in-github-action-postgres + - name: Increase PostgreSQL max_connections + run: | + docker exec -i cheese-backend-database-1 bash << EOF + sed -i -e 's/max_connections = 100/max_connections = 1000/' /var/lib/postgresql/data/postgresql.conf + sed -i -e 's/shared_buffers = 128MB/shared_buffers = 2GB/' /var/lib/postgresql/data/postgresql.conf + EOF + docker restart --time 0 cheese-backend-database-1 + + - name: Check Schema up to Date + run: | + docker compose exec backend cp prisma/schema.prisma prisma/schema.prisma.origin + docker compose exec backend pnpm build-prisma + docker compose exec backend diff prisma/schema.prisma prisma/schema.prisma.origin + + - name: Deploy migrations to Database + run: docker compose exec backend pnpm prisma migrate deploy + + - name: Check Prisma Structures Sync With Database + run: | + docker compose exec backend pnpm prisma migrate diff \ + --from-schema-datasource prisma/schema.prisma \ + --to-schema-datamodel prisma/schema.prisma \ + --exit-code + + - name: Try to Start Application + run: | + docker compose exec backend bash -c " + pnpm start | tee output & + while true; do + sleep 1 + if grep -q \"Nest application successfully started\" output; then + echo \"Detected 'Nest application successfully started'. Stopping pnpm...\" + pid=$(netstat -nlp | grep 3000 | awk '{print $7}' | awk -F'/' '{print $1}') + kill $pid + break + fi + if grep -q \"Command failed\" output; then + echo \"Nest application failed to start.\" + exit -1 + fi + done + " + + - name: Run Test + run: docker compose exec backend pnpm run test:cov diff --git a/.github/workflows/code-ql.yml b/.github/workflows/code-ql.yml new file mode 100644 index 00000000..da935a0b --- /dev/null +++ b/.github/workflows/code-ql.yml @@ -0,0 +1,83 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + pull_request: + branches: [ "main", "dev" ] + schedule: + - cron: '0 0 * * *' + +jobs: + analyze: + name: Analyze + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners + # Consider using larger runners for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + # required for all workflows + security-events: write + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + language: [ 'javascript-typescript' ] + # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] + # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + 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. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + 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 + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml new file mode 100644 index 00000000..7a4ce5fd --- /dev/null +++ b/.github/workflows/test-coverage.yml @@ -0,0 +1,145 @@ +# This file is a modified version of .github/workflows/test.yml + +name: Automatic Test Coverage + +on: [push, pull_request, workflow_dispatch] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + + strategy: + # only one run + matrix: + postgresql-version: [latest] + elasticsearch-version: [8.12.2] + node-version: [18.x] + + env: + TEST_REPEAT: 1 # Tests will be repeated + PORT: 7777 + JWT_SECRET: Test JWT Secret + PRISMA_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres?connection_limit=16 + DEFAULT_AVATAR_NAME: default.jpg + FILE_UPLOAD_PATH: /home/runner/work/cheese-backend + ELASTICSEARCH_NODE: http://127.0.0.1:9200/ + ELASTICSEARCH_MAX_RETRIES: 10 + ELASTICSEARCH_REQUEST_TIMEOUT: 60000 + ELASTICSEARCH_PING_TIMEOUT: 60000 + ELASTICSEARCH_SNIFF_ON_START: false + ELASTICSEARCH_AUTH_USERNAME: elastic + ELASTICSEARCH_AUTH_PASSWORD: your-elasticsearch-password + REDIS_HOST: localhost + REDIS_PORT: 6379 + + services: + # See: https://docs.github.com/en/actions/using-containerized-services/creating-postgresql-service-containers + # Label used to access the service container + postgres: + # Docker Hub image + image: postgres:${{ matrix.postgresql-version }} + # Provide the password for postgres + env: + POSTGRES_PASSWORD: postgres + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + --name my_postgres_container + ports: + # Maps tcp port 5432 on service container to the host + - 5432:5432 + elasticsearch: # See: https://discuss.elastic.co/t/set-password-and-user-with-docker-compose/225075 + image: docker.elastic.co/elasticsearch/elasticsearch:${{ matrix.elasticsearch-version }} + env: + discovery.type: single-node + xpack.security.enabled: true + ELASTIC_USERNAME: ${{ env.ELASTICSEARCH_AUTH_USERNAME }} + ELASTIC_PASSWORD: ${{ env.ELASTICSEARCH_AUTH_PASSWORD }} + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + ports: + # Maps tcp port 9200 on service container to the host + - 9200:9200 + valkey: + image: valkey/valkey:8.0.2 + ports: + - '6379:6379' + options: >- + --health-cmd "valkey-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 3 + --health-start-period 30s + + steps: + # See: https://remarkablemark.org/blog/2022/05/12/github-actions-postgresql-increase-max-connections-and-shared-buffers/ + # See: https://stackoverflow.com/questions/70673766/how-to-increase-max-connection-in-github-action-postgres + - name: Increase PostgreSQL max_connections + run: | + docker exec -i my_postgres_container bash << EOF + sed -i -e 's/max_connections = 100/max_connections = 1000/' /var/lib/postgresql/data/postgresql.conf + sed -i -e 's/shared_buffers = 128MB/shared_buffers = 2GB/' /var/lib/postgresql/data/postgresql.conf + EOF + docker restart --time 0 my_postgres_container + + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Install Dependencies + run: pnpm install + + - name: Initialize Database Structures with Prisma + run: pnpm prisma db push + + - name: Install ffmpeg + # uses: FedericoCarboni/setup-ffmpeg@v3 + run: | + sudo apt-get update + sudo apt-get install ffmpeg + + - name: Try to Start Application + run: | + pnpm start | tee output & + while true; do + sleep 1 + if grep -q "Nest application successfully started" output; then + echo "Detected 'Nest application successfully started'. Stopping pnpm..." + pid=$(netstat -nlp | grep :$PORT | awk '{print $7}' | awk -F'/' '{print $1}') + kill $pid + break + fi + if grep -q "Command failed" output; then + echo "Nest application failed to start." + exit -1 + fi + done + + - name: Run Tests + run: | + for i in {1..${{ env.TEST_REPEAT }}}; do + echo "Repeating Test [$i]" + pnpm test:cov --verbose + done + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5.3.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: SageSeekerSociety/cheese-backend diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..c8111bd9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,140 @@ +name: Automatic Test + +on: [push, pull_request, workflow_dispatch] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + + strategy: + # We conduct tests on a combination of so many platforms, + # not only to test compatibility, + # but also to identify bugs that may not be detected without extensive repetition. + matrix: + postgresql-version: [latest, 12] + elasticsearch-version: [8.12.2, 8.0.0] + node-version: [18.x, 20.x] + repeat: [1, 2] + + env: + TEST_REPEAT: 10 # Tests will be repeated + PORT: 7777 + JWT_SECRET: Test JWT Secret + PRISMA_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres?connection_limit=16 + DEFAULT_AVATAR_NAME: default.jpg + FILE_UPLOAD_PATH: /home/runner/work/cheese-backend + ELASTICSEARCH_NODE: http://127.0.0.1:9200/ + ELASTICSEARCH_MAX_RETRIES: 10 + ELASTICSEARCH_REQUEST_TIMEOUT: 60000 + ELASTICSEARCH_PING_TIMEOUT: 60000 + ELASTICSEARCH_SNIFF_ON_START: false + ELASTICSEARCH_AUTH_USERNAME: elastic + ELASTICSEARCH_AUTH_PASSWORD: your-elasticsearch-password + REDIS_HOST: localhost + REDIS_PORT: 6379 + + services: + # See: https://docs.github.com/en/actions/using-containerized-services/creating-postgresql-service-containers + # Label used to access the service container + postgres: + # Docker Hub image + image: postgres:${{ matrix.postgresql-version }} + # Provide the password for postgres + env: + POSTGRES_PASSWORD: postgres + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + --name my_postgres_container + ports: + # Maps tcp port 5432 on service container to the host + - 5432:5432 + elasticsearch: # See: https://discuss.elastic.co/t/set-password-and-user-with-docker-compose/225075 + image: docker.elastic.co/elasticsearch/elasticsearch:${{ matrix.elasticsearch-version }} + env: + discovery.type: single-node + xpack.security.enabled: true + ELASTIC_USERNAME: ${{ env.ELASTICSEARCH_AUTH_USERNAME }} + ELASTIC_PASSWORD: ${{ env.ELASTICSEARCH_AUTH_PASSWORD }} + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + ports: + # Maps tcp port 9200 on service container to the host + - 9200:9200 + valkey: + image: valkey/valkey:8.0.2 + ports: + - '6379:6379' + options: >- + --health-cmd "valkey-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 3 + --health-start-period 30s + + steps: + # See: https://remarkablemark.org/blog/2022/05/12/github-actions-postgresql-increase-max-connections-and-shared-buffers/ + # See: https://stackoverflow.com/questions/70673766/how-to-increase-max-connection-in-github-action-postgres + - name: Increase PostgreSQL max_connections + run: | + docker exec -i my_postgres_container bash << EOF + sed -i -e 's/max_connections = 100/max_connections = 1000/' /var/lib/postgresql/data/postgresql.conf + sed -i -e 's/shared_buffers = 128MB/shared_buffers = 2GB/' /var/lib/postgresql/data/postgresql.conf + EOF + docker restart --time 0 my_postgres_container + + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Install Dependencies + run: pnpm install + + - name: Initialize Database Structures with Prisma + run: pnpm prisma db push + + - name: Install ffmpeg + # uses: FedericoCarboni/setup-ffmpeg@v3 + run: | + sudo apt-get update + sudo apt-get install ffmpeg + + - name: Try to Start Application + run: | + pnpm start | tee output & + while true; do + sleep 1 + if grep -q "Nest application successfully started" output; then + echo "Detected 'Nest application successfully started'. Stopping pnpm..." + pid=$(netstat -nlp | grep :$PORT | awk '{print $7}' | awk -F'/' '{print $1}') + kill $pid + break + fi + if grep -q "Command failed" output; then + echo "Nest application failed to start." + exit -1 + fi + done + + - name: Run Tests + run: | + for i in {1..${{ env.TEST_REPEAT }}}; do + echo "Repeating Test [$i]" + pnpm test --verbose + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..63b11496 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# compiled output +/dist +/node_modules + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/test/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Node.js +.env + +.pnpm-store/ + +src/avatars/images + +# for customizing devContainer on container startup +.devcontainer/customize.sh + +# Runtime data +uploads/ \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 00000000..dd270eed --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,2 @@ +#!/bin/sh +pnpm commitlint --edit $1 diff --git a/.husky/install.mjs b/.husky/install.mjs new file mode 100644 index 00000000..ba8e33dd --- /dev/null +++ b/.husky/install.mjs @@ -0,0 +1,6 @@ +// Skip Husky install in production and CI +if (process.env.NODE_ENV === 'production' || process.env.CI === 'true') { + process.exit(0) +} +const husky = (await import('husky')).default +console.log(husky()) diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..eba7ce2a --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +#!/bin/sh +pnpm lint-staged diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..544138be --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/.prisma-case-format b/.prisma-case-format new file mode 100644 index 00000000..82b217c2 --- /dev/null +++ b/.prisma-case-format @@ -0,0 +1,2 @@ +# See: https://github.com/iiian/prisma-case-format +default: 'table=pascal; mapTable=snake; field=pascal; mapField=snake; enum=pascal; mapEnum=pascal' diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..ad92d626 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "editor.indentSize": "tabSize", + "editor.tabSize": 2, + "editor.codeActionsOnSave": { + "source.organizeImports": "always" + }, + "typescript.preferences.importModuleSpecifier": "relative", + "conventionalCommits.scopes": [ + "questions" + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..c45b8c8e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +FROM node:23 AS base + +ENV PNPM_HOME="/pnpm" +ENV PATH="${PNPM_HOME}:$PATH" +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg +RUN corepack enable +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +COPY .husky/install.mjs ./.husky/ +COPY prisma ./prisma/ + +FROM base AS dev-deps +WORKDIR /app +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile +RUN pnpm prisma generate + +FROM dev-deps AS dev +WORKDIR /app +COPY . ./ +EXPOSE 8000 +CMD ["tail", "-f", "/dev/null"] + +FROM dev-deps AS prod-deps +WORKDIR /app +ENV NODE_ENV="production" +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm prune --prod --ignore-scripts + +# note that we need to use development deps to build the product +FROM dev-deps AS prod-build +WORKDIR /app +COPY . ./ +RUN pnpm run build + +FROM node:23-slim AS prod +ENV NODE_ENV="production" +RUN apt-get update && apt-get install -y openssl +WORKDIR /app +COPY . ./ +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=prod-build /app/dist ./dist + +EXPOSE 8000 +CMD ["node", "dist/main"] diff --git a/README.md b/README.md index 08d29f87..90ec251c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,134 @@ # cheese-backend -芝士后端 + +![test](https://github.com/SageSeekerSociety/cheese-backend/actions/workflows/test.yml/badge.svg) +![test_docker](https://github.com/SageSeekerSociety/cheese-backend/actions/workflows/build-test-docker-dev.yml/badge.svg) +[![codecov](https://codecov.io/gh/SageSeekerSociety/cheese-backend/graph/badge.svg?token=ZWHHESBFJW)](https://codecov.io/gh/SageSeekerSociety/cheese-backend) + +## Description + +[Cheese Backend](https://github.com/SageSeekerSociety/cheese-backend) +The backend of the cheese Q&A system. + +## Run without installation + +If you only want to start the application, you can use `docs/scripts/cheese-start.sh` and `docs/scripts/cheese-restart.sh` +to start and restart the application. You do not need to do anything else if you use these scripts. By default, after the application +is started in this way, it will be available at `http://localhost:3000`. + +Notice that these scripts use the latest docker image on GitHub built from the `dev` branch, so it has nothing to do with your local code. +If you want to use your local code, you need to install the dependencies and run the app manually, as described below. + +## Installation + +Before installing this backend, ensure that you have installed the pnpm package manager. If you have not yet installed it, you can install it with the following command: + +```bash +corepack enable pnpm +``` + +After this repo is cloned, you should install the dependencies with the following command: + +```bash +pnpm install +``` + +You need to create a database for this backend. Currently, we only support PostgreSQL. +Also, you need to set up an Elasticsearch instance. It is used to provide full-text search feature. + +Setting up PostgreSQL and Elasticsearch can be complicated, so we recommend you to use Docker to set up the environment. +You can use `docs/scripts/dependency-start.sh` and `docs/scripts/dependency-restart.sh` to start and restart the dependencies. +If you set up dependencies in this way, then simply use `docs/scripts/dependency.env` as your `.env` file. + +```bash +docs/scripts/dependency-start.sh +cp docs/scripts/dependency.env .env +``` + +If you set up dependencies manually, you need to modify the `.env` file according to your condition. +Copy `sample.env` to `.env` and modify according to your condition. + +```bash +cp sample.env .env +``` + +Once you believe you have set up the environment correctly, you can run the following command to initialize the database schema: +```bash +pnpm build-prisma +pnpm prisma db push +``` + +You need to start the app once before running tests. +```bash +pnpm start +``` + +Now, you can run tests with the following command to ensure that the app is working correctly: +```bash +pnpm test +``` + +## Running the app + +For development, you can run the app with the following command: + +```bash +pnpm run start +``` + +With watch mode, the app will be recompiled automatically when you modify the source code. + +```bash +pnpm run start:dev +``` + +For production, you can run the app with the following command: + +```bash +pnpm run start:prod +``` + +## Build + +Nest.js is a framework that can be run directly without building, but you can still build the app with the following command: + +```bash +pnpm build +``` + +If you add to or modify .prisma files, you need to recompile the Prisma client with the following command: + +```bash +pnpm build-prisma +``` + +## Test + +Our primary testing approach involves e2e tests, as the app focuses extensively on CRUD operations, and the e2e tests can test the app more comprehensively. + +```bash +# run all tests +pnpm run test + +# run all tests with coverage report +pnpm run test:cov +``` + +With the commands above, all tests, including e2e tests and unit tests, will be run. + +## VSCode Environment + +We recommend you to use VSCode to develop this app. We strongly recommend you to install the following extensions as a basic development environment: + +[Prisma Import](https://marketplace.visualstudio.com/items?itemName=ajmnz.prisma-import) to help you view and edit the Prisma schema file. Do not use the official Prisma extension, because it does not support the prisma-import syntax, which is used in our project. + +In addition, you can install the following extensions for better development experience. However, they are not necessary, and you can choose alternatives if you like. + +[Git Extension Pack](https://marketplace.visualstudio.com/items?itemName=donjayamanne.git-extension-pack) to help you manage the git repository. + +[JavaScript and TypeScript Nightly](https://marketplace.visualstudio.com/items?itemName=ms-vscode.vscode-typescript-next&ssr=false#qna) for better TypeScript language support. + +[vscode-openapi-viewer](https://marketplace.visualstudio.com/items?itemName=AndrewButson.vscode-openapi-viewer) to help you view the OpenAPI document. + +## Development + +About the development of this app, you can refer to the [Development Guide](./docs/development-guide.md). diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 00000000..422b1944 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1 @@ +module.exports = { extends: ['@commitlint/config-conventional'] }; diff --git a/design/API.yml b/design/API.yml new file mode 100644 index 00000000..e4237858 --- /dev/null +++ b/design/API.yml @@ -0,0 +1,4089 @@ +openapi: "3.0.2" +info: + title: Cheese Community API + version: "1.0" +servers: + - url: http://localhost:7777/api/{version} + description: Local server + variables: + version: + default: v1 +components: + parameters: + questionId: + name: questionId + in: path + description: 问题 ID + required: true + schema: + type: integer + format: int64 + userId: + name: userId + in: path + description: 用户 ID + required: true + schema: + type: integer + format: int64 + groupId: + name: groupId + in: path + description: 圈子 ID + required: true + schema: + type: integer + format: int64 + answerId: + name: answerId + in: path + description: 回答 ID + required: true + schema: + type: integer + format: int64 + insightId: + name: insightId + in: path + description: 灵感 ID + required: true + schema: + type: integer + format: int64 + targetId: + name: targetId + in: path + description: 圈子目标 ID + required: true + schema: + type: integer + format: int64 + materialId: + name: materialId + in: path + description: 资料 ID + required: true + schema: + type: integer + format: int64 + materialBundleId: + name: materialBundleId + in: path + description: 资料包 ID + required: true + schema: + type: integer + format: int64 + commentId: + name: commentId + in: path + description: 评论 ID + required: true + schema: + type: integer + format: int64 + commentableId: + name: commentableId + in: path + description: 评论对象 ID + required: true + schema: + type: integer + format: int64 + commentableType: + name: commentableType + in: path + description: 评论对象类型 + required: true + schema: + type: string + enum: + - ANSWER + - COMMENT + - INSIGHT + - QUESTION + - MATERIAL_BUNDLE + page_start: + name: page_start + in: query + description: 该页第一个 item 的 ID,留空即为从头开始 + schema: + type: integer + format: int64 + page_size: + name: page_size + in: query + description: 每页 item 数量 + schema: + type: integer + format: int64 + default: 20 + + securitySchemes: + bearerAuth: + description: The access token is used in this way. + type: http + scheme: bearer + bearerFormat: JWT + bearerAuth2: + type: apiKey + description: The refresh token is used in this way. + name: REFRESH_TOKEN + in: cookie + schemas: + AttitudeType: + type: string + description: 态度类型,UNDEFINED表示未操作 + enum: + - POSITIVE + - NEGATIVE + - UNDEFINED + + AttitudeStats: + type: object + properties: + positive_count: + type: integer + description: 正面表态数量 + format: int64 + negative_count: + type: integer + description: 负面表态数量 + format: int64 + difference: + type: integer + description: 正面表态数量减去负面表态数量 + format: int64 + user_attitude: + allOf: + - $ref: "#/components/schemas/AttitudeType" + - description: 当前登录用户的态度 + + CommonResponse: + type: object + properties: + code: + type: integer + format: int32 + default: 0 + description: 错误码,0表示成功,其他表示失败 + message: + type: string + default: "" + description: 错误信息,成功时为空字符串 + required: + - code + - message + User: + type: object + properties: + id: + type: integer + format: int64 + description: 用户 ID + minimum: 1 + username: + type: string + description: 用户名 + example: "cheese" + nickname: + type: string + description: 昵称 + example: "芝士" + avatarId: + type: integer + description: 头像 id + intro: + type: string + description: 个人简介 + default: "This user has not set an introduction yet." + follow_count: + type: integer + format: int64 + description: 关注用户的数量 + minimum: 0 + example: 114 + fans_count: + type: integer + format: int64 + description: 粉丝数量 + minimum: 0 + example: 514 + question_count: + type: integer + format: int64 + description: 提问数量 + minimum: 0 + example: 1919 + answer_count: + type: integer + format: int64 + description: 回答数量 + minimum: 0 + example: 810 + is_follow: + type: boolean + description: 当前登录用户是否关注该用户 + required: + - id + - username + - nickname + - avatarId + - intro + - follow_count + - fans_count + - question_count + - answer_count + Page: + type: object + description: 分页信息 + properties: + page_start: + type: integer + format: int64 + description: 该页第一个 item 的 ID + page_size: + type: integer + format: int64 + description: 每页 item 数量 + has_prev: + type: boolean + description: 是否有上一页 + prev_start: + type: integer + format: int64 + description: 上一页第一个 item 的 ID + has_more: + type: boolean + description: 是否有下一页 + next_start: + type: integer + format: int64 + description: 下一页第一个 item 的 ID + Topic: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + required: + - id + - name + Question: + type: object + description: 问题详情 + properties: + id: + type: integer + format: int64 + description: 问题 ID + title: + type: string + description: 问题标题 + content: + type: string + description: 问题详情 + attachments: + type: array + description: 详情中出现的所有附件 + items: + $ref: "#/components/schemas/Attachment" + author: + $ref: "#/components/schemas/User" + type: + type: integer + description: 问题类型 + topics: + type: array + items: + $ref: "#/components/schemas/Topic" + created_at: + type: integer + format: int64 + description: 创建时间(时间戳) + updated_at: + type: integer + format: int64 + description: 最后修改时间(时间戳) + attitudes: + $ref: "#/components/schemas/AttitudeStats" + is_follow: + type: boolean + description: 是否关注问题 + my_answer_id: + type: integer + format: int64 + nullable: true + description: 当前登录用户的回答 ID + answer_count: + type: integer + description: 回答数量 + comment_count: + type: integer + description: 评论数量 + follow_count: + type: integer + description: 关注数量 + view_count: + type: integer + description: 浏览数量 + bounty: + type: integer + format: int64 + minimum: 0 + maximum: 20 + description: 悬赏数量 + bounty_start_at: + type: integer + format: int64 + description: 悬赏开始时间 + accepted_answer: + $ref: "#/components/schemas/Answer" + nullable: true + group: + $ref: "#/components/schemas/Group" + nullable: true + QuestionInvitation: + type: object + description: 问题邀请 + properties: + id: + type: integer + format: int64 + description: 邀请 ID + question_id: + type: integer + format: int64 + description: 问题 ID + user: + $ref: "#/components/schemas/User" + created_at: + type: integer + format: int64 + description: 创建时间(时间戳) + updated_at: + type: integer + format: int64 + description: 最后修改时间(时间戳) + is_answered: + type: boolean + description: 是否已回答 + required: + - id + - title + - content + - author + - type + - topics + - created_at + - updated_at + - attitudes + - is_follow + - is_like + - answer_count + - comment_count + - follow_count + - like_count + - view_count + - group + Answer: + type: object + description: 回答详情 + properties: + id: + type: integer + format: int64 + description: 回答 ID + question_id: + type: integer + format: int64 + description: 回答所属的问题 ID + content: + type: string + description: 回答内容 + attachments: + type: array + description: 内容中出现的所有附件 + items: + $ref: "#/components/schemas/Attachment" + author: + $ref: "#/components/schemas/User" + created_at: + type: integer + format: int64 + description: 创建时间(时间戳) + updated_at: + type: integer + format: int64 + description: 最后修改时间(时间戳) + attitudes: + $ref: "#/components/schemas/AttitudeStats" + is_favorite: + type: boolean + description: 是否收藏回答 + comment_count: + type: integer + description: 评论数量 + favorite_count: + type: integer + description: 收藏数量 + view_count: + type: integer + description: 浏览数量 + group: + $ref: "#/components/schemas/Group" + Comment: + type: object + description: 评论详情 + properties: + id: + type: integer + format: int64 + description: 评论 ID + commentable_id: + type: integer + format: int64 + description: 评论对象 ID + commentable_type: + type: string + description: 评论对象,大写 + example: ANSWER + content: + type: string + minLength: 1 + description: 评论内容 + user: + $ref: "#/components/schemas/User" + created_at: + type: integer + format: int64 + description: 创建时间(时间戳) + attitudes: + $ref: "#/components/schemas/AttitudeStats" + required: + - id + - commentable_id + - commentable_type + - content + - user + - created_at + - agree_type + - agree_count + Group: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + intro: + type: string + default: "" + avatarId: + type: integer + format: int64 + owner: + $ref: "#/components/schemas/User" + created_at: + type: integer + format: int64 + updated_at: + type: integer + format: int64 + member_count: + type: integer + format: int64 + question_count: + type: integer + format: int64 + answer_count: + type: integer + format: int64 + is_member: + type: boolean + is_owner: + type: boolean + required: + - id + - name + - intro + - avatarId + GroupTarget: + type: object + description: 小组目标 + properties: + id: + type: integer + format: int64 + name: + type: string + intro: + type: string + default: "" + created_at: + type: integer + format: int64 + start_date: + type: integer + format: int64 + end_date: + type: integer + format: int64 + attendance: + type: object + description: 小组目标的打卡信息 + properties: + frequency: + type: string + description: 打卡频率 + enum: + - daily + - weekly + - monthly + required: + - id + - name + - intro + Insight: + type: object + description: 灵感 + properties: + id: + type: integer + description: ID + format: int64 + content: + type: string + description: 内容 + author: + $ref: "#/components/schemas/User" + created_at: + type: integer + description: 创建时间 + format: int64 + updated_at: + type: integer + description: 最后更新时间 + format: int64 + attitudes: + $ref: "#/components/schemas/AttitudeStats" + comment_count: + type: integer + description: 评论数量 + view_count: + type: integer + description: 浏览数量 + medias: + type: array + description: 媒体文件,如果没有则为空数组(只支持图片和视频) + items: + $ref: "#/components/schemas/Attachment" + share_link: + type: object + description: 分享的链接,若无则为 undefined + properties: + title: + type: string + description: 页面标题 + abstract: + type: string + description: 页面摘要 + image: + type: string + description: 图片 URL + url: + type: string + description: 页面 URL + required: + - id + - content + - author + - created_at + - updated_at + - is_like + - like_count + - comment_count + - view_count + - medias + Attachment: + type: object + description: 附件 + properties: + id: + type: integer + description: 附件 ID + format: int64 + type: + type: string + description: 类型 + enum: + - image + - video + - audio + - file + url: + type: string + description: 访问 URL + meta: + oneOf: + - $ref: "#/components/schemas/ImageMeta" + - $ref: "#/components/schemas/VideoMeta" + - $ref: "#/components/schemas/AudioMeta" + - $ref: "#/components/schemas/FileMeta" + ImageMeta: + allOf: + - $ref: "#/components/schemas/FileMeta" + - type: object + description: 图片元数据 + properties: + height: + type: integer + description: 高度 + width: + type: integer + description: 宽度 + thumbnail: + type: string + description: 缩略图 URL + VideoMeta: + allOf: + - $ref: "#/components/schemas/FileMeta" + - type: object + description: 视频元数据 + properties: + duration: + type: integer + description: 时长 + height: + type: integer + description: 高度 + width: + type: integer + description: 宽度 + thumbnail: + type: string + description: 缩略图 URL + AudioMeta: + allOf: + - $ref: "#/components/schemas/FileMeta" + - type: object + description: 音频元数据 + properties: + duration: + type: integer + description: 时长 + format: int64 + FileMeta: + type: object + description: 文件元数据 + properties: + size: + type: integer + description: 文件大小 + format: int64 + name: + type: string + description: 文件名称 + mime: + type: string + description: MIME 类型 + hash: + type: string + description: 文件哈希 + LinkMeta: + type: object + description: 链接元数据 + properties: + title: + type: string + description: 页面标题 + abstract: + type: string + description: 页面摘要 + image: + type: string + description: 图片 URL + LoginRequest: + type: object + properties: + username: + type: string + password: + type: string + required: + - username + - password + LoginResponse: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + accessToken: + type: string + user: + $ref: "#/components/schemas/User" + requires2fa: + type: boolean + description: 是否需要进行 2FA 验证 + tempToken: + type: string + description: 2FA 验证临时令牌(仅在 requires2fa 为 true 时返回) + required: + - accessToken + - user + - requires2fa + required: + - data + RegisterRequest: + type: object + properties: + username: + type: string + nickname: + type: string + password: + type: string + email: + type: string + emailCode: + type: string + required: + - username + - nickname + - password + - email + - emailCode + RegisterResponse: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + accessToken: + type: string + user: + $ref: "#/components/schemas/User" + required: + - accessToken + - user + required: + - data + Material: + type: object + description: 用户上传的资料(图片、视频、音频、文件等) + properties: + id: + type: integer + description: 资料 ID + format: int64 + type: + type: string + description: 资料类型 + enum: + - image + - video + - audio + - file + uploader: + $ref: "#/components/schemas/User" + created_at: + type: integer + description: 创建时间 + format: int64 + expires: + type: integer + description: 过期时间,永不过期则为 undefined + format: int64 + nullable: true + download_count: + type: integer + description: 下载数 + format: int64 + url: + type: string + description: 资料下载 URL + meta: + oneOf: + - $ref: "#/components/schemas/ImageMeta" + - $ref: "#/components/schemas/VideoMeta" + - $ref: "#/components/schemas/AudioMeta" + - $ref: "#/components/schemas/FileMeta" + required: + - id + - type + - uploader + - created_at + - download_count + - url + - meta + MaterialBundle: + type: object + description: 用户分享的资料包 + properties: + id: + type: integer + description: 资料包 ID + format: int64 + title: + type: string + description: 资料包标题 + content: + type: string + description: 资料包描述 + creator: + $ref: "#/components/schemas/User" + created_at: + type: integer + description: 创建时间 + format: int64 + updated_at: + type: integer + description: 最后更新时间 + format: int64 + rating: + type: number + description: 评分 + format: float + example: 4.5 + minimum: 1 + maximum: 5 + rating_count: + type: integer + description: 评分人数 + format: int64 + my_rating: + type: number + description: 当前登录用户的评分,未评分则为 undefined + format: float + example: 4.5 + minimum: 1 + maximum: 5 + nullable: true + comments_count: + type: integer + description: 评论数 + format: int64 + materials: + type: array + description: 资料列表 + items: + $ref: "#/components/schemas/Material" + required: + - id + - type + - title + - content + - uploader + - created_at + - updated_at + - rating + - rating_count + - download_count + - comments_count + - url + - meta + + PasskeyChallengeResponse: + type: object + description: Passkey 登录挑战响应 + properties: + challenge: + type: string + description: 随机挑战字符串 + timeout: + type: integer + description: 挑战超时时间,单位毫秒 + rpId: + type: string + description: 依赖方标识符 + allowCredentials: + type: array + description: 允许使用的凭证ID列表 + items: + type: string + + PasskeyVerificationRequest: + type: object + description: Passkey 登录验证请求 + properties: + credentialId: + type: string + description: 凭证ID + clientDataJSON: + type: string + description: 客户端数据(Base64编码) + authenticatorData: + type: string + description: 认证器数据(Base64编码) + signature: + type: string + description: 签名(Base64编码) + userHandle: + type: string + description: 用户标识(可选) + + PasskeyVerificationResponse: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + accessToken: + type: string + user: + $ref: "#/components/schemas/User" + requires2fa: + type: boolean + description: 是否需要进行 2FA 验证 + tempToken: + type: string + description: 2FA 验证临时令牌(仅在 requires2fa 为 true 时返回) + required: + - accessToken + - user + - requires2fa + required: + - data + + PasskeyCredential: + type: object + description: 用户Passkey凭证信息 + properties: + id: + type: integer + format: int64 + description: 凭证记录ID + credentialId: + type: string + description: Passkey凭证ID + publicKey: + type: string + description: 公钥 + transports: + type: array + description: 支持的传输方式 + items: + type: string + created_at: + type: integer + format: int64 + description: 注册时间(时间戳) + + PasskeyCredentialRegistrationRequest: + type: object + description: 注册Passkey凭证请求 + properties: + credentialId: + type: string + description: 凭证ID + publicKey: + type: string + description: 公钥 + transports: + type: array + description: 支持的传输方式 + items: + type: string + required: + - credentialId + - publicKey + + TOTPSetupResponse: + type: object + description: TOTP 2FA 设置响应 + properties: + secret: + type: string + description: TOTP 密钥(仅在初始设置时返回) + otpauth_url: + type: string + description: TOTP 二维码 URL(仅在初始设置时返回) + backup_codes: + type: array + description: 备份代码列表(仅在初始设置或重新生成时返回) + items: + type: string + +paths: + /users: + post: + summary: 用户注册 + operationId: register + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RegisterRequest" + responses: + "201": + description: User registered successfully + headers: + Set-Cookie: + description: "refresh token" + schema: + type: string + example: "RefreshToken=...; Secure; HttpOnly; SameSite=Strict; Path=/users/auth/; Expires=Sat, 01 Jan 2025 00:00:00 GMT" + content: + application/json: + schema: + $ref: "#/components/schemas/RegisterResponse" + /users/auth/login: + post: + summary: 用户登录 + operationId: login + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: OK + headers: + Set-Cookie: + description: "refresh token" + schema: + type: string + example: "RefreshToken=...; Secure; HttpOnly; SameSite=Strict; Path=/users/auth; Expires=Sat, 01 Jan 2025 00:00:00 GMT" + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" + /users/auth/refresh-token: + post: + summary: 用户刷新 token + operationId: refresh-access-token + security: + - REFRESH_TOKEN: [] + responses: + "200": + description: OK + headers: + Set-Cookie: + description: "refresh token" + schema: + type: string + example: "RefreshToken=...; Secure; HttpOnly; SameSite=Strict; Path=/users/auth; Expires=Sat, 01 Jan 2025 00:00:00 GMT" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + accessToken: + type: string + required: + - accessToken + required: + - data + /users/auth/logout: + post: + summary: 用户退出登录 + operationId: logout + security: + - REFRESH_TOKEN: [] + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + + /users/verify/email: + post: + summary: 发送邮箱验证码 + operationId: verifyEmail + requestBody: + content: + application/json: + schema: + type: object + properties: + email: + type: string + required: + - email + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + /users/recover/password/request: + post: + summary: 重置密码请求 + operationId: recoverPasswordRequest + requestBody: + content: + application/json: + schema: + type: object + properties: + email: + type: string + required: + - email + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + /users/recover/password/verify: + post: + summary: 验证 Token 并重置密码 + operationId: recoverPasswordVerify + requestBody: + content: + application/json: + schema: + type: object + properties: + token: + type: string + new_password: + type: string + required: + - token + - new_password + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + /users/{userId}: + get: + summary: 获取用户信息 + operationId: getUserInfo + parameters: + - $ref: "#/components/parameters/userId" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + user: + $ref: "#/components/schemas/User" + required: + - data + put: + summary: 更新用户信息 + operationId: updateUserInfo + parameters: + - $ref: "#/components/parameters/userId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + nickname: + type: string + intro: + type: string + avatarId: + type: integer + format: int64 + required: + - nickname + - intro + - avatarId + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + /users/{userId}/questions: + get: + summary: 获取用户提问列表 + operationId: getUserQuestions + parameters: + - $ref: "#/components/parameters/userId" + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + questions: + type: array + items: + $ref: "#/components/schemas/Question" + page: + $ref: "#/components/schemas/Page" + required: + - data + /users/{userId}/answers: + get: + summary: 获取用户回答列表 + operationId: getUserAnswers + parameters: + - $ref: "#/components/parameters/userId" + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + answers: + type: array + items: + $ref: "#/components/schemas/Answer" + page: + $ref: "#/components/schemas/Page" + required: + - data + /users/{userId}/followers: + get: + summary: 获取用户粉丝列表 + operationId: getUserFollowers + parameters: + - $ref: "#/components/parameters/userId" + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + users: + type: array + items: + $ref: "#/components/schemas/User" + page: + $ref: "#/components/schemas/Page" + required: + - data + post: + summary: 关注用户 + operationId: followUser + parameters: + - $ref: "#/components/parameters/userId" + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + follow_count: + type: integer + description: 新的关注者数量 + required: + - follow_count + required: + - data + delete: + summary: 取消关注用户 + operationId: unfollowUser + parameters: + - $ref: "#/components/parameters/userId" + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + follow_count: + type: integer + description: 新的关注者数量 + required: + - follow_count + required: + - data + /users/{userId}/follow/questions: + get: + summary: 获取用户关注的问题列表 + operationId: getUserFollowQuestions + parameters: + - $ref: "#/components/parameters/userId" + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + questions: + type: array + items: + $ref: "#/components/schemas/Question" + page: + $ref: "#/components/schemas/Page" + required: + - data + /users/{userId}/follow/users: + get: + summary: 获取用户关注的用户列表 + operationId: getUserFollowUsers + parameters: + - $ref: "#/components/parameters/userId" + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + users: + type: array + items: + $ref: "#/components/schemas/User" + page: + $ref: "#/components/schemas/Page" + required: + - data + /questions: + get: + summary: 获取问题列表 + operationId: getQuestions + parameters: + - name: q + in: query + description: 搜索关键词 + required: true + schema: + type: string + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + questions: + type: array + items: + $ref: "#/components/schemas/Question" + page: + $ref: "#/components/schemas/Page" + required: + - data + post: + summary: 提问 + operationId: askQuestion + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + content: + type: string + type: + type: integer + description: 问题类型 + topics: + type: array + items: + type: integer + format: int64 + group_id: + type: integer + format: int64 + description: 所属小组 ID,留空表示不属于任何小组 + bounty: + type: integer + format: int64 + description: 悬赏金额,0 表示不设置悬赏 + default: 0 + maximum: 20 + required: + - title + - content + - type + - topics + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + id: + type: integer + format: int64 + required: + - data + /questions/{questionId}: + get: + summary: 获取问题详情 + operationId: getQuestionDetail + parameters: + - $ref: "#/components/parameters/questionId" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + question: + $ref: "#/components/schemas/Question" + required: + - data + put: + summary: 更新问题 + operationId: updateQuestion + parameters: + - $ref: "#/components/parameters/questionId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + content: + type: string + type: + type: integer + description: 问题类型 + topics: + type: array + items: + type: integer + format: int64 + required: + - title + - content + - type + - topics + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + delete: + summary: 删除问题 + operationId: deleteQuestion + parameters: + - $ref: "#/components/parameters/questionId" + security: + - bearerAuth: [] + responses: + "204": + description: No Content + /questions/{questionId}/followers: + get: + summary: 获取问题关注者列表 + operationId: getQuestionFollowers + parameters: + - $ref: "#/components/parameters/questionId" + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + users: + type: array + items: + $ref: "#/components/schemas/User" + page: + $ref: "#/components/schemas/Page" + required: + - data + put: + summary: 关注问题 + operationId: followQuestion + parameters: + - $ref: "#/components/parameters/questionId" + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + follow_count: + type: integer + description: 新的关注者数量 + required: + - follow_count + required: + - data + delete: + summary: 取消关注问题 + operationId: unfollowQuestion + parameters: + - $ref: "#/components/parameters/questionId" + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + follow_count: + type: integer + description: 新的关注者数量 + required: + - follow_count + required: + - data + /questions/{questionId}/attitudes: + post: + summary: 对问题表态 + operationId: attitudeQuestion + parameters: + - name: questionId + in: path + description: 问题 ID + required: true + schema: + type: integer + format: int64 + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + attitude_type: + $ref: "#/components/schemas/AttitudeType" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + attitudes: + $ref: "#/components/schemas/AttitudeStats" + required: + - attitudes + required: + - data + /questions/{id}/bounty: + put: + summary: 设置悬赏 + operationId: setBounty + parameters: + - name: id + in: path + description: 问题 ID + required: true + schema: + type: integer + minimum: 1 + maximum: 20 + format: int64 + security: + - bearerAuth: [] + responses: + "204": + description: No Content + /questions/{id}/invitations: + get: + summary: 获取该问题下被邀请回答的用户 + operationId: getQuestionInvitations + parameters: + - name: id + in: path + description: 问题 ID + required: true + schema: + type: integer + format: int64 + - name: page_start + in: query + description: 该页第一个用户的 ID,留空即为从头开始 + schema: + type: integer + format: int64 + - name: page_size + in: query + description: 每页用户数量 + schema: + type: integer + format: int64 + default: 20 + - name: sort + in: query + description: 排序方式字符串,例如 "+created_at" 表示按创建时间升序,"-created_at" 表示按创建时间降序,使用逗号来分隔先后多个排序字段 + schema: + type: string + example: "+created_at" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + invitations: + type: array + items: + $ref: "#/components/schemas/QuestionInvitation" + page: + $ref: "#/components/schemas/Page" + required: + - data + post: + summary: 邀请用户回答问题 + operationId: inviteUserAnswerQuestion + parameters: + - name: id + in: path + description: 问题 ID + required: true + schema: + type: integer + format: int64 + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + user_id: + type: integer + format: int64 + required: + - user_id + responses: + "201": + description: Successfully invited + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + invitation_id: + type: integer + description: 邀请 ID(如果邀请成功) + format: int64 + required: + - invitation_id + required: + - data + /questions/{id}/invitations/recommendations: + get: + summary: 获取推荐邀请用户列表 + operationId: getQuestionInvitationRecommendations + parameters: + - name: id + in: path + description: 问题 ID + required: true + schema: + type: integer + format: int64 + - name: page_size + in: query + description: 每页用户数量 + schema: + type: integer + format: int64 + default: 5 + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + users: + type: array + items: + $ref: "#/components/schemas/User" + required: + - users + required: + - data + /questions/{id}/invitations/{invitation_id}: + get: + summary: 获取邀请详情 + operationId: getInvitationDetail + parameters: + - name: id + in: path + description: 问题 ID + required: true + schema: + type: integer + format: int64 + - name: invitation_id + in: path + description: 邀请 ID + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + invitation: + $ref: "#/components/schemas/QuestionInvitation" + required: + - data + delete: + summary: 撤销邀请 + operationId: cancelInvitation + parameters: + - name: id + in: path + description: 问题 ID + required: true + schema: + type: integer + format: int64 + - name: invitation_id + in: path + description: 邀请 ID + required: true + schema: + type: integer + format: int64 + security: + - bearerAuth: [] + responses: + "204": + description: No Content + /questions/{id}/answers: + get: + summary: 获取问题回答列表 + operationId: getQuestionAnswers + parameters: + - $ref: "#/components/parameters/questionId" + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + answers: + type: array + items: + $ref: "#/components/schemas/Answer" + page: + $ref: "#/components/schemas/Page" + required: + - data + post: + summary: 回答问题 + operationId: answerQuestion + parameters: + - name: id + in: path + description: 问题 ID + required: true + schema: + type: integer + format: int64 + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + content: + type: string + required: + - content + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + id: + type: integer + format: int64 + required: + - id + required: + - data + /questions/{questionId}/answers/{answerId}: + get: + summary: 获取回答详情 + operationId: getAnswerDetail + parameters: + - $ref: "#/components/parameters/questionId" + - $ref: "#/components/parameters/answerId" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + question: + $ref: "#/components/schemas/Question" + answer: + $ref: "#/components/schemas/Answer" + required: + - data + put: + summary: 更新回答 + operationId: updateAnswer + parameters: + - $ref: "#/components/parameters/questionId" + - $ref: "#/components/parameters/answerId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + content: + type: string + required: + - content + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + delete: + summary: 删除回答 + operationId: deleteAnswer + parameters: + - $ref: "#/components/parameters/questionId" + - $ref: "#/components/parameters/answerId" + security: + - bearerAuth: [] + responses: + "204": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + /questions/{questionId}/answers/{answerId}/attitudes: + post: + summary: 对回答表态 + operationId: attitudeAnswer + parameters: + - name: questionId + in: path + description: 问题 ID + required: true + schema: + type: integer + format: int64 + - name: answerId + in: path + description: 回答 ID + required: true + schema: + type: integer + format: int64 + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + attitude_type: + $ref: "#/components/schemas/AttitudeType" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + attitudes: + $ref: "#/components/schemas/AttitudeStats" + required: + - attitudes + required: + - data + /questions/{questionId}/answers/{answerId}/favorite: + put: + summary: 收藏回答 + operationId: favoriteAnswer + parameters: + - $ref: "#/components/parameters/questionId" + - $ref: "#/components/parameters/answerId" + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + delete: + summary: 取消收藏回答 + operationId: unfavoriteAnswer + parameters: + - $ref: "#/components/parameters/questionId" + - $ref: "#/components/parameters/answerId" + security: + - bearerAuth: [] + responses: + "204": + description: No Content + /comments/{commentableType}/{commentableId}: + get: + summary: 获取对象评论列表 + operationId: getComments + parameters: + - $ref: "#/components/parameters/commentableId" + - $ref: "#/components/parameters/commentableType" + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + comments: + type: array + items: + type: object + allOf: + - $ref: "#/components/schemas/Comment" + page: + $ref: "#/components/schemas/Page" + post: + summary: 创建评论 + operationId: createComment + parameters: + - $ref: "#/components/parameters/commentableId" + - $ref: "#/components/parameters/commentableType" + requestBody: + content: + application/json: + schema: + type: object + properties: + content: + type: string + required: + - content + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + id: + type: integer + format: int64 + required: + - id + required: + - data + /comments/{commentId}: + get: + summary: 获取评论详情 + operationId: getCommentDetail + parameters: + - $ref: "#/components/parameters/commentId" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + comment: + $ref: "#/components/schemas/Comment" + required: + - data + patch: + summary: 更新评论 + operationId: updateComment + parameters: + - $ref: "#/components/parameters/commentId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + content: + $ref: "#/components/schemas/Comment/properties/content" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + delete: + summary: 删除评论 + operationId: deleteComment + parameters: + - $ref: "#/components/parameters/commentId" + security: + - bearerAuth: [] + responses: + "204": + description: No Content + /comments/{comment_id}/attitudes: + post: + summary: 对评论表态 + operationId: attitudeComment + parameters: + - $ref: "#/components/parameters/commentId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + attitude_type: + $ref: "#/components/schemas/AttitudeType" + required: + - attitude_type + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + attitudes: + $ref: "#/components/schemas/AttitudeStats" + required: + - attitudes + required: + - data + /topics: + get: + summary: 获取话题列表 + operationId: getTopics + parameters: + - name: q + in: query + description: 搜索关键词 + required: true + schema: + type: string + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + topics: + type: array + items: + $ref: "#/components/schemas/Topic" + page: + $ref: "#/components/schemas/Page" + required: + - data + post: + summary: 创建话题 + operationId: createTopic + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + required: + - name + responses: + "201": + description: Topic created successfully + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + id: + type: integer + format: int64 + required: + - data + /topics/{id}: + get: + summary: 获取话题详情 + operationId: getTopicDetail + parameters: + - name: id + in: path + description: 话题 ID + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + topic: + $ref: "#/components/schemas/Topic" + required: + - data + /groups: + get: + summary: 获取圈子列表 + operationId: getGroups + parameters: + - name: q + in: query + description: 搜索关键词,留空即为获取全部圈子 + schema: + type: string + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + - name: sort + in: query + description: 排序方式 + schema: + type: string + enum: + - recommend + - hot + - new + default: recommend + - name: joined + in: query + description: 是否为我参与的小组 + schema: + type: boolean + - name: managed + in: query + description: 是否为我管理的小组 + schema: + type: boolean + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + groups: + type: array + items: + $ref: "#/components/schemas/Group" + page: + $ref: "#/components/schemas/Page" + required: + - data + post: + summary: 创建圈子 + operationId: createGroup + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + intro: + type: string + avatarId: + type: integer + required: + - name + - intro + - avatarId + responses: + "201": + description: Group created successfully + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + id: + type: integer + format: int64 + required: + - data + /groups/{groupId}: + get: + summary: 获取圈子详情 + operationId: getGroupDetail + parameters: + - $ref: "#/components/parameters/groupId" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + group: + $ref: "#/components/schemas/Group" + required: + - data + put: + summary: 更新圈子 + operationId: updateGroup + parameters: + - $ref: "#/components/parameters/groupId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + intro: + type: string + avatarId: + type: integer + format: int64 + cover: + type: string + required: + - name + - intro + - avatarId + - cover + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + delete: + summary: 删除圈子 + operationId: deleteGroup + parameters: + - $ref: "#/components/parameters/groupId" + security: + - bearerAuth: [] + responses: + "204": + description: No Content + /groups/{groupId}/members: + get: + summary: 获取圈子成员列表 + operationId: getGroupMembers + parameters: + - $ref: "#/components/parameters/groupId" + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + members: + type: array + items: + $ref: "#/components/schemas/User" + page: + $ref: "#/components/schemas/Page" + required: + - data + post: + summary: 加入圈子 + operationId: joinGroup + parameters: + - $ref: "#/components/parameters/groupId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + intro: + type: string + required: + - intro + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + member_count: + type: integer + description: 新的成员数量 + is_member: + type: boolean + description: 是否已加入圈子 + is_waiting: + type: boolean + description: 是否正在等待审核 + required: + - data + delete: + summary: 退出圈子 + operationId: quitGroup + parameters: + - $ref: "#/components/parameters/groupId" + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + member_count: + type: integer + description: 新的成员数量 + required: + - data + /groups/{groupId}/questions: + get: + summary: 获取圈子问题列表 + operationId: getGroupQuestions + parameters: + - $ref: "#/components/parameters/groupId" + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + questions: + type: array + items: + $ref: "#/components/schemas/Question" + page: + $ref: "#/components/schemas/Page" + required: + - data + /groups/{groupId}/targets: + get: + summary: 获取圈子目标列表 + operationId: getGroupTargets + parameters: + - $ref: "#/components/parameters/groupId" + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + - name: sort + in: query + description: 排序方式 + schema: + type: string + enum: + - recommend + - hot + - new + default: recommend + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + targets: + type: array + items: + $ref: "#/components/schemas/GroupTarget" + page: + $ref: "#/components/schemas/Page" + required: + - data + post: + summary: 创建圈子目标 + operationId: createGroupTarget + parameters: + - $ref: "#/components/parameters/groupId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + intro: + type: string + start_time: + type: integer + format: int64 + end_time: + type: integer + format: int64 + attendance: + type: object + description: 目标打卡设置 + properties: + type: + type: string + enum: + - daily + - weekly + - monthly + required: + - name + - intro + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + id: + type: integer + format: int64 + required: + - data + /groups/{groupId}/targets/{targetId}: + get: + summary: 获取圈子目标详情 + operationId: getGroupTargetDetail + parameters: + - $ref: "#/components/parameters/groupId" + - $ref: "#/components/parameters/targetId" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + put: + summary: 更新圈子目标 + operationId: updateGroupTarget + parameters: + - $ref: "#/components/parameters/groupId" + - $ref: "#/components/parameters/targetId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + intro: + type: string + start_time: + type: integer + format: int64 + end_time: + type: integer + format: int64 + attendance: + type: object + description: 目标打卡设置 + properties: + type: + type: string + enum: + - daily + - weekly + - monthly + required: + - name + - intro + - start_time + - end_time + - attendance + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + delete: + summary: 删除圈子目标 + operationId: deleteGroupTarget + parameters: + - $ref: "#/components/parameters/groupId" + - $ref: "#/components/parameters/targetId" + security: + - bearerAuth: [] + responses: + "204": + description: No Content + /groups/{groupId}/targets/{targetId}/attend: + post: + summary: 打卡 + operationId: attendGroupTarget + parameters: + - $ref: "#/components/parameters/groupId" + - $ref: "#/components/parameters/targetId" + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + /feeds: + get: + summary: 获取动态列表 + operationId: getFeedList + parameters: + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + feeds: + type: array + items: + type: object + properties: + id: + type: string + description: 动态 ID + from: + type: string + description: 动态来源 + enum: + - follow_question + - follow_user + - follow_topic + - hot + - personalized + type: + type: string + description: 动态类型 + enum: + - question + - answer + data: + type: object + description: 动态数据 + oneOf: + - $ref: "#/components/schemas/Question" + - $ref: "#/components/schemas/Answer" + required: + - from + - type + - data + page: + type: object + properties: + page_size: + type: integer + format: int64 + description: 每页动态数量 + next_start: + type: string + description: 下一页第一条动态的 ID + has_more: + type: boolean + description: 是否还有更多动态 + required: + - data + /hot: + get: + summary: 获取热门问题列表 + operationId: getHotQuestionList + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + questions: + type: array + items: + $ref: "#/components/schemas/Question" + required: + - data + /search: + get: + summary: 搜索 + operationId: search + parameters: + - name: q + in: query + description: 搜索关键词 + required: true + schema: + type: string + - name: type + in: query + description: 搜索类型 + schema: + type: string + enum: + - question + - answer + - user + - topic + default: question + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + results: + type: array + items: + oneOf: + - $ref: "#/components/schemas/Question" + - $ref: "#/components/schemas/Answer" + - $ref: "#/components/schemas/User" + page: + $ref: "#/components/schemas/Page" + required: + - data + /images: + post: + summary: 上传图片 + operationId: uploadImage + security: + - bearerAuth: [] + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: 图片 ID + /insights: + post: + summary: 创建灵感 + operationId: createInsight + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + content: + type: string + description: 纯文本内容 + medias: + type: array + description: 媒体文件(图片或视频)列表 + items: + type: object + properties: + type: + type: string + enum: + - image + - video + id: + type: number + format: int64 + attachment: + oneOf: + - type: object + description: 附件文件 + properties: + type: + type: string + enum: + - file + id: + type: number + format: int64 + - type: object + description: 附件链接 + properties: + type: + type: string + enum: + - link + url: + type: string + example: https://example.com + required: + - content + responses: + "201": + description: Insight created successfully + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + id: + type: integer + format: int64 + required: + - id + required: + - data + /insights/{insightId}: + get: + summary: 获取灵感详情 + operationId: getInsightDetail + parameters: + - $ref: "#/components/parameters/insightId" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + insight: + $ref: "#/components/schemas/Insight" + required: + - data + put: + summary: 更新灵感 + operationId: updateInsight + parameters: + - $ref: "#/components/parameters/insightId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + content: + type: string + required: + - content + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + delete: + summary: 删除灵感 + operationId: deleteInsight + parameters: + - $ref: "#/components/parameters/insightId" + security: + - bearerAuth: [] + responses: + "204": + description: No Content + /insights/{insightId}/attitudes: + post: + summary: 对灵感表态 + operationId: attitudeInsight + parameters: + - $ref: "#/components/parameters/insightId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + attitude_type: + $ref: "#/components/schemas/AttitudeType" + required: + - attitude_type + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + attitudes: + $ref: "#/components/schemas/AttitudeStats" + required: + - attitudes + required: + - data + /avatars: + get: + summary: Get available avatarIds + operationId: getAvailableAvatarIds + parameters: + - name: type + in: query + description: Avatar type + schema: + type: string + enum: + - predefined + default: predefined + responses: + "200": + description: Get available avatarIds successfully + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + avatarIds: + type: array + items: + type: number + format: int64 + description: Available avatarIds + post: + summary: Upload an avatar + operationId: uploadAvatar + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + avatar: + type: string + format: binary + description: The avatar image to upload + responses: + "201": + description: Upload avatar successfully + content: + application/json: + schema: + type: object + properties: + id: + type: number + description: Unique identifier for the uploaded avatar + /avatars/default: + get: + summary: Get default avatar + operationId: getDefaultAvata + responses: + "200": + description: Get default avatar successfully + content: + image/*: + schema: + type: string + format: binary + /avatars/{id}: + get: + summary: Get an avatar by id + operationId: getAvatar + parameters: + - name: id + in: path + required: true + description: Unique identifier for the uploaded avatar + schema: + type: number + format: int64 + minimum: 1 + responses: + "200": + description: get avatar successfully + content: + image/*: + schema: + type: string + format: binary + /avatars/default/id: + get: + summary: Get default avatar id + operationId: getDefaultAvatarId + responses: + "200": + description: Get default avatarId successfully + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + avatarId: + type: number + description: Default avatarId + /avatars/predefined/id: + get: + summary: Get predefined avatarIds + operationId: getPreDefinedIds + responses: + "200": + description: Get Pre Defined AvatarIds successfully + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + avatarIds: + type: array + items: + type: integer + format: int64 + description: Predefined avatarIds + /attachments: + post: + summary: 上传附件 + operationId: uploadAttachment + security: + - bearerAuth: [] + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + type: + type: string + enum: + - image + - video + - audio + - file + description: 附件类型 + file: + type: string + format: binary + responses: + "201": + description: Attachment uploaded successfully + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + id: + type: integer + format: int64 + required: + - data + /attachments/{attachmentId}: + get: + summary: 获取附件详情 + operationId: getAttachmentDetail + parameters: + - name: attachmentId + in: path + description: 附件 ID + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + attachment: + $ref: "#/components/schemas/Attachment" + required: + - data + /materials: + post: + summary: 上传资料 + operationId: uploadMaterial + security: + - bearerAuth: [] + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + "201": + description: Material uploaded successfully + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + id: + type: string + description: 资料 ID + required: + - data + /materials/{materialId}: + get: + summary: 获取资料详情 + operationId: getMaterialDetail + parameters: + - $ref: "#/components/parameters/materialId" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + material: + $ref: "#/components/schemas/Material" + required: + - data + delete: + summary: 删除资料 + operationId: deleteMaterial + parameters: + - $ref: "#/components/parameters/materialId" + security: + - bearerAuth: [] + responses: + "204": + description: No Content + /material-bundles: + get: + summary: 获取资料包列表 + operationId: getMaterialBundleList + parameters: + - $ref: "#/components/parameters/page_start" + - $ref: "#/components/parameters/page_size" + - name: q + in: query + description: 搜索查询,支持 Search Syntax(如 "rating:>=4"),留空则为返回全部 + schema: + type: string + - name: sort + in: query + description: 排序方式,留空则为默认(推荐) + schema: + type: string + enum: + - rating + - download + - newest + nullable: true + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + materials: + type: array + items: + $ref: "#/components/schemas/MaterialBundle" + page: + $ref: "#/components/schemas/Page" + required: + - data + post: + summary: 创建资料包 + operationId: createMaterialBundle + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + description: 资料包标题 + content: + type: string + description: 资料包简介 + materials: + type: array + description: 资料包包含的资料 ID 列表 + items: + type: number + format: int64 + required: + - title + - content + - materials + responses: + "201": + description: MaterialBundle created successfully + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + id: + type: integer + format: int64 + required: + - data + /material-bundles/{materialBundleId}: + get: + summary: 获取资料包详情 + operationId: getMaterialBundleDetail + parameters: + - $ref: "#/components/parameters/materialBundleId" + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + materialBundle: + $ref: "#/components/schemas/MaterialBundle" + required: + - data + patch: + summary: 更新资料包 + operationId: updateMaterialBundle + parameters: + - $ref: "#/components/parameters/materialBundleId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + description: 资料包标题 + content: + type: string + description: 资料包简介 + materials: + type: array + description: 资料包包含的资料 ID 列表 + items: + type: number + format: int64 + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + delete: + summary: 删除资料包 + operationId: deleteMaterialBundle + parameters: + - $ref: "#/components/parameters/materialBundleId" + security: + - bearerAuth: [] + responses: + "204": + description: No Content + + /users/auth/passkey/challenge: + post: + summary: 获取Passkey登录挑战 + operationId: getPasskeyChallenge + description: 用于发起Passkey登录挑战,无需用户名 + responses: + "200": + description: Passkey登录挑战获取成功 + content: + application/json: + schema: + $ref: "#/components/schemas/PasskeyChallengeResponse" + + /users/auth/passkey/verify: + post: + summary: 验证 Passkey 并登录 + operationId: verifyPasskeyAuthentication + requestBody: + content: + application/json: + schema: + type: object + properties: + response: + type: object + description: WebAuthn 认证响应 + responses: + "201": + description: 认证成功 + headers: + Set-Cookie: + description: "refresh token" + schema: + type: string + example: "REFRESH_TOKEN=...; Secure; HttpOnly; SameSite=Strict; Path=/users/auth; Expires=Sat, 01 Jan 2025 00:00:00 GMT" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + user: + $ref: "#/components/schemas/User" + accessToken: + type: string + required: + - user + - accessToken + required: + - data + + /users/{userId}/passkeys: + get: + summary: 获取用户的 Passkey 列表 + operationId: getUserPasskeys + parameters: + - $ref: "#/components/parameters/userId" + security: + - bearerAuth: [] + responses: + "200": + description: 获取成功 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + passkeys: + type: array + items: + type: object + properties: + id: + type: string + description: Passkey 凭证 ID + createdAt: + type: string + format: date-time + description: 创建时间 + deviceType: + type: string + description: 设备类型 + backedUp: + type: boolean + description: 是否已备份 + required: + - passkeys + required: + - data + post: + summary: 验证并注册 Passkey + operationId: verifyPasskeyRegistration + parameters: + - $ref: "#/components/parameters/userId" + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + response: + type: object + description: WebAuthn 注册响应 + responses: + "201": + description: Passkey 注册成功 + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + + /users/{userId}/passkeys/options: + post: + summary: 获取 Passkey 注册选项 + operationId: getPasskeyRegistrationOptions + parameters: + - $ref: "#/components/parameters/userId" + security: + - bearerAuth: [] + responses: + "200": + description: 生成注册选项成功 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + options: + type: object + description: WebAuthn 注册选项 + required: + - data + + /users/{userId}/passkeys/{credentialId}: + delete: + summary: 删除 Passkey + operationId: deletePasskey + parameters: + - $ref: "#/components/parameters/userId" + - name: credentialId + in: path + description: Passkey 凭证 ID + required: true + schema: + type: string + security: + - bearerAuth: [] + responses: + "200": + description: 删除成功 + content: + application/json: + schema: + $ref: "#/components/schemas/CommonResponse" + + /users/auth/passkey/options: + post: + summary: 获取 Passkey 认证选项 + operationId: getPasskeyAuthenticationOptions + responses: + "200": + description: 生成认证选项成功 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CommonResponse" + - type: object + properties: + data: + type: object + properties: + options: + type: object + description: WebAuthn 认证选项 + required: + - data + + /users/{userId}/2fa/enable: + post: + summary: 启用 2FA + description: | + 两步流程: + 1. 不带 code 参数请求,获取 TOTP secret 和二维码 URL + 2. 用户扫描二维码后,提供 code 进行验证 + 注意: 需要 sudo 权限 + parameters: + - $ref: "#/components/parameters/userId" + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: TOTP 验证码,初始化时不需要提供 + responses: + "200": + description: 初始化成功 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "TOTP secret generated successfully" + data: + type: object + properties: + secret: + type: string + description: TOTP 密钥 + otpauth_url: + type: string + description: 用于生成二维码的 URL + backup_codes: + type: array + items: + type: string + description: 备份码列表(初始化时为空) + "201": + description: 启用成功 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 201 + message: + type: string + example: "2FA enabled successfully" + data: + type: object + properties: + secret: + type: string + description: TOTP 密钥 + otpauth_url: + type: string + description: 用于生成二维码的 URL + backup_codes: + type: array + items: + type: string + description: 备份码列表 + + /users/{userId}/2fa/disable: + post: + summary: 禁用 2FA + description: 需要 sudo 权限和当前有效的 TOTP 验证码 + parameters: + - $ref: "#/components/parameters/userId" + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: TOTP 验证码 + responses: + "200": + description: 禁用成功 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "2FA disabled successfully" + data: + type: object + properties: + success: + type: boolean + example: true + + /users/{userId}/2fa/backup-codes: + post: + summary: 生成新的备份码 + description: 需要 sudo 权限和当前有效的 TOTP 验证码 + parameters: + - $ref: "#/components/parameters/userId" + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: TOTP 验证码 + responses: + "201": + description: 生成成功 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 201 + message: + type: string + example: "New backup codes generated successfully" + data: + type: object + properties: + backup_codes: + type: array + items: + type: string + description: 新的备份码列表 + + /users/auth/verify-2fa: + post: + summary: 验证 2FA 并完成登录 + description: 用于登录时的 2FA 验证 + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + temp_token: + type: string + description: 临时 token + code: + type: string + description: TOTP 验证码 + responses: + "201": + description: 验证成功 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 201 + message: + type: string + example: "Login successfully" + data: + type: object + properties: + user: + $ref: "#/components/schemas/User" + accessToken: + type: string + description: 访问令牌 + requires2FA: + type: boolean + example: false + + /users/{userId}/2fa/status: + get: + summary: 获取 2FA 状态 + description: 获取用户当前的 2FA 启用状态和 Passkey 状态 + parameters: + - $ref: "#/components/parameters/userId" + security: + - bearerAuth: [] + responses: + "200": + description: 获取成功 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "Get 2FA status successfully" + data: + type: object + properties: + enabled: + type: boolean + description: 是否已启用 2FA + has_passkey: + type: boolean + description: 是否已注册 Passkey + + /users/{userId}/2fa/settings: + put: + summary: 更新 2FA 设置 + description: 更新用户的 2FA 相关设置,如是否始终要求验证 + parameters: + - $ref: "#/components/parameters/userId" + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + always_required: + type: boolean + description: 是否始终要求 2FA 验证 + responses: + "200": + description: 设置更新成功 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "2FA settings updated successfully" + data: + type: object + properties: + success: + type: boolean + always_required: + type: boolean diff --git a/design/tables/answers.txt b/design/tables/answers.txt new file mode 100644 index 00000000..b3d8af51 --- /dev/null +++ b/design/tables/answers.txt @@ -0,0 +1,48 @@ +Author: Nictheboy +Topic: 问题模块中与回答相关的的各个表结构 +Notice: 本文件只是设计阶段的想法,实际表结构以 .entity.ts 文件中的各个实体为准。 + +回答表: + id + answerer_id + question_id + content + group_id # 可空 + + created_at + updated_at + queried_at + is_deleted + deleted_at + +赞同(agree)关系表: + answer_id + user_id + created_at + +收藏(favourite)关系表 + answer_id + user_id + created_at + +回答评论表: + id + answer_id + user_id + father_id # 可空 + content + + created_at + is_deleted + deleted_at + +回答评论赞同表: + answer_comment_id + user_id + created_at + +回答浏览关系表: + answer_id + user_id + upsert_at # 最后一次创建时间或更新时间,代表最后一次浏览的时间 + upsert_cnt # 创建或更新的次数,代表浏览次数 diff --git a/design/tables/questions.txt b/design/tables/questions.txt new file mode 100644 index 00000000..71e4d2a7 --- /dev/null +++ b/design/tables/questions.txt @@ -0,0 +1,57 @@ +Author: Nictheboy +Topic: 问题模块中与问题相关的的各个表结构 +Notice: 本文件只是设计阶段的想法,实际表结构以 .entity.ts 文件中的各个实体为准。 + +提问表: + id + asker_id + title + content + type: int + group_id # 可空 + + created_at + updated_at + queried_at + is_deleted + deleted_at + +主题表: + id + name + + created_at + +主题关系表: + topic_id + question_id + +关注问题关系表: + user_id + question_id + + created_at + +// 赞同问题关系表: +// user_id +// question_id +// +// created_at +// +// 问题评论表: +// id +// question_id +// user_id +// content +// +// created_at +// updated_at +// queried_at +// is_deleted +// deleted_at +// +// 问题浏览关系表: +// question_id +// user_id +// upsert_at # 最后一次创建时间或更新时间,代表最后一次浏览的时间 +// upsert_cnt # 创建或更新的次数,代表浏览次数 diff --git a/design/tables/users.txt b/design/tables/users.txt new file mode 100644 index 00000000..6790a2cb --- /dev/null +++ b/design/tables/users.txt @@ -0,0 +1,56 @@ +Author: Nictheboy +Topic: 用户模块的各个表结构 +Notice: 本文件只是设计阶段的想法,实际表结构以 .entity.ts 文件中的各个实体为准。 + +用户表: + id + username + hashed_password + email + + created_at + updated_at + deleted_at # 可空 + +登录日志表: + id + user_id + ip + user_agent + logined_at + +用户公开信息表: + user_id + nickname + avatar + intro + + updated_at + queried_at + +用户公开信息浏览日志表: + id + viewer_id + viewee_id + + created_at + +注册验证码表: + email + code + + created_at + +重置密码请求表: + id + user_id + ip + user_agent + + created_at # 用于判断是否已经过期 + +关注关系表: + follower_id + followee_id + + created_at diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..5214efaf --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,25 @@ +services: + backend: + image: ghcr.io/sageseekersociety/cheese-backend-dev:${BACKEND_DEV_VERSION:-dev} + build: + context: . + target: dev + env_file: .env + depends_on: + database: + condition: service_healthy + es: + condition: service_healthy + # this will show warning in vscode but it's working + # !reset means delete the ports defined in docker-compose.yml + # instead of merging + ports: !override + - "7777:3000" + + frontend: + image: ghcr.io/sageseekersociety/cheese-frontend-dev:dev + env_file: .env + depends_on: + - backend + ports: !override + - "3000:3000" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..6ac53d69 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,86 @@ +services: + frontend: + image: ghcr.io/sageseekersociety/cheese-frontend:dev + env_file: .env + depends_on: + - backend + ports: + - "4173:80" + + backend: + image: ghcr.io/sageseekersociety/cheese-backend:dev + env_file: .env + volumes: + - type: volume + source: cheese_backend_uploads + target: /app/uploads + depends_on: + database: + condition: service_healthy + es: + condition: service_healthy + ports: + - "4174:3000" + + database: + image: postgres:16.2 + env_file: .env + volumes: + - type: volume + source: cheese_db_pg + target: /var/lib/postgresql/data + ports: + - '5432:5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'] + start_period: 180s + start_interval: 5s + interval: 1m + timeout: 10s + retries: 3 + + es: + image: docker.elastic.co/elasticsearch/elasticsearch:8.12.2 + env_file: .env + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - ELASTIC_PASSWORD=${ELASTICSEARCH_AUTH_PASSWORD} + deploy: + resources: + limits: + memory: 4g + volumes: + - type: volume + source: cheese_es + target: /usr/share/elasticsearch/data + ports: + - '127.0.0.1:9200:9200' + healthcheck: + test: + [ + 'CMD-SHELL', + "curl -s --user elastic:${ELASTICSEARCH_AUTH_PASSWORD} -X GET http://localhost:9200/_cluster/health?pretty | grep status | grep -E 'green|yello'", + ] + start_period: 180s + start_interval: 5s + interval: 1m + timeout: 10s + retries: 3 + + valkey: + image: valkey/valkey:8.0.2 + env_file: .env + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'valkey-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s + +volumes: + cheese_backend_uploads: + cheese_db_pg: + cheese_es: diff --git a/docs/development-guide.md b/docs/development-guide.md new file mode 100644 index 00000000..39179594 --- /dev/null +++ b/docs/development-guide.md @@ -0,0 +1,39 @@ +# Development Guide + +## Extracting Data from Requests + +When extracting data from requests, there are two common approaches: using NestJS built-in pipes or using Data Transfer Objects (DTOs) with `class-validator` and `class-transformer`. + +They have some nuances, like the former always transforming the data to the desired type, while the latter allows you to validate the data and transform it if necessary (See example below). + +We recommend using pipes only for receiving id parameters, like `@Param('id', ParseIntPipe) id: number`, where `number` metadata allows `ValidationPipe` with `transform: true`, which is enabled globally, to transform the string to a number, and the `ParseIntPipe` is only used to ensure `id` is not `undefined`. In other cases, we recommend using DTOs with `class-validator` and `class-transformer`, which is more flexible and can be used in complex scenarios. + +### Using DTOs + +For example, when handling a POST request to create a question, we use a DTO like this: + +```typescript +import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateQuestionDto { + @IsString() + @IsNotEmpty() + title: string; + + @IsString() + @IsNotEmpty() + content: string; + + @IsInt() + @IsOptional() + @Type(() => Number) + bounty: number = 0; +} +``` + +This DTO ensures that the title and content are strings and not empty. The bounty is an optional integer, and if it is not provided, it defaults to 0. + +However, you should be careful when using these convenient decorators. The libraries `class-validator` and `class-transformer` have long been [poorly maintained](https://github.com/typestack/class-validator/issues/1775), and there are some caveats when using them. + +In the example above, `@Type(() => Number)` is necessary to transform the string to a number, especially when the data is coming from `Query` or `Param`, where the data is always a string. Without it, the string will be mistakenly passed to the controller method. diff --git a/docs/scripts/cheese-restart.sh b/docs/scripts/cheese-restart.sh new file mode 100755 index 00000000..5a8258da --- /dev/null +++ b/docs/scripts/cheese-restart.sh @@ -0,0 +1,3 @@ +#!/bin/sh +sudo systemctl start docker +sudo docker restart elasticsearch postgres cheese_legacy diff --git a/docs/scripts/cheese-start.sh b/docs/scripts/cheese-start.sh new file mode 100755 index 00000000..64bf6c15 --- /dev/null +++ b/docs/scripts/cheese-start.sh @@ -0,0 +1,63 @@ +#!/bin/sh +sudo systemctl start docker.service + +sudo docker network create cheese_network + +sudo docker run -d \ + --name elasticsearch \ + --network cheese_network \ + -e discovery.type=single-node \ + -e xpack.security.enabled=true \ + -e ELASTIC_USERNAME=elastic \ + -e ELASTIC_PASSWORD=elastic \ + --health-cmd="curl http://localhost:9200/_cluster/health" \ + --health-interval=10s \ + --health-timeout=5s \ + --health-retries=10 \ + -p 9200:9200 \ + docker.elastic.co/elasticsearch/elasticsearch:8.12.1 + +sudo docker run -d \ + --name postgres \ + --network cheese_network \ + -e POSTGRES_PASSWORD=postgres \ + --health-cmd="pg_isready" \ + --health-interval=10s \ + --health-timeout=5s \ + --health-retries=5 \ + -p 5432:5432 \ + postgres +echo "Wait for 5 seconds please..." +sleep 5 +sudo docker exec -i postgres bash << EOF + sed -i -e 's/max_connections = 100/max_connections = 1000/' /var/lib/postgresql/data/postgresql.conf + sed -i -e 's/shared_buffers = 128MB/shared_buffers = 2GB/' /var/lib/postgresql/data/postgresql.conf +EOF +sudo docker restart --time 0 postgres + +sudo docker run -d \ + --name cheese_legacy \ + --network cheese_network \ + -p 3000:3000 \ + -e PORT=3000 \ + -e JWT_SECRET="test-secret" \ + -e PRISMA_DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres?schema=public&connection_limit=16" \ + -e ELASTICSEARCH_NODE=http://elasticsearch:9200/ \ + -e ELASTICSEARCH_AUTH_USERNAME=elastic \ + -e ELASTICSEARCH_AUTH_PASSWORD=elastic \ + -e FILE_UPLOAD_PATH=/app/uploads \ + -e DEFAULT_AVATAR_NAME=default.jpg \ + -e EMAIL_SMTP_HOST=smtp.example.com \ + -e EMAIL_SMTP_PORT=587 \ + -e EMAIL_SMTP_SSL_ENABLE=true \ + -e EMAIL_SMTP_USERNAME=user@example.com \ + -e EMAIL_SMTP_PASSWORD=a_super_strong_password \ + -e EMAIL_DEFAULT_FROM="No Reply " \ + ghcr.io/sageseekersociety/cheese-backend-dev:dev \ + bash -c ' + if [ ! -f "FLAG_INIT" ]; then + touch FLAG_INIT + pnpm prisma db push + fi + pnpm start + ' \ No newline at end of file diff --git a/docs/scripts/dependency-restart.sh b/docs/scripts/dependency-restart.sh new file mode 100755 index 00000000..99a1bc30 --- /dev/null +++ b/docs/scripts/dependency-restart.sh @@ -0,0 +1,3 @@ +#!/bin/sh +sudo systemctl start docker +sudo docker restart elasticsearch postgres diff --git a/docs/scripts/dependency-start.sh b/docs/scripts/dependency-start.sh new file mode 100755 index 00000000..a383a3db --- /dev/null +++ b/docs/scripts/dependency-start.sh @@ -0,0 +1,32 @@ +#!/bin/sh +sudo systemctl start docker.service + +sudo docker run -d \ + --name elasticsearch \ + -e discovery.type=single-node \ + -e xpack.security.enabled=true \ + -e ELASTIC_USERNAME=elastic \ + -e ELASTIC_PASSWORD=elastic \ + --health-cmd="curl http://localhost:9200/_cluster/health" \ + --health-interval=10s \ + --health-timeout=5s \ + --health-retries=10 \ + -p 9200:9200 \ + docker.elastic.co/elasticsearch/elasticsearch:8.12.1 + +sudo docker run -d \ + --name postgres \ + -e POSTGRES_PASSWORD=postgres \ + --health-cmd="pg_isready" \ + --health-interval=10s \ + --health-timeout=5s \ + --health-retries=5 \ + -p 5432:5432 \ + postgres +echo "Wait for 5 seconds please..." +sleep 5 +sudo docker exec -i postgres bash << EOF + sed -i -e 's/max_connections = 100/max_connections = 1000/' /var/lib/postgresql/data/postgresql.conf + sed -i -e 's/shared_buffers = 128MB/shared_buffers = 2GB/' /var/lib/postgresql/data/postgresql.conf +EOF +sudo docker restart --time 0 postgres diff --git a/docs/scripts/dependency.env b/docs/scripts/dependency.env new file mode 100644 index 00000000..1d574709 --- /dev/null +++ b/docs/scripts/dependency.env @@ -0,0 +1,63 @@ +# The port that the app will listen to +PORT=3000 + +# The secret used to sign the JWT token +# You MUST change this secret to your own secret! +# Otherwise, your app will be as insecure as with an empty admin password! +JWT_SECRET="test-secret" + +DB_HOST=localhost # set DB_HOST to database to use with docker +DB_USERNAME=postgres +DB_PASSWORD=postgres # your passowrd +DB_PASSWORD_URL_FORMAT=postgres # password in url-format, see https://github.com/prisma/prisma/discussions/15679 +DB_PORT=5432 +DB_NAME=postgres + +# The connection URL of the database for Prisma +# See https://www.prisma.io/docs/orm/reference/connection-urls for more information +# Keep align with the TypeORM configuration +PRISMA_DATABASE_URL="postgresql://${DB_USERNAME}:${DB_PASSWORD_URL_FORMAT}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=public&connection_limit=16" + +# The maximum amount of time the interactive transaction can run before being canceled and rolled back. +# See: https://github.com/prisma/prisma/releases/tag/5.10.0 +# See: https://github.com/prisma/prisma/issues/15028 +PRISMA_TRANSACTION_TIMEOUT=60000 # 60s + +# The configuration for Elasticsearch +ELASTICSEARCH_NODE=http://localhost:9200/ +ELASTICSEARCH_MAX_RETRIES=10 +ELASTICSEARCH_REQUEST_TIMEOUT=60000 +ELASTICSEARCH_PING_TIMEOUT=60000 +ELASTICSEARCH_SNIFF_ON_START=true +ELASTICSEARCH_AUTH_USERNAME=elastic +ELASTICSEARCH_AUTH_PASSWORD=elastic + +# The configuration for uploaded files +FILE_UPLOAD_PATH=/tmp/app/uploads +DEFAULT_AVATAR_NAME=default.jpg + + +# The configuration for CORS +CORS_ORIGINS=http://localhost:3000 # use `,` to separate multiple origins +CORS_METHODS=GET,POST,PUT,PATCH,DELETE +CORS_HEADERS=Content-Type,Authorization +CORS_CREDENTIALS=true + +# additionally setup the following if you want to use docker-compose +# to setup environment +POSTGRES_DB=${DB_NAME} +POSTGRES_USER=${DB_USERNAME} +POSTGRES_PASSWORD=${DB_PASSWORD} + +# Email configuration: +EMAIL_SMTP_HOST=smtp.example.com +EMAIL_SMTP_PORT=587 +EMAIL_SMTP_SSL_ENABLE=true +EMAIL_SMTP_USERNAME=user@example.com +EMAIL_SMTP_PASSWORD=a_super_strong_password +EMAIL_DEFAULT_FROM='"No Reply" ' + +# Email test configuration: +# Enabling email test means when you run test, emails will be sent. +EMAILTEST_ENABLE=false +EMAILTEST_RECEIVER=developer@example.com diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..da96a887 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,26 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..f2ad3987 --- /dev/null +++ b/flake.lock @@ -0,0 +1,59 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1713714899, + "narHash": "sha256-+z/XjO3QJs5rLE5UOf015gdVauVRQd2vZtsFkaXBq2Y=", + "rev": "6143fc5eeb9c4f00163267708e26191d1e918932", + "revCount": 615148, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.615148%2Brev-6143fc5eeb9c4f00163267708e26191d1e918932/018f054f-2276-71b1-bbf0-25db28e7784e/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1.0.tar.gz" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..cbe85394 --- /dev/null +++ b/flake.nix @@ -0,0 +1,39 @@ +{ + description = "Cheese Backend develop environment"; + + # Flake inputs + inputs = { + nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1.0.tar.gz"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: let + pkgs = import nixpkgs { + inherit system; + config = { allowUnfree = true; }; + }; + in { + devShell = pkgs.mkShell { + nativeBuildInputs = [ pkgs.bashInteractive ]; + buildInputs = with pkgs; [ + nodePackages.prisma + prisma-engines + nodejs_21 + nodePackages_latest.pnpm + ffmpeg_7 + postgresql + elasticsearch + openssl + ]; + shellHook = with pkgs; '' + export PRISMA_SCHEMA_ENGINE_BINARY="${prisma-engines}/bin/schema-engine" + export PRISMA_QUERY_ENGINE_BINARY="${prisma-engines}/bin/query-engine" + export PRISMA_QUERY_ENGINE_LIBRARY="${prisma-engines}/lib/libquery_engine.node" + export PRISMA_INTROSPECTION_ENGINE_BINARY="${prisma-engines}/bin/introspection-engine" + export PRISMA_FMT_BINARY="${prisma-engines}/bin/prisma-fmt" + echo "Welcome to Cheese Backend develop environment!" + ''; + }; + }); +} diff --git a/jest.config.json b/jest.config.json new file mode 100644 index 00000000..920478f4 --- /dev/null +++ b/jest.config.json @@ -0,0 +1,18 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testMatch": ["**/*.e2e-spec.ts", "**/*.spec.ts"], + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "src/*/**/*.ts", + "!src/*/**/*.dto.ts", + "!src/*/**/*.es-doc.ts", + "!src/*/**/*.enum.ts", + "!src/common/config/configuration.ts", + "!src/auth/definitions.ts" + ], + "testTimeout": 60000 +} diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 00000000..34057b4e --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "assets": [ + "resources/**/*" + ], + "watchAssets": true + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..bcff89a5 --- /dev/null +++ b/package.json @@ -0,0 +1,117 @@ +{ + "name": "cheese-backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build-prisma": "prisma-import -s \"src/**/*.prisma\" -o prisma/schema.prisma --force && prisma-case-format --file prisma/schema.prisma && prisma generate", + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "test": "jest --maxWorkers=4 --forceExit", + "test:watch": "jest --maxWorkers=4 --watch --forceExit", + "test:cov": "jest --maxWorkers=4 --coverage --forceExit", + "test:debug": "node --nolazy --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/jest/bin/jest.js --maxWorkers=4 --runInBand --forceExit", + "format": "prettier --write {src,test}/**/*.ts && git status", + "lint": "eslint {src,test}/**/*.ts", + "lint:fix": "eslint --fix {src,test}/**/*.ts", + "prepare": "husky" + }, + "lint-staged": { + "{src,test}/**/*.ts": [ + "eslint --fix", + "prettier --write" + ] + }, + "dependencies": { + "@elastic/elasticsearch": "^8.15.1", + "@liaoliaots/nestjs-redis": "^10.0.0", + "@nestjs-modules/mailer": "^2.0.2", + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.7", + "@nestjs/elasticsearch": "^11.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/mapped-types": "^2.0.6", + "@nestjs/platform-express": "^10.4.15", + "@nestjs/serve-static": "^4.0.2", + "@prisma/client": "6.4.1", + "@simplewebauthn/server": "^13.1.1", + "@types/md5": "^2.3.5", + "ajv": "^8.17.1", + "async-mutex": "^0.5.0", + "bcryptjs": "^2.4.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "connect-redis": "^8.0.1", + "express": "^4.21.2", + "express-session": "^1.18.1", + "fluent-ffmpeg": "^2.1.3", + "handlebars": "^4.7.8", + "ioredis": "^5.5.0", + "md5": "^2.3.0", + "mime-types": "^2.1.35", + "multer": "1.4.5-lts.1", + "nodemailer": "^6.10.0", + "otplib": "^12.0.1", + "pg": "^8.13.0", + "prisma-json-types-generator": "^3.0.4", + "qrcode": "^1.5.4", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "secure-remote-password": "^0.3.1", + "uuid": "^10.0.0" + }, + "devDependencies": { + "@commitlint/cli": "^19.7.1", + "@commitlint/config-conventional": "^19.7.1", + "@nestjs/cli": "^11.0.2", + "@nestjs/schematics": "^10.1.4", + "@nestjs/testing": "^10.4.15", + "@types/bcryptjs": "^2.4.6", + "@types/express": "^5.0.0", + "@types/express-session": "^1.18.1", + "@types/fluent-ffmpeg": "^2.1.27", + "@types/jest": "^29.5.14", + "@types/mime-types": "^2.1.4", + "@types/multer": "^1.4.12", + "@types/node": "^22.10.2", + "@types/qrcode": "^1.5.5", + "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "^8.24.1", + "@typescript-eslint/parser": "^8.18.0", + "eslint": "^9.8.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.3", + "husky": "^9.1.7", + "jest": "^29.7.0", + "lint-staged": "^15.2.11", + "prettier": "^3.5.1", + "prisma": "^5.22.0", + "prisma-case-format": "^2.2.1", + "prisma-import": "^1.0.5", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-json-schema-generator": "^2.3.0", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.2" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@nestjs/core", + "@prisma/client", + "@prisma/engines", + "bignum", + "prisma" + ] + }, + "packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..e2d62891 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,11366 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@elastic/elasticsearch': + specifier: ^8.15.1 + version: 8.15.1 + '@liaoliaots/nestjs-redis': + specifier: ^10.0.0 + version: 10.0.0(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.7)(ioredis@5.5.0) + '@nestjs-modules/mailer': + specifier: ^2.0.2 + version: 2.0.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.7)(nodemailer@6.10.0) + '@nestjs/common': + specifier: ^10.4.15 + version: 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^3.3.0 + version: 3.3.0(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.4.7 + version: 10.4.7(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/elasticsearch': + specifier: ^11.0.0 + version: 11.0.0(@elastic/elasticsearch@8.15.1)(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^10.2.0 + version: 10.2.0(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/mapped-types': + specifier: ^2.0.6 + version: 2.0.6(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + '@nestjs/platform-express': + specifier: ^10.4.15 + version: 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.7) + '@nestjs/serve-static': + specifier: ^4.0.2 + version: 4.0.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.7)(express@4.21.2) + '@prisma/client': + specifier: 6.4.1 + version: 6.4.1(prisma@5.22.0)(typescript@5.7.2) + '@simplewebauthn/server': + specifier: ^13.1.1 + version: 13.1.1 + '@types/md5': + specifier: ^2.3.5 + version: 2.3.5 + ajv: + specifier: ^8.17.1 + version: 8.17.1 + async-mutex: + specifier: ^0.5.0 + version: 0.5.0 + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.1 + connect-redis: + specifier: ^8.0.1 + version: 8.0.1(express-session@1.18.1) + express: + specifier: ^4.21.2 + version: 4.21.2 + express-session: + specifier: ^1.18.1 + version: 1.18.1 + fluent-ffmpeg: + specifier: ^2.1.3 + version: 2.1.3 + handlebars: + specifier: ^4.7.8 + version: 4.7.8 + ioredis: + specifier: ^5.5.0 + version: 5.5.0 + md5: + specifier: ^2.3.0 + version: 2.3.0 + mime-types: + specifier: ^2.1.35 + version: 2.1.35 + multer: + specifier: 1.4.5-lts.1 + version: 1.4.5-lts.1 + nodemailer: + specifier: ^6.10.0 + version: 6.10.0 + otplib: + specifier: ^12.0.1 + version: 12.0.1 + pg: + specifier: ^8.13.0 + version: 8.13.0 + prisma-json-types-generator: + specifier: ^3.0.4 + version: 3.0.4(prisma@5.22.0)(typescript@5.7.2) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + secure-remote-password: + specifier: ^0.3.1 + version: 0.3.1 + uuid: + specifier: ^10.0.0 + version: 10.0.0 + devDependencies: + '@commitlint/cli': + specifier: ^19.7.1 + version: 19.7.1(@types/node@22.10.2)(typescript@5.7.2) + '@commitlint/config-conventional': + specifier: ^19.7.1 + version: 19.7.1 + '@nestjs/cli': + specifier: ^11.0.2 + version: 11.0.2(@types/node@22.10.2) + '@nestjs/schematics': + specifier: ^10.1.4 + version: 10.1.4(chokidar@4.0.3)(typescript@5.7.2) + '@nestjs/testing': + specifier: ^10.4.15 + version: 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.7)(@nestjs/platform-express@10.4.15) + '@types/bcryptjs': + specifier: ^2.4.6 + version: 2.4.6 + '@types/express': + specifier: ^5.0.0 + version: 5.0.0 + '@types/express-session': + specifier: ^1.18.1 + version: 1.18.1 + '@types/fluent-ffmpeg': + specifier: ^2.1.27 + version: 2.1.27 + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/mime-types': + specifier: ^2.1.4 + version: 2.1.4 + '@types/multer': + specifier: ^1.4.12 + version: 1.4.12 + '@types/node': + specifier: ^22.10.2 + version: 22.10.2 + '@types/qrcode': + specifier: ^1.5.5 + version: 1.5.5 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.2 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + '@typescript-eslint/eslint-plugin': + specifier: ^8.24.1 + version: 8.24.1(@typescript-eslint/parser@8.18.0(eslint@9.8.0)(typescript@5.7.2))(eslint@9.8.0)(typescript@5.7.2) + '@typescript-eslint/parser': + specifier: ^8.18.0 + version: 8.18.0(eslint@9.8.0)(typescript@5.7.2) + eslint: + specifier: ^9.8.0 + version: 9.8.0 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@9.8.0) + eslint-plugin-prettier: + specifier: ^5.2.3 + version: 5.2.3(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.8.0))(eslint@9.8.0)(prettier@3.5.1) + husky: + specifier: ^9.1.7 + version: 9.1.7 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)) + lint-staged: + specifier: ^15.2.11 + version: 15.2.11 + prettier: + specifier: ^3.5.1 + version: 3.5.1 + prisma: + specifier: ^5.22.0 + version: 5.22.0 + prisma-case-format: + specifier: ^2.2.1 + version: 2.2.1 + prisma-import: + specifier: ^1.0.5 + version: 1.0.5 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + supertest: + specifier: ^7.0.0 + version: 7.0.0 + ts-jest: + specifier: ^29.2.5 + version: 29.2.5(@babel/core@7.24.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.9))(jest@29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)))(typescript@5.7.2) + ts-json-schema-generator: + specifier: ^2.3.0 + version: 2.3.0 + ts-loader: + specifier: ^9.5.1 + version: 9.5.1(typescript@5.7.2)(webpack@5.97.1) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.10.2)(typescript@5.7.2) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.7.2 + version: 5.7.2 + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@angular-devkit/core@17.3.8': + resolution: {integrity: sha512-Q8q0voCGudbdCgJ7lXdnyaxKHbNQBARH68zPQV72WT8NWy+Gw/tys870i6L58NWbBaCJEUcIj/kb6KoakSRu+Q==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^3.5.2 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/core@19.0.1': + resolution: {integrity: sha512-oXIAV3hXqUW3Pmm95pvEmb+24n1cKQG62FzhQSjOIrMeHiCbGLNuc8zHosIi2oMrcCJJxR6KzWjThvbuzDwWlw==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/core@19.1.3': + resolution: {integrity: sha512-of/TKfJ/vL+/qvr4PbDTtqbFJGFHPfu6bEJrIZsLMYA+Mej8SyTx3kDm4LLnKQBtWVYDqkrxvcpOb4+NmHNLfA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics-cli@19.1.3': + resolution: {integrity: sha512-levMPch+Mni/cEVd/b9RUzasxWqlafBVjgrofbaSlxgZmr4pRJ/tihzrNnygNUaXoBqhTtXU5aFxTGbJhS35eA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular-devkit/schematics@17.3.8': + resolution: {integrity: sha512-QRVEYpIfgkprNHc916JlPuNbLzOgrm9DZalHasnLUz4P6g7pR21olb8YCyM2OTJjombNhya9ZpckcADU5Qyvlg==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular-devkit/schematics@19.0.1': + resolution: {integrity: sha512-N9dV8WpNRULykNj8fSxQrta85gPKxb315J3xugLS2uwiFWhz7wo5EY1YeYhoVKoVcNB2ng9imJgC5aO52AHZwg==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular-devkit/schematics@19.1.3': + resolution: {integrity: sha512-DfN45eJQtfXXeQwjb7vDqSJ+8e6BW3rXUB2i6IC2CbOYrLWhMBgfv3/uTm++IbCFW2zX3Yk3yqq3d4yua2no7w==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@antfu/ni@0.21.4': + resolution: {integrity: sha512-O0Uv9LbLDSoEg26fnMDdDRiPwFJnQSoD4WnrflDwKCJm8Cx/0mV4cGxwBLXan5mGIrpK4Dd7vizf4rQm0QCEAA==} + hasBin: true + + '@babel/code-frame@7.24.7': + resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} + engines: {node: '>=6.9.0'} + + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.24.9': + resolution: {integrity: sha512-e701mcfApCJqMMueQI0Fb68Amflj83+dvAvHawoBpAz+GDjCIyGHzNwnefjsWJ3xiYAqqiQFoWbspGYBdb2/ng==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.24.9': + resolution: {integrity: sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.24.10': + resolution: {integrity: sha512-o9HBZL1G2129luEUlG1hB4N/nlYNWHnpwlND9eOMclRqqu1YDy2sSYVCFUZwl8I1Gxh+QSRrP2vD7EpUmFVXxg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.24.8': + resolution: {integrity: sha512-oU+UoqCHdp+nWVDkpldqIQL/i/bvAv53tRqLG/s+cOXxe66zOYLU7ar/Xs3LdmBihrUMEUhwu6dMZwbNOYDwvw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-environment-visitor@7.24.7': + resolution: {integrity: sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-function-name@7.24.7': + resolution: {integrity: sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-hoist-variables@7.24.7': + resolution: {integrity: sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.24.7': + resolution: {integrity: sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.24.9': + resolution: {integrity: sha512-oYbh+rtFKj/HwBQkFlUzvcybzklmVdVV3UU+mN7n2t/q3yGHbuVdNxyFvSBO1tfvjyArpHNcWMAzsSPdyI46hw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.24.8': + resolution: {integrity: sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-simple-access@7.24.7': + resolution: {integrity: sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-split-export-declaration@7.24.7': + resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.24.8': + resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.24.8': + resolution: {integrity: sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.24.8': + resolution: {integrity: sha512-gV2265Nkcz7weJJfvDoAEVzC1e2OTDpkGbEsebse8koXUJUXPsCMi7sRo/+SPMuMZ9MtUPnGwITTnQnU5YjyaQ==} + engines: {node: '>=6.9.0'} + + '@babel/highlight@7.24.7': + resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.24.8': + resolution: {integrity: sha512-WzfbgXOkGzZiXXCqk43kKwZjzwx4oulxZi3nq2TYL9mOjQv6kYwul9mz6ID36njuL7Xkp6nJEfok848Zj10j/w==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.24.7': + resolution: {integrity: sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.24.7': + resolution: {integrity: sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.24.8': + resolution: {integrity: sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.24.7': + resolution: {integrity: sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.24.8': + resolution: {integrity: sha512-t0P1xxAPzEDcEPmjprAQq19NWum4K0EQPjMwZQZbHt+GiZqvjCHjj755Weq1YRPVzBI+3zSfvScfpnuIecVFJQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.24.9': + resolution: {integrity: sha512-xm8XrMKz0IlUdocVbYJe0Z9xEgidU7msskG8BbhnTPK/HZ2z/7FP7ykqPgrUH+C+r414mNfNWam1f2vqOjqjYQ==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@commitlint/cli@19.7.1': + resolution: {integrity: sha512-iObGjR1tE/PfDtDTEfd+tnRkB3/HJzpQqRTyofS2MPPkDn1mp3DBC8SoPDayokfAy+xKhF8+bwRCJO25Nea0YQ==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@19.7.1': + resolution: {integrity: sha512-fsEIF8zgiI/FIWSnykdQNj/0JE4av08MudLTyYHm4FlLWemKoQvPNUYU2M/3tktWcCEyq7aOkDDgtjrmgWFbvg==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@19.5.0': + resolution: {integrity: sha512-CHtj92H5rdhKt17RmgALhfQt95VayrUo2tSqY9g2w+laAXyk7K/Ef6uPm9tn5qSIwSmrLjKaXK9eiNuxmQrDBw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@19.5.0': + resolution: {integrity: sha512-Kv0pYZeMrdg48bHFEU5KKcccRfKmISSm9MvgIgkpI6m+ohFTB55qZlBW6eYqh/XDfRuIO0x4zSmvBjmOwWTwkg==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@19.5.0': + resolution: {integrity: sha512-aqyGgytXhl2ejlk+/rfgtwpPexYyri4t8/n4ku6rRJoRhGZpLFMqrZ+YaubeGysCP6oz4mMA34YSTaSOKEeNrg==} + engines: {node: '>=v18'} + + '@commitlint/format@19.5.0': + resolution: {integrity: sha512-yNy088miE52stCI3dhG/vvxFo9e4jFkU1Mj3xECfzp/bIS/JUay4491huAlVcffOoMK1cd296q0W92NlER6r3A==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@19.7.1': + resolution: {integrity: sha512-3IaOc6HVg2hAoGleRK3r9vL9zZ3XY0rf1RsUf6jdQLuaD46ZHnXBiOPTyQ004C4IvYjSWqJwlh0/u2P73aIE3g==} + engines: {node: '>=v18'} + + '@commitlint/lint@19.7.1': + resolution: {integrity: sha512-LhcPfVjcOcOZA7LEuBBeO00o3MeZa+tWrX9Xyl1r9PMd5FWsEoZI9IgnGqTKZ0lZt5pO3ZlstgnRyY1CJJc9Xg==} + engines: {node: '>=v18'} + + '@commitlint/load@19.6.1': + resolution: {integrity: sha512-kE4mRKWWNju2QpsCWt428XBvUH55OET2N4QKQ0bF85qS/XbsRGG1MiTByDNlEVpEPceMkDr46LNH95DtRwcsfA==} + engines: {node: '>=v18'} + + '@commitlint/message@19.5.0': + resolution: {integrity: sha512-R7AM4YnbxN1Joj1tMfCyBryOC5aNJBdxadTZkuqtWi3Xj0kMdutq16XQwuoGbIzL2Pk62TALV1fZDCv36+JhTQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@19.5.0': + resolution: {integrity: sha512-cZ/IxfAlfWYhAQV0TwcbdR1Oc0/r0Ik1GEessDJ3Lbuma/MRO8FRQX76eurcXtmhJC//rj52ZSZuXUg0oIX0Fw==} + engines: {node: '>=v18'} + + '@commitlint/read@19.5.0': + resolution: {integrity: sha512-TjS3HLPsLsxFPQj6jou8/CZFAmOP2y+6V4PGYt3ihbQKTY1Jnv0QG28WRKl/d1ha6zLODPZqsxLEov52dhR9BQ==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@19.5.0': + resolution: {integrity: sha512-CU/GscZhCUsJwcKTJS9Ndh3AKGZTNFIOoQB2n8CmFnizE0VnEuJoum+COW+C1lNABEeqk6ssfc1Kkalm4bDklA==} + engines: {node: '>=v18'} + + '@commitlint/rules@19.6.0': + resolution: {integrity: sha512-1f2reW7lbrI0X0ozZMesS/WZxgPa4/wi56vFuJENBmed6mWq5KsheN/nxqnl/C23ioxpPO/PL6tXpiiFy5Bhjw==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@19.5.0': + resolution: {integrity: sha512-R772oj3NHPkodOSRZ9bBVNq224DOxQtNef5Pl8l2M8ZnkkzQfeSTr4uxawV2Sd3ui05dUVzvLNnzenDBO1KBeQ==} + engines: {node: '>=v18'} + + '@commitlint/top-level@19.5.0': + resolution: {integrity: sha512-IP1YLmGAk0yWrImPRRc578I3dDUI5A2UBJx9FbSOjxe9sTlzFiwVJ+zeMLgAtHMtGZsC8LUnzmW1qRemkFU4ng==} + engines: {node: '>=v18'} + + '@commitlint/types@19.5.0': + resolution: {integrity: sha512-DSHae2obMSMkAtTBSOulg5X7/z+rGLxcXQIkg3OmWvY6wifojge5uVMydfhUvs7yQj+V7jNmRZ2Xzl8GJyqRgg==} + engines: {node: '>=v18'} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@css-inline/css-inline-android-arm-eabi@0.14.1': + resolution: {integrity: sha512-LNUR8TY4ldfYi0mi/d4UNuHJ+3o8yLQH9r2Nt6i4qeg1i7xswfL3n/LDLRXvGjBYqeEYNlhlBQzbPwMX1qrU6A==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@css-inline/css-inline-android-arm64@0.14.1': + resolution: {integrity: sha512-tH5us0NYGoTNBHOUHVV7j9KfJ4DtFOeTLA3cM0XNoMtArNu2pmaaBMFJPqECzavfXkLc7x5Z22UPZYjoyHfvCA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@css-inline/css-inline-darwin-arm64@0.14.1': + resolution: {integrity: sha512-QE5W1YRIfRayFrtrcK/wqEaxNaqLULPI0gZB4ArbFRd3d56IycvgBasDTHPre5qL2cXCO3VyPx+80XyHOaVkag==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@css-inline/css-inline-darwin-x64@0.14.1': + resolution: {integrity: sha512-mAvv2sN8awNFsbvBzlFkZPbCNZ6GCWY5/YcIz7V5dPYw+bHHRbjnlkNTEZq5BsDxErVrMIGvz05PGgzuNvZvdQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@css-inline/css-inline-linux-arm-gnueabihf@0.14.1': + resolution: {integrity: sha512-AWC44xL0X7BgKvrWEqfSqkT2tJA5kwSGrAGT+m0gt11wnTYySvQ6YpX0fTY9i3ppYGu4bEdXFjyK2uY1DTQMHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@css-inline/css-inline-linux-arm64-gnu@0.14.1': + resolution: {integrity: sha512-drj0ciiJgdP3xKXvNAt4W+FH4KKMs8vB5iKLJ3HcH07sNZj58Sx++2GxFRS1el3p+GFp9OoYA6dgouJsGEqt0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@css-inline/css-inline-linux-arm64-musl@0.14.1': + resolution: {integrity: sha512-FzknI+st8eA8YQSdEJU9ykcM0LZjjigBuynVF5/p7hiMm9OMP8aNhWbhZ8LKJpKbZrQsxSGS4g9Vnr6n6FiSdQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@css-inline/css-inline-linux-x64-gnu@0.14.1': + resolution: {integrity: sha512-yubbEye+daDY/4vXnyASAxH88s256pPati1DfVoZpU1V0+KP0BZ1dByZOU1ktExurbPH3gZOWisAnBE9xon0Uw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@css-inline/css-inline-linux-x64-musl@0.14.1': + resolution: {integrity: sha512-6CRAZzoy1dMLPC/tns2rTt1ZwPo0nL/jYBEIAsYTCWhfAnNnpoLKVh5Nm+fSU3OOwTTqU87UkGrFJhObD/wobQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@css-inline/css-inline-win32-x64-msvc@0.14.1': + resolution: {integrity: sha512-nzotGiaiuiQW78EzsiwsHZXbxEt6DiMUFcDJ6dhiliomXxnlaPyBfZb6/FMBgRJOf6sknDt/5695OttNmbMYzg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@css-inline/css-inline@0.14.1': + resolution: {integrity: sha512-u4eku+hnPqqHIGq/ZUQcaP0TrCbYeLIYBaK7qClNRGZbnh8RC4gVxLEIo8Pceo1nOK9E5G4Lxzlw5KnXcvflfA==} + engines: {node: '>= 10'} + + '@elastic/elasticsearch@8.15.1': + resolution: {integrity: sha512-L3YzSaxrasMMGtcxnktiUDjS5f177L0zpHsBH+jL0LgPhdMk9xN/VKrAaYzvri86IlV5IbveA0ANV6o/BDUmhQ==} + engines: {node: '>=18'} + + '@elastic/transport@8.9.1': + resolution: {integrity: sha512-jasKNQeOb1vNf9aEYg+8zXmetaFjApDTSCC4QTl6aTixvyiRiSLcCiB8P6Q0lY9JIII/BhqNl8WbpFnsKitntw==} + engines: {node: '>=18'} + + '@eslint-community/eslint-utils@4.4.0': + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/eslint-utils@4.4.1': + resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.11.0': + resolution: {integrity: sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.17.1': + resolution: {integrity: sha512-BlYOpej8AQ8Ev9xVqroV7a02JK3SkBAaN9GfMMH9W6Ch8FlQlkjGw4Ir7+FgYwfirivAf4t+GtzuAxqfukmISA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.1.0': + resolution: {integrity: sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.8.0': + resolution: {integrity: sha512-MfluB7EUfxXtv3i/++oh89uzAr4PDI4nn201hsp+qaXqsjAWzinlZEHEfPgAX4doIlKvPG/i0A9dpKxOLII8yA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.4': + resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.3.0': + resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} + engines: {node: '>=18.18'} + + '@inquirer/checkbox@4.1.1': + resolution: {integrity: sha512-os5kFd/52gZTl/W6xqMfhaKVJHQM8V/U1P8jcSaQJ/C4Qhdrf2jEXdA/HaxfQs9iiUA/0yzYhk5d3oRHTxGDDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.5': + resolution: {integrity: sha512-ZB2Cz8KeMINUvoeDi7IrvghaVkYT2RB0Zb31EaLWOE87u276w4wnApv0SH2qWaJ3r0VSUa3BIuz7qAV2ZvsZlg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.1.6': + resolution: {integrity: sha512-Bwh/Zk6URrHwZnSSzAZAKH7YgGYi0xICIBDFOqBQoXNNAzBHw/bgXgLmChfp+GyR3PnChcTbiCTZGC6YJNJkMA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.6': + resolution: {integrity: sha512-l0smvr8g/KAVdXx4I92sFxZiaTG4kFc06cFZw+qqwTirwdUHMFLnouXBB9OafWhpO3cfEkEz2CdPoCmor3059A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.8': + resolution: {integrity: sha512-k0ouAC6L+0Yoj/j0ys2bat0fYcyFVtItDB7h+pDFKaDDSFJey/C/YY1rmIOqkmFVZ5rZySeAQuS8zLcKkKRLmg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.10': + resolution: {integrity: sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw==} + engines: {node: '>=18'} + + '@inquirer/input@4.1.5': + resolution: {integrity: sha512-bB6wR5wBCz5zbIVBPnhp94BHv/G4eKbUEjlpCw676pI2chcvzTx1MuwZSCZ/fgNOdqDlAxkhQ4wagL8BI1D3Zg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.8': + resolution: {integrity: sha512-CTKs+dT1gw8dILVWATn8Ugik1OHLkkfY82J+Musb57KpmF6EKyskv8zmMiEJPzOnLTZLo05X/QdMd8VH9oulXw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.8': + resolution: {integrity: sha512-MgA+Z7o3K1df2lGY649fyOBowHGfrKRz64dx3+b6c1w+h2W7AwBoOkHhhF/vfhbs5S4vsKNCuDzS3s9r5DpK1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.2.1': + resolution: {integrity: sha512-v2JSGri6/HXSfoGIwuKEn8sNCQK6nsB2BNpy2lSX6QH9bsECrMv93QHnj5+f+1ZWpF/VNioIV2B/PDox8EvGuQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + + '@inquirer/prompts@7.2.3': + resolution: {integrity: sha512-hzfnm3uOoDySDXfDNOm9usOuYIaQvTgKp/13l1uJoe6UNY+Zpcn2RYt0jXz3yA+yemGHvDOxVzqWl3S5sQq53Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + + '@inquirer/rawlist@4.0.8': + resolution: {integrity: sha512-hl7rvYW7Xl4un8uohQRUgO6uc2hpn7PKqfcGkCOWC0AA4waBxAv6MpGOFCEDrUaBCP+pXPVqp4LmnpWmn1E1+g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.0.8': + resolution: {integrity: sha512-ihSE9D3xQAupNg/aGDZaukqoUSXG2KfstWosVmFCG7jbMQPaj2ivxWtsB+CnYY/T4D6LX1GHKixwJLunNCffww==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.0.8': + resolution: {integrity: sha512-Io2prxFyN2jOCcu4qJbVoilo19caiD3kqkD3WR0q3yDA5HUCo83v4LrRtg55ZwniYACW64z36eV7gyVbOfORjA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.4': + resolution: {integrity: sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@ioredis/commands@1.2.0': + resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.6': + resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + + '@liaoliaots/nestjs-redis@10.0.0': + resolution: {integrity: sha512-uCTmlzM4q+UYADwsJEQph0mbf4u0MrktFhByi50M5fNy/+fJoWlhSqrgvjtVKjHnqydxy1gyuU6vHJEOBp9cjg==} + engines: {node: '>=16.13.0'} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/core': ^10.0.0 + ioredis: ^5.0.0 + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@nestjs-modules/mailer@2.0.2': + resolution: {integrity: sha512-+z4mADQasg0H1ZaGu4zZTuKv2pu+XdErqx99PLFPzCDNTN/q9U59WPgkxVaHnsvKHNopLj5Xap7G4ZpptduoYw==} + peerDependencies: + '@nestjs/common': '>=7.0.9' + '@nestjs/core': '>=7.0.9' + nodemailer: '>=6.4.6' + + '@nestjs/cli@11.0.2': + resolution: {integrity: sha512-y1dKk+Q94vnWhJe8eoz1Qs5WIYHSgO0xZttsFnDbYW1A6CBUVanc4RocbiyhwC/GjWPO4D5JmTXjW5mRH6wprA==} + engines: {node: '>= 20.11'} + hasBin: true + peerDependencies: + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 + '@swc/core': ^1.3.62 + peerDependenciesMeta: + '@swc/cli': + optional: true + '@swc/core': + optional: true + + '@nestjs/common@10.4.15': + resolution: {integrity: sha512-vaLg1ZgwhG29BuLDxPA9OAcIlgqzp9/N8iG0wGapyUNTf4IY4O6zAHgN6QalwLhFxq7nOI021vdRojR1oF3bqg==} + peerDependencies: + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@3.3.0': + resolution: {integrity: sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@10.4.7': + resolution: {integrity: sha512-AIpQzW/vGGqSLkKvll1R7uaSNv99AxZI2EFyVJPNGDgFsfXaohfV1Ukl6f+s75Km+6Fj/7aNl80EqzNWQCS8Ig==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/microservices': ^10.0.0 + '@nestjs/platform-express': ^10.0.0 + '@nestjs/websockets': ^10.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/elasticsearch@11.0.0': + resolution: {integrity: sha512-7wf8+I1Q9M4sNRr+NY1cF8XdEhCelHjozQjgwGjoQDhs+Kp8VsLEaUQnYGR1oSEigSUEEvGOhgNaYQz7zbZpAg==} + peerDependencies: + '@elastic/elasticsearch': ^7.4.0 || ^8.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.2.0 + + '@nestjs/jwt@10.2.0': + resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@nestjs/mapped-types@2.0.6': + resolution: {integrity: sha512-84ze+CPfp1OWdpRi1/lOu59hOhTz38eVzJvRKrg9ykRFwDz+XleKfMsG0gUqNZYFa6v53XYzeD+xItt8uDW7NQ==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/platform-express@10.4.15': + resolution: {integrity: sha512-63ZZPkXHjoDyO7ahGOVcybZCRa7/Scp6mObQKjcX/fTEq1YJeU75ELvMsuQgc8U2opMGOBD7GVuc4DV0oeDHoA==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/core': ^10.0.0 + + '@nestjs/schematics@10.1.4': + resolution: {integrity: sha512-QpY8ez9cTvXXPr3/KBrtSgXQHMSV6BkOUYy2c2TTe6cBqriEdGnCYqGl8cnfrQl3632q3lveQPaZ/c127dHsEw==} + peerDependencies: + typescript: '>=4.8.2' + + '@nestjs/schematics@11.0.0': + resolution: {integrity: sha512-wts8lG0GfNWw3Wk9aaG5I/wcMIAdm7HjjeThQfUZhJxeIFT82Z3F5+0cYdHH4ii2pYQGiCSrR1VcuMwPiHoecg==} + peerDependencies: + typescript: '>=4.8.2' + + '@nestjs/serve-static@4.0.2': + resolution: {integrity: sha512-cT0vdWN5ar7jDI2NKbhf4LcwJzU4vS5sVpMkVrHuyLcltbrz6JdGi1TfIMMatP2pNiq5Ie/uUdPSFDVaZX/URQ==} + peerDependencies: + '@fastify/static': ^6.5.0 || ^7.0.0 + '@nestjs/common': ^9.0.0 || ^10.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 + express: ^4.18.1 + fastify: ^4.7.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + express: + optional: true + fastify: + optional: true + + '@nestjs/testing@10.4.15': + resolution: {integrity: sha512-eGlWESkACMKti+iZk1hs6FUY/UqObmMaa8HAN9JLnaYkoLf1Jeh+EuHlGnfqo/Rq77oznNLIyaA3PFjrFDlNUg==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/core': ^10.0.0 + '@nestjs/microservices': ^10.0.0 + '@nestjs/platform-express': ^10.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nuxtjs/opencollective@0.3.2': + resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + + '@opentelemetry/api@1.4.1': + resolution: {integrity: sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@otplib/core@12.0.1': + resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} + + '@otplib/plugin-crypto@12.0.1': + resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==} + + '@otplib/plugin-thirty-two@12.0.1': + resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==} + + '@otplib/preset-default@12.0.1': + resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==} + + '@otplib/preset-v11@12.0.1': + resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==} + + '@peculiar/asn1-android@2.3.15': + resolution: {integrity: sha512-8U2TIj59cRlSXTX2d0mzUKP7whfWGFMzTeC3qPgAbccXFrPNZLaDhpNEdG5U2QZ/tBv/IHlCJ8s+KYXpJeop6w==} + + '@peculiar/asn1-ecc@2.3.15': + resolution: {integrity: sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA==} + + '@peculiar/asn1-rsa@2.3.15': + resolution: {integrity: sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg==} + + '@peculiar/asn1-schema@2.3.15': + resolution: {integrity: sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==} + + '@peculiar/asn1-x509@2.3.15': + resolution: {integrity: sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.1.1': + resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@prisma/client@6.4.1': + resolution: {integrity: sha512-A7Mwx44+GVZVexT5e2GF/WcKkEkNNKbgr059xpr5mn+oUm2ZW1svhe+0TRNBwCdzhfIZ+q23jEgsNPvKD9u+6g==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/debug@4.16.2': + resolution: {integrity: sha512-7L7WbG0qNNZYgLpsVB8rCHCXEyHFyIycRlRDNwkVfjQmACC2OW6AWCYCbfdjQhkF/t7+S3njj8wAWAocSs+Brw==} + + '@prisma/debug@5.22.0': + resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} + + '@prisma/debug@5.9.1': + resolution: {integrity: sha512-yAHFSFCg8KVoL0oRUno3m60GAjsUKYUDkQ+9BA2X2JfVR3kRVSJFc/GpQ2fSORi4pSHZR9orfM4UC9OVXIFFTA==} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': + resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} + + '@prisma/engines@4.16.2': + resolution: {integrity: sha512-vx1nxVvN4QeT/cepQce68deh/Turxy5Mr+4L4zClFuK1GlxN3+ivxfuv+ej/gvidWn1cE1uAhW7ALLNlYbRUAw==} + + '@prisma/engines@5.22.0': + resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} + + '@prisma/fetch-engine@4.16.2': + resolution: {integrity: sha512-lnCnHcOaNn0kw8qTJbVcNhyfIf5Lus2GFXbj3qpkdKEIB9xLgqkkuTP+35q1xFaqwQ0vy4HFpdRUpFP7njE15g==} + + '@prisma/fetch-engine@5.22.0': + resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} + + '@prisma/generator-helper@4.16.2': + resolution: {integrity: sha512-bMOH7y73Ui7gpQrioFeavMQA+Tf8ksaVf8Nhs9rQNzuSg8SSV6E9baczob0L5KGZTSgYoqnrRxuo03kVJYrnIg==} + + '@prisma/generator-helper@5.9.1': + resolution: {integrity: sha512-WMdEUPpPYxUGruRQM6e6IVTWXFjt1hHdF/m2TO7pWxhPo7/ZeoTOF9fH8JsvVSV78DYLOQkx9osjFLXZu447Kw==} + + '@prisma/get-platform@4.16.2': + resolution: {integrity: sha512-fnDey1/iSefHJRMB+w243BhWENf+paRouPMdCqIVqu8dYkR1NqhldblsSUC4Zr2sKS7Ta2sK4OLdt9IH+PZTfw==} + + '@prisma/get-platform@5.22.0': + resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} + + '@prisma/internals@4.16.2': + resolution: {integrity: sha512-/3OiSADA3RRgsaeEE+MDsBgL6oAMwddSheXn6wtYGUnjERAV/BmF5bMMLnTykesQqwZ1s8HrISrJ0Vf6cjOxMg==} + + '@prisma/prisma-fmt-wasm@4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81': + resolution: {integrity: sha512-g090+dEH7wrdCw359+8J9+TGH84qK28V/dxwINjhhNCtju9lej99z9w/AVsJP9UhhcCPS4psYz4iu8d53uxVpA==} + + '@prisma/prisma-fmt-wasm@4.8.0-9.5be62d4c40defb9d2faf09dc30edaa449580d417': + resolution: {integrity: sha512-eZExLfHW45uJjvaSVttFoY1LvrbyOHpzxuJ9Dt7E18AHvHJhtkUFapSe7SwIHgYS+HgK2ZDhCpnprU2XIg9GGw==} + + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + + '@simplewebauthn/server@13.1.1': + resolution: {integrity: sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==} + engines: {node: '>=20.0.0'} + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.6.8': + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.6': + resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + + '@types/bcryptjs@2.4.6': + resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} + + '@types/body-parser@1.19.5': + resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/conventional-commits-parser@5.0.0': + resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/cross-spawn@6.0.2': + resolution: {integrity: sha512-KuwNhp3eza+Rhu8IFI5HUXRP0LIhqH5cAjubUvGXXthh4YYBuP2ntwEX+Cz8GJoZUHlKo247wPWOfA9LYEq4cw==} + + '@types/debug@4.1.8': + resolution: {integrity: sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==} + + '@types/ejs@3.1.5': + resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + + '@types/express-serve-static-core@4.19.5': + resolution: {integrity: sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==} + + '@types/express-serve-static-core@5.0.1': + resolution: {integrity: sha512-CRICJIl0N5cXDONAdlTv5ShATZ4HEwk6kDDIW2/w9qOWKg+NU/5F8wYRWCrONad0/UKkloNSmmyN/wX4rtpbVA==} + + '@types/express-session@1.18.1': + resolution: {integrity: sha512-S6TkD/lljxDlQ2u/4A70luD8/ZxZcrU5pQwI1rVXCiaVIywoFgbA+PIUNDjPhQpPdK0dGleLtYc/y7XWBfclBg==} + + '@types/express@4.17.21': + resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + + '@types/express@5.0.0': + resolution: {integrity: sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==} + + '@types/fluent-ffmpeg@2.1.27': + resolution: {integrity: sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/http-errors@2.0.4': + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.5': + resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} + + '@types/md5@2.3.5': + resolution: {integrity: sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/mime-types@2.1.4': + resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/mjml-core@4.15.0': + resolution: {integrity: sha512-jSRWTOpwRS/uHIBfGdvLl0a7MaoBZZYHKI+HhsFYChrUOKVJTnjSYsuV6wx0snv6ZaX3TUo5OP/gNsz/uzZz1A==} + + '@types/mjml@4.7.4': + resolution: {integrity: sha512-vyi1vzWgMzFMwZY7GSZYX0GU0dmtC8vLHwpgk+NWmwbwRSrlieVyJ9sn5elodwUfklJM7yGl0zQeet1brKTWaQ==} + + '@types/ms@0.7.34': + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + + '@types/multer@1.4.12': + resolution: {integrity: sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==} + + '@types/node@22.10.2': + resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==} + + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + + '@types/pug@2.0.10': + resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} + + '@types/qrcode@1.5.5': + resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} + + '@types/qs@6.9.15': + resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + '@types/send@0.17.4': + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + + '@types/serve-static@1.15.7': + resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/superagent@8.1.7': + resolution: {integrity: sha512-NmIsd0Yj4DDhftfWvvAku482PZum4DBW7U51OvS8gvOkDDY0WT1jsVyDV3hK+vplrsYw8oDwi9QxOM7U68iwww==} + + '@types/supertest@6.0.2': + resolution: {integrity: sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/validator@13.12.0': + resolution: {integrity: sha512-nH45Lk7oPIJ1RVOF6JgFI6Dy0QpHEzq4QecZhvguxYPDwT8c93prCMqAtiIttm39voZ+DDR+qkNnMpJmMBRqag==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.32': + resolution: {integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==} + + '@typescript-eslint/eslint-plugin@8.24.1': + resolution: {integrity: sha512-ll1StnKtBigWIGqvYDVuDmXJHVH4zLVot1yQ4fJtLpL7qacwkxJc1T0bptqw+miBQ/QfUbhl1TcQ4accW5KUyA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.8.0' + + '@typescript-eslint/parser@8.18.0': + resolution: {integrity: sha512-hgUZ3kTEpVzKaK3uNibExUYm6SKKOmTU2BOxBSvOYwtJEPdVQ70kZJpPjstlnhCHcuc2WGfSbpKlb/69ttyN5Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.8.0' + + '@typescript-eslint/scope-manager@8.18.0': + resolution: {integrity: sha512-PNGcHop0jkK2WVYGotk/hxj+UFLhXtGPiGtiaWgVBVP1jhMoMCHlTyJA+hEj4rszoSdLTK3fN4oOatrL0Cp+Xw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.24.1': + resolution: {integrity: sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/type-utils@8.24.1': + resolution: {integrity: sha512-/Do9fmNgCsQ+K4rCz0STI7lYB4phTtEXqqCAs3gZW0pnK7lWNkvWd5iW545GSmApm4AzmQXmSqXPO565B4WVrw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.8.0' + + '@typescript-eslint/types@8.18.0': + resolution: {integrity: sha512-FNYxgyTCAnFwTrzpBGq+zrnoTO4x0c1CKYY5MuUTzpScqmY5fmsh2o3+57lqdI3NZucBDCzDgdEbIaNfAjAHQA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.24.1': + resolution: {integrity: sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.18.0': + resolution: {integrity: sha512-rqQgFRu6yPkauz+ms3nQpohwejS8bvgbPyIDq13cgEDbkXt4LH4OkDMT0/fN1RUtzG8e8AKJyDBoocuQh8qNeg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.8.0' + + '@typescript-eslint/typescript-estree@8.24.1': + resolution: {integrity: sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.8.0' + + '@typescript-eslint/utils@8.24.1': + resolution: {integrity: sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.8.0' + + '@typescript-eslint/visitor-keys@8.18.0': + resolution: {integrity: sha512-pCh/qEA8Lb1wVIqNvBke8UaRjJ6wrAWkJO5yyIbs8Yx6TNGYyfNjOo61tLv+WwLvoLPp4BQ8B7AHKijl8NGUfw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.24.1': + resolution: {integrity: sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + JSONStream@1.3.5: + resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} + hasBin: true + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.3: + resolution: {integrity: sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==} + engines: {node: '>=0.4.0'} + + acorn@7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} + hasBin: true + + acorn@8.12.1: + resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + engines: {node: '>=0.4.0'} + hasBin: true + + acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.1: + resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} + engines: {node: '>= 14'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + alce@1.2.0: + resolution: {integrity: sha512-XppPf2S42nO2WhvKzlwzlfcApcXHzjlod30pKmcWjRgLOtqoe5DMuqdiYoM6AgyXksc6A6pV4v1L/WW217e57w==} + engines: {node: '>=0.8.0'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.0.0: + resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + ansis@3.9.0: + resolution: {integrity: sha512-PcDrVe15ldexeZMsVLBAzBwF2KhZgaU0R+CHxH+x5kqn/pO+UWVBZJ+NEXMPpEOLUFeNsnNdoWYc2gwO+MVkDg==} + engines: {node: '>=16'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.1: + resolution: {integrity: sha512-8KyabkmbYrH+9ibcTScQ1xCJC/CGcugdVIwB+53f5sZziXgwUh3iXlAlANMxcZyDEfTHMe6+Z5FofV8nopXP7w==} + engines: {node: '>= 10'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-to-hex@1.0.0: + resolution: {integrity: sha512-arycdkxgK1cj6s03GDb96tlCxOl1n3kg9M2OHseUc6Pqyqp+lgfceFPmG507eI5V+oxOSEnlOw/dFc7LXBXF4Q==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + asn1js@3.0.5: + resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==} + engines: {node: '>=12.0.0'} + + assert-never@1.3.0: + resolution: {integrity: sha512-9Z3vxQ+berkL/JJo0dK+EY3Lp0s3NtSnP3VCLsh5HDcZPrh0M+KQRK5sWhUeyPPH+/RCxZqOxLMR+YC6vlviEQ==} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + + async@0.2.10: + resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} + + async@3.2.5: + resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-preset-current-node-syntax@1.0.1: + resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==} + peerDependencies: + '@babel/core': ^7.0.0 + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + babel-walk@3.0.0-canary-5: + resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} + engines: {node: '>= 10.0.0'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camel-case@3.0.0: + resolution: {integrity: sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==} + + camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001697: + resolution: {integrity: sha512-GwNPlWJin8E+d7Gxq96jxM6w0w+VFeyyXRsjU58emtkYqnbwHqXm5uT2uCmO0RQE9htWknOP4xtBlLmM/gWxvQ==} + + capital-case@1.0.4: + resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chalk@5.4.1: + resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + change-case@4.1.2: + resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + character-parser@2.2.0: + resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} + + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + + charenc@0.0.2: + resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + + checkpoint-client@1.1.24: + resolution: {integrity: sha512-nIOlLhDS7MKs4tUzS3LCm+sE1NgTCVnVrXlD0RRxaoEkkLu8LIWSUNiNWai6a+LK5unLzTyZeTCYX1Smqy0YoA==} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} + engines: {node: '>= 6'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@3.8.0: + resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==} + engines: {node: '>=8'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.3.1: + resolution: {integrity: sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==} + + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.1: + resolution: {integrity: sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==} + + clean-css@4.2.4: + resolution: {integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==} + engines: {node: '>= 4.0'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.2: + resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + comment-json@4.2.3: + resolution: {integrity: sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw==} + engines: {node: '>= 6'} + + comment-json@4.2.5: + resolution: {integrity: sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==} + engines: {node: '>= 6'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + connect-redis@8.0.1: + resolution: {integrity: sha512-7iOI214/r15ahvu0rqKCHhsgpMdOgyLwqlw/icSTnnAR75xFvMyfxAE+je4M87rZLjDlKzKcTc48XxQXYFsMgA==} + engines: {node: '>=18'} + peerDependencies: + express-session: '>=1' + + consola@2.15.3: + resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} + + constant-case@3.0.4: + resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} + + constantinople@4.0.1: + resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + conventional-changelog-angular@7.0.0: + resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} + engines: {node: '>=16'} + + conventional-changelog-conventionalcommits@7.0.2: + resolution: {integrity: sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==} + engines: {node: '>=16'} + + conventional-commits-parser@5.0.0: + resolution: {integrity: sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==} + engines: {node: '>=16'} + hasBin: true + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + cosmiconfig-typescript-loader@6.1.0: + resolution: {integrity: sha512-tJ1w35ZRUiM5FeTzT7DtYWAFFv37ZLqSRkGi2oeCK1gPhvaWjkAtfXvLmvE1pRfxxp9aQo6ba/Pvg1dKj05D4g==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cross-spawn@6.0.6: + resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} + engines: {node: '>=4.8'} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypt@0.0.2: + resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + + crypto-digest-sync@1.0.0: + resolution: {integrity: sha512-UQBOB5z+HF4iA8shKQ3PPwhCmdFAihwcytD1Qh4uiz78x04cZZmKtZ1F1VyAjkrA8uEZqXt2tMXfj3dJHtcbng==} + + crypto-random-hex@1.0.0: + resolution: {integrity: sha512-1DuZQ03El13TRgfrqbbjW40Gvi4OKInny/Wxqj23/JMXe214C/3Tlz92bKXWDW3NZT5RjXUGdYW4qiIOUPf+cA==} + + crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + + css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + + dargs@8.1.0: + resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} + engines: {node: '>=12'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.5: + resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.6: + resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + dedent@1.5.3: + resolution: {integrity: sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + del@6.1.1: + resolution: {integrity: sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==} + engines: {node: '>=10'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + display-notification@2.0.0: + resolution: {integrity: sha512-TdmtlAcdqy1NU+j7zlkDdMnCL878zriLaBmoD9quOoq1ySSSGv03l0hXK5CvIFZlIfFI/hizqdQuW+Num7xuhw==} + engines: {node: '>=4'} + + doctypes@1.1.0: + resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} + + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@3.3.0: + resolution: {integrity: sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==} + engines: {node: '>= 4'} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + + domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dotenv-expand@10.0.0: + resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} + engines: {node: '>=12'} + + dotenv@16.0.3: + resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} + engines: {node: '>=12'} + + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + editorconfig@1.0.4: + resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} + engines: {node: '>=14'} + hasBin: true + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-to-chromium@1.5.93: + resolution: {integrity: sha512-M+29jTcfNNoR9NV7la4SwUqzWAxEwnc7ThA5e1m6LRSotmpfpCpLcIfgtSCVL+MllNLgAyM/5ru86iMRemPzDQ==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encode-utf8@1.0.3: + resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + encoding-japanese@2.0.0: + resolution: {integrity: sha512-++P0RhebUC8MJAwJOsT93dT+5oc5oPImp1HubZpAuCZ5kTLnhuuBhKHj2jJeO/Gj93idPBWmIuQ9QWMe5rX3pQ==} + engines: {node: '>=8.10.0'} + + encoding-japanese@2.1.0: + resolution: {integrity: sha512-58XySVxUgVlBikBTbQ8WdDxBDHIdXucB16LO5PBHR8t75D54wQrNo4cg+58+R1CtJfKnsVsvt9XlteRaR8xw1w==} + engines: {node: '>=8.10.0'} + + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + + enhanced-resolve@5.17.0: + resolution: {integrity: sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==} + engines: {node: '>=10.13.0'} + + enhanced-resolve@5.18.1: + resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} + engines: {node: '>=10.13.0'} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + + es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.6.0: + resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-goat@3.0.0: + resolution: {integrity: sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==} + engines: {node: '>=10'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-applescript@1.0.0: + resolution: {integrity: sha512-4/hFwoYaC6TkpDn9A3pTC52zQPArFeXuIfhUtCGYdauTzXVP9H3BDr3oO/QzQehMpLDC7srvYgfwvImPFGfvBA==} + engines: {node: '>=0.10.0'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@9.1.0: + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.2.3: + resolution: {integrity: sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '*' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@8.0.2: + resolution: {integrity: sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.0.0: + resolution: {integrity: sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@4.2.0: + resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.8.0: + resolution: {integrity: sha512-K8qnZ/QJzT2dLKdZJVX6W4XOwBzutMYmt0lqUS+JdXgd+HTYFlonFgkJ8s44d/zMPPCnOOk0kMWCApCPhiOy9A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + + espree@10.1.0: + resolution: {integrity: sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@1.2.5: + resolution: {integrity: sha512-S9VbPDU0adFErpDai3qDkjq8+G05ONtKzcyNrPKg/ZKa+tf879nX2KexNU95b31UoTJjRLInNBHHHjFPoCd7lQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@1.9.3: + resolution: {integrity: sha512-25w1fMXQrGdoquWnScXZGckOv+Wes+JDnuN/+7ex3SauFRS72r2lFDec0EKPt2YD1wUJ/IrfEex+9yp4hfSOJA==} + engines: {node: '>=0.10.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@0.10.0: + resolution: {integrity: sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw==} + engines: {node: '>=4'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + express-session@1.18.1: + resolution: {integrity: sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==} + engines: {node: '>= 0.8.0'} + + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + + extend-object@1.0.0: + resolution: {integrity: sha512-0dHDIXC7y7LDmCh/lp1oYkmv73K25AMugQI07r8eFopkW6f7Ufn1q+ETMsJjnV9Am14SlElkqy3O92r6xEaxPw==} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.0.1: + resolution: {integrity: sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==} + + fast-write-atomic@0.2.1: + resolution: {integrity: sha512-WvJe06IfNYlr+6cO3uQkdKdy3Cb1LlCJSF8zRs2eT8yuhdbSlR9nIt+TgQ92RUxiRrQm+/S7RARnMfCs5iuAjw==} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + filelist@1.0.4: + resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + find-up@6.3.0: + resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + + fixpack@4.0.0: + resolution: {integrity: sha512-5SM1+H2CcuJ3gGEwTiVo/+nd/hYpNj9Ch3iMDOQ58ndY+VGQ2QdvaUTkd3otjZvYnd/8LF/HkJ5cx7PBq0orCQ==} + hasBin: true + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + + fluent-ffmpeg@2.1.3: + resolution: {integrity: sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==} + engines: {node: '>=18'} + + foreground-child@3.2.1: + resolution: {integrity: sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==} + engines: {node: '>=14'} + + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + + fork-ts-checker-webpack-plugin@9.0.2: + resolution: {integrity: sha512-Uochze2R8peoN1XqlSi/rGUkDQpRogtLFocP9+PGu68zk1BDAKXfdeCdyVZpgTk8V8WFVQXdEz426VKjXLO1Gg==} + engines: {node: '>=12.13.0', yarn: '>=1.0.0'} + peerDependencies: + typescript: '>3.6.0' + webpack: ^5.11.0 + + form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + + formidable@3.5.1: + resolution: {integrity: sha512-WJWKelbRHN41m5dumb0/k8TeAx7Id/y3a+Z7QfhxP/htI9Js5zYaEDtG8uMgG0vM0lOlqnmjE99/kfpOYi/0Og==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fp-ts@2.16.0: + resolution: {integrity: sha512-bLq+KgbiXdTEoT1zcARrWEpa5z6A/8b7PcDW7Gef3NSisQ+VS7ll2Xbf1E+xsgik0rWub/8u0qP/iTTjj+PhxQ==} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.1.1: + resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} + engines: {node: '>=14.14'} + + fs-jetpack@5.1.0: + resolution: {integrity: sha512-Xn4fDhLydXkuzepZVsr02jakLlmoARPy+YWIclo4kh0GyNGUHnTqeH/w/qIsVn50dFxtp8otPL2t/HcPJBbxUA==} + + fs-monkey@1.0.6: + resolution: {integrity: sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.2.0: + resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==} + engines: {node: '>=18'} + + get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + + get-stream@3.0.0: + resolution: {integrity: sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==} + engines: {node: '>=4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + git-raw-commits@4.0.0: + resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} + engines: {node: '>=16'} + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@10.3.12: + resolution: {integrity: sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@11.0.1: + resolution: {integrity: sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==} + engines: {node: 20 || >=22} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + glob@8.0.3: + resolution: {integrity: sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==} + engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported + + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + + global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-own-prop@2.0.0: + resolution: {integrity: sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + + has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasha@5.2.2: + resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} + engines: {node: '>=8'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + header-case@2.0.4: + resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} + + hex-to-array-buffer@1.1.0: + resolution: {integrity: sha512-vvl3IM8FfT1uOnHtEqyjkDK9Luqz6MQrH82qIvVnjyXxRhkeaEZyRRPiBgf2yym3nweRVEfayxt/1SoTXZYd4Q==} + + hexoid@1.0.0: + resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} + engines: {node: '>=8'} + + hosted-git-info@2.8.9: + resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + hpagent@1.2.0: + resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} + engines: {node: '>=14'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-minifier@4.0.0: + resolution: {integrity: sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==} + engines: {node: '>=6'} + hasBin: true + + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + + htmlparser2@5.0.1: + resolution: {integrity: sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.0: + resolution: {integrity: sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.0: + resolution: {integrity: sha512-0euwPCRyAPSgGdzD1IVN9nJYHtBhJwb6XPfbpQcYbPCwrBidX6GzxmchnaF4sfF/jPb74Ojx5g4yTg3sixlyPw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-walk@5.0.1: + resolution: {integrity: sha512-yemi4pMf51WKT7khInJqAvsIGzoqYXblnsz0ql8tM+yi1EKYTY1evX4NAbJrLL/Aanr2HyZeluqU+Oi7MGHokw==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + ignore@5.3.1: + resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} + engines: {node: '>= 4'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-local@3.1.0: + resolution: {integrity: sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==} + engines: {node: '>=8'} + hasBin: true + + import-meta-resolve@4.1.0: + resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ioredis@5.5.0: + resolution: {integrity: sha512-7CutT89g23FfSa8MDoIFs2GYYa0PaNiW/OrT+nRyjRXHDZd17HmIgy+reOQ/yhh72NznNjGuS8kbCAcA4Ro4mw==} + engines: {node: '>=12.22.0'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + + is-core-module@2.15.0: + resolution: {integrity: sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-expression@4.0.0: + resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + engines: {node: '>=18'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-cwd@2.2.0: + resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==} + engines: {node: '>=6'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + + is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + + is-stream@1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-text-path@2.0.0: + resolution: {integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==} + engines: {node: '>=8'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jackspeak@4.0.2: + resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==} + engines: {node: 20 || >=22} + + jake@10.9.2: + resolution: {integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==} + engines: {node: '>=10'} + hasBin: true + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + + js-beautify@1.15.1: + resolution: {integrity: sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + + js-stringify@1.0.2: + resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsbn@1.1.0: + resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + + jsesc@2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.2.1: + resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jstransformer@1.0.0: + resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} + + juice@10.0.0: + resolution: {integrity: sha512-9f68xmhGrnIi6DBkiiP3rUrQN33SEuaKu1+njX6VgMP+jwZAsnT33WIzlrWICL9matkhYu3OyrqSUP55YTIdGg==} + engines: {node: '>=10.0.0'} + hasBin: true + + jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + libbase64@1.2.1: + resolution: {integrity: sha512-l+nePcPbIG1fNlqMzrh68MLkX/gTxk/+vdvAb388Ssi7UuUN31MI44w4Yf33mM3Cm4xDfw48mdf3rkdHszLNew==} + + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.2.0: + resolution: {integrity: sha512-X2U5Wx0YmK0rXFbk67ASMeqYIkZ6E5vY7pNWRKtnNzqjvdYYG8xtPDpCnuUEnPU9vlgNev+JoSrcaKSUaNvfsw==} + + libmime@5.3.5: + resolution: {integrity: sha512-nSlR1yRZ43L3cZCiWEw7ali3jY29Hz9CQQ96Oy+sSspYnIP5N54ucOPHqooBsXzwrX1pwn13VUE05q4WmzfaLg==} + + libphonenumber-js@1.11.4: + resolution: {integrity: sha512-F/R50HQuWWYcmU/esP5jrH5LiWYaN7DpN0a/99U8+mnGGtnx8kmRE+649dQh3v+CowXXZc8vpkf5AmYkO0AQ7Q==} + + libqp@2.0.1: + resolution: {integrity: sha512-Ka0eC5LkF3IPNQHJmYBWljJsw0UvM6j+QdKRbWyCdTmYwvIDE6a7bCm0UkTAL/K+3KXK5qXT/ClcInU01OpdLg==} + + libqp@2.1.0: + resolution: {integrity: sha512-O6O6/fsG5jiUVbvdgT7YX3xY3uIadR6wEZ7+vy9u7PKHAlSEB6blvC1o5pHBjgsi95Uo0aiBBdkyFecj6jtb7A==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + lint-staged@15.2.11: + resolution: {integrity: sha512-Ev6ivCTYRTGs9ychvpVw35m/bcNDuBN+mnTeObCL5h+boS5WzBEC6LHI4I9F/++sZm1m+J2LEiy0gxL/R9TBqQ==} + engines: {node: '>=18.12.0'} + hasBin: true + + liquidjs@10.16.0: + resolution: {integrity: sha512-XIgkYmiEXt1dS6Pi3IMIed43mMf9IuejnmmRiIo9g56GsKtYvPW5Y1AcM3cN1yyMsl0H5PfRoj4Y5DUpbNmg9g==} + engines: {node: '>=14'} + hasBin: true + + listr2@8.2.5: + resolution: {integrity: sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==} + engines: {node: '>=18.0.0'} + + loader-runner@4.3.0: + resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} + engines: {node: '>=6.11.5'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + lower-case@1.1.4: + resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} + + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.0.2: + resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + magic-string@0.30.12: + resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + magic-string@0.30.8: + resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} + engines: {node: '>=12'} + + mailparser@3.7.1: + resolution: {integrity: sha512-RCnBhy5q8XtB3mXzxcAfT1huNqN93HTYYyL6XawlIKycfxM/rXPg9tXoZ7D46+SgCS1zxKzw+BayDQSvncSTTw==} + + mailsplit@5.4.0: + resolution: {integrity: sha512-wnYxX5D5qymGIPYLwnp6h8n1+6P6vz/MJn5AzGjZ8pwICWssL+CCQjWBIToOVHASmATot4ktvlLo6CyLfOXWYA==} + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + md5@2.3.0: + resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + mensch@0.3.4: + resolution: {integrity: sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==} + + meow@12.1.1: + resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} + engines: {node: '>=16.10'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.7: + resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} + engines: {node: '>=8.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.0.1: + resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} + engines: {node: 20 || >=22} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + + minimatch@9.0.1: + resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mjml-accordion@4.15.3: + resolution: {integrity: sha512-LPNVSj1LyUVYT9G1gWwSw3GSuDzDsQCu0tPB2uDsq4VesYNnU6v3iLCQidMiR6azmIt13OEozG700ygAUuA6Ng==} + + mjml-body@4.15.3: + resolution: {integrity: sha512-7pfUOVPtmb0wC+oUOn4xBsAw4eT5DyD6xqaxj/kssu6RrFXOXgJaVnDPAI9AzIvXJ/5as9QrqRGYAddehwWpHQ==} + + mjml-button@4.15.3: + resolution: {integrity: sha512-79qwn9AgdGjJR1vLnrcm2rq2AsAZkKC5JPwffTMG+Nja6zGYpTDZFZ56ekHWr/r1b5WxkukcPj2PdevUug8c+Q==} + + mjml-carousel@4.15.3: + resolution: {integrity: sha512-3ju6I4l7uUhPRrJfN3yK9AMsfHvrYbRkcJ1GRphFHzUj37B2J6qJOQUpzA547Y4aeh69TSb7HFVf1t12ejQxVw==} + + mjml-cli@4.15.3: + resolution: {integrity: sha512-+V2TDw3tXUVEptFvLSerz125C2ogYl8klIBRY1m5BHd4JvGVf3yhx8N3PngByCzA6PGcv/eydGQN+wy34SHf0Q==} + hasBin: true + + mjml-column@4.15.3: + resolution: {integrity: sha512-hYdEFdJGHPbZJSEysykrevEbB07yhJGSwfDZEYDSbhQQFjV2tXrEgYcFD5EneMaowjb55e3divSJxU4c5q4Qgw==} + + mjml-core@4.15.3: + resolution: {integrity: sha512-Dmwk+2cgSD9L9GmTbEUNd8QxkTZtW9P7FN/ROZW/fGZD6Hq6/4TB0zEspg2Ow9eYjZXO2ofOJ3PaQEEShKV0kQ==} + + mjml-divider@4.15.3: + resolution: {integrity: sha512-vh27LQ9FG/01y0b9ntfqm+GT5AjJnDSDY9hilss2ixIUh0FemvfGRfsGVeV5UBVPBKK7Ffhvfqc7Rciob9Spzw==} + + mjml-group@4.15.3: + resolution: {integrity: sha512-HSu/rKnGZVKFq3ciT46vi1EOy+9mkB0HewO4+P6dP/Y0UerWkN6S3UK11Cxsj0cAp0vFwkPDCdOeEzRdpFEkzA==} + + mjml-head-attributes@4.15.3: + resolution: {integrity: sha512-2ISo0r5ZKwkrvJgDou9xVPxxtXMaETe2AsAA02L89LnbB2KC0N5myNsHV0sEysTw9+CfCmgjAb0GAI5QGpxKkQ==} + + mjml-head-breakpoint@4.15.3: + resolution: {integrity: sha512-Eo56FA5C2v6ucmWQL/JBJ2z641pLOom4k0wP6CMZI2utfyiJ+e2Uuinj1KTrgDcEvW4EtU9HrfAqLK9UosLZlg==} + + mjml-head-font@4.15.3: + resolution: {integrity: sha512-CzV2aDPpiNIIgGPHNcBhgyedKY4SX3BJoTwOobSwZVIlEA6TAWB4Z9WwFUmQqZOgo1AkkiTHPZQvGcEhFFXH6g==} + + mjml-head-html-attributes@4.15.3: + resolution: {integrity: sha512-MDNDPMBOgXUZYdxhosyrA2kudiGO8aogT0/cODyi2Ed9o/1S7W+je11JUYskQbncqhWKGxNyaP4VWa+6+vUC/g==} + + mjml-head-preview@4.15.3: + resolution: {integrity: sha512-J2PxCefUVeFwsAExhrKo4lwxDevc5aKj888HBl/wN4EuWOoOg06iOGCxz4Omd8dqyFsrqvbBuPqRzQ+VycGmaA==} + + mjml-head-style@4.15.3: + resolution: {integrity: sha512-9J+JuH+mKrQU65CaJ4KZegACUgNIlYmWQYx3VOBR/tyz+8kDYX7xBhKJCjQ1I4wj2Tvga3bykd89Oc2kFZ5WOw==} + + mjml-head-title@4.15.3: + resolution: {integrity: sha512-IM59xRtsxID4DubQ0iLmoCGXguEe+9BFG4z6y2xQDrscIa4QY3KlfqgKGT69ojW+AVbXXJPEVqrAi4/eCsLItQ==} + + mjml-head@4.15.3: + resolution: {integrity: sha512-o3mRuuP/MB5fZycjD3KH/uXsnaPl7Oo8GtdbJTKtH1+O/3pz8GzGMkscTKa97l03DAG2EhGrzzLcU2A6eshwFw==} + + mjml-hero@4.15.3: + resolution: {integrity: sha512-9cLAPuc69yiuzNrMZIN58j+HMK1UWPaq2i3/Fg2ZpimfcGFKRcPGCbEVh0v+Pb6/J0+kf8yIO0leH20opu3AyQ==} + + mjml-image@4.15.3: + resolution: {integrity: sha512-g1OhSdofIytE9qaOGdTPmRIp7JsCtgO0zbsn1Fk6wQh2gEL55Z40j/VoghslWAWTgT2OHFdBKnMvWtN6U5+d2Q==} + + mjml-migrate@4.15.3: + resolution: {integrity: sha512-sr/+35RdxZroNQVegjpfRHJ5hda9XCgaS4mK2FGO+Mb1IUevKfeEPII3F/cHDpNwFeYH3kAgyqQ22ClhGLWNBA==} + hasBin: true + + mjml-navbar@4.15.3: + resolution: {integrity: sha512-VsKH/Jdlf8Yu3y7GpzQV5n7JMdpqvZvTSpF6UQXL0PWOm7k6+LX+sCZimOfpHJ+wCaaybpxokjWZ71mxOoCWoA==} + + mjml-parser-xml@4.15.3: + resolution: {integrity: sha512-Tz0UX8/JVYICLjT+U8J1f/TFxIYVYjzZHeh4/Oyta0pLpRLeZlxEd71f3u3kdnulCKMP4i37pFRDmyLXAlEuLw==} + + mjml-preset-core@4.15.3: + resolution: {integrity: sha512-1zZS8P4O0KweWUqNS655+oNnVMPQ1Rq1GaZq5S9JfwT1Vh/m516lSmiTW9oko6gGHytt5s6Yj6oOeu5Zm8FoLw==} + + mjml-raw@4.15.3: + resolution: {integrity: sha512-IGyHheOYyRchBLiAEgw3UM11kFNmBSMupu2BDdejC6ZiDhEAdG+tyERlsCwDPYtXanvFpGWULIu3XlsUPc+RZw==} + + mjml-section@4.15.3: + resolution: {integrity: sha512-JfVPRXH++Hd933gmQfG8JXXCBCR6fIzC3DwiYycvanL/aW1cEQ2EnebUfQkt5QzlYjOkJEH+JpccAsq3ln6FZQ==} + + mjml-social@4.15.3: + resolution: {integrity: sha512-7sD5FXrESOxpT9Z4Oh36bS6u/geuUrMP1aCg2sjyAwbPcF1aWa2k9OcatQfpRf6pJEhUZ18y6/WBBXmMVmSzXg==} + + mjml-spacer@4.15.3: + resolution: {integrity: sha512-3B7Qj+17EgDdAtZ3NAdMyOwLTX1jfmJuY7gjyhS2HtcZAmppW+cxqHUBwCKfvSRgTQiccmEvtNxaQK+tfyrZqA==} + + mjml-table@4.15.3: + resolution: {integrity: sha512-FLx7DcRKTdKdcOCbMyBaeudeHaHpwPveRrBm6WyQe3LXx6FfdmOh59i71/16LFQMgBOD3N4/UJkzxLzlTJzMqQ==} + + mjml-text@4.15.3: + resolution: {integrity: sha512-+C0hxCmw9kg0XzT6vhE5mFkK6y225nC8UEQcN94K0fBCjPKkM+HqZMwGX205fzdGRi+Bxa55b/VhrIVwdv+8vw==} + + mjml-validator@4.15.3: + resolution: {integrity: sha512-Xb72KdqRwjv/qM2rJpV22syyP2N3cRQ9VVDrN6u2FSzLq02buFNxmSPJ7CKhat3PrUNdVHU75KZwOf/tz4UEhA==} + + mjml-wrapper@4.15.3: + resolution: {integrity: sha512-ditsCijeHJrmBmObtJmQ18ddLxv5oPyMTdPU8Di8APOnD2zPk7Z4UAuJSl7HXB45oFiivr3MJf4koFzMUSZ6Gg==} + + mjml@4.15.3: + resolution: {integrity: sha512-bW2WpJxm6HS+S3Yu6tq1DUPFoTxU9sPviUSmnL7Ua+oVO3WA5ILFWqvujUlz+oeuM+HCwEyMiP5xvKNPENVjYA==} + hasBin: true + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@1.4.4-lts.1: + resolution: {integrity: sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==} + engines: {node: '>= 6.0.0'} + + multer@1.4.5-lts.1: + resolution: {integrity: sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==} + engines: {node: '>= 6.0.0'} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + new-github-issue-url@0.2.1: + resolution: {integrity: sha512-md4cGoxuT4T4d/HDOXbrUHkTKrp/vp+m3aOA7XXVYwNsUNMK49g3SQicTSeV5GIz/5QVGAeYRAOlyp9OvlgsYA==} + engines: {node: '>=10'} + + nice-try@1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + + no-case@2.3.2: + resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} + + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-emoji@1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + + node-fetch@2.6.11: + resolution: {integrity: sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + nodemailer@6.10.0: + resolution: {integrity: sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==} + engines: {node: '>=6.0.0'} + + nodemailer@6.9.13: + resolution: {integrity: sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==} + engines: {node: '>=6.0.0'} + + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + normalize-package-data@2.5.0: + resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + + normalize-package-data@3.0.3: + resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} + engines: {node: '>=10'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-bundled@2.0.1: + resolution: {integrity: sha512-gZLxXdjEzE/+mOstGDqR6b0EkhJ+kM6fxM6vUuckuctuVPh80Q6pw/rSZj9s4Gex9GxWtIicO1pc8DB9KZWudw==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + npm-normalize-package-bin@2.0.0: + resolution: {integrity: sha512-awzfKUO7v0FscrSpRoogyNm0sajikhBWpU0QMrW09AMi9n1PoKU6WaIqUzuJSQnpciZZmJ/jMZ2Egfmb/9LiWQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + npm-packlist@5.1.3: + resolution: {integrity: sha512-263/0NGrn32YFYi4J533qzrQ/krmmrWwhKkzwTuM4f/07ug51odoaNjUexxO4vxlzURHcmYMH1QjvHjsNDKLVg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + hasBin: true + + npm-run-path@2.0.2: + resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} + engines: {node: '>=4'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.2: + resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + otplib@12.0.1: + resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==} + + p-event@4.2.0: + resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==} + engines: {node: '>=8'} + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + p-wait-for@3.2.0: + resolution: {integrity: sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA==} + engines: {node: '>=8'} + + package-json-from-dist@1.0.0: + resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pad-start@1.0.2: + resolution: {integrity: sha512-EBN8Ez1SVRcZT1XsIE4WkdnZ5coLoaChkIgAET6gIlaLhXqCz9upVk0DQWFtOYkrpTVvbEppRUnqhTiJrBdkfw==} + + param-case@2.1.1: + resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==} + + param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse5-htmlparser2-tree-adapter@7.0.0: + resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==} + + parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + + path-case@3.0.4: + resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@2.0.1: + resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} + engines: {node: '>=4'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + path-to-regexp@0.2.5: + resolution: {integrity: sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q==} + + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + + pg-cloudflare@1.1.1: + resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} + + pg-connection-string@2.7.0: + resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.7.0: + resolution: {integrity: sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.7.0: + resolution: {integrity: sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.13.0: + resolution: {integrity: sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==} + engines: {node: '>= 8.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.0.1: + resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.1: + resolution: {integrity: sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==} + engines: {node: '>=12'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + + prettier@3.5.1: + resolution: {integrity: sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + preview-email@3.0.20: + resolution: {integrity: sha512-QbAokW2F3p0thQfp2WTZ0rBy+IZuCnf9gIUCLffr+8hq85esq6pzCA7S0eUdD6oTmtKROqoNeH2rXZWrRow7EA==} + engines: {node: '>=14'} + + prisma-case-format@2.2.1: + resolution: {integrity: sha512-AMUtHbvlZkw49q4doBgr5+vV2AY/QW0remSfIBvC4uaneFDcAay4/ct2OBIUUw8PWwLBqppOt0Ka6BmmJb30JQ==} + hasBin: true + + prisma-import@1.0.5: + resolution: {integrity: sha512-4cTI8esL3GnPda3FY0PBrVgrE6i49FXMI3hAKW8Kg1eD9WInXEww3xnia1Iuo49p0Ga4Ud2GfZ3YYOtI1+15qg==} + hasBin: true + + prisma-json-types-generator@3.0.4: + resolution: {integrity: sha512-W53OpjBdGZxCsYv7MlUX69d7TPA9lEsQbDf9ddF0J93FX5EvaIRDMexdFPe0KTxiuquGvZTDJgeNXb3gIqEhJw==} + engines: {node: '>=14.0'} + hasBin: true + peerDependencies: + prisma: ^5.1 + typescript: ^5.1 + + prisma@5.22.0: + resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} + engines: {node: '>=16.13'} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pug-attrs@3.0.0: + resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} + + pug-code-gen@3.0.3: + resolution: {integrity: sha512-cYQg0JW0w32Ux+XTeZnBEeuWrAY7/HNE6TWnhiHGnnRYlCgyAUPoyh9KzCMa9WhcJlJ1AtQqpEYHc+vbCzA+Aw==} + + pug-error@2.1.0: + resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==} + + pug-filters@4.0.0: + resolution: {integrity: sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==} + + pug-lexer@5.0.1: + resolution: {integrity: sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==} + + pug-linker@4.0.0: + resolution: {integrity: sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==} + + pug-load@3.0.0: + resolution: {integrity: sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==} + + pug-parser@6.0.0: + resolution: {integrity: sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==} + + pug-runtime@3.0.1: + resolution: {integrity: sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==} + + pug-strip-comments@2.0.0: + resolution: {integrity: sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==} + + pug-walk@2.0.0: + resolution: {integrity: sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==} + + pug@3.0.3: + resolution: {integrity: sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.3: + resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} + engines: {node: '>=6.0.0'} + + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + random-bytes@1.0.0: + resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} + engines: {node: '>= 0.8'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + read-pkg-up@7.0.1: + resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} + engines: {node: '>=8'} + + read-pkg-up@9.1.0: + resolution: {integrity: sha512-vaMRR1AC1nrd5CQM0PhlRsO5oc2AAigqr7cCrZ/MW/Rsaflz4RlgzkpL4qoU/z1F6wrbd85iFv1OQj/y5RdGvg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + read-pkg@5.2.0: + resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} + engines: {node: '>=8'} + + read-pkg@7.1.0: + resolution: {integrity: sha512-5iOehe+WF75IccPc30bWTbpdDQLOCc3Uu8bi3Dte3Eueij81yx1Mrufk8qBx/YAbR4uL1FdUr+7BKXDwEtisXg==} + engines: {node: '>=12.20'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.1: + resolution: {integrity: sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==} + engines: {node: '>= 14.18.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + relateurl@0.2.7: + resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} + engines: {node: '>= 0.10'} + + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + + replace-string@3.1.0: + resolution: {integrity: sha512-yPpxc4ZR2makceA9hy/jHNqc7QVkd4Je/N0WRHm6bs3PtivPuPynxE5ejU/mp5EhnCv8+uZL7vhz8rkluSlx+Q==} + engines: {node: '>=8'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve.exports@2.0.2: + resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} + engines: {node: '>=10'} + + resolve@1.22.2: + resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} + hasBin: true + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + run-applescript@3.2.0: + resolution: {integrity: sha512-Ep0RsvAjnRcBX1p5vogbaBdAGu/8j/ewpvGqnQYunnLd9SM0vWcPJewPKNnWFggf0hF0pwIgwV5XK7qQ7UZ8Qg==} + engines: {node: '>=4'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.4.3: + resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.0: + resolution: {integrity: sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==} + engines: {node: '>= 10.13.0'} + + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + + secure-remote-password@0.3.1: + resolution: {integrity: sha512-iEp/qLRfb9XYhfKFrPFfdeD7KVreCjhDKSTRP1G1nRIO0Sw1hjnVHD58ymOhiy9Zf5quHbDIbG9cTupji7qwnA==} + + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.0: + resolution: {integrity: sha512-DrfFnPzblFmNrIZzg5RzHegbiRWg7KMR7btwi2yjHwx06zsUbO5g613sVwEV7FTwmzJu+Io0lJe2GJ3LxqpvBQ==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.1: + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + sentence-case@3.0.4: + resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@1.2.0: + resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + engines: {node: '>=0.10.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@1.0.0: + resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} + engines: {node: '>=0.10.0'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + + slick@1.12.2: + resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==} + + snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.18: + resolution: {integrity: sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-eof@1.0.0: + resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} + engines: {node: '>=0.10.0'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + superagent@9.0.2: + resolution: {integrity: sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==} + engines: {node: '>=14.18.0'} + + supertest@7.0.0: + resolution: {integrity: sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==} + engines: {node: '>=14.18.0'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + synckit@0.9.2: + resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} + engines: {node: ^14.18.0 || >=16.0.0} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + temp-dir@1.0.0: + resolution: {integrity: sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==} + engines: {node: '>=4'} + + temp-dir@2.0.0: + resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + engines: {node: '>=8'} + + temp-write@4.0.0: + resolution: {integrity: sha512-HIeWmj77uOOHb0QX7siN3OtwV3CTntquin6TNVg6SHOqCP3hYKmox90eeFOGaY1MqJ9WYDDjkyZrW6qS5AWpbw==} + engines: {node: '>=8'} + + tempy@1.0.1: + resolution: {integrity: sha512-biM9brNqxSc04Ee71hzFbryD11nX7VPhQQY32AdDmjFvodsRFz/3ufeoTZ6uYkRFfGo188tENcASNs3vTdsM0w==} + engines: {node: '>=10'} + + terminal-link@2.1.1: + resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} + engines: {node: '>=8'} + + terser-webpack-plugin@5.3.11: + resolution: {integrity: sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + + terser@5.38.0: + resolution: {integrity: sha512-a4GD5R1TjEeuCT6ZRiYMHmIf7okbCPEuhQET8bczV6FrQMMlFXA1n+G0KKjdlFCm3TEHV77GxfZB3vZSUQGFpg==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + text-extensions@2.4.0: + resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} + engines: {node: '>=8'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thirty-two@1.0.2: + resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==} + engines: {node: '>=0.2.6'} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tlds@1.252.0: + resolution: {integrity: sha512-GA16+8HXvqtfEnw/DTcwB0UU354QE1n3+wh08oFjr6Znl7ZLAeUgYzCcK+/CCrOyE0vnHR8/pu3XXG3vDijXpQ==} + hasBin: true + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + tmp@0.2.1: + resolution: {integrity: sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==} + engines: {node: '>=8.17.0'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-stream@1.0.0: + resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-api-utils@2.0.1: + resolution: {integrity: sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-jest@29.2.5: + resolution: {integrity: sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + + ts-json-schema-generator@2.3.0: + resolution: {integrity: sha512-t4lBQAwZc0sOJq9LJt3NgbznIcslVnm0JeEMFq8qIRklpMRY8jlYD0YmnRWbqBKANxkby91P1XanSSlSOFpUmg==} + engines: {node: '>=18.0.0'} + hasBin: true + + ts-loader@9.5.1: + resolution: {integrity: sha512-rNH3sK9kGZcH9dYzC7CewQm4NtxJTjSEVRJ2DyBZR7f8/wcta+iV44UPCXc5+nzDzivKtlzV6c9P4e+oFhDLYg==} + engines: {node: '>=12.0.0'} + peerDependencies: + typescript: '*' + webpack: ^5.0.0 + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + ts-pattern@4.3.0: + resolution: {integrity: sha512-pefrkcd4lmIVR0LA49Imjf9DYLK8vtWhqBPA3Ya1ir8xCW0O2yjL9dsCVvI7pCodLC5q7smNpEtDR2yVulQxOg==} + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + + tslib@2.6.3: + resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.16.0: + resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@0.6.0: + resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} + engines: {node: '>=8'} + + type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.7.2: + resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.7.3: + resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + uglify-js@3.19.0: + resolution: {integrity: sha512-wNKHUY2hYYkf6oSFfhwwiHo4WCHzHmzcXsqXYTN9ja3iApYIFbb2U6ics9hBcYLHcYGQoAlwnZlTrf3oF+BL/Q==} + engines: {node: '>=0.8.0'} + hasBin: true + + uid-safe@2.1.5: + resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} + engines: {node: '>= 0.8'} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + + undici@6.19.3: + resolution: {integrity: sha512-xWvTmUYvfiKATSKntAVRpPO5bfRcrG9FpiHI916j8dK8nUMjeM0uE8dx7ftKoPUQ2RtRi0KMDL10/03orVgTMA==} + engines: {node: '>=18.17'} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.1.2: + resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + upper-case-first@2.0.2: + resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} + + upper-case@1.1.3: + resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} + + upper-case@2.0.2: + resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + uuid@3.4.0: + resolution: {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. + hasBin: true + + uuid@9.0.0: + resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + valid-data-url@3.0.1: + resolution: {integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==} + engines: {node: '>=10'} + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + validator@13.12.0: + resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + watchpack@2.4.2: + resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-resource-inliner@6.0.1: + resolution: {integrity: sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==} + engines: {node: '>=10.0.0'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.2.3: + resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} + engines: {node: '>=10.13.0'} + + webpack@5.97.1: + resolution: {integrity: sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + with@7.0.2: + resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} + engines: {node: '>= 10.0.0'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.6.1: + resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==} + engines: {node: '>= 14'} + hasBin: true + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.1.1: + resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} + engines: {node: '>=12.20'} + + yoctocolors-cjs@2.1.2: + resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} + engines: {node: '>=18'} + + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + '@angular-devkit/core@17.3.8(chokidar@4.0.3)': + dependencies: + ajv: 8.12.0 + ajv-formats: 2.1.1(ajv@8.12.0) + jsonc-parser: 3.2.1 + picomatch: 4.0.1 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/core@19.0.1(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.2 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/core@19.1.3(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.2 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/schematics-cli@19.1.3(@types/node@22.10.2)(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.1.3(chokidar@4.0.3) + '@angular-devkit/schematics': 19.1.3(chokidar@4.0.3) + '@inquirer/prompts': 7.2.1(@types/node@22.10.2) + ansi-colors: 4.1.3 + symbol-observable: 4.0.0 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@types/node' + - chokidar + + '@angular-devkit/schematics@17.3.8(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 17.3.8(chokidar@4.0.3) + jsonc-parser: 3.2.1 + magic-string: 0.30.8 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@19.0.1(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.0.1(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.12 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@19.1.3(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.1.3(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@antfu/ni@0.21.4': {} + + '@babel/code-frame@7.24.7': + dependencies: + '@babel/highlight': 7.24.7 + picocolors: 1.0.1 + + '@babel/code-frame@7.26.2': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.24.9': {} + + '@babel/core@7.24.9': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.24.10 + '@babel/helper-compilation-targets': 7.24.8 + '@babel/helper-module-transforms': 7.24.9(@babel/core@7.24.9) + '@babel/helpers': 7.24.8 + '@babel/parser': 7.24.8 + '@babel/template': 7.24.7 + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 + convert-source-map: 2.0.0 + debug: 4.4.0 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.24.10': + dependencies: + '@babel/types': 7.24.9 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 2.5.2 + + '@babel/helper-compilation-targets@7.24.8': + dependencies: + '@babel/compat-data': 7.24.9 + '@babel/helper-validator-option': 7.24.8 + browserslist: 4.24.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-environment-visitor@7.24.7': + dependencies: + '@babel/types': 7.24.9 + + '@babel/helper-function-name@7.24.7': + dependencies: + '@babel/template': 7.24.7 + '@babel/types': 7.24.9 + + '@babel/helper-hoist-variables@7.24.7': + dependencies: + '@babel/types': 7.24.9 + + '@babel/helper-module-imports@7.24.7': + dependencies: + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.24.9(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-module-imports': 7.24.7 + '@babel/helper-simple-access': 7.24.7 + '@babel/helper-split-export-declaration': 7.24.7 + '@babel/helper-validator-identifier': 7.25.9 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.24.8': {} + + '@babel/helper-simple-access@7.24.7': + dependencies: + '@babel/traverse': 7.24.8 + '@babel/types': 7.24.9 + transitivePeerDependencies: + - supports-color + + '@babel/helper-split-export-declaration@7.24.7': + dependencies: + '@babel/types': 7.24.9 + + '@babel/helper-string-parser@7.24.8': {} + + '@babel/helper-validator-identifier@7.25.9': {} + + '@babel/helper-validator-option@7.24.8': {} + + '@babel/helpers@7.24.8': + dependencies: + '@babel/template': 7.24.7 + '@babel/types': 7.24.9 + + '@babel/highlight@7.24.7': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/parser@7.24.8': + dependencies: + '@babel/types': 7.24.9 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/plugin-syntax-typescript@7.24.7(@babel/core@7.24.9)': + dependencies: + '@babel/core': 7.24.9 + '@babel/helper-plugin-utils': 7.24.8 + + '@babel/runtime@7.24.8': + dependencies: + regenerator-runtime: 0.14.1 + optional: true + + '@babel/template@7.24.7': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.24.8 + '@babel/types': 7.24.9 + + '@babel/traverse@7.24.8': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.24.10 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-function-name': 7.24.7 + '@babel/helper-hoist-variables': 7.24.7 + '@babel/helper-split-export-declaration': 7.24.7 + '@babel/parser': 7.24.8 + '@babel/types': 7.24.9 + debug: 4.4.0 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.24.9': + dependencies: + '@babel/helper-string-parser': 7.24.8 + '@babel/helper-validator-identifier': 7.25.9 + to-fast-properties: 2.0.0 + + '@bcoe/v8-coverage@0.2.3': {} + + '@colors/colors@1.5.0': + optional: true + + '@commitlint/cli@19.7.1(@types/node@22.10.2)(typescript@5.7.2)': + dependencies: + '@commitlint/format': 19.5.0 + '@commitlint/lint': 19.7.1 + '@commitlint/load': 19.6.1(@types/node@22.10.2)(typescript@5.7.2) + '@commitlint/read': 19.5.0 + '@commitlint/types': 19.5.0 + tinyexec: 0.3.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/config-conventional@19.7.1': + dependencies: + '@commitlint/types': 19.5.0 + conventional-changelog-conventionalcommits: 7.0.2 + + '@commitlint/config-validator@19.5.0': + dependencies: + '@commitlint/types': 19.5.0 + ajv: 8.17.1 + + '@commitlint/ensure@19.5.0': + dependencies: + '@commitlint/types': 19.5.0 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.upperfirst: 4.3.1 + + '@commitlint/execute-rule@19.5.0': {} + + '@commitlint/format@19.5.0': + dependencies: + '@commitlint/types': 19.5.0 + chalk: 5.4.1 + + '@commitlint/is-ignored@19.7.1': + dependencies: + '@commitlint/types': 19.5.0 + semver: 7.7.0 + + '@commitlint/lint@19.7.1': + dependencies: + '@commitlint/is-ignored': 19.7.1 + '@commitlint/parse': 19.5.0 + '@commitlint/rules': 19.6.0 + '@commitlint/types': 19.5.0 + + '@commitlint/load@19.6.1(@types/node@22.10.2)(typescript@5.7.2)': + dependencies: + '@commitlint/config-validator': 19.5.0 + '@commitlint/execute-rule': 19.5.0 + '@commitlint/resolve-extends': 19.5.0 + '@commitlint/types': 19.5.0 + chalk: 5.4.1 + cosmiconfig: 9.0.0(typescript@5.7.2) + cosmiconfig-typescript-loader: 6.1.0(@types/node@22.10.2)(cosmiconfig@9.0.0(typescript@5.7.2))(typescript@5.7.2) + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + lodash.uniq: 4.5.0 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@19.5.0': {} + + '@commitlint/parse@19.5.0': + dependencies: + '@commitlint/types': 19.5.0 + conventional-changelog-angular: 7.0.0 + conventional-commits-parser: 5.0.0 + + '@commitlint/read@19.5.0': + dependencies: + '@commitlint/top-level': 19.5.0 + '@commitlint/types': 19.5.0 + git-raw-commits: 4.0.0 + minimist: 1.2.8 + tinyexec: 0.3.2 + + '@commitlint/resolve-extends@19.5.0': + dependencies: + '@commitlint/config-validator': 19.5.0 + '@commitlint/types': 19.5.0 + global-directory: 4.0.1 + import-meta-resolve: 4.1.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 + + '@commitlint/rules@19.6.0': + dependencies: + '@commitlint/ensure': 19.5.0 + '@commitlint/message': 19.5.0 + '@commitlint/to-lines': 19.5.0 + '@commitlint/types': 19.5.0 + + '@commitlint/to-lines@19.5.0': {} + + '@commitlint/top-level@19.5.0': + dependencies: + find-up: 7.0.0 + + '@commitlint/types@19.5.0': + dependencies: + '@types/conventional-commits-parser': 5.0.0 + chalk: 5.3.0 + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@css-inline/css-inline-android-arm-eabi@0.14.1': + optional: true + + '@css-inline/css-inline-android-arm64@0.14.1': + optional: true + + '@css-inline/css-inline-darwin-arm64@0.14.1': + optional: true + + '@css-inline/css-inline-darwin-x64@0.14.1': + optional: true + + '@css-inline/css-inline-linux-arm-gnueabihf@0.14.1': + optional: true + + '@css-inline/css-inline-linux-arm64-gnu@0.14.1': + optional: true + + '@css-inline/css-inline-linux-arm64-musl@0.14.1': + optional: true + + '@css-inline/css-inline-linux-x64-gnu@0.14.1': + optional: true + + '@css-inline/css-inline-linux-x64-musl@0.14.1': + optional: true + + '@css-inline/css-inline-win32-x64-msvc@0.14.1': + optional: true + + '@css-inline/css-inline@0.14.1': + optionalDependencies: + '@css-inline/css-inline-android-arm-eabi': 0.14.1 + '@css-inline/css-inline-android-arm64': 0.14.1 + '@css-inline/css-inline-darwin-arm64': 0.14.1 + '@css-inline/css-inline-darwin-x64': 0.14.1 + '@css-inline/css-inline-linux-arm-gnueabihf': 0.14.1 + '@css-inline/css-inline-linux-arm64-gnu': 0.14.1 + '@css-inline/css-inline-linux-arm64-musl': 0.14.1 + '@css-inline/css-inline-linux-x64-gnu': 0.14.1 + '@css-inline/css-inline-linux-x64-musl': 0.14.1 + '@css-inline/css-inline-win32-x64-msvc': 0.14.1 + + '@elastic/elasticsearch@8.15.1': + dependencies: + '@elastic/transport': 8.9.1 + tslib: 2.7.0 + transitivePeerDependencies: + - supports-color + + '@elastic/transport@8.9.1': + dependencies: + '@opentelemetry/api': 1.9.0 + debug: 4.4.0 + hpagent: 1.2.0 + ms: 2.1.3 + secure-json-parse: 2.7.0 + tslib: 2.7.0 + undici: 6.19.3 + transitivePeerDependencies: + - supports-color + + '@eslint-community/eslint-utils@4.4.0(eslint@9.8.0)': + dependencies: + eslint: 9.8.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.4.1(eslint@9.8.0)': + dependencies: + eslint: 9.8.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.11.0': {} + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/config-array@0.17.1': + dependencies: + '@eslint/object-schema': 2.1.4 + debug: 4.4.0 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/eslintrc@3.1.0': + dependencies: + ajv: 6.12.6 + debug: 4.4.0 + espree: 10.1.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.8.0': {} + + '@eslint/object-schema@2.1.4': {} + + '@hexagon/base64@1.1.28': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.3.0': {} + + '@inquirer/checkbox@4.1.1(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.1.6(@types/node@22.10.2) + '@inquirer/figures': 1.0.10 + '@inquirer/type': 3.0.4(@types/node@22.10.2) + ansi-escapes: 4.3.2 + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/confirm@5.1.5(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.1.6(@types/node@22.10.2) + '@inquirer/type': 3.0.4(@types/node@22.10.2) + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/core@10.1.6(@types/node@22.10.2)': + dependencies: + '@inquirer/figures': 1.0.10 + '@inquirer/type': 3.0.4(@types/node@22.10.2) + ansi-escapes: 4.3.2 + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/editor@4.2.6(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.1.6(@types/node@22.10.2) + '@inquirer/type': 3.0.4(@types/node@22.10.2) + external-editor: 3.1.0 + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/expand@4.0.8(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.1.6(@types/node@22.10.2) + '@inquirer/type': 3.0.4(@types/node@22.10.2) + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/figures@1.0.10': {} + + '@inquirer/input@4.1.5(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.1.6(@types/node@22.10.2) + '@inquirer/type': 3.0.4(@types/node@22.10.2) + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/number@3.0.8(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.1.6(@types/node@22.10.2) + '@inquirer/type': 3.0.4(@types/node@22.10.2) + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/password@4.0.8(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.1.6(@types/node@22.10.2) + '@inquirer/type': 3.0.4(@types/node@22.10.2) + ansi-escapes: 4.3.2 + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/prompts@7.2.1(@types/node@22.10.2)': + dependencies: + '@inquirer/checkbox': 4.1.1(@types/node@22.10.2) + '@inquirer/confirm': 5.1.5(@types/node@22.10.2) + '@inquirer/editor': 4.2.6(@types/node@22.10.2) + '@inquirer/expand': 4.0.8(@types/node@22.10.2) + '@inquirer/input': 4.1.5(@types/node@22.10.2) + '@inquirer/number': 3.0.8(@types/node@22.10.2) + '@inquirer/password': 4.0.8(@types/node@22.10.2) + '@inquirer/rawlist': 4.0.8(@types/node@22.10.2) + '@inquirer/search': 3.0.8(@types/node@22.10.2) + '@inquirer/select': 4.0.8(@types/node@22.10.2) + '@types/node': 22.10.2 + + '@inquirer/prompts@7.2.3(@types/node@22.10.2)': + dependencies: + '@inquirer/checkbox': 4.1.1(@types/node@22.10.2) + '@inquirer/confirm': 5.1.5(@types/node@22.10.2) + '@inquirer/editor': 4.2.6(@types/node@22.10.2) + '@inquirer/expand': 4.0.8(@types/node@22.10.2) + '@inquirer/input': 4.1.5(@types/node@22.10.2) + '@inquirer/number': 3.0.8(@types/node@22.10.2) + '@inquirer/password': 4.0.8(@types/node@22.10.2) + '@inquirer/rawlist': 4.0.8(@types/node@22.10.2) + '@inquirer/search': 3.0.8(@types/node@22.10.2) + '@inquirer/select': 4.0.8(@types/node@22.10.2) + '@types/node': 22.10.2 + + '@inquirer/rawlist@4.0.8(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.1.6(@types/node@22.10.2) + '@inquirer/type': 3.0.4(@types/node@22.10.2) + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/search@3.0.8(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.1.6(@types/node@22.10.2) + '@inquirer/figures': 1.0.10 + '@inquirer/type': 3.0.4(@types/node@22.10.2) + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/select@4.0.8(@types/node@22.10.2)': + dependencies: + '@inquirer/core': 10.1.6(@types/node@22.10.2) + '@inquirer/figures': 1.0.10 + '@inquirer/type': 3.0.4(@types/node@22.10.2) + ansi-escapes: 4.3.2 + yoctocolors-cjs: 2.1.2 + optionalDependencies: + '@types/node': 22.10.2 + + '@inquirer/type@3.0.4(@types/node@22.10.2)': + optionalDependencies: + '@types/node': 22.10.2 + + '@ioredis/commands@1.2.0': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.10.2 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.10.2 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.7 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.10.2 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 22.10.2 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.25 + '@types/node': 22.10.2 + chalk: 4.1.2 + collect-v8-coverage: 1.0.2 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.7 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.2 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.24.9 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.25 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.7 + pirates: 4.0.6 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.10.2 + '@types/yargs': 17.0.32 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/source-map@0.3.6': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@levischuck/tiny-cbor@0.2.11': {} + + '@liaoliaots/nestjs-redis@10.0.0(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.7)(ioredis@5.5.0)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.7(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.2) + ioredis: 5.5.0 + tslib: 2.7.0 + + '@lukeed/csprng@1.1.0': {} + + '@nestjs-modules/mailer@2.0.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.7)(nodemailer@6.10.0)': + dependencies: + '@css-inline/css-inline': 0.14.1 + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.7(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.2) + glob: 10.3.12 + nodemailer: 6.10.0 + optionalDependencies: + '@types/ejs': 3.1.5 + '@types/mjml': 4.7.4 + '@types/pug': 2.0.10 + ejs: 3.1.10 + handlebars: 4.7.8 + liquidjs: 10.16.0 + mjml: 4.15.3 + preview-email: 3.0.20 + pug: 3.0.3 + transitivePeerDependencies: + - encoding + + '@nestjs/cli@11.0.2(@types/node@22.10.2)': + dependencies: + '@angular-devkit/core': 19.1.3(chokidar@4.0.3) + '@angular-devkit/schematics': 19.1.3(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.1.3(@types/node@22.10.2)(chokidar@4.0.3) + '@inquirer/prompts': 7.2.3(@types/node@22.10.2) + '@nestjs/schematics': 11.0.0(chokidar@4.0.3)(typescript@5.7.3) + ansis: 3.9.0 + chokidar: 4.0.3 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.3)(webpack@5.97.1) + glob: 11.0.1 + node-emoji: 1.11.0 + ora: 5.4.1 + tree-kill: 1.2.2 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.7.3 + webpack: 5.97.1 + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@types/node' + - esbuild + - uglify-js + - webpack-cli + + '@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + iterare: 1.2.1 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.1 + + '@nestjs/config@3.3.0(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 16.4.5 + dotenv-expand: 10.0.0 + lodash: 4.17.21 + rxjs: 7.8.2 + + '@nestjs/core@10.4.7(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxtjs/opencollective': 0.3.2 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 3.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.7.0 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.7) + transitivePeerDependencies: + - encoding + + '@nestjs/elasticsearch@11.0.0(@elastic/elasticsearch@8.15.1)(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@elastic/elasticsearch': 8.15.1 + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + rxjs: 7.8.2 + + '@nestjs/jwt@10.2.0(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.5 + jsonwebtoken: 9.0.2 + + '@nestjs/mapped-types@2.0.6(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.1 + + '@nestjs/platform-express@10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.7)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.7(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.2) + body-parser: 1.20.3 + cors: 2.8.5 + express: 4.21.2 + multer: 1.4.4-lts.1 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/schematics@10.1.4(chokidar@4.0.3)(typescript@5.7.2)': + dependencies: + '@angular-devkit/core': 17.3.8(chokidar@4.0.3) + '@angular-devkit/schematics': 17.3.8(chokidar@4.0.3) + comment-json: 4.2.3 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.7.2 + transitivePeerDependencies: + - chokidar + + '@nestjs/schematics@11.0.0(chokidar@4.0.3)(typescript@5.7.3)': + dependencies: + '@angular-devkit/core': 19.0.1(chokidar@4.0.3) + '@angular-devkit/schematics': 19.0.1(chokidar@4.0.3) + comment-json: 4.2.5 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.7.3 + transitivePeerDependencies: + - chokidar + + '@nestjs/serve-static@4.0.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.7)(express@4.21.2)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.7(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.2) + path-to-regexp: 0.2.5 + optionalDependencies: + express: 4.21.2 + + '@nestjs/testing@10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.7)(@nestjs/platform-express@10.4.15)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.7(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.7) + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@nuxtjs/opencollective@0.3.2': + dependencies: + chalk: 4.1.2 + consola: 2.15.3 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + '@one-ini/wasm@0.1.1': + optional: true + + '@opentelemetry/api@1.4.1': {} + + '@opentelemetry/api@1.9.0': {} + + '@otplib/core@12.0.1': {} + + '@otplib/plugin-crypto@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + + '@otplib/plugin-thirty-two@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + thirty-two: 1.0.2 + + '@otplib/preset-default@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + '@otplib/plugin-crypto': 12.0.1 + '@otplib/plugin-thirty-two': 12.0.1 + + '@otplib/preset-v11@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + '@otplib/plugin-crypto': 12.0.1 + '@otplib/plugin-thirty-two': 12.0.1 + + '@peculiar/asn1-android@2.3.15': + dependencies: + '@peculiar/asn1-schema': 2.3.15 + asn1js: 3.0.5 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.3.15': + dependencies: + '@peculiar/asn1-schema': 2.3.15 + '@peculiar/asn1-x509': 2.3.15 + asn1js: 3.0.5 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.3.15': + dependencies: + '@peculiar/asn1-schema': 2.3.15 + '@peculiar/asn1-x509': 2.3.15 + asn1js: 3.0.5 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.3.15': + dependencies: + asn1js: 3.0.5 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.3.15': + dependencies: + '@peculiar/asn1-schema': 2.3.15 + asn1js: 3.0.5 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.1.1': {} + + '@prisma/client@6.4.1(prisma@5.22.0)(typescript@5.7.2)': + optionalDependencies: + prisma: 5.22.0 + typescript: 5.7.2 + + '@prisma/debug@4.16.2': + dependencies: + '@types/debug': 4.1.8 + debug: 4.3.4 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - supports-color + + '@prisma/debug@5.22.0': {} + + '@prisma/debug@5.9.1': {} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} + + '@prisma/engines@4.16.2': {} + + '@prisma/engines@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/fetch-engine': 5.22.0 + '@prisma/get-platform': 5.22.0 + + '@prisma/fetch-engine@4.16.2': + dependencies: + '@prisma/debug': 4.16.2 + '@prisma/get-platform': 4.16.2 + execa: 5.1.1 + find-cache-dir: 3.3.2 + fs-extra: 11.1.1 + hasha: 5.2.2 + http-proxy-agent: 7.0.0 + https-proxy-agent: 7.0.0 + kleur: 4.1.5 + node-fetch: 2.6.11 + p-filter: 2.1.0 + p-map: 4.0.0 + p-retry: 4.6.2 + progress: 2.0.3 + rimraf: 3.0.2 + temp-dir: 2.0.0 + tempy: 1.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@prisma/fetch-engine@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/get-platform': 5.22.0 + + '@prisma/generator-helper@4.16.2': + dependencies: + '@prisma/debug': 4.16.2 + '@types/cross-spawn': 6.0.2 + cross-spawn: 7.0.3 + kleur: 4.1.5 + transitivePeerDependencies: + - supports-color + + '@prisma/generator-helper@5.9.1': + dependencies: + '@prisma/debug': 5.9.1 + + '@prisma/get-platform@4.16.2': + dependencies: + '@prisma/debug': 4.16.2 + escape-string-regexp: 4.0.0 + execa: 5.1.1 + fs-jetpack: 5.1.0 + kleur: 4.1.5 + replace-string: 3.1.0 + strip-ansi: 6.0.1 + tempy: 1.0.1 + terminal-link: 2.1.1 + ts-pattern: 4.3.0 + transitivePeerDependencies: + - supports-color + + '@prisma/get-platform@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + + '@prisma/internals@4.16.2': + dependencies: + '@antfu/ni': 0.21.4 + '@opentelemetry/api': 1.4.1 + '@prisma/debug': 4.16.2 + '@prisma/engines': 4.16.2 + '@prisma/fetch-engine': 4.16.2 + '@prisma/generator-helper': 4.16.2 + '@prisma/get-platform': 4.16.2 + '@prisma/prisma-fmt-wasm': 4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81 + archiver: 5.3.1 + arg: 5.0.2 + checkpoint-client: 1.1.24 + cli-truncate: 2.1.0 + dotenv: 16.0.3 + escape-string-regexp: 4.0.0 + execa: 5.1.1 + find-up: 5.0.0 + fp-ts: 2.16.0 + fs-extra: 11.1.1 + fs-jetpack: 5.1.0 + global-dirs: 3.0.1 + globby: 11.1.0 + indent-string: 4.0.0 + is-windows: 1.0.2 + is-wsl: 2.2.0 + kleur: 4.1.5 + new-github-issue-url: 0.2.1 + node-fetch: 2.6.11 + npm-packlist: 5.1.3 + open: 7.4.2 + p-map: 4.0.0 + prompts: 2.4.2 + read-pkg-up: 7.0.1 + replace-string: 3.1.0 + resolve: 1.22.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + strip-indent: 3.0.0 + temp-dir: 2.0.0 + temp-write: 4.0.0 + tempy: 1.0.1 + terminal-link: 2.1.1 + tmp: 0.2.1 + ts-pattern: 4.3.0 + transitivePeerDependencies: + - encoding + - supports-color + + '@prisma/prisma-fmt-wasm@4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81': {} + + '@prisma/prisma-fmt-wasm@4.8.0-9.5be62d4c40defb9d2faf09dc30edaa449580d417': {} + + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + optional: true + + '@simplewebauthn/server@13.1.1': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.3.15 + '@peculiar/asn1-ecc': 2.3.15 + '@peculiar/asn1-rsa': 2.3.15 + '@peculiar/asn1-schema': 2.3.15 + '@peculiar/asn1-x509': 2.3.15 + + '@sinclair/typebox@0.27.8': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.24.8 + '@babel/types': 7.24.9 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.6 + + '@types/babel__generator@7.6.8': + dependencies: + '@babel/types': 7.24.9 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.24.8 + '@babel/types': 7.24.9 + + '@types/babel__traverse@7.20.6': + dependencies: + '@babel/types': 7.24.9 + + '@types/bcryptjs@2.4.6': {} + + '@types/body-parser@1.19.5': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.10.2 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.10.2 + + '@types/conventional-commits-parser@5.0.0': + dependencies: + '@types/node': 22.10.2 + + '@types/cookiejar@2.1.5': {} + + '@types/cross-spawn@6.0.2': + dependencies: + '@types/node': 22.10.2 + + '@types/debug@4.1.8': + dependencies: + '@types/ms': 0.7.34 + + '@types/ejs@3.1.5': + optional: true + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.6 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.6 + '@types/json-schema': 7.0.15 + + '@types/estree@1.0.6': {} + + '@types/express-serve-static-core@4.19.5': + dependencies: + '@types/node': 22.10.2 + '@types/qs': 6.9.15 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + + '@types/express-serve-static-core@5.0.1': + dependencies: + '@types/node': 22.10.2 + '@types/qs': 6.9.15 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + + '@types/express-session@1.18.1': + dependencies: + '@types/express': 5.0.0 + + '@types/express@4.17.21': + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 4.19.5 + '@types/qs': 6.9.15 + '@types/serve-static': 1.15.7 + + '@types/express@5.0.0': + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 5.0.1 + '@types/qs': 6.9.15 + '@types/serve-static': 1.15.7 + + '@types/fluent-ffmpeg@2.1.27': + dependencies: + '@types/node': 22.10.2 + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 22.10.2 + + '@types/http-errors@2.0.4': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.5': + dependencies: + '@types/node': 22.10.2 + + '@types/md5@2.3.5': {} + + '@types/methods@1.1.4': {} + + '@types/mime-types@2.1.4': {} + + '@types/mime@1.3.5': {} + + '@types/mjml-core@4.15.0': + optional: true + + '@types/mjml@4.7.4': + dependencies: + '@types/mjml-core': 4.15.0 + optional: true + + '@types/ms@0.7.34': {} + + '@types/multer@1.4.12': + dependencies: + '@types/express': 4.17.21 + + '@types/node@22.10.2': + dependencies: + undici-types: 6.20.0 + + '@types/normalize-package-data@2.4.4': {} + + '@types/pug@2.0.10': + optional: true + + '@types/qrcode@1.5.5': + dependencies: + '@types/node': 22.10.2 + + '@types/qs@6.9.15': {} + + '@types/range-parser@1.2.7': {} + + '@types/retry@0.12.0': {} + + '@types/send@0.17.4': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.10.2 + + '@types/serve-static@1.15.7': + dependencies: + '@types/http-errors': 2.0.4 + '@types/node': 22.10.2 + '@types/send': 0.17.4 + + '@types/stack-utils@2.0.3': {} + + '@types/superagent@8.1.7': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.10.2 + + '@types/supertest@6.0.2': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.7 + + '@types/uuid@10.0.0': {} + + '@types/validator@13.12.0': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.32': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.24.1(@typescript-eslint/parser@8.18.0(eslint@9.8.0)(typescript@5.7.2))(eslint@9.8.0)(typescript@5.7.2)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.18.0(eslint@9.8.0)(typescript@5.7.2) + '@typescript-eslint/scope-manager': 8.24.1 + '@typescript-eslint/type-utils': 8.24.1(eslint@9.8.0)(typescript@5.7.2) + '@typescript-eslint/utils': 8.24.1(eslint@9.8.0)(typescript@5.7.2) + '@typescript-eslint/visitor-keys': 8.24.1 + eslint: 9.8.0 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 2.0.1(typescript@5.7.2) + typescript: 5.7.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.18.0(eslint@9.8.0)(typescript@5.7.2)': + dependencies: + '@typescript-eslint/scope-manager': 8.18.0 + '@typescript-eslint/types': 8.18.0 + '@typescript-eslint/typescript-estree': 8.18.0(typescript@5.7.2) + '@typescript-eslint/visitor-keys': 8.18.0 + debug: 4.3.6 + eslint: 9.8.0 + typescript: 5.7.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.18.0': + dependencies: + '@typescript-eslint/types': 8.18.0 + '@typescript-eslint/visitor-keys': 8.18.0 + + '@typescript-eslint/scope-manager@8.24.1': + dependencies: + '@typescript-eslint/types': 8.24.1 + '@typescript-eslint/visitor-keys': 8.24.1 + + '@typescript-eslint/type-utils@8.24.1(eslint@9.8.0)(typescript@5.7.2)': + dependencies: + '@typescript-eslint/typescript-estree': 8.24.1(typescript@5.7.2) + '@typescript-eslint/utils': 8.24.1(eslint@9.8.0)(typescript@5.7.2) + debug: 4.4.0 + eslint: 9.8.0 + ts-api-utils: 2.0.1(typescript@5.7.2) + typescript: 5.7.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.18.0': {} + + '@typescript-eslint/types@8.24.1': {} + + '@typescript-eslint/typescript-estree@8.18.0(typescript@5.7.2)': + dependencies: + '@typescript-eslint/types': 8.18.0 + '@typescript-eslint/visitor-keys': 8.18.0 + debug: 4.4.0 + fast-glob: 3.3.2 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 1.4.3(typescript@5.7.2) + typescript: 5.7.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.24.1(typescript@5.7.2)': + dependencies: + '@typescript-eslint/types': 8.24.1 + '@typescript-eslint/visitor-keys': 8.24.1 + debug: 4.4.0 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.1 + ts-api-utils: 2.0.1(typescript@5.7.2) + typescript: 5.7.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.24.1(eslint@9.8.0)(typescript@5.7.2)': + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@9.8.0) + '@typescript-eslint/scope-manager': 8.24.1 + '@typescript-eslint/types': 8.24.1 + '@typescript-eslint/typescript-estree': 8.24.1(typescript@5.7.2) + eslint: 9.8.0 + typescript: 5.7.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.18.0': + dependencies: + '@typescript-eslint/types': 8.18.0 + eslint-visitor-keys: 4.2.0 + + '@typescript-eslint/visitor-keys@8.24.1': + dependencies: + '@typescript-eslint/types': 8.24.1 + eslint-visitor-keys: 4.2.0 + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + JSONStream@1.3.5: + dependencies: + jsonparse: 1.3.1 + through: 2.3.8 + + abbrev@2.0.0: + optional: true + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-jsx@5.3.2(acorn@8.12.1): + dependencies: + acorn: 8.12.1 + + acorn-walk@8.3.3: + dependencies: + acorn: 8.12.1 + + acorn@7.4.1: + optional: true + + acorn@8.12.1: {} + + acorn@8.14.0: {} + + agent-base@7.1.1: + dependencies: + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv-formats@2.1.1(ajv@8.12.0): + optionalDependencies: + ajv: 8.12.0 + + ajv-formats@2.1.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-keywords@3.5.2(ajv@6.12.6): + dependencies: + ajv: 6.12.6 + + ajv-keywords@5.1.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + fast-deep-equal: 3.1.3 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.12.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.1 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alce@1.2.0: + dependencies: + esprima: 1.2.5 + estraverse: 1.9.3 + optional: true + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.0.1: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.1: {} + + ansis@3.9.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + append-field@1.0.0: {} + + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.1: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.5 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + + arg@4.1.3: {} + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-buffer-to-hex@1.0.0: {} + + array-flatten@1.1.1: {} + + array-ify@1.0.0: {} + + array-timsort@1.0.3: {} + + array-union@2.1.0: {} + + asap@2.0.6: {} + + asn1js@3.0.5: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.3 + tslib: 2.8.1 + + assert-never@1.3.0: + optional: true + + astral-regex@2.0.0: {} + + async-mutex@0.5.0: + dependencies: + tslib: 2.6.3 + + async@0.2.10: {} + + async@3.2.5: {} + + asynckit@0.4.0: {} + + babel-jest@29.7.0(@babel/core@7.24.9): + dependencies: + '@babel/core': 7.24.9 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.24.9) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.24.8 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.24.7 + '@babel/types': 7.24.9 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.20.6 + + babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.9): + dependencies: + '@babel/core': 7.24.9 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.9) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.24.9) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.24.9) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.9) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.9) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.9) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.9) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.9) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.9) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.9) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.9) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.9) + + babel-preset-jest@29.6.3(@babel/core@7.24.9): + dependencies: + '@babel/core': 7.24.9 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.9) + + babel-walk@3.0.0-canary-5: + dependencies: + '@babel/types': 7.24.9 + optional: true + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + bcryptjs@2.4.3: {} + + binary-extensions@2.3.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@1.20.3: + dependencies: + 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.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + boolbase@1.0.0: + optional: true + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.24.4: + dependencies: + caniuse-lite: 1.0.30001697 + electron-to-chromium: 1.5.93 + node-releases: 2.0.19 + update-browserslist-db: 1.1.2(browserslist@4.24.4) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-crc32@0.2.13: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind@1.0.7: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + set-function-length: 1.2.2 + + callsites@3.1.0: {} + + camel-case@3.0.0: + dependencies: + no-case: 2.3.2 + upper-case: 1.1.3 + optional: true + + camel-case@4.1.2: + dependencies: + pascal-case: 3.1.2 + tslib: 2.8.1 + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001697: {} + + capital-case@1.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case-first: 2.0.2 + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + optional: true + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.3.0: {} + + chalk@5.4.1: {} + + change-case@4.1.2: + dependencies: + camel-case: 4.1.2 + capital-case: 1.0.4 + constant-case: 3.0.4 + dot-case: 3.0.4 + header-case: 2.0.4 + no-case: 3.0.4 + param-case: 3.0.4 + pascal-case: 3.1.2 + path-case: 3.0.4 + sentence-case: 3.0.4 + snake-case: 3.0.4 + tslib: 2.8.1 + + char-regex@1.0.2: {} + + character-parser@2.2.0: + dependencies: + is-regex: 1.1.4 + optional: true + + chardet@0.7.0: {} + + charenc@0.0.2: {} + + checkpoint-client@1.1.24: + dependencies: + ci-info: 3.8.0 + env-paths: 2.2.1 + fast-write-atomic: 0.2.1 + make-dir: 3.1.0 + ms: 2.1.3 + node-fetch: 2.6.11 + uuid: 9.0.0 + transitivePeerDependencies: + - encoding + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + optional: true + + cheerio@1.0.0-rc.12: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.1.0 + htmlparser2: 8.0.2 + parse5: 7.1.2 + parse5-htmlparser2-tree-adapter: 7.0.0 + optional: true + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.1 + + chrome-trace-event@1.0.4: {} + + ci-info@3.8.0: {} + + ci-info@3.9.0: {} + + cjs-module-lexer@1.3.1: {} + + class-transformer@0.5.1: {} + + class-validator@0.14.1: + dependencies: + '@types/validator': 13.12.0 + libphonenumber-js: 1.11.4 + validator: 13.12.0 + + clean-css@4.2.4: + dependencies: + source-map: 0.6.1 + optional: true + + clean-stack@2.2.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-truncate@2.1.0: + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + + cli-width@4.1.0: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + cluster-key-slot@1.1.2: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.2: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@10.0.1: + optional: true + + commander@12.1.0: {} + + commander@2.20.3: {} + + commander@4.1.1: {} + + commander@6.2.1: + optional: true + + commander@7.2.0: {} + + comment-json@4.2.3: + dependencies: + 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 + + comment-json@4.2.5: + dependencies: + 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 + + commondir@1.0.1: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + component-emitter@1.3.1: {} + + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + concat-map@0.0.1: {} + + concat-stream@1.6.2: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + optional: true + + connect-redis@8.0.1(express-session@1.18.1): + dependencies: + express-session: 1.18.1 + + consola@2.15.3: {} + + constant-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case: 2.0.2 + + constantinople@4.0.1: + dependencies: + '@babel/parser': 7.24.8 + '@babel/types': 7.24.9 + optional: true + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + conventional-changelog-angular@7.0.0: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@7.0.2: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@5.0.0: + dependencies: + JSONStream: 1.3.5 + is-text-path: 2.0.0 + meow: 12.1.1 + split2: 4.2.0 + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.6: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.1: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + core-util-is@1.0.3: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig-typescript-loader@6.1.0(@types/node@22.10.2)(cosmiconfig@9.0.0(typescript@5.7.2))(typescript@5.7.2): + dependencies: + '@types/node': 22.10.2 + cosmiconfig: 9.0.0(typescript@5.7.2) + jiti: 2.4.2 + typescript: 5.7.2 + + cosmiconfig@8.3.6(typescript@5.7.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.7.3 + + cosmiconfig@9.0.0(typescript@5.7.2): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.7.2 + + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + + create-jest@29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + create-require@1.1.1: {} + + cross-spawn@6.0.6: + dependencies: + nice-try: 1.0.5 + path-key: 2.0.1 + semver: 5.7.2 + shebang-command: 1.2.0 + which: 1.3.1 + optional: true + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypt@0.0.2: {} + + crypto-digest-sync@1.0.0: {} + + crypto-random-hex@1.0.0: + dependencies: + array-buffer-to-hex: 1.0.0 + + crypto-random-string@2.0.0: {} + + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + optional: true + + css-what@6.1.0: + optional: true + + dargs@8.1.0: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.3.4: + dependencies: + ms: 2.1.2 + + debug@4.3.5: + dependencies: + ms: 2.1.2 + + debug@4.3.6: + dependencies: + ms: 2.1.2 + + debug@4.4.0: + dependencies: + ms: 2.1.3 + + decamelize@1.2.0: {} + + dedent@1.5.3: {} + + deep-extend@0.6.0: + optional: true + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + + del@6.1.1: + dependencies: + globby: 11.1.0 + graceful-fs: 4.2.11 + is-glob: 4.0.3 + is-path-cwd: 2.2.0 + is-path-inside: 3.0.3 + p-map: 4.0.0 + rimraf: 3.0.2 + slash: 3.0.0 + + delayed-stream@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-indent@6.1.0: + optional: true + + detect-newline@3.1.0: {} + + detect-node@2.1.0: + optional: true + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + diff-sequences@29.6.3: {} + + diff@4.0.2: {} + + dijkstrajs@1.0.3: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + display-notification@2.0.0: + dependencies: + escape-string-applescript: 1.0.0 + run-applescript: 3.2.0 + optional: true + + doctypes@1.1.0: + optional: true + + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + optional: true + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + optional: true + + domelementtype@2.3.0: + optional: true + + domhandler@3.3.0: + dependencies: + domelementtype: 2.3.0 + optional: true + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + optional: true + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + optional: true + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + optional: true + + domutils@3.1.0: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + optional: true + + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv-expand@10.0.0: {} + + dotenv@16.0.3: {} + + dotenv@16.4.5: {} + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + editorconfig@1.0.4: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.1 + semver: 7.7.1 + optional: true + + ee-first@1.1.1: {} + + ejs@3.1.10: + dependencies: + jake: 10.9.2 + + electron-to-chromium@1.5.93: {} + + emittery@0.13.1: {} + + emoji-regex@10.4.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encode-utf8@1.0.3: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + encoding-japanese@2.0.0: + optional: true + + encoding-japanese@2.1.0: + optional: true + + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.17.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + enhanced-resolve@5.18.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + entities@2.2.0: + optional: true + + entities@4.5.0: + optional: true + + env-paths@2.2.1: {} + + environment@1.1.0: {} + + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.0: + dependencies: + get-intrinsic: 1.2.4 + + es-errors@1.3.0: {} + + es-module-lexer@1.6.0: {} + + escalade@3.2.0: {} + + escape-goat@3.0.0: + optional: true + + escape-html@1.0.3: {} + + escape-string-applescript@1.0.0: + optional: true + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@9.1.0(eslint@9.8.0): + dependencies: + eslint: 9.8.0 + + eslint-plugin-prettier@5.2.3(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.8.0))(eslint@9.8.0)(prettier@3.5.1): + dependencies: + eslint: 9.8.0 + prettier: 3.5.1 + prettier-linter-helpers: 1.0.0 + synckit: 0.9.2 + optionalDependencies: + '@types/eslint': 9.6.1 + eslint-config-prettier: 9.1.0(eslint@9.8.0) + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@8.0.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.0.0: {} + + eslint-visitor-keys@4.2.0: {} + + eslint@9.8.0: + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.8.0) + '@eslint-community/regexpp': 4.11.0 + '@eslint/config-array': 0.17.1 + '@eslint/eslintrc': 3.1.0 + '@eslint/js': 9.8.0 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.3.0 + '@nodelib/fs.walk': 1.2.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.5 + escape-string-regexp: 4.0.0 + eslint-scope: 8.0.2 + eslint-visitor-keys: 4.0.0 + espree: 10.1.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.1 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + 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.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@10.1.0: + dependencies: + acorn: 8.12.1 + acorn-jsx: 5.3.2(acorn@8.12.1) + eslint-visitor-keys: 4.0.0 + + esprima@1.2.5: + optional: true + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@1.9.3: + optional: true + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventemitter3@5.0.1: {} + + events@3.3.0: {} + + execa@0.10.0: + dependencies: + cross-spawn: 6.0.6 + get-stream: 3.0.0 + is-stream: 1.1.0 + npm-run-path: 2.0.2 + p-finally: 1.0.0 + signal-exit: 3.0.7 + strip-eof: 1.0.0 + optional: true + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.3 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + express-session@1.18.1: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.7 + 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 + transitivePeerDependencies: + - supports-color + + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend-object@1.0.0: + optional: true + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.7 + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.0.1: {} + + fast-write-atomic@0.2.1: {} + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + filelist@1.0.4: + dependencies: + minimatch: 5.1.6 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + find-up@6.3.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + + fixpack@4.0.0: + dependencies: + alce: 1.2.0 + chalk: 3.0.0 + detect-indent: 6.1.0 + detect-newline: 3.1.0 + extend-object: 1.0.0 + rc: 1.2.8 + optional: true + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + + flatted@3.3.1: {} + + fluent-ffmpeg@2.1.3: + dependencies: + async: 0.2.10 + which: 1.3.1 + + foreground-child@3.2.1: + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.3)(webpack@5.97.1): + dependencies: + '@babel/code-frame': 7.26.2 + chalk: 4.1.2 + chokidar: 3.6.0 + cosmiconfig: 8.3.6(typescript@5.7.3) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.2 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.7.1 + tapable: 2.2.1 + typescript: 5.7.3 + webpack: 5.97.1 + + form-data@4.0.0: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + formidable@3.5.1: + dependencies: + dezalgo: 1.0.4 + hexoid: 1.0.0 + once: 1.4.0 + + forwarded@0.2.0: {} + + fp-ts@2.16.0: {} + + fresh@0.5.2: {} + + fs-constants@1.0.0: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs-extra@11.1.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs-jetpack@5.1.0: + dependencies: + minimatch: 5.1.6 + + fs-monkey@1.0.6: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.2.0: {} + + get-intrinsic@1.2.4: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + get-package-type@0.1.0: {} + + get-port@5.1.1: + optional: true + + get-stream@3.0.0: + optional: true + + get-stream@6.0.1: {} + + get-stream@8.0.1: {} + + git-raw-commits@4.0.0: + dependencies: + dargs: 8.1.0 + meow: 12.1.1 + split2: 4.2.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@10.3.12: + dependencies: + foreground-child: 3.2.1 + jackspeak: 2.3.6 + minimatch: 9.0.5 + minipass: 7.1.2 + path-scurry: 1.11.1 + + glob@10.4.5: + dependencies: + foreground-child: 3.2.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 1.11.1 + + glob@11.0.1: + dependencies: + foreground-child: 3.3.0 + jackspeak: 4.0.2 + minimatch: 10.0.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.0 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.0.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + + global-dirs@3.0.1: + dependencies: + ini: 2.0.0 + + globals@11.12.0: {} + + globals@14.0.0: {} + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.0.1: + dependencies: + get-intrinsic: 1.2.4 + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.0 + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-own-prop@2.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.0 + + has-proto@1.0.3: {} + + has-symbols@1.0.3: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.0.3 + optional: true + + hasha@5.2.2: + dependencies: + is-stream: 2.0.1 + type-fest: 0.8.1 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: + optional: true + + header-case@2.0.4: + dependencies: + capital-case: 1.0.4 + tslib: 2.8.1 + + hex-to-array-buffer@1.1.0: {} + + hexoid@1.0.0: {} + + hosted-git-info@2.8.9: {} + + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + hpagent@1.2.0: {} + + html-escaper@2.0.2: {} + + html-minifier@4.0.0: + dependencies: + camel-case: 3.0.0 + clean-css: 4.2.4 + commander: 2.20.3 + he: 1.2.0 + param-case: 2.1.1 + relateurl: 0.2.7 + uglify-js: 3.19.0 + optional: true + + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + optional: true + + htmlparser2@5.0.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 3.3.0 + domutils: 2.8.0 + entities: 2.2.0 + optional: true + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + optional: true + + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + optional: true + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.0: + dependencies: + agent-base: 7.1.1 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.0: + dependencies: + agent-base: 7.1.1 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@5.0.0: {} + + husky@9.1.7: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + + ieee754@1.2.1: {} + + ignore-walk@5.0.1: + dependencies: + minimatch: 5.1.6 + + ignore@5.3.1: {} + + ignore@5.3.2: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.1.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + import-meta-resolve@4.1.0: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: + optional: true + + ini@2.0.0: {} + + ini@4.1.1: {} + + ioredis@5.5.0: + dependencies: + '@ioredis/commands': 1.2.0 + cluster-key-slot: 1.1.2 + debug: 4.4.0 + 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 + transitivePeerDependencies: + - supports-color + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-buffer@1.1.6: {} + + is-core-module@2.15.0: + dependencies: + hasown: 2.0.2 + + is-docker@2.2.1: {} + + is-expression@4.0.0: + dependencies: + acorn: 7.4.1 + object-assign: 4.1.1 + optional: true + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.2.0 + + is-generator-fn@2.1.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-path-cwd@2.2.0: {} + + is-path-inside@3.0.3: {} + + is-promise@2.2.2: + optional: true + + is-regex@1.1.4: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + optional: true + + is-stream@1.1.0: + optional: true + + is-stream@2.0.1: {} + + is-stream@3.0.0: {} + + is-text-path@2.0.0: + dependencies: + text-extensions: 2.4.0 + + is-unicode-supported@0.1.0: {} + + is-windows@1.0.2: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.24.9 + '@babel/parser': 7.24.8 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.24.9 + '@babel/parser': 7.24.8 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.0 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.1.7: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterare@1.2.1: {} + + jackspeak@2.3.6: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jackspeak@4.0.2: + dependencies: + '@isaacs/cliui': 8.0.2 + + jake@10.9.2: + dependencies: + async: 3.2.5 + chalk: 4.1.2 + filelist: 1.0.4 + minimatch: 3.1.2 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.10.2 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.5.3 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)) + exit: 0.1.2 + import-local: 3.1.0 + jest-config: 29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)): + dependencies: + '@babel/core': 7.24.9 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.24.9) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.7 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.10.2 + ts-node: 10.9.2(@types/node@22.10.2)(typescript@5.7.2) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.10.2 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 22.10.2 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.7 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.24.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.7 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.10.2 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.8 + resolve.exports: 2.0.2 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.10.2 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.10.2 + chalk: 4.1.2 + cjs-module-lexer: 1.3.1 + collect-v8-coverage: 1.0.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.24.9 + '@babel/generator': 7.24.10 + '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.24.9) + '@babel/plugin-syntax-typescript': 7.24.7(@babel/core@7.24.9) + '@babel/types': 7.24.9 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.9) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.7.1 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.10.2 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.10.2 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@27.5.1: + dependencies: + '@types/node': 22.10.2 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest-worker@29.7.0: + dependencies: + '@types/node': 22.10.2 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)) + '@jest/types': 29.6.3 + import-local: 3.1.0 + jest-cli: 29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jiti@2.4.2: {} + + js-beautify@1.15.1: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.4 + glob: 10.4.5 + js-cookie: 3.0.5 + nopt: 7.2.1 + optional: true + + js-cookie@3.0.5: + optional: true + + js-stringify@1.0.2: + optional: true + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsbn@1.1.0: {} + + jsesc@2.5.2: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-parser@3.2.1: {} + + jsonc-parser@3.3.1: {} + + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonparse@1.3.1: {} + + jsonwebtoken@9.0.2: + dependencies: + 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.1.1 + ms: 2.1.3 + semver: 7.6.3 + + jstransformer@1.0.0: + dependencies: + is-promise: 2.2.2 + promise: 7.3.1 + optional: true + + juice@10.0.0: + dependencies: + cheerio: 1.0.0-rc.12 + commander: 6.2.1 + mensch: 0.3.4 + slick: 1.12.2 + web-resource-inliner: 6.0.1 + transitivePeerDependencies: + - encoding + optional: true + + jwa@1.4.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + leac@0.6.0: + optional: true + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + libbase64@1.2.1: + optional: true + + libbase64@1.3.0: + optional: true + + libmime@5.2.0: + dependencies: + encoding-japanese: 2.0.0 + iconv-lite: 0.6.3 + libbase64: 1.2.1 + libqp: 2.0.1 + optional: true + + libmime@5.3.5: + dependencies: + encoding-japanese: 2.1.0 + iconv-lite: 0.6.3 + libbase64: 1.3.0 + libqp: 2.1.0 + optional: true + + libphonenumber-js@1.11.4: {} + + libqp@2.0.1: + optional: true + + libqp@2.1.0: + optional: true + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + optional: true + + lint-staged@15.2.11: + dependencies: + chalk: 5.3.0 + commander: 12.1.0 + debug: 4.4.0 + execa: 8.0.1 + lilconfig: 3.1.3 + listr2: 8.2.5 + micromatch: 4.0.8 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.6.1 + transitivePeerDependencies: + - supports-color + + liquidjs@10.16.0: + dependencies: + commander: 10.0.1 + optional: true + + listr2@8.2.5: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 + + loader-runner@4.3.0: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.flatten@4.4.0: {} + + lodash.includes@4.3.0: {} + + lodash.isarguments@3.1.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.kebabcase@4.1.1: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lodash.mergewith@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.snakecase@4.1.1: {} + + lodash.startcase@4.4.0: {} + + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + + lodash.upperfirst@4.3.1: {} + + lodash@4.17.21: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.0.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + lower-case@1.1.4: + optional: true + + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + + lru-cache@10.4.3: {} + + lru-cache@11.0.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + magic-string@0.30.12: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + magic-string@0.30.8: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + mailparser@3.7.1: + dependencies: + encoding-japanese: 2.1.0 + he: 1.2.0 + html-to-text: 9.0.5 + iconv-lite: 0.6.3 + libmime: 5.3.5 + linkify-it: 5.0.0 + mailsplit: 5.4.0 + nodemailer: 6.9.13 + punycode.js: 2.3.1 + tlds: 1.252.0 + optional: true + + mailsplit@5.4.0: + dependencies: + libbase64: 1.2.1 + libmime: 5.2.0 + libqp: 2.0.1 + optional: true + + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.1 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + md5@2.3.0: + dependencies: + charenc: 0.0.2 + crypt: 0.0.2 + is-buffer: 1.1.6 + + media-typer@0.3.0: {} + + memfs@3.5.3: + dependencies: + fs-monkey: 1.0.6 + + mensch@0.3.4: + optional: true + + meow@12.1.1: {} + + merge-descriptors@1.0.3: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromatch@4.0.7: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mime@2.6.0: {} + + mimic-fn@2.1.0: {} + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + min-indent@1.0.1: {} + + minimatch@10.0.1: + dependencies: + brace-expansion: 2.0.1 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.1 + + minimatch@9.0.1: + dependencies: + brace-expansion: 2.0.1 + optional: true + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + mjml-accordion@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-body@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-button@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-carousel@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-cli@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + chokidar: 3.6.0 + glob: 10.4.5 + html-minifier: 4.0.0 + js-beautify: 1.15.1 + lodash: 4.17.21 + minimatch: 9.0.5 + mjml-core: 4.15.3 + mjml-migrate: 4.15.3 + mjml-parser-xml: 4.15.3 + mjml-validator: 4.15.3 + yargs: 17.7.2 + transitivePeerDependencies: + - encoding + optional: true + + mjml-column@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-core@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + cheerio: 1.0.0-rc.12 + detect-node: 2.1.0 + html-minifier: 4.0.0 + js-beautify: 1.15.1 + juice: 10.0.0 + lodash: 4.17.21 + mjml-migrate: 4.15.3 + mjml-parser-xml: 4.15.3 + mjml-validator: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-divider@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-group@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-attributes@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-breakpoint@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-font@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-html-attributes@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-preview@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-style@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-title@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-hero@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-image@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-migrate@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + js-beautify: 1.15.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + mjml-parser-xml: 4.15.3 + yargs: 17.7.2 + transitivePeerDependencies: + - encoding + optional: true + + mjml-navbar@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-parser-xml@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + detect-node: 2.1.0 + htmlparser2: 9.1.0 + lodash: 4.17.21 + optional: true + + mjml-preset-core@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + mjml-accordion: 4.15.3 + mjml-body: 4.15.3 + mjml-button: 4.15.3 + mjml-carousel: 4.15.3 + mjml-column: 4.15.3 + mjml-divider: 4.15.3 + mjml-group: 4.15.3 + mjml-head: 4.15.3 + mjml-head-attributes: 4.15.3 + mjml-head-breakpoint: 4.15.3 + mjml-head-font: 4.15.3 + mjml-head-html-attributes: 4.15.3 + mjml-head-preview: 4.15.3 + mjml-head-style: 4.15.3 + mjml-head-title: 4.15.3 + mjml-hero: 4.15.3 + mjml-image: 4.15.3 + mjml-navbar: 4.15.3 + mjml-raw: 4.15.3 + mjml-section: 4.15.3 + mjml-social: 4.15.3 + mjml-spacer: 4.15.3 + mjml-table: 4.15.3 + mjml-text: 4.15.3 + mjml-wrapper: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-raw@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-section@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-social@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-spacer@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-table@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-text@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-validator@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + optional: true + + mjml-wrapper@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + lodash: 4.17.21 + mjml-core: 4.15.3 + mjml-section: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml@4.15.3: + dependencies: + '@babel/runtime': 7.24.8 + mjml-cli: 4.15.3 + mjml-core: 4.15.3 + mjml-migrate: 4.15.3 + mjml-preset-core: 4.15.3 + mjml-validator: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.0.0: {} + + ms@2.1.2: {} + + ms@2.1.3: {} + + multer@1.4.4-lts.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + + multer@1.4.5-lts.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + + mute-stream@2.0.0: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + neo-async@2.6.2: {} + + new-github-issue-url@0.2.1: {} + + nice-try@1.0.5: + optional: true + + no-case@2.3.2: + dependencies: + lower-case: 1.1.4 + optional: true + + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + + node-abort-controller@3.1.1: {} + + node-emoji@1.11.0: + dependencies: + lodash: 4.17.21 + + node-fetch@2.6.11: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-int64@0.4.0: {} + + node-releases@2.0.19: {} + + nodemailer@6.10.0: {} + + nodemailer@6.9.13: + optional: true + + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + optional: true + + normalize-package-data@2.5.0: + dependencies: + hosted-git-info: 2.8.9 + resolve: 1.22.2 + semver: 5.7.2 + validate-npm-package-license: 3.0.4 + + normalize-package-data@3.0.3: + dependencies: + hosted-git-info: 4.1.0 + is-core-module: 2.15.0 + semver: 7.7.1 + validate-npm-package-license: 3.0.4 + + normalize-path@3.0.0: {} + + npm-bundled@2.0.1: + dependencies: + npm-normalize-package-bin: 2.0.0 + + npm-normalize-package-bin@2.0.0: {} + + npm-packlist@5.1.3: + dependencies: + glob: 8.1.0 + ignore-walk: 5.0.1 + npm-bundled: 2.0.1 + npm-normalize-package-bin: 2.0.0 + + npm-run-path@2.0.2: + dependencies: + path-key: 2.0.1 + optional: true + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + optional: true + + object-assign@4.1.1: {} + + object-inspect@1.13.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.0.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + os-tmpdir@1.0.2: {} + + otplib@12.0.1: + dependencies: + '@otplib/core': 12.0.1 + '@otplib/preset-default': 12.0.1 + '@otplib/preset-v11': 12.0.1 + + p-event@4.2.0: + dependencies: + p-timeout: 3.2.0 + optional: true + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-finally@1.0.0: + optional: true + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.1.1 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + p-map@2.1.0: {} + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + optional: true + + p-try@2.2.0: {} + + p-wait-for@3.2.0: + dependencies: + p-timeout: 3.2.0 + optional: true + + package-json-from-dist@1.0.0: {} + + package-json-from-dist@1.0.1: {} + + pad-start@1.0.2: {} + + param-case@2.1.1: + dependencies: + no-case: 2.3.2 + optional: true + + param-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.26.2 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse5-htmlparser2-tree-adapter@7.0.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.1.2 + optional: true + + parse5@7.1.2: + dependencies: + entities: 4.5.0 + optional: true + + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + optional: true + + parseurl@1.3.3: {} + + pascal-case@3.1.2: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + path-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + path-exists@4.0.0: {} + + path-exists@5.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@2.0.1: + optional: true + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-scurry@2.0.0: + dependencies: + lru-cache: 11.0.2 + minipass: 7.1.2 + + path-to-regexp@0.1.12: {} + + path-to-regexp@0.2.5: {} + + path-to-regexp@3.3.0: {} + + path-type@4.0.0: {} + + peberminta@0.9.0: + optional: true + + pg-cloudflare@1.1.1: + optional: true + + pg-connection-string@2.7.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.7.0(pg@8.13.0): + dependencies: + pg: 8.13.0 + + pg-protocol@1.7.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.13.0: + dependencies: + pg-connection-string: 2.7.0 + pg-pool: 3.7.0(pg@8.13.0) + pg-protocol: 1.7.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.1.1 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.1: {} + + picomatch@4.0.2: {} + + pidtree@0.6.0: {} + + pirates@4.0.6: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pluralize@8.0.0: {} + + pngjs@5.0.0: {} + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.0: + dependencies: + fast-diff: 1.3.0 + + prettier@3.5.1: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + preview-email@3.0.20: + dependencies: + ci-info: 3.9.0 + display-notification: 2.0.0 + fixpack: 4.0.0 + get-port: 5.1.1 + mailparser: 3.7.1 + nodemailer: 6.10.0 + open: 7.4.2 + p-event: 4.2.0 + p-wait-for: 3.2.0 + pug: 3.0.3 + uuid: 9.0.1 + optional: true + + prisma-case-format@2.2.1: + dependencies: + '@prisma/internals': 4.16.2 + chalk: 4.1.2 + change-case: 4.1.2 + commander: 7.2.0 + js-yaml: 4.1.0 + pluralize: 8.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + prisma-import@1.0.5: + dependencies: + '@prisma/internals': 4.16.2 + '@prisma/prisma-fmt-wasm': 4.8.0-9.5be62d4c40defb9d2faf09dc30edaa449580d417 + chalk: 4.1.2 + glob: 8.0.3 + prompts: 2.4.2 + read-pkg-up: 9.1.0 + transitivePeerDependencies: + - encoding + - supports-color + + prisma-json-types-generator@3.0.4(prisma@5.22.0)(typescript@5.7.2): + dependencies: + '@prisma/generator-helper': 5.9.1 + prisma: 5.22.0 + tslib: 2.6.2 + typescript: 5.7.2 + + prisma@5.22.0: + dependencies: + '@prisma/engines': 5.22.0 + optionalDependencies: + fsevents: 2.3.3 + + process-nextick-args@2.0.1: {} + + progress@2.0.3: {} + + promise@7.3.1: + dependencies: + asap: 2.0.6 + optional: true + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proto-list@1.2.4: + optional: true + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pug-attrs@3.0.0: + dependencies: + constantinople: 4.0.1 + js-stringify: 1.0.2 + pug-runtime: 3.0.1 + optional: true + + pug-code-gen@3.0.3: + dependencies: + constantinople: 4.0.1 + doctypes: 1.1.0 + js-stringify: 1.0.2 + pug-attrs: 3.0.0 + pug-error: 2.1.0 + pug-runtime: 3.0.1 + void-elements: 3.1.0 + with: 7.0.2 + optional: true + + pug-error@2.1.0: + optional: true + + pug-filters@4.0.0: + dependencies: + constantinople: 4.0.1 + jstransformer: 1.0.0 + pug-error: 2.1.0 + pug-walk: 2.0.0 + resolve: 1.22.8 + optional: true + + pug-lexer@5.0.1: + dependencies: + character-parser: 2.2.0 + is-expression: 4.0.0 + pug-error: 2.1.0 + optional: true + + pug-linker@4.0.0: + dependencies: + pug-error: 2.1.0 + pug-walk: 2.0.0 + optional: true + + pug-load@3.0.0: + dependencies: + object-assign: 4.1.1 + pug-walk: 2.0.0 + optional: true + + pug-parser@6.0.0: + dependencies: + pug-error: 2.1.0 + token-stream: 1.0.0 + optional: true + + pug-runtime@3.0.1: + optional: true + + pug-strip-comments@2.0.0: + dependencies: + pug-error: 2.1.0 + optional: true + + pug-walk@2.0.0: + optional: true + + pug@3.0.3: + dependencies: + pug-code-gen: 3.0.3 + pug-filters: 4.0.0 + pug-lexer: 5.0.1 + pug-linker: 4.0.0 + pug-load: 3.0.0 + pug-parser: 6.0.0 + pug-runtime: 3.0.1 + pug-strip-comments: 2.0.0 + optional: true + + punycode.js@2.3.1: + optional: true + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.3: {} + + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + qs@6.13.0: + dependencies: + side-channel: 1.0.6 + + queue-microtask@1.2.3: {} + + random-bytes@1.0.0: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + optional: true + + react-is@18.3.1: {} + + read-pkg-up@7.0.1: + dependencies: + find-up: 4.1.0 + read-pkg: 5.2.0 + type-fest: 0.8.1 + + read-pkg-up@9.1.0: + dependencies: + find-up: 6.3.0 + read-pkg: 7.1.0 + type-fest: 2.19.0 + + read-pkg@5.2.0: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 2.5.0 + parse-json: 5.2.0 + type-fest: 0.6.0 + + read-pkg@7.1.0: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 3.0.3 + parse-json: 5.2.0 + type-fest: 2.19.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.6 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + readdirp@4.1.1: {} + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + reflect-metadata@0.2.2: {} + + regenerator-runtime@0.14.1: + optional: true + + relateurl@0.2.7: + optional: true + + repeat-string@1.6.1: {} + + replace-string@3.1.0: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-main-filename@2.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve.exports@2.0.2: {} + + resolve@1.22.2: + dependencies: + is-core-module: 2.15.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@1.22.8: + dependencies: + is-core-module: 2.15.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retry@0.13.1: {} + + reusify@1.0.4: {} + + rfdc@1.4.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + run-applescript@3.2.0: + dependencies: + execa: 0.10.0 + optional: true + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.4.3: {} + + safer-buffer@2.1.2: {} + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + + schema-utils@4.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + ajv-keywords: 5.1.0(ajv@8.17.1) + + secure-json-parse@2.7.0: {} + + secure-remote-password@0.3.1: + dependencies: + array-buffer-to-hex: 1.0.0 + crypto-digest-sync: 1.0.0 + crypto-random-hex: 1.0.0 + encode-utf8: 1.0.3 + hex-to-array-buffer: 1.1.0 + jsbn: 1.1.0 + pad-start: 1.0.2 + + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + optional: true + + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.6.3: {} + + semver@7.7.0: {} + + semver@7.7.1: {} + + send@0.19.0: + dependencies: + 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 + transitivePeerDependencies: + - supports-color + + sentence-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + upper-case-first: 2.0.2 + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + + setprototypeof@1.2.0: {} + + shebang-command@1.2.0: + dependencies: + shebang-regex: 1.0.0 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@1.0.0: + optional: true + + shebang-regex@3.0.0: {} + + side-channel@1.0.6: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + slice-ansi@3.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.0.0 + + slick@1.12.2: + optional: true + + snake-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.18 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.18 + + spdx-license-ids@3.0.18: {} + + split2@4.2.0: {} + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + standard-as-callback@2.1.0: {} + + statuses@2.0.1: {} + + streamsearch@1.1.0: {} + + string-argv@0.3.2: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.2.0 + strip-ansi: 7.1.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.0.1 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-eof@1.0.0: + optional: true + + strip-final-newline@2.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@2.0.1: + optional: true + + strip-json-comments@3.1.1: {} + + superagent@9.0.2: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.0 + fast-safe-stringify: 2.1.1 + form-data: 4.0.0 + formidable: 3.5.1 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.13.0 + transitivePeerDependencies: + - supports-color + + supertest@7.0.0: + dependencies: + methods: 1.1.2 + superagent: 9.0.2 + transitivePeerDependencies: + - supports-color + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-observable@4.0.0: {} + + synckit@0.9.2: + dependencies: + '@pkgr/core': 0.1.1 + tslib: 2.8.1 + + tapable@2.2.1: {} + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.4 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + temp-dir@1.0.0: {} + + temp-dir@2.0.0: {} + + temp-write@4.0.0: + dependencies: + graceful-fs: 4.2.11 + is-stream: 2.0.1 + make-dir: 3.1.0 + temp-dir: 1.0.0 + uuid: 3.4.0 + + tempy@1.0.1: + dependencies: + del: 6.1.1 + is-stream: 2.0.1 + temp-dir: 2.0.0 + type-fest: 0.16.0 + unique-string: 2.0.0 + + terminal-link@2.1.1: + dependencies: + ansi-escapes: 4.3.2 + supports-hyperlinks: 2.3.0 + + terser-webpack-plugin@5.3.11(webpack@5.97.1): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + jest-worker: 27.5.1 + schema-utils: 4.3.0 + serialize-javascript: 6.0.2 + terser: 5.38.0 + webpack: 5.97.1 + + terser@5.38.0: + dependencies: + '@jridgewell/source-map': 0.3.6 + acorn: 8.14.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + text-extensions@2.4.0: {} + + text-table@0.2.0: {} + + thirty-two@1.0.2: {} + + through@2.3.8: {} + + tinyexec@0.3.2: {} + + tlds@1.252.0: + optional: true + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + tmp@0.2.1: + dependencies: + rimraf: 3.0.2 + + tmpl@1.0.5: {} + + to-fast-properties@2.0.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + token-stream@1.0.0: + optional: true + + tr46@0.0.3: {} + + tree-kill@1.2.2: {} + + ts-api-utils@1.4.3(typescript@5.7.2): + dependencies: + typescript: 5.7.2 + + ts-api-utils@2.0.1(typescript@5.7.2): + dependencies: + typescript: 5.7.2 + + ts-jest@29.2.5(@babel/core@7.24.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.9))(jest@29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)))(typescript@5.7.2): + dependencies: + bs-logger: 0.2.6 + ejs: 3.1.10 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@22.10.2)(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2)) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.6.3 + typescript: 5.7.2 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.24.9 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.24.9) + + ts-json-schema-generator@2.3.0: + dependencies: + '@types/json-schema': 7.0.15 + commander: 12.1.0 + glob: 10.4.5 + json5: 2.2.3 + normalize-path: 3.0.0 + safe-stable-stringify: 2.4.3 + tslib: 2.6.3 + typescript: 5.7.2 + + ts-loader@9.5.1(typescript@5.7.2)(webpack@5.97.1): + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.17.0 + micromatch: 4.0.7 + semver: 7.6.3 + source-map: 0.7.4 + typescript: 5.7.2 + webpack: 5.97.1 + + ts-node@10.9.2(@types/node@22.10.2)(typescript@5.7.2): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.10.2 + acorn: 8.12.1 + acorn-walk: 8.3.3 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.7.2 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + ts-pattern@4.3.0: {} + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.18.1 + tapable: 2.2.1 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.6.2: {} + + tslib@2.6.3: {} + + tslib@2.7.0: {} + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.16.0: {} + + type-fest@0.21.3: {} + + type-fest@0.6.0: {} + + type-fest@0.8.1: {} + + type-fest@2.19.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray@0.0.6: {} + + typescript@5.7.2: {} + + typescript@5.7.3: {} + + uc.micro@2.1.0: + optional: true + + uglify-js@3.19.0: + optional: true + + uid-safe@2.1.5: + dependencies: + random-bytes: 1.0.0 + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + undici-types@6.20.0: {} + + undici@6.19.3: {} + + unicorn-magic@0.1.0: {} + + unique-string@2.0.0: + dependencies: + crypto-random-string: 2.0.0 + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.1.2(browserslist@4.24.4): + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + upper-case-first@2.0.2: + dependencies: + tslib: 2.8.1 + + upper-case@1.1.3: + optional: true + + upper-case@2.0.2: + dependencies: + tslib: 2.8.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@10.0.0: {} + + uuid@3.4.0: {} + + uuid@9.0.0: {} + + uuid@9.0.1: + optional: true + + v8-compile-cache-lib@3.0.1: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + valid-data-url@3.0.1: + optional: true + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + validator@13.12.0: {} + + vary@1.1.2: {} + + void-elements@3.1.0: + optional: true + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + watchpack@2.4.2: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-resource-inliner@6.0.1: + dependencies: + ansi-colors: 4.1.3 + escape-goat: 3.0.0 + htmlparser2: 5.0.1 + mime: 2.6.0 + node-fetch: 2.7.0 + valid-data-url: 3.0.1 + transitivePeerDependencies: + - encoding + optional: true + + webidl-conversions@3.0.1: {} + + webpack-node-externals@3.0.0: {} + + webpack-sources@3.2.3: {} + + webpack@5.97.1: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.6 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.14.0 + browserslist: 4.24.4 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.1 + es-module-lexer: 1.6.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.11(webpack@5.97.1) + watchpack: 2.4.2 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-module@2.0.1: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + with@7.0.2: + dependencies: + '@babel/parser': 7.24.8 + '@babel/types': 7.24.9 + assert-never: 1.3.0 + babel-walk: 3.0.0-canary-5 + optional: true + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + xtend@4.0.2: {} + + y18n@4.0.3: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yaml@2.6.1: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs-parser@21.1.1: {} + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + yocto-queue@1.1.1: {} + + yoctocolors-cjs@2.1.2: {} + + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 diff --git a/prisma/migrations/20240321040602_init/migration.sql b/prisma/migrations/20240321040602_init/migration.sql new file mode 100644 index 00000000..a197555d --- /dev/null +++ b/prisma/migrations/20240321040602_init/migration.sql @@ -0,0 +1,770 @@ +-- CreateEnum +CREATE TYPE "attitudable_type" AS ENUM ('COMMENT', 'QUESTION', 'ANSWER'); + +-- CreateEnum +CREATE TYPE "attitude_type" AS ENUM ('UNDEFINED', 'POSITIVE', 'NEGATIVE'); + +-- CreateEnum +CREATE TYPE "attitude_type_not_undefined" AS ENUM ('POSITIVE', 'NEGATIVE'); + +-- CreateEnum +CREATE TYPE "comment_commentabletype_enum" AS ENUM ('ANSWER', 'COMMENT', 'QUESTION'); + +-- CreateTable +CREATE TABLE "answer" ( + "id" SERIAL NOT NULL, + "createdById" INTEGER NOT NULL, + "questionId" INTEGER NOT NULL, + "groupId" INTEGER, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_9232db17b63fb1e94f97e5c224f" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "answer_delete_log" ( + "id" SERIAL NOT NULL, + "deleterId" INTEGER, + "answerId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_f1696d27f69ec9c6133a12aadcf" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "answer_favorited_by_user" ( + "answerId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "PK_5a857fe93c44fdb538ec5aa4771" PRIMARY KEY ("answerId","userId") +); + +-- CreateTable +CREATE TABLE "answer_query_log" ( + "id" SERIAL NOT NULL, + "viewerId" INTEGER, + "answerId" INTEGER NOT NULL, + "ip" VARCHAR NOT NULL, + "userAgent" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_4f65c4804d0693f458a716aa72c" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "answer_update_log" ( + "id" SERIAL NOT NULL, + "updaterId" INTEGER, + "answerId" INTEGER NOT NULL, + "oldContent" TEXT NOT NULL, + "newContent" TEXT NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_5ae381609b7ae9f2319fe26031f" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "answer_user_attitude" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "answerId" INTEGER NOT NULL, + "type" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "PK_c06b4ffc5a74d07cb867d6b3f98" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "attitude" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "attitudableType" "attitudable_type" NOT NULL, + "attitudableId" INTEGER NOT NULL, + "attitude" "attitude_type_not_undefined" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "attitude_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "attitude_log" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "attitudableType" "attitudable_type" NOT NULL, + "attitudableId" INTEGER NOT NULL, + "attitude" "attitude_type" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "attitude_log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "session" ( + "id" SERIAL NOT NULL, + "validUntil" TIMESTAMP(6) NOT NULL, + "revoked" BOOLEAN NOT NULL, + "userId" INTEGER NOT NULL, + "authorization" TEXT NOT NULL, + "lastRefreshedAt" BIGINT NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_f55da76ac1c3ac420f444d2ff11" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "session_refresh_log" ( + "id" SERIAL NOT NULL, + "sessionId" INTEGER NOT NULL, + "oldRefreshToken" TEXT NOT NULL, + "newRefreshToken" TEXT NOT NULL, + "accessToken" TEXT NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_f8f46c039b0955a7df6ad6631d7" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "avatar" ( + "id" SERIAL NOT NULL, + "url" VARCHAR NOT NULL, + "name" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "avatarType" VARCHAR NOT NULL, + "usageCount" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "avatar_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "comment" ( + "id" SERIAL NOT NULL, + "commentableType" "comment_commentabletype_enum" NOT NULL, + "commentableId" INTEGER NOT NULL, + "content" TEXT NOT NULL, + "createdById" INTEGER NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_0b0e4bbc8415ec426f87f3a88e2" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "comment_delete_log" ( + "id" SERIAL NOT NULL, + "commentId" INTEGER NOT NULL, + "operatedById" INTEGER NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_429889b4bdc646cb80ef8bc1814" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "comment_query_log" ( + "id" SERIAL NOT NULL, + "commentId" INTEGER NOT NULL, + "viewerId" INTEGER, + "ip" VARCHAR NOT NULL, + "userAgent" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_afbfb3d92cbf55c99cb6bdcd58f" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "group" ( + "id" SERIAL NOT NULL, + "name" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT ('now'::text)::timestamp(3) with time zone, + "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_256aa0fda9b1de1a73ee0b7106b" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "group_membership" ( + "id" SERIAL NOT NULL, + "groupId" INTEGER NOT NULL, + "memberId" INTEGER NOT NULL, + "role" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_b631623cf04fa74513b975e7059" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "group_profile" ( + "id" SERIAL NOT NULL, + "intro" VARCHAR NOT NULL, + "avatarId" INTEGER, + "groupId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_2a62b59d1bf8a3191c992e8daf4" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "group_question_relationship" ( + "id" SERIAL NOT NULL, + "groupId" INTEGER NOT NULL, + "questionId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL, + "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_47ee7be0b0f0e51727012382922" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "group_target" ( + "id" SERIAL NOT NULL, + "groupId" INTEGER NOT NULL, + "name" VARCHAR NOT NULL, + "intro" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + "startedAt" DATE NOT NULL, + "endedAt" DATE NOT NULL, + "attendanceFrequency" VARCHAR NOT NULL, + + CONSTRAINT "PK_f1671a42b347bd96ce6595f91ee" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "question_elasticsearch_relation" ( + "id" SERIAL NOT NULL, + "questionId" INTEGER NOT NULL, + "elasticsearchId" TEXT NOT NULL, + + CONSTRAINT "question_elasticsearch_relation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "question_invitation_relation" ( + "id" SERIAL NOT NULL, + "questionId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "question_invitation_relation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "question" ( + "id" SERIAL NOT NULL, + "createdById" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "type" INTEGER NOT NULL, + "groupId" INTEGER, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_21e5786aa0ea704ae185a79b2d5" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "question_follower_relation" ( + "id" SERIAL NOT NULL, + "questionId" INTEGER NOT NULL, + "followerId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_5f5ce2e314f975612a13d601362" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "question_query_log" ( + "id" SERIAL NOT NULL, + "viewerId" INTEGER, + "questionId" INTEGER NOT NULL, + "ip" VARCHAR NOT NULL, + "userAgent" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_2876061262a774e4aba4daaaae4" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "question_search_log" ( + "id" SERIAL NOT NULL, + "keywords" VARCHAR NOT NULL, + "firstQuestionId" INTEGER, + "pageSize" INTEGER NOT NULL, + "result" VARCHAR NOT NULL, + "duration" DOUBLE PRECISION NOT NULL, + "searcherId" INTEGER, + "ip" VARCHAR NOT NULL, + "userAgent" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_6f41b41474cf92c67a7da97384c" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "question_topic_relation" ( + "id" SERIAL NOT NULL, + "questionId" INTEGER NOT NULL, + "topicId" INTEGER NOT NULL, + "createdById" INTEGER NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_c50ec8a9ac6c3007f0861e4a383" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "topic" ( + "id" SERIAL NOT NULL, + "name" VARCHAR NOT NULL, + "createdById" INTEGER NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_33aa4ecb4e4f20aa0157ea7ef61" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "topic_search_log" ( + "id" SERIAL NOT NULL, + "keywords" VARCHAR NOT NULL, + "firstTopicId" INTEGER, + "pageSize" INTEGER NOT NULL, + "result" VARCHAR NOT NULL, + "duration" DOUBLE PRECISION NOT NULL, + "searcherId" INTEGER, + "ip" VARCHAR NOT NULL, + "userAgent" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_41a432f5f993017b2502c73c78e" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user" ( + "id" SERIAL NOT NULL, + "username" VARCHAR NOT NULL, + "hashedPassword" VARCHAR NOT NULL, + "email" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_following_relationship" ( + "id" SERIAL NOT NULL, + "followeeId" INTEGER NOT NULL, + "followerId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_3b0199015f8814633fc710ff09d" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_login_log" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "ip" VARCHAR NOT NULL, + "userAgent" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_f8db79b1af1f385db4f45a2222e" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_profile" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "nickname" VARCHAR NOT NULL, + "avatarId" INTEGER NOT NULL, + "intro" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deletedAt" TIMESTAMP(6), + + CONSTRAINT "PK_f44d0cd18cfd80b0fed7806c3b7" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_profile_query_log" ( + "id" SERIAL NOT NULL, + "viewerId" INTEGER, + "vieweeId" INTEGER NOT NULL, + "ip" VARCHAR NOT NULL, + "userAgent" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_9aeff7c959703fad866e9ad581a" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_register_log" ( + "id" SERIAL NOT NULL, + "email" VARCHAR NOT NULL, + "type" INTEGER NOT NULL, + "registerRequestId" INTEGER, + "ip" VARCHAR NOT NULL, + "userAgent" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_3596a6f74bd2a80be930f6d1e39" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_register_request" ( + "id" SERIAL NOT NULL, + "email" VARCHAR NOT NULL, + "code" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_cdf2d880551e43d9362ddd37ae0" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_reset_password_log" ( + "id" SERIAL NOT NULL, + "userId" INTEGER, + "type" INTEGER NOT NULL, + "ip" VARCHAR NOT NULL, + "userAgent" VARCHAR NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_3ee4f25e7f4f1d5a9bd9817b62b" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "IDX_1887685ce6667b435b01c646a2" ON "answer"("groupId"); + +-- CreateIndex +CREATE INDEX "IDX_a4013f10cd6924793fbd5f0d63" ON "answer"("questionId"); + +-- CreateIndex +CREATE INDEX "IDX_f636f6e852686173ea947f2904" ON "answer"("createdById"); + +-- CreateIndex +CREATE INDEX "IDX_910393b814aac627593588c17f" ON "answer_delete_log"("answerId"); + +-- CreateIndex +CREATE INDEX "IDX_c2d0251df4669e17a57d6dbc06" ON "answer_delete_log"("deleterId"); + +-- CreateIndex +CREATE INDEX "IDX_9556368d270d73579a68db7e1b" ON "answer_favorited_by_user"("userId"); + +-- CreateIndex +CREATE INDEX "IDX_c27a91d761c26ad612a0a35697" ON "answer_favorited_by_user"("answerId"); + +-- CreateIndex +CREATE INDEX "IDX_71ed57d6bb340716f5e17043bb" ON "answer_query_log"("answerId"); + +-- CreateIndex +CREATE INDEX "IDX_f4b7cd859700f8928695b6c2ba" ON "answer_query_log"("viewerId"); + +-- CreateIndex +CREATE INDEX "IDX_0ef2a982b61980d95b5ae7f1a6" ON "answer_update_log"("updaterId"); + +-- CreateIndex +CREATE INDEX "IDX_6f0964cf74c12678a86e49b23f" ON "answer_update_log"("answerId"); + +-- CreateIndex +CREATE INDEX "attitude_userId_idx" ON "attitude"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "attitude_attitudableId_userId_attitudableType_key" ON "attitude"("attitudableId", "userId", "attitudableType"); + +-- CreateIndex +CREATE INDEX "attitude_log_attitudableId_attitudableType_idx" ON "attitude_log"("attitudableId", "attitudableType"); + +-- CreateIndex +CREATE INDEX "attitude_log_userId_idx" ON "attitude_log"("userId"); + +-- CreateIndex +CREATE INDEX "IDX_3d2f174ef04fb312fdebd0ddc5" ON "session"("userId"); + +-- CreateIndex +CREATE INDEX "IDX_bb46e87d5b3f1e55c625755c00" ON "session"("validUntil"); + +-- CreateIndex +CREATE INDEX "IDX_525212ea7a75cba69724e42303" ON "comment"("commentableId"); + +-- CreateIndex +CREATE INDEX "IDX_63ac916757350d28f05c5a6a4b" ON "comment"("createdById"); + +-- CreateIndex +CREATE INDEX "IDX_53f0a8befcc12c0f7f2bab7584" ON "comment_delete_log"("operatedById"); + +-- CreateIndex +CREATE INDEX "IDX_66705ce7d7908554cff01b260e" ON "comment_delete_log"("commentId"); + +-- CreateIndex +CREATE INDEX "IDX_4020ff7fcffb2737e990f8bde5" ON "comment_query_log"("commentId"); + +-- CreateIndex +CREATE INDEX "IDX_4ead8566a6fa987264484b13d5" ON "comment_query_log"("viewerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "IDX_8a45300fd825918f3b40195fbd" ON "group"("name"); + +-- CreateIndex +CREATE INDEX "IDX_7d88d00d8617a802b698c0cd60" ON "group_membership"("memberId"); + +-- CreateIndex +CREATE INDEX "IDX_b1411f07fafcd5ad93c6ee1642" ON "group_membership"("groupId"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_7359ba99cc116d00cf74e048ed" ON "group_profile"("groupId"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_5b1232271bf29d99456fcf39e7" ON "group_question_relationship"("questionId"); + +-- CreateIndex +CREATE INDEX "IDX_b31bf3b3688ec41daaced89a0a" ON "group_question_relationship"("groupId"); + +-- CreateIndex +CREATE INDEX "IDX_19d57f140124c5100e8e1ca308" ON "group_target"("groupId"); + +-- CreateIndex +CREATE UNIQUE INDEX "question_elasticsearch_relation_questionId_key" ON "question_elasticsearch_relation"("questionId"); + +-- CreateIndex +CREATE INDEX "question_invitation_relation_questionId_idx" ON "question_invitation_relation"("questionId"); + +-- CreateIndex +CREATE INDEX "question_invitation_relation_userId_idx" ON "question_invitation_relation"("userId"); + +-- CreateIndex +CREATE INDEX "IDX_187915d8eaa010cde8b053b35d" ON "question"("createdById"); + +-- CreateIndex +CREATE INDEX "IDX_8b24620899a8556c3f22f52145" ON "question"("title", "content"); + +-- CreateIndex +CREATE INDEX "IDX_ac7c68d428ab7ffd2f4752eeaa" ON "question"("groupId"); + +-- CreateIndex +CREATE INDEX "IDX_21a30245c4a32d5ac67da80901" ON "question_follower_relation"("followerId"); + +-- CreateIndex +CREATE INDEX "IDX_6544f7f7579bf88e3c62f995f8" ON "question_follower_relation"("questionId"); + +-- CreateIndex +CREATE INDEX "IDX_8ce4bcc67caf0406e6f20923d4" ON "question_query_log"("viewerId"); + +-- CreateIndex +CREATE INDEX "IDX_a0ee1672e103ed0a0266f217a3" ON "question_query_log"("questionId"); + +-- CreateIndex +CREATE INDEX "IDX_13c7e9fd7403cc5a87ab6524bc" ON "question_search_log"("searcherId"); + +-- CreateIndex +CREATE INDEX "IDX_2fbe3aa9f62233381aefeafa00" ON "question_search_log"("keywords"); + +-- CreateIndex +CREATE INDEX "IDX_dd4b9a1b83559fa38a3a50463f" ON "question_topic_relation"("topicId"); + +-- CreateIndex +CREATE INDEX "IDX_fab99c5e4fc380d9b7f9abbbb0" ON "question_topic_relation"("questionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "idx_topic_name_unique" ON "topic"("name"); + +-- CreateIndex +CREATE INDEX "IDX_59d7548ea797208240417106e2" ON "topic"("createdById"); + +-- CreateIndex +CREATE INDEX "idx_topic_name_ft" ON "topic"("name"); + +-- CreateIndex +CREATE INDEX "IDX_85c1844b4fa3e29b1b8dfaeac6" ON "topic_search_log"("keywords"); + +-- CreateIndex +CREATE INDEX "IDX_fe1e75b8b625499f0119faaba5" ON "topic_search_log"("searcherId"); + +-- CreateIndex +CREATE UNIQUE INDEX "IDX_78a916df40e02a9deb1c4b75ed" ON "user"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "IDX_e12875dfb3b1d92d7d7c5377e2" ON "user"("email"); + +-- CreateIndex +CREATE INDEX "IDX_868df0c2c3a138ee54d2a515bc" ON "user_following_relationship"("followerId"); + +-- CreateIndex +CREATE INDEX "IDX_c78831eeee179237b1482d0c6f" ON "user_following_relationship"("followeeId"); + +-- CreateIndex +CREATE INDEX "IDX_66c592c7f7f20d1214aba2d004" ON "user_login_log"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "IDX_51cb79b5555effaf7d69ba1cff" ON "user_profile"("userId"); + +-- CreateIndex +CREATE INDEX "IDX_1261db28434fde159acda6094b" ON "user_profile_query_log"("viewerId"); + +-- CreateIndex +CREATE INDEX "IDX_ff592e4403b328be0de4f2b397" ON "user_profile_query_log"("vieweeId"); + +-- CreateIndex +CREATE INDEX "IDX_3af79f07534d9f1c945cd4c702" ON "user_register_log"("email"); + +-- CreateIndex +CREATE INDEX "IDX_c1d0ecc369d7a6a3d7e876c589" ON "user_register_request"("email"); + +-- AddForeignKey +ALTER TABLE "answer" ADD CONSTRAINT "FK_1887685ce6667b435b01c646a2c" FOREIGN KEY ("groupId") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer" ADD CONSTRAINT "FK_a4013f10cd6924793fbd5f0d637" FOREIGN KEY ("questionId") REFERENCES "question"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer" ADD CONSTRAINT "FK_f636f6e852686173ea947f29045" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_delete_log" ADD CONSTRAINT "FK_910393b814aac627593588c17fd" FOREIGN KEY ("answerId") REFERENCES "answer"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_delete_log" ADD CONSTRAINT "FK_c2d0251df4669e17a57d6dbc06f" FOREIGN KEY ("deleterId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_favorited_by_user" ADD CONSTRAINT "FK_9556368d270d73579a68db7e1bf" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "answer_favorited_by_user" ADD CONSTRAINT "FK_c27a91d761c26ad612a0a356971" FOREIGN KEY ("answerId") REFERENCES "answer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "answer_query_log" ADD CONSTRAINT "FK_71ed57d6bb340716f5e17043bbb" FOREIGN KEY ("answerId") REFERENCES "answer"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_query_log" ADD CONSTRAINT "FK_f4b7cd859700f8928695b6c2bab" FOREIGN KEY ("viewerId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_update_log" ADD CONSTRAINT "FK_0ef2a982b61980d95b5ae7f1a60" FOREIGN KEY ("updaterId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_update_log" ADD CONSTRAINT "FK_6f0964cf74c12678a86e49b23fe" FOREIGN KEY ("answerId") REFERENCES "answer"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_user_attitude" ADD CONSTRAINT "FK_2de5146dd65213f724e32745d06" FOREIGN KEY ("answerId") REFERENCES "answer"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_user_attitude" ADD CONSTRAINT "FK_7555fb52fdf623d67f9884ea63d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "attitude" ADD CONSTRAINT "attitude_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "attitude_log" ADD CONSTRAINT "attitude_log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comment" ADD CONSTRAINT "FK_63ac916757350d28f05c5a6a4ba" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "comment_delete_log" ADD CONSTRAINT "FK_53f0a8befcc12c0f7f2bab7584d" FOREIGN KEY ("operatedById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "comment_delete_log" ADD CONSTRAINT "FK_66705ce7d7908554cff01b260ec" FOREIGN KEY ("commentId") REFERENCES "comment"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "comment_query_log" ADD CONSTRAINT "FK_4020ff7fcffb2737e990f8bde5e" FOREIGN KEY ("commentId") REFERENCES "comment"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "comment_query_log" ADD CONSTRAINT "FK_4ead8566a6fa987264484b13d54" FOREIGN KEY ("viewerId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "group_membership" ADD CONSTRAINT "FK_7d88d00d8617a802b698c0cd609" FOREIGN KEY ("memberId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "group_membership" ADD CONSTRAINT "FK_b1411f07fafcd5ad93c6ee16424" FOREIGN KEY ("groupId") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "group_profile" ADD CONSTRAINT "group_profile_avatarId_fkey" FOREIGN KEY ("avatarId") REFERENCES "avatar"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "group_profile" ADD CONSTRAINT "FK_7359ba99cc116d00cf74e048edd" FOREIGN KEY ("groupId") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "group_question_relationship" ADD CONSTRAINT "FK_5b1232271bf29d99456fcf39e75" FOREIGN KEY ("questionId") REFERENCES "question"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "group_question_relationship" ADD CONSTRAINT "FK_b31bf3b3688ec41daaced89a0ab" FOREIGN KEY ("groupId") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "group_target" ADD CONSTRAINT "FK_19d57f140124c5100e8e1ca3088" FOREIGN KEY ("groupId") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_elasticsearch_relation" ADD CONSTRAINT "fk_question_elasticsearch_relation_question_id" FOREIGN KEY ("questionId") REFERENCES "question"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "question_invitation_relation" ADD CONSTRAINT "question_invitation_relation_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "question"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "question_invitation_relation" ADD CONSTRAINT "question_invitation_relation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "question" ADD CONSTRAINT "FK_187915d8eaa010cde8b053b35d5" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_follower_relation" ADD CONSTRAINT "FK_21a30245c4a32d5ac67da809010" FOREIGN KEY ("followerId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_follower_relation" ADD CONSTRAINT "FK_6544f7f7579bf88e3c62f995f8a" FOREIGN KEY ("questionId") REFERENCES "question"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_query_log" ADD CONSTRAINT "FK_8ce4bcc67caf0406e6f20923d4d" FOREIGN KEY ("viewerId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_query_log" ADD CONSTRAINT "FK_a0ee1672e103ed0a0266f217a3f" FOREIGN KEY ("questionId") REFERENCES "question"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_search_log" ADD CONSTRAINT "FK_13c7e9fd7403cc5a87ab6524bc4" FOREIGN KEY ("searcherId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_topic_relation" ADD CONSTRAINT "FK_d439ea68a02c1e7ea9863fc3df1" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_topic_relation" ADD CONSTRAINT "FK_dd4b9a1b83559fa38a3a50463fd" FOREIGN KEY ("topicId") REFERENCES "topic"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_topic_relation" ADD CONSTRAINT "FK_fab99c5e4fc380d9b7f9abbbb02" FOREIGN KEY ("questionId") REFERENCES "question"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "topic" ADD CONSTRAINT "FK_59d7548ea797208240417106e2d" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "topic_search_log" ADD CONSTRAINT "FK_fe1e75b8b625499f0119faaba5b" FOREIGN KEY ("searcherId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_following_relationship" ADD CONSTRAINT "FK_868df0c2c3a138ee54d2a515bce" FOREIGN KEY ("followerId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_following_relationship" ADD CONSTRAINT "FK_c78831eeee179237b1482d0c6fb" FOREIGN KEY ("followeeId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_login_log" ADD CONSTRAINT "FK_66c592c7f7f20d1214aba2d0046" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_profile" ADD CONSTRAINT "fk_user_profile_user_id" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_profile" ADD CONSTRAINT "fk_user_profile_avatar_id" FOREIGN KEY ("avatarId") REFERENCES "avatar"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_profile_query_log" ADD CONSTRAINT "FK_1261db28434fde159acda6094bc" FOREIGN KEY ("viewerId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_profile_query_log" ADD CONSTRAINT "FK_ff592e4403b328be0de4f2b3973" FOREIGN KEY ("vieweeId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/prisma/migrations/20240322065045_prisma_case_format/migration.sql b/prisma/migrations/20240322065045_prisma_case_format/migration.sql new file mode 100644 index 00000000..0aeb9a12 --- /dev/null +++ b/prisma/migrations/20240322065045_prisma_case_format/migration.sql @@ -0,0 +1,1204 @@ +/* + Warnings: + + - You are about to drop the column `createdAt` on the `answer` table. All the data in the column will be lost. + - You are about to drop the column `createdById` on the `answer` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `answer` table. All the data in the column will be lost. + - You are about to drop the column `groupId` on the `answer` table. All the data in the column will be lost. + - You are about to drop the column `questionId` on the `answer` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `answer` table. All the data in the column will be lost. + - You are about to drop the column `answerId` on the `answer_delete_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `answer_delete_log` table. All the data in the column will be lost. + - You are about to drop the column `deleterId` on the `answer_delete_log` table. All the data in the column will be lost. + - The primary key for the `answer_favorited_by_user` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `answerId` on the `answer_favorited_by_user` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `answer_favorited_by_user` table. All the data in the column will be lost. + - You are about to drop the column `answerId` on the `answer_query_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `answer_query_log` table. All the data in the column will be lost. + - You are about to drop the column `userAgent` on the `answer_query_log` table. All the data in the column will be lost. + - You are about to drop the column `viewerId` on the `answer_query_log` table. All the data in the column will be lost. + - You are about to drop the column `answerId` on the `answer_update_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `answer_update_log` table. All the data in the column will be lost. + - You are about to drop the column `newContent` on the `answer_update_log` table. All the data in the column will be lost. + - You are about to drop the column `oldContent` on the `answer_update_log` table. All the data in the column will be lost. + - You are about to drop the column `updaterId` on the `answer_update_log` table. All the data in the column will be lost. + - You are about to drop the column `answerId` on the `answer_user_attitude` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `answer_user_attitude` table. All the data in the column will be lost. + - You are about to drop the column `attitudableId` on the `attitude` table. All the data in the column will be lost. + - You are about to drop the column `attitudableType` on the `attitude` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `attitude` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `attitude` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `attitude` table. All the data in the column will be lost. + - You are about to drop the column `attitudableId` on the `attitude_log` table. All the data in the column will be lost. + - You are about to drop the column `attitudableType` on the `attitude_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `attitude_log` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `attitude_log` table. All the data in the column will be lost. + - You are about to drop the column `avatarType` on the `avatar` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `avatar` table. All the data in the column will be lost. + - You are about to drop the column `usageCount` on the `avatar` table. All the data in the column will be lost. + - You are about to drop the column `commentableId` on the `comment` table. All the data in the column will be lost. + - You are about to drop the column `commentableType` on the `comment` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `comment` table. All the data in the column will be lost. + - You are about to drop the column `createdById` on the `comment` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `comment` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `comment` table. All the data in the column will be lost. + - You are about to drop the column `commentId` on the `comment_delete_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `comment_delete_log` table. All the data in the column will be lost. + - You are about to drop the column `operatedById` on the `comment_delete_log` table. All the data in the column will be lost. + - You are about to drop the column `commentId` on the `comment_query_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `comment_query_log` table. All the data in the column will be lost. + - You are about to drop the column `userAgent` on the `comment_query_log` table. All the data in the column will be lost. + - You are about to drop the column `viewerId` on the `comment_query_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `group` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `group` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `group` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `group_membership` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `group_membership` table. All the data in the column will be lost. + - You are about to drop the column `groupId` on the `group_membership` table. All the data in the column will be lost. + - You are about to drop the column `memberId` on the `group_membership` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `group_membership` table. All the data in the column will be lost. + - You are about to drop the column `avatarId` on the `group_profile` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `group_profile` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `group_profile` table. All the data in the column will be lost. + - You are about to drop the column `groupId` on the `group_profile` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `group_profile` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `group_question_relationship` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `group_question_relationship` table. All the data in the column will be lost. + - You are about to drop the column `groupId` on the `group_question_relationship` table. All the data in the column will be lost. + - You are about to drop the column `questionId` on the `group_question_relationship` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `group_question_relationship` table. All the data in the column will be lost. + - You are about to drop the column `attendanceFrequency` on the `group_target` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `group_target` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `group_target` table. All the data in the column will be lost. + - You are about to drop the column `endedAt` on the `group_target` table. All the data in the column will be lost. + - You are about to drop the column `groupId` on the `group_target` table. All the data in the column will be lost. + - You are about to drop the column `startedAt` on the `group_target` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `group_target` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `question` table. All the data in the column will be lost. + - You are about to drop the column `createdById` on the `question` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `question` table. All the data in the column will be lost. + - You are about to drop the column `groupId` on the `question` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `question` table. All the data in the column will be lost. + - You are about to drop the column `elasticsearchId` on the `question_elasticsearch_relation` table. All the data in the column will be lost. + - You are about to drop the column `questionId` on the `question_elasticsearch_relation` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `question_follower_relation` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `question_follower_relation` table. All the data in the column will be lost. + - You are about to drop the column `followerId` on the `question_follower_relation` table. All the data in the column will be lost. + - You are about to drop the column `questionId` on the `question_follower_relation` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `question_invitation_relation` table. All the data in the column will be lost. + - You are about to drop the column `questionId` on the `question_invitation_relation` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `question_invitation_relation` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `question_invitation_relation` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `question_query_log` table. All the data in the column will be lost. + - You are about to drop the column `questionId` on the `question_query_log` table. All the data in the column will be lost. + - You are about to drop the column `userAgent` on the `question_query_log` table. All the data in the column will be lost. + - You are about to drop the column `viewerId` on the `question_query_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `question_search_log` table. All the data in the column will be lost. + - You are about to drop the column `firstQuestionId` on the `question_search_log` table. All the data in the column will be lost. + - You are about to drop the column `pageSize` on the `question_search_log` table. All the data in the column will be lost. + - You are about to drop the column `searcherId` on the `question_search_log` table. All the data in the column will be lost. + - You are about to drop the column `userAgent` on the `question_search_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `question_topic_relation` table. All the data in the column will be lost. + - You are about to drop the column `createdById` on the `question_topic_relation` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `question_topic_relation` table. All the data in the column will be lost. + - You are about to drop the column `questionId` on the `question_topic_relation` table. All the data in the column will be lost. + - You are about to drop the column `topicId` on the `question_topic_relation` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `session` table. All the data in the column will be lost. + - You are about to drop the column `lastRefreshedAt` on the `session` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `session` table. All the data in the column will be lost. + - You are about to drop the column `validUntil` on the `session` table. All the data in the column will be lost. + - You are about to drop the column `accessToken` on the `session_refresh_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `session_refresh_log` table. All the data in the column will be lost. + - You are about to drop the column `newRefreshToken` on the `session_refresh_log` table. All the data in the column will be lost. + - You are about to drop the column `oldRefreshToken` on the `session_refresh_log` table. All the data in the column will be lost. + - You are about to drop the column `sessionId` on the `session_refresh_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `topic` table. All the data in the column will be lost. + - You are about to drop the column `createdById` on the `topic` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `topic` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `topic_search_log` table. All the data in the column will be lost. + - You are about to drop the column `firstTopicId` on the `topic_search_log` table. All the data in the column will be lost. + - You are about to drop the column `pageSize` on the `topic_search_log` table. All the data in the column will be lost. + - You are about to drop the column `searcherId` on the `topic_search_log` table. All the data in the column will be lost. + - You are about to drop the column `userAgent` on the `topic_search_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `user` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `user` table. All the data in the column will be lost. + - You are about to drop the column `hashedPassword` on the `user` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `user` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `user_following_relationship` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `user_following_relationship` table. All the data in the column will be lost. + - You are about to drop the column `followeeId` on the `user_following_relationship` table. All the data in the column will be lost. + - You are about to drop the column `followerId` on the `user_following_relationship` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `user_login_log` table. All the data in the column will be lost. + - You are about to drop the column `userAgent` on the `user_login_log` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `user_login_log` table. All the data in the column will be lost. + - You are about to drop the column `avatarId` on the `user_profile` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `user_profile` table. All the data in the column will be lost. + - You are about to drop the column `deletedAt` on the `user_profile` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `user_profile` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `user_profile` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `user_profile_query_log` table. All the data in the column will be lost. + - You are about to drop the column `userAgent` on the `user_profile_query_log` table. All the data in the column will be lost. + - You are about to drop the column `vieweeId` on the `user_profile_query_log` table. All the data in the column will be lost. + - You are about to drop the column `viewerId` on the `user_profile_query_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `user_register_log` table. All the data in the column will be lost. + - You are about to drop the column `registerRequestId` on the `user_register_log` table. All the data in the column will be lost. + - You are about to drop the column `userAgent` on the `user_register_log` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `user_register_request` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `user_reset_password_log` table. All the data in the column will be lost. + - You are about to drop the column `userAgent` on the `user_reset_password_log` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `user_reset_password_log` table. All the data in the column will be lost. + - A unique constraint covering the columns `[attitudable_id,user_id,attitudable_type]` on the table `attitude` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[group_id]` on the table `group_profile` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[question_id]` on the table `group_question_relationship` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[question_id]` on the table `question_elasticsearch_relation` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[user_id]` on the table `user_profile` will be added. If there are existing duplicate values, this will fail. + - Added the required column `created_by_id` to the `answer` table without a default value. This is not possible if the table is not empty. + - Added the required column `question_id` to the `answer` table without a default value. This is not possible if the table is not empty. + - Added the required column `answer_id` to the `answer_delete_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `answer_id` to the `answer_favorited_by_user` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_id` to the `answer_favorited_by_user` table without a default value. This is not possible if the table is not empty. + - Added the required column `answer_id` to the `answer_query_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_agent` to the `answer_query_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `answer_id` to the `answer_update_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `new_content` to the `answer_update_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `old_content` to the `answer_update_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `answer_id` to the `answer_user_attitude` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_id` to the `answer_user_attitude` table without a default value. This is not possible if the table is not empty. + - Added the required column `attitudable_id` to the `attitude` table without a default value. This is not possible if the table is not empty. + - Added the required column `attitudable_type` to the `attitude` table without a default value. This is not possible if the table is not empty. + - Added the required column `updated_at` to the `attitude` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_id` to the `attitude` table without a default value. This is not possible if the table is not empty. + - Changed the type of `attitude` on the `attitude` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Added the required column `attitudable_id` to the `attitude_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `attitudable_type` to the `attitude_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_id` to the `attitude_log` table without a default value. This is not possible if the table is not empty. + - Changed the type of `attitude` on the `attitude_log` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Added the required column `avatar_type` to the `avatar` table without a default value. This is not possible if the table is not empty. + - Added the required column `commentable_id` to the `comment` table without a default value. This is not possible if the table is not empty. + - Added the required column `commentable_type` to the `comment` table without a default value. This is not possible if the table is not empty. + - Added the required column `created_by_id` to the `comment` table without a default value. This is not possible if the table is not empty. + - Added the required column `comment_id` to the `comment_delete_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `operated_by_id` to the `comment_delete_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `comment_id` to the `comment_query_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_agent` to the `comment_query_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `group_id` to the `group_membership` table without a default value. This is not possible if the table is not empty. + - Added the required column `member_id` to the `group_membership` table without a default value. This is not possible if the table is not empty. + - Added the required column `group_id` to the `group_profile` table without a default value. This is not possible if the table is not empty. + - Added the required column `created_at` to the `group_question_relationship` table without a default value. This is not possible if the table is not empty. + - Added the required column `group_id` to the `group_question_relationship` table without a default value. This is not possible if the table is not empty. + - Added the required column `question_id` to the `group_question_relationship` table without a default value. This is not possible if the table is not empty. + - Added the required column `attendance_frequency` to the `group_target` table without a default value. This is not possible if the table is not empty. + - Added the required column `ended_at` to the `group_target` table without a default value. This is not possible if the table is not empty. + - Added the required column `group_id` to the `group_target` table without a default value. This is not possible if the table is not empty. + - Added the required column `started_at` to the `group_target` table without a default value. This is not possible if the table is not empty. + - Added the required column `created_by_id` to the `question` table without a default value. This is not possible if the table is not empty. + - Added the required column `elasticsearch_id` to the `question_elasticsearch_relation` table without a default value. This is not possible if the table is not empty. + - Added the required column `question_id` to the `question_elasticsearch_relation` table without a default value. This is not possible if the table is not empty. + - Added the required column `follower_id` to the `question_follower_relation` table without a default value. This is not possible if the table is not empty. + - Added the required column `question_id` to the `question_follower_relation` table without a default value. This is not possible if the table is not empty. + - Added the required column `question_id` to the `question_invitation_relation` table without a default value. This is not possible if the table is not empty. + - Added the required column `updated_at` to the `question_invitation_relation` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_id` to the `question_invitation_relation` table without a default value. This is not possible if the table is not empty. + - Added the required column `question_id` to the `question_query_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_agent` to the `question_query_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `page_size` to the `question_search_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_agent` to the `question_search_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `created_by_id` to the `question_topic_relation` table without a default value. This is not possible if the table is not empty. + - Added the required column `question_id` to the `question_topic_relation` table without a default value. This is not possible if the table is not empty. + - Added the required column `topic_id` to the `question_topic_relation` table without a default value. This is not possible if the table is not empty. + - Added the required column `last_refreshed_at` to the `session` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_id` to the `session` table without a default value. This is not possible if the table is not empty. + - Added the required column `valid_until` to the `session` table without a default value. This is not possible if the table is not empty. + - Added the required column `access_token` to the `session_refresh_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `new_refresh_token` to the `session_refresh_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `old_refresh_token` to the `session_refresh_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `session_id` to the `session_refresh_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `created_by_id` to the `topic` table without a default value. This is not possible if the table is not empty. + - Added the required column `page_size` to the `topic_search_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_agent` to the `topic_search_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `hashed_password` to the `user` table without a default value. This is not possible if the table is not empty. + - Added the required column `followee_id` to the `user_following_relationship` table without a default value. This is not possible if the table is not empty. + - Added the required column `follower_id` to the `user_following_relationship` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_agent` to the `user_login_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_id` to the `user_login_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `avatar_id` to the `user_profile` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_id` to the `user_profile` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_agent` to the `user_profile_query_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `viewee_id` to the `user_profile_query_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_agent` to the `user_register_log` table without a default value. This is not possible if the table is not empty. + - Added the required column `user_agent` to the `user_reset_password_log` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "AttitudableType" AS ENUM ('COMMENT', 'QUESTION', 'ANSWER'); + +-- CreateEnum +CREATE TYPE "AttitudeType" AS ENUM ('UNDEFINED', 'POSITIVE', 'NEGATIVE'); + +-- CreateEnum +CREATE TYPE "AttitudeTypeNotUndefined" AS ENUM ('POSITIVE', 'NEGATIVE'); + +-- CreateEnum +CREATE TYPE "CommentCommentabletypeEnum" AS ENUM ('ANSWER', 'COMMENT', 'QUESTION'); + +-- DropForeignKey +ALTER TABLE "answer" DROP CONSTRAINT "FK_1887685ce6667b435b01c646a2c"; + +-- DropForeignKey +ALTER TABLE "answer" DROP CONSTRAINT "FK_a4013f10cd6924793fbd5f0d637"; + +-- DropForeignKey +ALTER TABLE "answer" DROP CONSTRAINT "FK_f636f6e852686173ea947f29045"; + +-- DropForeignKey +ALTER TABLE "answer_delete_log" DROP CONSTRAINT "FK_910393b814aac627593588c17fd"; + +-- DropForeignKey +ALTER TABLE "answer_delete_log" DROP CONSTRAINT "FK_c2d0251df4669e17a57d6dbc06f"; + +-- DropForeignKey +ALTER TABLE "answer_favorited_by_user" DROP CONSTRAINT "FK_9556368d270d73579a68db7e1bf"; + +-- DropForeignKey +ALTER TABLE "answer_favorited_by_user" DROP CONSTRAINT "FK_c27a91d761c26ad612a0a356971"; + +-- DropForeignKey +ALTER TABLE "answer_query_log" DROP CONSTRAINT "FK_71ed57d6bb340716f5e17043bbb"; + +-- DropForeignKey +ALTER TABLE "answer_query_log" DROP CONSTRAINT "FK_f4b7cd859700f8928695b6c2bab"; + +-- DropForeignKey +ALTER TABLE "answer_update_log" DROP CONSTRAINT "FK_0ef2a982b61980d95b5ae7f1a60"; + +-- DropForeignKey +ALTER TABLE "answer_update_log" DROP CONSTRAINT "FK_6f0964cf74c12678a86e49b23fe"; + +-- DropForeignKey +ALTER TABLE "answer_user_attitude" DROP CONSTRAINT "FK_2de5146dd65213f724e32745d06"; + +-- DropForeignKey +ALTER TABLE "answer_user_attitude" DROP CONSTRAINT "FK_7555fb52fdf623d67f9884ea63d"; + +-- DropForeignKey +ALTER TABLE "attitude" DROP CONSTRAINT "attitude_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "attitude_log" DROP CONSTRAINT "attitude_log_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "comment" DROP CONSTRAINT "FK_63ac916757350d28f05c5a6a4ba"; + +-- DropForeignKey +ALTER TABLE "comment_delete_log" DROP CONSTRAINT "FK_53f0a8befcc12c0f7f2bab7584d"; + +-- DropForeignKey +ALTER TABLE "comment_delete_log" DROP CONSTRAINT "FK_66705ce7d7908554cff01b260ec"; + +-- DropForeignKey +ALTER TABLE "comment_query_log" DROP CONSTRAINT "FK_4020ff7fcffb2737e990f8bde5e"; + +-- DropForeignKey +ALTER TABLE "comment_query_log" DROP CONSTRAINT "FK_4ead8566a6fa987264484b13d54"; + +-- DropForeignKey +ALTER TABLE "group_membership" DROP CONSTRAINT "FK_7d88d00d8617a802b698c0cd609"; + +-- DropForeignKey +ALTER TABLE "group_membership" DROP CONSTRAINT "FK_b1411f07fafcd5ad93c6ee16424"; + +-- DropForeignKey +ALTER TABLE "group_profile" DROP CONSTRAINT "FK_7359ba99cc116d00cf74e048edd"; + +-- DropForeignKey +ALTER TABLE "group_profile" DROP CONSTRAINT "group_profile_avatarId_fkey"; + +-- DropForeignKey +ALTER TABLE "group_question_relationship" DROP CONSTRAINT "FK_5b1232271bf29d99456fcf39e75"; + +-- DropForeignKey +ALTER TABLE "group_question_relationship" DROP CONSTRAINT "FK_b31bf3b3688ec41daaced89a0ab"; + +-- DropForeignKey +ALTER TABLE "group_target" DROP CONSTRAINT "FK_19d57f140124c5100e8e1ca3088"; + +-- DropForeignKey +ALTER TABLE "question" DROP CONSTRAINT "FK_187915d8eaa010cde8b053b35d5"; + +-- DropForeignKey +ALTER TABLE "question_elasticsearch_relation" DROP CONSTRAINT "fk_question_elasticsearch_relation_question_id"; + +-- DropForeignKey +ALTER TABLE "question_follower_relation" DROP CONSTRAINT "FK_21a30245c4a32d5ac67da809010"; + +-- DropForeignKey +ALTER TABLE "question_follower_relation" DROP CONSTRAINT "FK_6544f7f7579bf88e3c62f995f8a"; + +-- DropForeignKey +ALTER TABLE "question_invitation_relation" DROP CONSTRAINT "question_invitation_relation_questionId_fkey"; + +-- DropForeignKey +ALTER TABLE "question_invitation_relation" DROP CONSTRAINT "question_invitation_relation_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "question_query_log" DROP CONSTRAINT "FK_8ce4bcc67caf0406e6f20923d4d"; + +-- DropForeignKey +ALTER TABLE "question_query_log" DROP CONSTRAINT "FK_a0ee1672e103ed0a0266f217a3f"; + +-- DropForeignKey +ALTER TABLE "question_search_log" DROP CONSTRAINT "FK_13c7e9fd7403cc5a87ab6524bc4"; + +-- DropForeignKey +ALTER TABLE "question_topic_relation" DROP CONSTRAINT "FK_d439ea68a02c1e7ea9863fc3df1"; + +-- DropForeignKey +ALTER TABLE "question_topic_relation" DROP CONSTRAINT "FK_dd4b9a1b83559fa38a3a50463fd"; + +-- DropForeignKey +ALTER TABLE "question_topic_relation" DROP CONSTRAINT "FK_fab99c5e4fc380d9b7f9abbbb02"; + +-- DropForeignKey +ALTER TABLE "topic" DROP CONSTRAINT "FK_59d7548ea797208240417106e2d"; + +-- DropForeignKey +ALTER TABLE "topic_search_log" DROP CONSTRAINT "FK_fe1e75b8b625499f0119faaba5b"; + +-- DropForeignKey +ALTER TABLE "user_following_relationship" DROP CONSTRAINT "FK_868df0c2c3a138ee54d2a515bce"; + +-- DropForeignKey +ALTER TABLE "user_following_relationship" DROP CONSTRAINT "FK_c78831eeee179237b1482d0c6fb"; + +-- DropForeignKey +ALTER TABLE "user_login_log" DROP CONSTRAINT "FK_66c592c7f7f20d1214aba2d0046"; + +-- DropForeignKey +ALTER TABLE "user_profile" DROP CONSTRAINT "fk_user_profile_avatar_id"; + +-- DropForeignKey +ALTER TABLE "user_profile" DROP CONSTRAINT "fk_user_profile_user_id"; + +-- DropForeignKey +ALTER TABLE "user_profile_query_log" DROP CONSTRAINT "FK_1261db28434fde159acda6094bc"; + +-- DropForeignKey +ALTER TABLE "user_profile_query_log" DROP CONSTRAINT "FK_ff592e4403b328be0de4f2b3973"; + +-- DropIndex +DROP INDEX "IDX_1887685ce6667b435b01c646a2"; + +-- DropIndex +DROP INDEX "IDX_a4013f10cd6924793fbd5f0d63"; + +-- DropIndex +DROP INDEX "IDX_f636f6e852686173ea947f2904"; + +-- DropIndex +DROP INDEX "IDX_910393b814aac627593588c17f"; + +-- DropIndex +DROP INDEX "IDX_c2d0251df4669e17a57d6dbc06"; + +-- DropIndex +DROP INDEX "IDX_9556368d270d73579a68db7e1b"; + +-- DropIndex +DROP INDEX "IDX_c27a91d761c26ad612a0a35697"; + +-- DropIndex +DROP INDEX "IDX_71ed57d6bb340716f5e17043bb"; + +-- DropIndex +DROP INDEX "IDX_f4b7cd859700f8928695b6c2ba"; + +-- DropIndex +DROP INDEX "IDX_0ef2a982b61980d95b5ae7f1a6"; + +-- DropIndex +DROP INDEX "IDX_6f0964cf74c12678a86e49b23f"; + +-- DropIndex +DROP INDEX "attitude_attitudableId_userId_attitudableType_key"; + +-- DropIndex +DROP INDEX "attitude_userId_idx"; + +-- DropIndex +DROP INDEX "attitude_log_attitudableId_attitudableType_idx"; + +-- DropIndex +DROP INDEX "attitude_log_userId_idx"; + +-- DropIndex +DROP INDEX "IDX_525212ea7a75cba69724e42303"; + +-- DropIndex +DROP INDEX "IDX_63ac916757350d28f05c5a6a4b"; + +-- DropIndex +DROP INDEX "IDX_53f0a8befcc12c0f7f2bab7584"; + +-- DropIndex +DROP INDEX "IDX_66705ce7d7908554cff01b260e"; + +-- DropIndex +DROP INDEX "IDX_4020ff7fcffb2737e990f8bde5"; + +-- DropIndex +DROP INDEX "IDX_4ead8566a6fa987264484b13d5"; + +-- DropIndex +DROP INDEX "IDX_7d88d00d8617a802b698c0cd60"; + +-- DropIndex +DROP INDEX "IDX_b1411f07fafcd5ad93c6ee1642"; + +-- DropIndex +DROP INDEX "REL_7359ba99cc116d00cf74e048ed"; + +-- DropIndex +DROP INDEX "IDX_b31bf3b3688ec41daaced89a0a"; + +-- DropIndex +DROP INDEX "REL_5b1232271bf29d99456fcf39e7"; + +-- DropIndex +DROP INDEX "IDX_19d57f140124c5100e8e1ca308"; + +-- DropIndex +DROP INDEX "IDX_187915d8eaa010cde8b053b35d"; + +-- DropIndex +DROP INDEX "IDX_ac7c68d428ab7ffd2f4752eeaa"; + +-- DropIndex +DROP INDEX "question_elasticsearch_relation_questionId_key"; + +-- DropIndex +DROP INDEX "IDX_21a30245c4a32d5ac67da80901"; + +-- DropIndex +DROP INDEX "IDX_6544f7f7579bf88e3c62f995f8"; + +-- DropIndex +DROP INDEX "question_invitation_relation_questionId_idx"; + +-- DropIndex +DROP INDEX "question_invitation_relation_userId_idx"; + +-- DropIndex +DROP INDEX "IDX_8ce4bcc67caf0406e6f20923d4"; + +-- DropIndex +DROP INDEX "IDX_a0ee1672e103ed0a0266f217a3"; + +-- DropIndex +DROP INDEX "IDX_13c7e9fd7403cc5a87ab6524bc"; + +-- DropIndex +DROP INDEX "IDX_dd4b9a1b83559fa38a3a50463f"; + +-- DropIndex +DROP INDEX "IDX_fab99c5e4fc380d9b7f9abbbb0"; + +-- DropIndex +DROP INDEX "IDX_3d2f174ef04fb312fdebd0ddc5"; + +-- DropIndex +DROP INDEX "IDX_bb46e87d5b3f1e55c625755c00"; + +-- DropIndex +DROP INDEX "IDX_59d7548ea797208240417106e2"; + +-- DropIndex +DROP INDEX "IDX_fe1e75b8b625499f0119faaba5"; + +-- DropIndex +DROP INDEX "IDX_868df0c2c3a138ee54d2a515bc"; + +-- DropIndex +DROP INDEX "IDX_c78831eeee179237b1482d0c6f"; + +-- DropIndex +DROP INDEX "IDX_66c592c7f7f20d1214aba2d004"; + +-- DropIndex +DROP INDEX "IDX_51cb79b5555effaf7d69ba1cff"; + +-- DropIndex +DROP INDEX "IDX_1261db28434fde159acda6094b"; + +-- DropIndex +DROP INDEX "IDX_ff592e4403b328be0de4f2b397"; + +-- AlterTable +ALTER TABLE "answer" DROP COLUMN "createdAt", +DROP COLUMN "createdById", +DROP COLUMN "deletedAt", +DROP COLUMN "groupId", +DROP COLUMN "questionId", +DROP COLUMN "updatedAt", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "created_by_id" INTEGER NOT NULL, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "group_id" INTEGER, +ADD COLUMN "question_id" INTEGER NOT NULL, +ADD COLUMN "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "answer_delete_log" DROP COLUMN "answerId", +DROP COLUMN "createdAt", +DROP COLUMN "deleterId", +ADD COLUMN "answer_id" INTEGER NOT NULL, +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "deleter_id" INTEGER; + +-- AlterTable +ALTER TABLE "answer_favorited_by_user" DROP CONSTRAINT "PK_5a857fe93c44fdb538ec5aa4771", +DROP COLUMN "answerId", +DROP COLUMN "userId", +ADD COLUMN "answer_id" INTEGER NOT NULL, +ADD COLUMN "user_id" INTEGER NOT NULL, +ADD CONSTRAINT "PK_5a857fe93c44fdb538ec5aa4771" PRIMARY KEY ("answer_id", "user_id"); + +-- AlterTable +ALTER TABLE "answer_query_log" DROP COLUMN "answerId", +DROP COLUMN "createdAt", +DROP COLUMN "userAgent", +DROP COLUMN "viewerId", +ADD COLUMN "answer_id" INTEGER NOT NULL, +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "user_agent" VARCHAR NOT NULL, +ADD COLUMN "viewer_id" INTEGER; + +-- AlterTable +ALTER TABLE "answer_update_log" DROP COLUMN "answerId", +DROP COLUMN "createdAt", +DROP COLUMN "newContent", +DROP COLUMN "oldContent", +DROP COLUMN "updaterId", +ADD COLUMN "answer_id" INTEGER NOT NULL, +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "new_content" TEXT NOT NULL, +ADD COLUMN "old_content" TEXT NOT NULL, +ADD COLUMN "updater_id" INTEGER; + +-- AlterTable +ALTER TABLE "answer_user_attitude" DROP COLUMN "answerId", +DROP COLUMN "userId", +ADD COLUMN "answer_id" INTEGER NOT NULL, +ADD COLUMN "user_id" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "attitude" DROP COLUMN "attitudableId", +DROP COLUMN "attitudableType", +DROP COLUMN "createdAt", +DROP COLUMN "updatedAt", +DROP COLUMN "userId", +ADD COLUMN "attitudable_id" INTEGER NOT NULL, +ADD COLUMN "attitudable_type" "AttitudableType" NOT NULL, +ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL, +ADD COLUMN "user_id" INTEGER NOT NULL, +DROP COLUMN "attitude", +ADD COLUMN "attitude" "AttitudeTypeNotUndefined" NOT NULL; + +-- AlterTable +ALTER TABLE "attitude_log" DROP COLUMN "attitudableId", +DROP COLUMN "attitudableType", +DROP COLUMN "createdAt", +DROP COLUMN "userId", +ADD COLUMN "attitudable_id" INTEGER NOT NULL, +ADD COLUMN "attitudable_type" "AttitudableType" NOT NULL, +ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "user_id" INTEGER NOT NULL, +DROP COLUMN "attitude", +ADD COLUMN "attitude" "AttitudeType" NOT NULL; + +-- AlterTable +ALTER TABLE "avatar" DROP COLUMN "avatarType", +DROP COLUMN "createdAt", +DROP COLUMN "usageCount", +ADD COLUMN "avatar_type" VARCHAR NOT NULL, +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "usage_count" INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "comment" DROP COLUMN "commentableId", +DROP COLUMN "commentableType", +DROP COLUMN "createdAt", +DROP COLUMN "createdById", +DROP COLUMN "deletedAt", +DROP COLUMN "updatedAt", +ADD COLUMN "commentable_id" INTEGER NOT NULL, +ADD COLUMN "commentable_type" "CommentCommentabletypeEnum" NOT NULL, +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "created_by_id" INTEGER NOT NULL, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "comment_delete_log" DROP COLUMN "commentId", +DROP COLUMN "createdAt", +DROP COLUMN "operatedById", +ADD COLUMN "comment_id" INTEGER NOT NULL, +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "operated_by_id" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "comment_query_log" DROP COLUMN "commentId", +DROP COLUMN "createdAt", +DROP COLUMN "userAgent", +DROP COLUMN "viewerId", +ADD COLUMN "comment_id" INTEGER NOT NULL, +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "user_agent" VARCHAR NOT NULL, +ADD COLUMN "viewer_id" INTEGER; + +-- AlterTable +ALTER TABLE "group" DROP COLUMN "createdAt", +DROP COLUMN "deletedAt", +DROP COLUMN "updatedAt", +ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT ('now'::text)::timestamp(3) with time zone, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "group_membership" DROP COLUMN "createdAt", +DROP COLUMN "deletedAt", +DROP COLUMN "groupId", +DROP COLUMN "memberId", +DROP COLUMN "updatedAt", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "group_id" INTEGER NOT NULL, +ADD COLUMN "member_id" INTEGER NOT NULL, +ADD COLUMN "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "group_profile" DROP COLUMN "avatarId", +DROP COLUMN "createdAt", +DROP COLUMN "deletedAt", +DROP COLUMN "groupId", +DROP COLUMN "updatedAt", +ADD COLUMN "avatar_id" INTEGER, +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "group_id" INTEGER NOT NULL, +ADD COLUMN "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "group_question_relationship" DROP COLUMN "createdAt", +DROP COLUMN "deletedAt", +DROP COLUMN "groupId", +DROP COLUMN "questionId", +DROP COLUMN "updatedAt", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "group_id" INTEGER NOT NULL, +ADD COLUMN "question_id" INTEGER NOT NULL, +ADD COLUMN "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "group_target" DROP COLUMN "attendanceFrequency", +DROP COLUMN "createdAt", +DROP COLUMN "deletedAt", +DROP COLUMN "endedAt", +DROP COLUMN "groupId", +DROP COLUMN "startedAt", +DROP COLUMN "updatedAt", +ADD COLUMN "attendance_frequency" VARCHAR NOT NULL, +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "ended_at" DATE NOT NULL, +ADD COLUMN "group_id" INTEGER NOT NULL, +ADD COLUMN "started_at" DATE NOT NULL, +ADD COLUMN "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "question" DROP COLUMN "createdAt", +DROP COLUMN "createdById", +DROP COLUMN "deletedAt", +DROP COLUMN "groupId", +DROP COLUMN "updatedAt", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "created_by_id" INTEGER NOT NULL, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "group_id" INTEGER, +ADD COLUMN "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "question_elasticsearch_relation" DROP COLUMN "elasticsearchId", +DROP COLUMN "questionId", +ADD COLUMN "elasticsearch_id" TEXT NOT NULL, +ADD COLUMN "question_id" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "question_follower_relation" DROP COLUMN "createdAt", +DROP COLUMN "deletedAt", +DROP COLUMN "followerId", +DROP COLUMN "questionId", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "follower_id" INTEGER NOT NULL, +ADD COLUMN "question_id" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "question_invitation_relation" DROP COLUMN "createdAt", +DROP COLUMN "questionId", +DROP COLUMN "updatedAt", +DROP COLUMN "userId", +ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "question_id" INTEGER NOT NULL, +ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL, +ADD COLUMN "user_id" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "question_query_log" DROP COLUMN "createdAt", +DROP COLUMN "questionId", +DROP COLUMN "userAgent", +DROP COLUMN "viewerId", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "question_id" INTEGER NOT NULL, +ADD COLUMN "user_agent" VARCHAR NOT NULL, +ADD COLUMN "viewer_id" INTEGER; + +-- AlterTable +ALTER TABLE "question_search_log" DROP COLUMN "createdAt", +DROP COLUMN "firstQuestionId", +DROP COLUMN "pageSize", +DROP COLUMN "searcherId", +DROP COLUMN "userAgent", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "first_question_id" INTEGER, +ADD COLUMN "page_size" INTEGER NOT NULL, +ADD COLUMN "searcher_id" INTEGER, +ADD COLUMN "user_agent" VARCHAR NOT NULL; + +-- AlterTable +ALTER TABLE "question_topic_relation" DROP COLUMN "createdAt", +DROP COLUMN "createdById", +DROP COLUMN "deletedAt", +DROP COLUMN "questionId", +DROP COLUMN "topicId", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "created_by_id" INTEGER NOT NULL, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "question_id" INTEGER NOT NULL, +ADD COLUMN "topic_id" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "session" DROP COLUMN "createdAt", +DROP COLUMN "lastRefreshedAt", +DROP COLUMN "userId", +DROP COLUMN "validUntil", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "last_refreshed_at" BIGINT NOT NULL, +ADD COLUMN "user_id" INTEGER NOT NULL, +ADD COLUMN "valid_until" TIMESTAMP(6) NOT NULL; + +-- AlterTable +ALTER TABLE "session_refresh_log" DROP COLUMN "accessToken", +DROP COLUMN "createdAt", +DROP COLUMN "newRefreshToken", +DROP COLUMN "oldRefreshToken", +DROP COLUMN "sessionId", +ADD COLUMN "access_token" TEXT NOT NULL, +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "new_refresh_token" TEXT NOT NULL, +ADD COLUMN "old_refresh_token" TEXT NOT NULL, +ADD COLUMN "session_id" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "topic" DROP COLUMN "createdAt", +DROP COLUMN "createdById", +DROP COLUMN "deletedAt", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "created_by_id" INTEGER NOT NULL, +ADD COLUMN "deleted_at" TIMESTAMP(6); + +-- AlterTable +ALTER TABLE "topic_search_log" DROP COLUMN "createdAt", +DROP COLUMN "firstTopicId", +DROP COLUMN "pageSize", +DROP COLUMN "searcherId", +DROP COLUMN "userAgent", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "first_topic_id" INTEGER, +ADD COLUMN "page_size" INTEGER NOT NULL, +ADD COLUMN "searcher_id" INTEGER, +ADD COLUMN "user_agent" VARCHAR NOT NULL; + +-- AlterTable +ALTER TABLE "user" DROP COLUMN "createdAt", +DROP COLUMN "deletedAt", +DROP COLUMN "hashedPassword", +DROP COLUMN "updatedAt", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "hashed_password" VARCHAR NOT NULL, +ADD COLUMN "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "user_following_relationship" DROP COLUMN "createdAt", +DROP COLUMN "deletedAt", +DROP COLUMN "followeeId", +DROP COLUMN "followerId", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "followee_id" INTEGER NOT NULL, +ADD COLUMN "follower_id" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "user_login_log" DROP COLUMN "createdAt", +DROP COLUMN "userAgent", +DROP COLUMN "userId", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "user_agent" VARCHAR NOT NULL, +ADD COLUMN "user_id" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "user_profile" DROP COLUMN "avatarId", +DROP COLUMN "createdAt", +DROP COLUMN "deletedAt", +DROP COLUMN "updatedAt", +DROP COLUMN "userId", +ADD COLUMN "avatar_id" INTEGER NOT NULL, +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "deleted_at" TIMESTAMP(6), +ADD COLUMN "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "user_id" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "user_profile_query_log" DROP COLUMN "createdAt", +DROP COLUMN "userAgent", +DROP COLUMN "vieweeId", +DROP COLUMN "viewerId", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "user_agent" VARCHAR NOT NULL, +ADD COLUMN "viewee_id" INTEGER NOT NULL, +ADD COLUMN "viewer_id" INTEGER; + +-- AlterTable +ALTER TABLE "user_register_log" DROP COLUMN "createdAt", +DROP COLUMN "registerRequestId", +DROP COLUMN "userAgent", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "register_request_id" INTEGER, +ADD COLUMN "user_agent" VARCHAR NOT NULL; + +-- AlterTable +ALTER TABLE "user_register_request" DROP COLUMN "createdAt", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "user_reset_password_log" DROP COLUMN "createdAt", +DROP COLUMN "userAgent", +DROP COLUMN "userId", +ADD COLUMN "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "user_agent" VARCHAR NOT NULL, +ADD COLUMN "user_id" INTEGER; + +-- DropEnum +DROP TYPE "attitudable_type"; + +-- DropEnum +DROP TYPE "attitude_type"; + +-- DropEnum +DROP TYPE "attitude_type_not_undefined"; + +-- DropEnum +DROP TYPE "comment_commentabletype_enum"; + +-- CreateIndex +CREATE INDEX "IDX_1887685ce6667b435b01c646a2" ON "answer"("group_id"); + +-- CreateIndex +CREATE INDEX "IDX_a4013f10cd6924793fbd5f0d63" ON "answer"("question_id"); + +-- CreateIndex +CREATE INDEX "IDX_f636f6e852686173ea947f2904" ON "answer"("created_by_id"); + +-- CreateIndex +CREATE INDEX "IDX_910393b814aac627593588c17f" ON "answer_delete_log"("answer_id"); + +-- CreateIndex +CREATE INDEX "IDX_c2d0251df4669e17a57d6dbc06" ON "answer_delete_log"("deleter_id"); + +-- CreateIndex +CREATE INDEX "IDX_9556368d270d73579a68db7e1b" ON "answer_favorited_by_user"("user_id"); + +-- CreateIndex +CREATE INDEX "IDX_c27a91d761c26ad612a0a35697" ON "answer_favorited_by_user"("answer_id"); + +-- CreateIndex +CREATE INDEX "IDX_71ed57d6bb340716f5e17043bb" ON "answer_query_log"("answer_id"); + +-- CreateIndex +CREATE INDEX "IDX_f4b7cd859700f8928695b6c2ba" ON "answer_query_log"("viewer_id"); + +-- CreateIndex +CREATE INDEX "IDX_0ef2a982b61980d95b5ae7f1a6" ON "answer_update_log"("updater_id"); + +-- CreateIndex +CREATE INDEX "IDX_6f0964cf74c12678a86e49b23f" ON "answer_update_log"("answer_id"); + +-- CreateIndex +CREATE INDEX "attitude_user_id_idx" ON "attitude"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "attitude_attitudable_id_user_id_attitudable_type_key" ON "attitude"("attitudable_id", "user_id", "attitudable_type"); + +-- CreateIndex +CREATE INDEX "attitude_log_attitudable_id_attitudable_type_idx" ON "attitude_log"("attitudable_id", "attitudable_type"); + +-- CreateIndex +CREATE INDEX "attitude_log_user_id_idx" ON "attitude_log"("user_id"); + +-- CreateIndex +CREATE INDEX "IDX_525212ea7a75cba69724e42303" ON "comment"("commentable_id"); + +-- CreateIndex +CREATE INDEX "IDX_63ac916757350d28f05c5a6a4b" ON "comment"("created_by_id"); + +-- CreateIndex +CREATE INDEX "IDX_53f0a8befcc12c0f7f2bab7584" ON "comment_delete_log"("operated_by_id"); + +-- CreateIndex +CREATE INDEX "IDX_66705ce7d7908554cff01b260e" ON "comment_delete_log"("comment_id"); + +-- CreateIndex +CREATE INDEX "IDX_4020ff7fcffb2737e990f8bde5" ON "comment_query_log"("comment_id"); + +-- CreateIndex +CREATE INDEX "IDX_4ead8566a6fa987264484b13d5" ON "comment_query_log"("viewer_id"); + +-- CreateIndex +CREATE INDEX "IDX_7d88d00d8617a802b698c0cd60" ON "group_membership"("member_id"); + +-- CreateIndex +CREATE INDEX "IDX_b1411f07fafcd5ad93c6ee1642" ON "group_membership"("group_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_7359ba99cc116d00cf74e048ed" ON "group_profile"("group_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_5b1232271bf29d99456fcf39e7" ON "group_question_relationship"("question_id"); + +-- CreateIndex +CREATE INDEX "IDX_b31bf3b3688ec41daaced89a0a" ON "group_question_relationship"("group_id"); + +-- CreateIndex +CREATE INDEX "IDX_19d57f140124c5100e8e1ca308" ON "group_target"("group_id"); + +-- CreateIndex +CREATE INDEX "IDX_187915d8eaa010cde8b053b35d" ON "question"("created_by_id"); + +-- CreateIndex +CREATE INDEX "IDX_ac7c68d428ab7ffd2f4752eeaa" ON "question"("group_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "question_elasticsearch_relation_question_id_key" ON "question_elasticsearch_relation"("question_id"); + +-- CreateIndex +CREATE INDEX "IDX_21a30245c4a32d5ac67da80901" ON "question_follower_relation"("follower_id"); + +-- CreateIndex +CREATE INDEX "IDX_6544f7f7579bf88e3c62f995f8" ON "question_follower_relation"("question_id"); + +-- CreateIndex +CREATE INDEX "question_invitation_relation_question_id_idx" ON "question_invitation_relation"("question_id"); + +-- CreateIndex +CREATE INDEX "question_invitation_relation_user_id_idx" ON "question_invitation_relation"("user_id"); + +-- CreateIndex +CREATE INDEX "IDX_8ce4bcc67caf0406e6f20923d4" ON "question_query_log"("viewer_id"); + +-- CreateIndex +CREATE INDEX "IDX_a0ee1672e103ed0a0266f217a3" ON "question_query_log"("question_id"); + +-- CreateIndex +CREATE INDEX "IDX_13c7e9fd7403cc5a87ab6524bc" ON "question_search_log"("searcher_id"); + +-- CreateIndex +CREATE INDEX "IDX_dd4b9a1b83559fa38a3a50463f" ON "question_topic_relation"("topic_id"); + +-- CreateIndex +CREATE INDEX "IDX_fab99c5e4fc380d9b7f9abbbb0" ON "question_topic_relation"("question_id"); + +-- CreateIndex +CREATE INDEX "IDX_3d2f174ef04fb312fdebd0ddc5" ON "session"("user_id"); + +-- CreateIndex +CREATE INDEX "IDX_bb46e87d5b3f1e55c625755c00" ON "session"("valid_until"); + +-- CreateIndex +CREATE INDEX "IDX_59d7548ea797208240417106e2" ON "topic"("created_by_id"); + +-- CreateIndex +CREATE INDEX "IDX_fe1e75b8b625499f0119faaba5" ON "topic_search_log"("searcher_id"); + +-- CreateIndex +CREATE INDEX "IDX_868df0c2c3a138ee54d2a515bc" ON "user_following_relationship"("follower_id"); + +-- CreateIndex +CREATE INDEX "IDX_c78831eeee179237b1482d0c6f" ON "user_following_relationship"("followee_id"); + +-- CreateIndex +CREATE INDEX "IDX_66c592c7f7f20d1214aba2d004" ON "user_login_log"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "IDX_51cb79b5555effaf7d69ba1cff" ON "user_profile"("user_id"); + +-- CreateIndex +CREATE INDEX "IDX_1261db28434fde159acda6094b" ON "user_profile_query_log"("viewer_id"); + +-- CreateIndex +CREATE INDEX "IDX_ff592e4403b328be0de4f2b397" ON "user_profile_query_log"("viewee_id"); + +-- AddForeignKey +ALTER TABLE "answer" ADD CONSTRAINT "FK_1887685ce6667b435b01c646a2c" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer" ADD CONSTRAINT "FK_a4013f10cd6924793fbd5f0d637" FOREIGN KEY ("question_id") REFERENCES "question"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer" ADD CONSTRAINT "FK_f636f6e852686173ea947f29045" FOREIGN KEY ("created_by_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_delete_log" ADD CONSTRAINT "FK_910393b814aac627593588c17fd" FOREIGN KEY ("answer_id") REFERENCES "answer"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_delete_log" ADD CONSTRAINT "FK_c2d0251df4669e17a57d6dbc06f" FOREIGN KEY ("deleter_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_favorited_by_user" ADD CONSTRAINT "FK_9556368d270d73579a68db7e1bf" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "answer_favorited_by_user" ADD CONSTRAINT "FK_c27a91d761c26ad612a0a356971" FOREIGN KEY ("answer_id") REFERENCES "answer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "answer_query_log" ADD CONSTRAINT "FK_71ed57d6bb340716f5e17043bbb" FOREIGN KEY ("answer_id") REFERENCES "answer"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_query_log" ADD CONSTRAINT "FK_f4b7cd859700f8928695b6c2bab" FOREIGN KEY ("viewer_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_update_log" ADD CONSTRAINT "FK_0ef2a982b61980d95b5ae7f1a60" FOREIGN KEY ("updater_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_update_log" ADD CONSTRAINT "FK_6f0964cf74c12678a86e49b23fe" FOREIGN KEY ("answer_id") REFERENCES "answer"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_user_attitude" ADD CONSTRAINT "FK_2de5146dd65213f724e32745d06" FOREIGN KEY ("answer_id") REFERENCES "answer"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "answer_user_attitude" ADD CONSTRAINT "FK_7555fb52fdf623d67f9884ea63d" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "attitude" ADD CONSTRAINT "attitude_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "attitude_log" ADD CONSTRAINT "attitude_log_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comment" ADD CONSTRAINT "FK_63ac916757350d28f05c5a6a4ba" FOREIGN KEY ("created_by_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "comment_delete_log" ADD CONSTRAINT "FK_53f0a8befcc12c0f7f2bab7584d" FOREIGN KEY ("operated_by_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "comment_delete_log" ADD CONSTRAINT "FK_66705ce7d7908554cff01b260ec" FOREIGN KEY ("comment_id") REFERENCES "comment"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "comment_query_log" ADD CONSTRAINT "FK_4020ff7fcffb2737e990f8bde5e" FOREIGN KEY ("comment_id") REFERENCES "comment"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "comment_query_log" ADD CONSTRAINT "FK_4ead8566a6fa987264484b13d54" FOREIGN KEY ("viewer_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "group_membership" ADD CONSTRAINT "FK_7d88d00d8617a802b698c0cd609" FOREIGN KEY ("member_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "group_membership" ADD CONSTRAINT "FK_b1411f07fafcd5ad93c6ee16424" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "group_profile" ADD CONSTRAINT "group_profile_avatar_id_fkey" FOREIGN KEY ("avatar_id") REFERENCES "avatar"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "group_profile" ADD CONSTRAINT "FK_7359ba99cc116d00cf74e048edd" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "group_question_relationship" ADD CONSTRAINT "FK_5b1232271bf29d99456fcf39e75" FOREIGN KEY ("question_id") REFERENCES "question"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "group_question_relationship" ADD CONSTRAINT "FK_b31bf3b3688ec41daaced89a0ab" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "group_target" ADD CONSTRAINT "FK_19d57f140124c5100e8e1ca3088" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_elasticsearch_relation" ADD CONSTRAINT "fk_question_elasticsearch_relation_question_id" FOREIGN KEY ("question_id") REFERENCES "question"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "question_invitation_relation" ADD CONSTRAINT "question_invitation_relation_question_id_fkey" FOREIGN KEY ("question_id") REFERENCES "question"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "question_invitation_relation" ADD CONSTRAINT "question_invitation_relation_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "question" ADD CONSTRAINT "FK_187915d8eaa010cde8b053b35d5" FOREIGN KEY ("created_by_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_follower_relation" ADD CONSTRAINT "FK_21a30245c4a32d5ac67da809010" FOREIGN KEY ("follower_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_follower_relation" ADD CONSTRAINT "FK_6544f7f7579bf88e3c62f995f8a" FOREIGN KEY ("question_id") REFERENCES "question"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_query_log" ADD CONSTRAINT "FK_8ce4bcc67caf0406e6f20923d4d" FOREIGN KEY ("viewer_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_query_log" ADD CONSTRAINT "FK_a0ee1672e103ed0a0266f217a3f" FOREIGN KEY ("question_id") REFERENCES "question"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_search_log" ADD CONSTRAINT "FK_13c7e9fd7403cc5a87ab6524bc4" FOREIGN KEY ("searcher_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_topic_relation" ADD CONSTRAINT "FK_d439ea68a02c1e7ea9863fc3df1" FOREIGN KEY ("created_by_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_topic_relation" ADD CONSTRAINT "FK_dd4b9a1b83559fa38a3a50463fd" FOREIGN KEY ("topic_id") REFERENCES "topic"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "question_topic_relation" ADD CONSTRAINT "FK_fab99c5e4fc380d9b7f9abbbb02" FOREIGN KEY ("question_id") REFERENCES "question"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "topic" ADD CONSTRAINT "FK_59d7548ea797208240417106e2d" FOREIGN KEY ("created_by_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "topic_search_log" ADD CONSTRAINT "FK_fe1e75b8b625499f0119faaba5b" FOREIGN KEY ("searcher_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_following_relationship" ADD CONSTRAINT "FK_868df0c2c3a138ee54d2a515bce" FOREIGN KEY ("follower_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_following_relationship" ADD CONSTRAINT "FK_c78831eeee179237b1482d0c6fb" FOREIGN KEY ("followee_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_login_log" ADD CONSTRAINT "FK_66c592c7f7f20d1214aba2d0046" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_profile" ADD CONSTRAINT "fk_user_profile_user_id" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_profile" ADD CONSTRAINT "fk_user_profile_avatar_id" FOREIGN KEY ("avatar_id") REFERENCES "avatar"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_profile_query_log" ADD CONSTRAINT "FK_1261db28434fde159acda6094bc" FOREIGN KEY ("viewer_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "user_profile_query_log" ADD CONSTRAINT "FK_ff592e4403b328be0de4f2b3973" FOREIGN KEY ("viewee_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/prisma/migrations/20240322082656_question_es_relation_add_index/migration.sql b/prisma/migrations/20240322082656_question_es_relation_add_index/migration.sql new file mode 100644 index 00000000..9d5345fc --- /dev/null +++ b/prisma/migrations/20240322082656_question_es_relation_add_index/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "question_elasticsearch_relation_question_id_idx" ON "question_elasticsearch_relation"("question_id"); + +-- CreateIndex +CREATE INDEX "question_elasticsearch_relation_elasticsearch_id_idx" ON "question_elasticsearch_relation"("elasticsearch_id"); diff --git a/prisma/migrations/20240325074950_feat_acceptance_bounty/migration.sql b/prisma/migrations/20240325074950_feat_acceptance_bounty/migration.sql new file mode 100644 index 00000000..4a0cd383 --- /dev/null +++ b/prisma/migrations/20240325074950_feat_acceptance_bounty/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - A unique constraint covering the columns `[acceptedAnswerId]` on the table `question` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "question" ADD COLUMN "acceptedAnswerId" INTEGER, +ADD COLUMN "bounty" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "bounty_start_at" TIMESTAMP(3); + +-- CreateIndex +CREATE UNIQUE INDEX "question_acceptedAnswerId_key" ON "question"("acceptedAnswerId"); + +-- AddForeignKey +ALTER TABLE "question" ADD CONSTRAINT "question_acceptedAnswerId_fkey" FOREIGN KEY ("acceptedAnswerId") REFERENCES "answer"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20240325134151_acceptance_bounty/migration.sql b/prisma/migrations/20240325134151_acceptance_bounty/migration.sql new file mode 100644 index 00000000..f5f72693 --- /dev/null +++ b/prisma/migrations/20240325134151_acceptance_bounty/migration.sql @@ -0,0 +1,22 @@ +/* + Warnings: + + - You are about to drop the column `acceptedAnswerId` on the `question` table. All the data in the column will be lost. + - A unique constraint covering the columns `[accepted_answer_id]` on the table `question` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE "question" DROP CONSTRAINT "question_acceptedAnswerId_fkey"; + +-- DropIndex +DROP INDEX "question_acceptedAnswerId_key"; + +-- AlterTable +ALTER TABLE "question" DROP COLUMN "acceptedAnswerId", +ADD COLUMN "accepted_answer_id" INTEGER; + +-- CreateIndex +CREATE UNIQUE INDEX "question_accepted_answer_id_key" ON "question"("accepted_answer_id"); + +-- AddForeignKey +ALTER TABLE "question" ADD CONSTRAINT "question_accepted_answer_id_fkey" FOREIGN KEY ("accepted_answer_id") REFERENCES "answer"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20240326071224_add_materials/migration.sql b/prisma/migrations/20240326071224_add_materials/migration.sql new file mode 100644 index 00000000..58431753 --- /dev/null +++ b/prisma/migrations/20240326071224_add_materials/migration.sql @@ -0,0 +1,13 @@ +-- CreateEnum +CREATE TYPE "MaterialType" AS ENUM ('image', 'file', 'audio', 'video'); + +-- CreateTable +CREATE TABLE "material" ( + "id" SERIAL NOT NULL, + "type" "MaterialType" NOT NULL, + "url" TEXT NOT NULL, + "name" TEXT NOT NULL, + "meta" JSON NOT NULL, + + CONSTRAINT "material_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20240406100658_use_timestamptz/migration.sql b/prisma/migrations/20240406100658_use_timestamptz/migration.sql new file mode 100644 index 00000000..d2975623 --- /dev/null +++ b/prisma/migrations/20240406100658_use_timestamptz/migration.sql @@ -0,0 +1,109 @@ +-- AlterTable +ALTER TABLE "answer" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "answer_delete_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "answer_query_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "answer_update_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "avatar" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "comment_delete_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "comment_query_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "group" ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "group_membership" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "group_profile" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "group_question_relationship" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "group_target" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "question" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "bounty_start_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "question_follower_relation" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "question_query_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "question_search_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "question_topic_relation" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "session" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "valid_until" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "session_refresh_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "topic" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "topic_search_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "user" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "user_following_relationship" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "user_login_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "user_profile" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "user_profile_query_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "user_register_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "user_register_request" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); + +-- AlterTable +ALTER TABLE "user_reset_password_log" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(6); diff --git a/prisma/migrations/20240413142918_refactor_avatar/migration.sql b/prisma/migrations/20240413142918_refactor_avatar/migration.sql new file mode 100644 index 00000000..2f5ec588 --- /dev/null +++ b/prisma/migrations/20240413142918_refactor_avatar/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Changed the type of `avatar_type` on the `avatar` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- CreateEnum +CREATE TYPE "AvatarType" AS ENUM ('default', 'predefined', 'upload'); + +-- AlterTable +ALTER TABLE "avatar" DROP COLUMN "avatar_type", +ADD COLUMN "avatar_type" "AvatarType" NOT NULL; diff --git a/prisma/migrations/20240414154153_deprecate_answer_attitude/migration.sql b/prisma/migrations/20240414154153_deprecate_answer_attitude/migration.sql new file mode 100644 index 00000000..e7d0b47f --- /dev/null +++ b/prisma/migrations/20240414154153_deprecate_answer_attitude/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + - You are about to drop the `answer_user_attitude` table. If the table is not empty, all the data it contains will be lost. +*/ +-- DropForeignKey +ALTER TABLE "answer_user_attitude" DROP CONSTRAINT "FK_2de5146dd65213f724e32745d06"; + +-- DropForeignKey +ALTER TABLE "answer_user_attitude" DROP CONSTRAINT "FK_7555fb52fdf623d67f9884ea63d"; + +-- DropTable +DROP TABLE "answer_user_attitude"; diff --git a/prisma/migrations/20240618022355_refactor_remove_typeorm/migration.sql b/prisma/migrations/20240618022355_refactor_remove_typeorm/migration.sql new file mode 100644 index 00000000..8f702e16 --- /dev/null +++ b/prisma/migrations/20240618022355_refactor_remove_typeorm/migration.sql @@ -0,0 +1,48 @@ +/* + Warnings: + + - You are about to drop the column `register_request_id` on the `user_register_log` table. All the data in the column will be lost. + - Changed the type of `type` on the `user_register_log` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Changed the type of `type` on the `user_reset_password_log` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- CreateEnum +CREATE TYPE "UserRegisterLogType" AS ENUM ('RequestSuccess', 'RequestFailDueToAlreadyRegistered', 'RequestFailDueToInvalidOrNotSupportedEmail', 'RequestFailDurToSecurity', 'RequestFailDueToSendEmailFailure', 'Success', 'FailDueToUserExistence', 'FailDueToWrongCodeOrExpired'); + +-- CreateEnum +CREATE TYPE "UserResetPasswordLogType" AS ENUM ('RequestSuccess', 'RequestFailDueToNoneExistentEmail', 'RequestFailDueToSecurity', 'Success', 'FailDueToInvalidToken', 'FailDueToExpiredRequest', 'FailDueToNoUser'); + +-- AlterTable +ALTER TABLE "answer_query_log" ALTER COLUMN "user_agent" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "attitude" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "comment_query_log" ALTER COLUMN "user_agent" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "question_query_log" ALTER COLUMN "user_agent" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "question_search_log" ALTER COLUMN "user_agent" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "topic_search_log" ALTER COLUMN "user_agent" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "user_login_log" ALTER COLUMN "user_agent" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "user_profile_query_log" ALTER COLUMN "user_agent" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "user_register_log" DROP COLUMN "register_request_id", +DROP COLUMN "type", +ADD COLUMN "type" "UserRegisterLogType" NOT NULL, +ALTER COLUMN "user_agent" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "user_reset_password_log" DROP COLUMN "type", +ADD COLUMN "type" "UserResetPasswordLogType" NOT NULL, +ALTER COLUMN "user_agent" DROP NOT NULL; diff --git a/prisma/migrations/20240722102452_refactor_materials/migration.sql b/prisma/migrations/20240722102452_refactor_materials/migration.sql new file mode 100644 index 00000000..9a0042f4 --- /dev/null +++ b/prisma/migrations/20240722102452_refactor_materials/migration.sql @@ -0,0 +1,60 @@ +/* + Warnings: + + - Added the required column `uploader_id` to the `material` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "AttachmentType" AS ENUM ('image', 'video', 'audio', 'file'); + +-- AlterTable +ALTER TABLE "material" ADD COLUMN "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "download_count" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "expires" INTEGER, +ADD COLUMN "uploader_id" INTEGER NOT NULL; + +-- CreateTable +CREATE TABLE "attachment" ( + "id" SERIAL NOT NULL, + "type" "AttachmentType" NOT NULL, + "url" TEXT NOT NULL, + "meta" JSON NOT NULL, + + CONSTRAINT "attachment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "material_bundle" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "creator_id" INTEGER NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL, + "rating" DOUBLE PRECISION NOT NULL DEFAULT 0, + "rating_count" INTEGER NOT NULL DEFAULT 0, + "my_rating" DOUBLE PRECISION, + "comments_count" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "material_bundle_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "materialbundles_relation" ( + "material_id" INTEGER NOT NULL, + "bundle_id" INTEGER NOT NULL, + + CONSTRAINT "materialbundles_relation_pkey" PRIMARY KEY ("material_id","bundle_id") +); + +-- AddForeignKey +ALTER TABLE "material_bundle" ADD CONSTRAINT "material_bundle_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "material" ADD CONSTRAINT "material_uploader_id_fkey" FOREIGN KEY ("uploader_id") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "materialbundles_relation" ADD CONSTRAINT "materialbundles_relation_material_id_fkey" FOREIGN KEY ("material_id") REFERENCES "material"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "materialbundles_relation" ADD CONSTRAINT "materialbundles_relation_bundle_id_fkey" FOREIGN KEY ("bundle_id") REFERENCES "material_bundle"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250219083432_add_passkey_and_2fa/migration.sql b/prisma/migrations/20250219083432_add_passkey_and_2fa/migration.sql new file mode 100644 index 00000000..52498778 --- /dev/null +++ b/prisma/migrations/20250219083432_add_passkey_and_2fa/migration.sql @@ -0,0 +1,43 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "totp_always_required" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "totp_enabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "totp_secret" VARCHAR(64); + +-- CreateTable +CREATE TABLE "user_backup_code" ( + "id" SERIAL NOT NULL, + "user_id" INTEGER NOT NULL, + "code_hash" VARCHAR(128) NOT NULL, + "used" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_backup_code_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "passkey" ( + "id" SERIAL NOT NULL, + "credential_id" TEXT NOT NULL, + "public_key" BYTEA NOT NULL, + "counter" INTEGER NOT NULL, + "device_type" TEXT NOT NULL, + "backed_up" BOOLEAN NOT NULL, + "transports" TEXT, + "user_id" INTEGER NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PK_passkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "IDX_user_backup_code_user_id" ON "user_backup_code"("user_id"); + +-- CreateIndex +CREATE INDEX "IDX_passkey_user_id" ON "passkey"("user_id"); + +-- AddForeignKey +ALTER TABLE "user_backup_code" ADD CONSTRAINT "user_backup_code_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250219084632_add_srp_fields/migration.sql b/prisma/migrations/20250219084632_add_srp_fields/migration.sql new file mode 100644 index 00000000..bc2d188c --- /dev/null +++ b/prisma/migrations/20250219084632_add_srp_fields/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "srp_salt" VARCHAR(500), +ADD COLUMN "srp_upgraded" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "srp_verifier" VARCHAR(1000); diff --git a/prisma/migrations/20250219101440_make_hashed_password_optional/migration.sql b/prisma/migrations/20250219101440_make_hashed_password_optional/migration.sql new file mode 100644 index 00000000..4e9cc90d --- /dev/null +++ b/prisma/migrations/20250219101440_make_hashed_password_optional/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user" ALTER COLUMN "hashed_password" DROP NOT NULL; diff --git a/prisma/migrations/20250219164036_add_last_password_changed_at/migration.sql b/prisma/migrations/20250219164036_add_last_password_changed_at/migration.sql new file mode 100644 index 00000000..5409e5cf --- /dev/null +++ b/prisma/migrations/20250219164036_add_last_password_changed_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "last_password_changed_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..fbffa92c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 00000000..45978c60 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,796 @@ +// +// Autogenerated by `prisma-import` +// Any modifications will be overwritten on subsequent runs. +// + +// +// app.prisma +// + +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "debian-openssl-3.0.x", "debian-openssl-1.1.x"] +} + +// JSON Types Generator: This generator uses `prisma-json-types-generator` to automatically +// generate TypeScript types for JSON fields in the schema. This enhances type safety and +// developer experience by providing strong typing for JSON fields, which are otherwise +// treated as a generic object in TypeScript. +generator json { + provider = "prisma-json-types-generator" + namespace = "PrismaJson" + // clientOutput = "" + // (./ -> relative to schema, or an importable path to require() it) +} + +datasource db { + provider = "postgresql" + url = env("PRISMA_DATABASE_URL") +} + +// +// answer.prisma +// + +model Answer { + id Int @id(map: "PK_9232db17b63fb1e94f97e5c224f") @default(autoincrement()) + createdById Int @map("created_by_id") + questionId Int @map("question_id") + groupId Int? @map("group_id") + content String + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + group Group? @relation(fields: [groupId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_1887685ce6667b435b01c646a2c") + question Question @relation(fields: [questionId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_a4013f10cd6924793fbd5f0d637") + user User @relation(fields: [createdById], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_f636f6e852686173ea947f29045") + answerDeleteLog AnswerDeleteLog[] + answerFavoritedByUser AnswerFavoritedByUser[] + answerQueryLog AnswerQueryLog[] + answerUpdateLog AnswerUpdateLog[] + acceptedByQuestion Question? @relation("AcceptedAnswer") + + @@index([groupId], map: "IDX_1887685ce6667b435b01c646a2") + @@index([questionId], map: "IDX_a4013f10cd6924793fbd5f0d63") + @@index([createdById], map: "IDX_f636f6e852686173ea947f2904") + @@map("answer") +} + +model AnswerDeleteLog { + id Int @id(map: "PK_f1696d27f69ec9c6133a12aadcf") @default(autoincrement()) + deleterId Int? @map("deleter_id") + answerId Int @map("answer_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + answer Answer @relation(fields: [answerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_910393b814aac627593588c17fd") + user User? @relation(fields: [deleterId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_c2d0251df4669e17a57d6dbc06f") + + @@index([answerId], map: "IDX_910393b814aac627593588c17f") + @@index([deleterId], map: "IDX_c2d0251df4669e17a57d6dbc06") + @@map("answer_delete_log") +} + +model AnswerFavoritedByUser { + answerId Int @map("answer_id") + userId Int @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_9556368d270d73579a68db7e1bf") + answer Answer @relation(fields: [answerId], references: [id], onDelete: Cascade, map: "FK_c27a91d761c26ad612a0a356971") + + @@id([answerId, userId], map: "PK_5a857fe93c44fdb538ec5aa4771") + @@index([userId], map: "IDX_9556368d270d73579a68db7e1b") + @@index([answerId], map: "IDX_c27a91d761c26ad612a0a35697") + @@map("answer_favorited_by_user") +} + +model AnswerQueryLog { + id Int @id(map: "PK_4f65c4804d0693f458a716aa72c") @default(autoincrement()) + viewerId Int? @map("viewer_id") + answerId Int @map("answer_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + answer Answer @relation(fields: [answerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_71ed57d6bb340716f5e17043bbb") + user User? @relation(fields: [viewerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_f4b7cd859700f8928695b6c2bab") + + @@index([answerId], map: "IDX_71ed57d6bb340716f5e17043bb") + @@index([viewerId], map: "IDX_f4b7cd859700f8928695b6c2ba") + @@map("answer_query_log") +} + +model AnswerUpdateLog { + id Int @id(map: "PK_5ae381609b7ae9f2319fe26031f") @default(autoincrement()) + updaterId Int? @map("updater_id") + answerId Int @map("answer_id") + oldContent String @map("old_content") + newContent String @map("new_content") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User? @relation(fields: [updaterId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_0ef2a982b61980d95b5ae7f1a60") + answer Answer @relation(fields: [answerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_6f0964cf74c12678a86e49b23fe") + + @@index([updaterId], map: "IDX_0ef2a982b61980d95b5ae7f1a6") + @@index([answerId], map: "IDX_6f0964cf74c12678a86e49b23f") + @@map("answer_update_log") +} + +// +// attachments.prisma +// + +enum AttachmentType { + image + video + audio + file +} + +model Attachment { + id Int @id @default(autoincrement()) + type AttachmentType + url String + /// [metaType] + meta Json @db.Json + + @@map("attachment") +} + +// +// attitude.prisma +// + +// +// Description: This file defines the database stucture of attitude. +// +// Author(s): +// Nictheboy Li +// +// + +enum AttitudableType { + COMMENT + QUESTION + ANSWER +} + +enum AttitudeType { + UNDEFINED + POSITIVE + NEGATIVE +} + +// Although UNDEFINED is supported, +// it should not be stored in database. +enum AttitudeTypeNotUndefined { + POSITIVE + NEGATIVE +} + +model Attitude { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int @map("user_id") + attitudableType AttitudableType @map("attitudable_type") + attitudableId Int @map("attitudable_id") + attitude AttitudeTypeNotUndefined + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt() @map("updated_at") + + @@unique([attitudableId, userId, attitudableType]) + @@index([userId]) + @@map("attitude") +} + +model AttitudeLog { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int @map("user_id") + attitudableType AttitudableType @map("attitudable_type") + attitudableId Int @map("attitudable_id") + attitude AttitudeType + createdAt DateTime @default(now()) @map("created_at") + + @@index([attitudableId, attitudableType]) + @@index([userId]) + @@map("attitude_log") +} + +// +// session.prisma +// + +model Session { + id Int @id(map: "PK_f55da76ac1c3ac420f444d2ff11") @default(autoincrement()) + validUntil DateTime @map("valid_until") @db.Timestamptz(6) + revoked Boolean + userId Int @map("user_id") + authorization String + lastRefreshedAt BigInt @map("last_refreshed_at") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([userId], map: "IDX_3d2f174ef04fb312fdebd0ddc5") + @@index([validUntil], map: "IDX_bb46e87d5b3f1e55c625755c00") + @@map("session") +} + +model SessionRefreshLog { + id Int @id(map: "PK_f8f46c039b0955a7df6ad6631d7") @default(autoincrement()) + sessionId Int @map("session_id") + oldRefreshToken String @map("old_refresh_token") + newRefreshToken String @map("new_refresh_token") + accessToken String @map("access_token") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@map("session_refresh_log") +} + +// +// avatars.prisma +// + +enum AvatarType { + default + predefined + upload +} + +model Avatar { + id Int @id @default(autoincrement()) + url String @db.VarChar + name String @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + avatarType AvatarType @map("avatar_type") + usageCount Int @default(0) @map("usage_count") + groupProfile GroupProfile[] + userProfile UserProfile[] + + @@map("avatar") +} + +// +// comment.prisma +// + +enum CommentCommentabletypeEnum { + ANSWER + COMMENT + QUESTION +} + +model Comment { + id Int @id(map: "PK_0b0e4bbc8415ec426f87f3a88e2") @default(autoincrement()) + commentableType CommentCommentabletypeEnum @map("commentable_type") + commentableId Int @map("commentable_id") + content String + createdById Int @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamp(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamp(6) + user User @relation(fields: [createdById], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_63ac916757350d28f05c5a6a4ba") + commentDeleteLog CommentDeleteLog[] + commentQueryLog CommentQueryLog[] + + @@index([commentableId], map: "IDX_525212ea7a75cba69724e42303") + @@index([createdById], map: "IDX_63ac916757350d28f05c5a6a4b") + @@map("comment") +} + +model CommentDeleteLog { + id Int @id(map: "PK_429889b4bdc646cb80ef8bc1814") @default(autoincrement()) + commentId Int @map("comment_id") + operatedById Int @map("operated_by_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User @relation(fields: [operatedById], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_53f0a8befcc12c0f7f2bab7584d") + comment Comment @relation(fields: [commentId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_66705ce7d7908554cff01b260ec") + + @@index([operatedById], map: "IDX_53f0a8befcc12c0f7f2bab7584") + @@index([commentId], map: "IDX_66705ce7d7908554cff01b260e") + @@map("comment_delete_log") +} + +model CommentQueryLog { + id Int @id(map: "PK_afbfb3d92cbf55c99cb6bdcd58f") @default(autoincrement()) + commentId Int @map("comment_id") + viewerId Int? @map("viewer_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + comment Comment @relation(fields: [commentId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_4020ff7fcffb2737e990f8bde5e") + user User? @relation(fields: [viewerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_4ead8566a6fa987264484b13d54") + + @@index([commentId], map: "IDX_4020ff7fcffb2737e990f8bde5") + @@index([viewerId], map: "IDX_4ead8566a6fa987264484b13d5") + @@map("comment_query_log") +} + +// +// groups.prisma +// + +model Group { + id Int @id(map: "PK_256aa0fda9b1de1a73ee0b7106b") @default(autoincrement()) + name String @unique(map: "IDX_8a45300fd825918f3b40195fbd") @db.VarChar + createdAt DateTime @default(dbgenerated("('now'::text)::timestamp(3) with time zone")) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + answer Answer[] + groupMembership GroupMembership[] + groupProfile GroupProfile? + groupQuestionRelationship GroupQuestionRelationship[] + groupTarget GroupTarget[] + + @@map("group") +} + +model GroupMembership { + id Int @id(map: "PK_b631623cf04fa74513b975e7059") @default(autoincrement()) + groupId Int @map("group_id") + memberId Int @map("member_id") + role String @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + user User @relation(fields: [memberId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_7d88d00d8617a802b698c0cd609") + group Group @relation(fields: [groupId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_b1411f07fafcd5ad93c6ee16424") + + @@index([memberId], map: "IDX_7d88d00d8617a802b698c0cd60") + @@index([groupId], map: "IDX_b1411f07fafcd5ad93c6ee1642") + @@map("group_membership") +} + +model GroupProfile { + id Int @id(map: "PK_2a62b59d1bf8a3191c992e8daf4") @default(autoincrement()) + intro String @db.VarChar + avatarId Int? @map("avatar_id") + avatar Avatar? @relation(fields: [avatarId], references: [id]) + groupId Int @unique(map: "REL_7359ba99cc116d00cf74e048ed") @map("group_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + group Group @relation(fields: [groupId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_7359ba99cc116d00cf74e048edd") + + @@map("group_profile") +} + +model GroupQuestionRelationship { + id Int @id(map: "PK_47ee7be0b0f0e51727012382922") @default(autoincrement()) + groupId Int @map("group_id") + questionId Int @unique(map: "REL_5b1232271bf29d99456fcf39e7") @map("question_id") + createdAt DateTime @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + question Question @relation(fields: [questionId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_5b1232271bf29d99456fcf39e75") + group Group @relation(fields: [groupId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_b31bf3b3688ec41daaced89a0ab") + + @@index([groupId], map: "IDX_b31bf3b3688ec41daaced89a0a") + @@map("group_question_relationship") +} + +model GroupTarget { + id Int @id(map: "PK_f1671a42b347bd96ce6595f91ee") @default(autoincrement()) + groupId Int @map("group_id") + name String @db.VarChar + intro String @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + startedAt DateTime @map("started_at") @db.Date + endedAt DateTime @map("ended_at") @db.Date + attendanceFrequency String @map("attendance_frequency") @db.VarChar + group Group @relation(fields: [groupId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_19d57f140124c5100e8e1ca3088") + + @@index([groupId], map: "IDX_19d57f140124c5100e8e1ca308") + @@map("group_target") +} + +// +// materialbundles.prisma +// + +model MaterialBundle { + id Int @id @default(autoincrement()) + title String + content String + creator User @relation(fields: [creatorId], references: [id]) + creatorId Int @map("creator_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt() @map("updated_at") @db.Timestamptz(6) + rating Float @default(0) + ratingCount Int @default(0) @map("rating_count") + myRating Float? @map("my_rating") + commentsCount Int @default(0) @map("comments_count") + materials MaterialbundlesRelation[] + + @@map("material_bundle") +} + +// +// materials.prisma +// + +enum MaterialType { + image + file + audio + video +} + +model Material { + id Int @id @default(autoincrement()) + type MaterialType + url String + name String + uploader User @relation(fields: [uploaderId], references: [id]) + uploaderId Int @map("uploader_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + expires Int? + downloadCount Int @default(0) @map("download_count") + materialBundles MaterialbundlesRelation[] + /// [metaType] + meta Json @db.Json + + @@map("material") +} + +model MaterialbundlesRelation { + material Material @relation(fields: [materialId], references: [id]) + materialId Int @map("material_id") + bundle MaterialBundle @relation(fields: [bundleId], references: [id], onDelete: Cascade) + bundleId Int @map("bundle_id") + + @@id([materialId, bundleId]) + @@map("materialbundles_relation") +} + +// +// questions.es.prisma +// + +model QuestionElasticsearchRelation { + id Int @id @default(autoincrement()) + question Question @relation(fields: [questionId], references: [id], map: "fk_question_elasticsearch_relation_question_id") + questionId Int @unique @map("question_id") + elasticsearchId String @map("elasticsearch_id") + + @@index([questionId]) + @@index([elasticsearchId]) + @@map("question_elasticsearch_relation") +} + +// +// questions.invitation.prisma +// + +model QuestionInvitationRelation { + id Int @id @default(autoincrement()) + questionId Int @map("question_id") + question Question @relation(fields: [questionId], references: [id]) + userId Int @map("user_id") + user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([questionId]) + @@index([userId]) + @@map("question_invitation_relation") +} + +// +// questions.prisma +// + +model Question { + id Int @id(map: "PK_21e5786aa0ea704ae185a79b2d5") @default(autoincrement()) + createdById Int @map("created_by_id") + title String + content String + type Int + groupId Int? @map("group_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + answer Answer[] + groupQuestionRelationship GroupQuestionRelationship? + user User @relation(fields: [createdById], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_187915d8eaa010cde8b053b35d5") + bounty Int @default(0) + bountyStartAt DateTime? @map("bounty_start_at") @db.Timestamptz(6) + acceptedAnswer Answer? @relation("AcceptedAnswer", fields: [acceptedAnswerId], references: [id]) + acceptedAnswerId Int? @unique() @map("accepted_answer_id") + questionFollowerRelation QuestionFollowerRelation[] + questionQueryLog QuestionQueryLog[] + questionTopicRelation QuestionTopicRelation[] + questionInvitationRelation QuestionInvitationRelation[] + questionElasticsearchRelation QuestionElasticsearchRelation? + + @@index([createdById], map: "IDX_187915d8eaa010cde8b053b35d") + @@index([title, content], map: "IDX_8b24620899a8556c3f22f52145") + @@index([groupId], map: "IDX_ac7c68d428ab7ffd2f4752eeaa") + @@map("question") +} + +model QuestionFollowerRelation { + id Int @id(map: "PK_5f5ce2e314f975612a13d601362") @default(autoincrement()) + questionId Int @map("question_id") + followerId Int @map("follower_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + user User @relation(fields: [followerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_21a30245c4a32d5ac67da809010") + question Question @relation(fields: [questionId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_6544f7f7579bf88e3c62f995f8a") + + @@index([followerId], map: "IDX_21a30245c4a32d5ac67da80901") + @@index([questionId], map: "IDX_6544f7f7579bf88e3c62f995f8") + @@map("question_follower_relation") +} + +model QuestionQueryLog { + id Int @id(map: "PK_2876061262a774e4aba4daaaae4") @default(autoincrement()) + viewerId Int? @map("viewer_id") + questionId Int @map("question_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User? @relation(fields: [viewerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_8ce4bcc67caf0406e6f20923d4d") + question Question @relation(fields: [questionId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_a0ee1672e103ed0a0266f217a3f") + + @@index([viewerId], map: "IDX_8ce4bcc67caf0406e6f20923d4") + @@index([questionId], map: "IDX_a0ee1672e103ed0a0266f217a3") + @@map("question_query_log") +} + +model QuestionSearchLog { + id Int @id(map: "PK_6f41b41474cf92c67a7da97384c") @default(autoincrement()) + keywords String @db.VarChar + firstQuestionId Int? @map("first_question_id") + pageSize Int @map("page_size") + result String @db.VarChar + duration Float + searcherId Int? @map("searcher_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User? @relation(fields: [searcherId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_13c7e9fd7403cc5a87ab6524bc4") + + @@index([searcherId], map: "IDX_13c7e9fd7403cc5a87ab6524bc") + @@index([keywords], map: "IDX_2fbe3aa9f62233381aefeafa00") + @@map("question_search_log") +} + +model QuestionTopicRelation { + id Int @id(map: "PK_c50ec8a9ac6c3007f0861e4a383") @default(autoincrement()) + questionId Int @map("question_id") + topicId Int @map("topic_id") + createdById Int @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + user User @relation(fields: [createdById], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_d439ea68a02c1e7ea9863fc3df1") + topic Topic @relation(fields: [topicId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_dd4b9a1b83559fa38a3a50463fd") + question Question @relation(fields: [questionId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_fab99c5e4fc380d9b7f9abbbb02") + + @@index([topicId], map: "IDX_dd4b9a1b83559fa38a3a50463f") + @@index([questionId], map: "IDX_fab99c5e4fc380d9b7f9abbbb0") + @@map("question_topic_relation") +} + +// +// topics.prisma +// + +model Topic { + id Int @id(map: "PK_33aa4ecb4e4f20aa0157ea7ef61") @default(autoincrement()) + name String @unique(map: "idx_topic_name_unique") @db.VarChar + createdById Int @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + questionTopicRelation QuestionTopicRelation[] + user User @relation(fields: [createdById], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_59d7548ea797208240417106e2d") + + @@index([createdById], map: "IDX_59d7548ea797208240417106e2") + @@index([name], map: "idx_topic_name_ft") + @@map("topic") +} + +model TopicSearchLog { + id Int @id(map: "PK_41a432f5f993017b2502c73c78e") @default(autoincrement()) + keywords String @db.VarChar + firstTopicId Int? @map("first_topic_id") + pageSize Int @map("page_size") + result String @db.VarChar + duration Float + searcherId Int? @map("searcher_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User? @relation(fields: [searcherId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_fe1e75b8b625499f0119faaba5b") + + @@index([keywords], map: "IDX_85c1844b4fa3e29b1b8dfaeac6") + @@index([searcherId], map: "IDX_fe1e75b8b625499f0119faaba5") + @@map("topic_search_log") +} + +// +// users.prisma +// + +model User { + id Int @id(map: "PK_cace4a159ff9f2512dd42373760") @default(autoincrement()) + username String @unique(map: "IDX_78a916df40e02a9deb1c4b75ed") @db.VarChar + hashedPassword String? @map("hashed_password") @db.VarChar + srpSalt String? @map("srp_salt") @db.VarChar(500) + srpVerifier String? @map("srp_verifier") @db.VarChar(1000) + srpUpgraded Boolean @default(false) @map("srp_upgraded") + email String @unique(map: "IDX_e12875dfb3b1d92d7d7c5377e2") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + lastPasswordChangedAt DateTime @default(now()) @map("last_password_changed_at") @db.Timestamptz(6) + answer Answer[] + answerDeleteLog AnswerDeleteLog[] + answerFavoritedByUser AnswerFavoritedByUser[] + answerQueryLog AnswerQueryLog[] + answerUpdateLog AnswerUpdateLog[] + comment Comment[] + commentDeleteLog CommentDeleteLog[] + commentQueryLog CommentQueryLog[] + groupMembership GroupMembership[] + question Question[] + questionFollowerRelation QuestionFollowerRelation[] + questionQueryLog QuestionQueryLog[] + questionSearchLog QuestionSearchLog[] + questionTopicRelation QuestionTopicRelation[] + topic Topic[] + topicSearchLog TopicSearchLog[] + userFollowingRelationshipUserFollowingRelationshipFollowerIdTouser UserFollowingRelationship[] @relation("user_following_relationship_followerIdTouser") + userFollowingRelationshipUserFollowingRelationshipFolloweeIdTouser UserFollowingRelationship[] @relation("user_following_relationship_followeeIdTouser") + userLoginLog UserLoginLog[] + userProfile UserProfile? + userProfileQueryLogUserProfileQueryLogViewerIdTouser UserProfileQueryLog[] @relation("user_profile_query_log_viewerIdTouser") + userProfileQueryLogUserProfileQueryLogVieweeIdTouser UserProfileQueryLog[] @relation("user_profile_query_log_vieweeIdTouser") + attitude Attitude[] + attitudeLog AttitudeLog[] + questionInvitationRelation QuestionInvitationRelation[] + material Material[] + materialBundle MaterialBundle[] + passkeys Passkey[] + totpSecret String? @map("totp_secret") @db.VarChar(64) + totpEnabled Boolean @default(false) @map("totp_enabled") + totpAlwaysRequired Boolean @default(false) @map("totp_always_required") + backupCodes UserBackupCode[] + + @@map("user") +} + +model UserFollowingRelationship { + id Int @id(map: "PK_3b0199015f8814633fc710ff09d") @default(autoincrement()) + followeeId Int @map("followee_id") + followerId Int @map("follower_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + userUserFollowingRelationshipFollowerIdTouser User @relation("user_following_relationship_followerIdTouser", fields: [followerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_868df0c2c3a138ee54d2a515bce") + userUserFollowingRelationshipFolloweeIdTouser User @relation("user_following_relationship_followeeIdTouser", fields: [followeeId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_c78831eeee179237b1482d0c6fb") + + @@index([followerId], map: "IDX_868df0c2c3a138ee54d2a515bc") + @@index([followeeId], map: "IDX_c78831eeee179237b1482d0c6f") + @@map("user_following_relationship") +} + +model UserLoginLog { + id Int @id(map: "PK_f8db79b1af1f385db4f45a2222e") @default(autoincrement()) + userId Int @map("user_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User @relation(fields: [userId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_66c592c7f7f20d1214aba2d0046") + + @@index([userId], map: "IDX_66c592c7f7f20d1214aba2d004") + @@map("user_login_log") +} + +model UserProfile { + id Int @id(map: "PK_f44d0cd18cfd80b0fed7806c3b7") @default(autoincrement()) + userId Int @unique(map: "IDX_51cb79b5555effaf7d69ba1cff") @map("user_id") + nickname String @db.VarChar + avatarId Int @map("avatar_id") + intro String @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + user User @relation(fields: [userId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "fk_user_profile_user_id") + avatar Avatar @relation(fields: [avatarId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "fk_user_profile_avatar_id") + + @@map("user_profile") +} + +model UserProfileQueryLog { + id Int @id(map: "PK_9aeff7c959703fad866e9ad581a") @default(autoincrement()) + viewerId Int? @map("viewer_id") + vieweeId Int @map("viewee_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + userUserProfileQueryLogViewerIdTouser User? @relation("user_profile_query_log_viewerIdTouser", fields: [viewerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_1261db28434fde159acda6094bc") + userUserProfileQueryLogVieweeIdTouser User @relation("user_profile_query_log_vieweeIdTouser", fields: [vieweeId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_ff592e4403b328be0de4f2b3973") + + @@index([viewerId], map: "IDX_1261db28434fde159acda6094b") + @@index([vieweeId], map: "IDX_ff592e4403b328be0de4f2b397") + @@map("user_profile_query_log") +} + +enum UserRegisterLogType { + RequestSuccess + RequestFailDueToAlreadyRegistered + RequestFailDueToInvalidOrNotSupportedEmail + RequestFailDurToSecurity + RequestFailDueToSendEmailFailure + Success + FailDueToUserExistence + FailDueToWrongCodeOrExpired +} + +model UserRegisterLog { + id Int @id(map: "PK_3596a6f74bd2a80be930f6d1e39") @default(autoincrement()) + email String @db.VarChar + type UserRegisterLogType + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([email], map: "IDX_3af79f07534d9f1c945cd4c702") + @@map("user_register_log") +} + +model UserRegisterRequest { + id Int @id(map: "PK_cdf2d880551e43d9362ddd37ae0") @default(autoincrement()) + email String @db.VarChar + code String @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([email], map: "IDX_c1d0ecc369d7a6a3d7e876c589") + @@map("user_register_request") +} + +enum UserResetPasswordLogType { + RequestSuccess + RequestFailDueToNoneExistentEmail + RequestFailDueToSecurity + Success + FailDueToInvalidToken + FailDueToExpiredRequest + FailDueToNoUser +} + +model UserResetPasswordLog { + id Int @id(map: "PK_3ee4f25e7f4f1d5a9bd9817b62b") @default(autoincrement()) + userId Int? @map("user_id") + type UserResetPasswordLogType + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@map("user_reset_password_log") +} + +model UserBackupCode { + id Int @id @default(autoincrement()) + userId Int @map("user_id") + codeHash String @map("code_hash") @db.VarChar(128) // 加盐哈希存储 + used Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User @relation(fields: [userId], references: [id]) + + @@index([userId], map: "IDX_user_backup_code_user_id") + @@map("user_backup_code") +} + +model Passkey { + id Int @id(map: "PK_passkey") @default(autoincrement()) + credentialId String @map("credential_id") // 凭证ID + publicKey Bytes @map("public_key") // 存储公钥(二进制数据) + counter Int // 验证计数器 + deviceType String @map("device_type") // 'singleDevice' 或 'multiDevice' + backedUp Boolean @map("backed_up") // 是否已备份 + transports String? // 可选,存储传输方式(JSON 数组字符串) + userId Int @map("user_id") // 关联用户ID + user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + + @@index([userId], map: "IDX_passkey_user_id") + @@map("passkey") +} diff --git a/sample.env b/sample.env new file mode 100644 index 00000000..bb7d6e06 --- /dev/null +++ b/sample.env @@ -0,0 +1,84 @@ +APP_NAME=Cheese + +# The port that the app will listen to +PORT=3000 + +# The secret used to sign the JWT token +# You MUST change this secret to your own secret! +# Otherwise, your app will be as insecure as with an empty admin password! +JWT_SECRET="test-secret" + +DB_HOST=database # set DB_HOST to database to use with docker +DB_USERNAME=username +DB_PASSWORD=mypassword # your passowrd +DB_PASSWORD_URL_FORMAT=mypassword # password in url-format, see https://github.com/prisma/prisma/discussions/15679 +DB_PORT=5432 +DB_NAME=mydb + +# The connection URL of the database for Prisma +# See https://www.prisma.io/docs/orm/reference/connection-urls for more information +PRISMA_DATABASE_URL="postgresql://${DB_USERNAME}:${DB_PASSWORD_URL_FORMAT}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=public&connection_limit=16" + +# The configuration for Elasticsearch +ELASTICSEARCH_NODE=http://es:9200/ +ELASTICSEARCH_MAX_RETRIES=10 +ELASTICSEARCH_REQUEST_TIMEOUT=60000 +ELASTICSEARCH_PING_TIMEOUT=60000 +ELASTICSEARCH_SNIFF_ON_START=true +ELASTICSEARCH_AUTH_USERNAME=elastic +ELASTICSEARCH_AUTH_PASSWORD=your-elasticsearch-password + +# The configuration for uploaded files +FILE_UPLOAD_PATH=/app/uploads +DEFAULT_AVATAR_NAME=default.jpg + +# This is used by the Cookie containing the very sensitive refresh token. +# For example, if you set this to "/api/legacy", +# the cookie will only be sent to "/api/legacy/users/auth" +COOKIE_BASE_URL=/ + +# The configuration for CORS +CORS_ORIGINS=http://localhost:3000 # use `,` to separate multiple origins +CORS_METHODS=GET,POST,PUT,PATCH,DELETE +CORS_HEADERS=Content-Type,Authorization +CORS_CREDENTIALS=true + +# This url means the frontend url, usually it is the same as the CORS_ORIGINS +# It is used to send the password reset email +FRONTEND_BASE_URL=http://localhost:3000 + +# The prefix of the password reset link in the email +# This prefix will be appended to FRONTEND_BASE_URL +PASSWORD_RESET_PREFIX=/account/recover/password/verify?token= + +# additionally setup the following if you want to use docker-compose +# to setup environment +POSTGRES_DB=${DB_NAME} +POSTGRES_USER=${DB_USERNAME} +POSTGRES_PASSWORD=${DB_PASSWORD} + +# Email configuration: +EMAIL_SMTP_HOST=smtp.example.com +EMAIL_SMTP_PORT=587 +EMAIL_SMTP_SSL_ENABLE=true +EMAIL_SMTP_USERNAME=user@example.com +EMAIL_SMTP_PASSWORD=a_super_strong_password +EMAIL_DEFAULT_FROM='"No Reply" ' + +# Email test configuration: +# Enabling email test means when you run test, emails will be sent. +EMAILTEST_ENABLE=false +EMAILTEST_RECEIVER=developer@example.com + +# Use real ip or X-Forwarded-For header +TRUST_X_FORWARDED_FOR=false + +REDIS_HOST=valkey +REDIS_PORT=6379 +# REDIS_USERNAME= +# REDIS_PASSWORD= + +WEB_AUTHN_RP_NAME=Cheese Community +WEB_AUTHN_RP_ID=localhost +WEB_AUTHN_ORIGIN=http://localhost:3000 +TOTP_ENCRYPTION_KEY=sm2vSXU3SudEuBd2r6ewGiap1LbqGbjf \ No newline at end of file diff --git a/src/answer/DTO/agree-answer.dto.ts b/src/answer/DTO/agree-answer.dto.ts new file mode 100644 index 00000000..f182588a --- /dev/null +++ b/src/answer/DTO/agree-answer.dto.ts @@ -0,0 +1,7 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class AgreeAnswerResponseDto extends BaseResponseDto { + data: { + agree_count: number; + }; +} diff --git a/src/answer/DTO/answer.dto.ts b/src/answer/DTO/answer.dto.ts new file mode 100644 index 00000000..3d2950c1 --- /dev/null +++ b/src/answer/DTO/answer.dto.ts @@ -0,0 +1,19 @@ +import { AttitudeStateDto } from '../../attitude/DTO/attitude-state.dto'; +import { GroupDto } from '../../groups/DTO/group.dto'; +import { UserDto } from '../../users/DTO/user.dto'; + +export class AnswerDto { + id: number; + question_id: number; + content: string; + author: UserDto; + created_at: number; // timestamp + updated_at: number; // timestamp + attitudes: AttitudeStateDto; + is_favorite: boolean; + comment_count: number; + favorite_count: number; + view_count: number; + is_group: boolean; + group?: GroupDto; +} diff --git a/src/answer/DTO/create-answer.dto.ts b/src/answer/DTO/create-answer.dto.ts new file mode 100644 index 00000000..34587498 --- /dev/null +++ b/src/answer/DTO/create-answer.dto.ts @@ -0,0 +1,7 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class CreateAnswerResponseDto extends BaseResponseDto { + data: { + id: number; + }; +} diff --git a/src/answer/DTO/get-answer-detail.dto.ts b/src/answer/DTO/get-answer-detail.dto.ts new file mode 100644 index 00000000..0d876237 --- /dev/null +++ b/src/answer/DTO/get-answer-detail.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { QuestionDto } from '../../questions/DTO/question.dto'; +import { AnswerDto } from './answer.dto'; + +export class GetAnswerDetailResponseDto extends BaseResponseDto { + data: { + question: QuestionDto; + answer: AnswerDto; + }; +} diff --git a/src/answer/DTO/get-answers.dto.ts b/src/answer/DTO/get-answers.dto.ts new file mode 100644 index 00000000..ed3523cd --- /dev/null +++ b/src/answer/DTO/get-answers.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { AnswerDto } from './answer.dto'; + +export class GetAnswersResponseDto extends BaseResponseDto { + data: { + answers: AnswerDto[]; + page: PageDto; + }; +} diff --git a/src/answer/DTO/update-answer.dto.ts b/src/answer/DTO/update-answer.dto.ts new file mode 100644 index 00000000..453e90c1 --- /dev/null +++ b/src/answer/DTO/update-answer.dto.ts @@ -0,0 +1,14 @@ +import { IsArray, IsInt, IsOptional, IsString } from 'class-validator'; + +export class UpdateAnswerRequestDto { + @IsString() + content: string; + + @IsArray() + @IsOptional() + topics: number[]; + + @IsInt() + @IsOptional() + group_id: number; +} diff --git a/src/answer/answer.controller.ts b/src/answer/answer.controller.ts new file mode 100644 index 00000000..a13879ce --- /dev/null +++ b/src/answer/answer.controller.ts @@ -0,0 +1,218 @@ +import { + Body, + Controller, + Delete, + Get, + Headers, + Ip, + Param, + ParseIntPipe, + Post, + Put, + Query, +} from '@nestjs/common'; +import { AttitudeTypeDto } from '../attitude/DTO/attitude.dto'; +import { UpdateAttitudeResponseDto } from '../attitude/DTO/update-attitude.dto'; +import { AuthService } from '../auth/auth.service'; +import { + AuthToken, + CurrentUserOwnResource, + Guard, + ResourceId, + ResourceOwnerIdGetter, +} from '../auth/guard.decorator'; +import { UserId } from '../auth/user-id.decorator'; +import { BaseResponseDto } from '../common/DTO/base-response.dto'; +import { PageDto } from '../common/DTO/page.dto'; +import { QuestionsService } from '../questions/questions.service'; +import { CreateAnswerResponseDto } from './DTO/create-answer.dto'; +import { GetAnswerDetailResponseDto } from './DTO/get-answer-detail.dto'; +import { GetAnswersResponseDto } from './DTO/get-answers.dto'; +import { UpdateAnswerRequestDto } from './DTO/update-answer.dto'; +import { AnswerService } from './answer.service'; + +@Controller('/questions/:question_id/answers') +export class AnswerController { + constructor( + private readonly authService: AuthService, + private readonly answerService: AnswerService, + private readonly questionsService: QuestionsService, + ) {} + + @ResourceOwnerIdGetter('answer') + async getAnswerOwner(answerId: number): Promise { + return this.answerService.getCreatedByIdAcrossQuestions(answerId); + } + + @ResourceOwnerIdGetter('question') + async getQuestionOwner(questionId: number): Promise { + return this.questionsService.getQuestionCreatedById(questionId); + } + + @Get('/') + @Guard('enumerate-answers', 'question') + async getQuestionAnswers( + @Param('question_id', ParseIntPipe) @ResourceId() questionId: number, + @Query() + { page_start: pageStart, page_size: pageSize }: PageDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + ): Promise { + const [answers, page] = await this.answerService.getQuestionAnswers( + questionId, + pageStart, + pageSize, + userId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Answers fetched successfully.', + data: { + answers, + page, + }, + }; + } + + @Post('/') + @Guard('create', 'answer') + @CurrentUserOwnResource() + async answerQuestion( + @Param('question_id', ParseIntPipe) questionId: number, + @Body('content') content: string, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + const answerId = await this.answerService.createAnswer( + questionId, + userId, + content, + ); + return { + code: 201, + message: 'Answer created successfully.', + data: { + id: answerId, + }, + }; + } + + @Get('/:answer_id') + @Guard('query', 'answer') + async getAnswerDetail( + @Param('question_id', ParseIntPipe) questionId: number, + @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + ): Promise { + const answerDto = await this.answerService.getAnswerDto( + questionId, + answerId, + userId, + ip, + userAgent, + ); + const questionDto = await this.questionsService.getQuestionDto( + answerDto.question_id, + userId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Answer fetched successfully.', + data: { + question: questionDto, + answer: answerDto, + }, + }; + } + + @Put('/:answer_id') + @Guard('modify', 'answer') + async updateAnswer( + @Param('question_id', ParseIntPipe) questionId: number, + @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, + @Body() { content }: UpdateAnswerRequestDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + await this.answerService.updateAnswer( + questionId, + answerId, + content, + userId, + ); + return { + code: 200, + message: 'Answer updated successfully.', + }; + } + + @Delete('/:answer_id') + @Guard('delete', 'answer') + async deleteAnswer( + @Param('question_id', ParseIntPipe) questionId: number, + @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + await this.answerService.deleteAnswer(questionId, answerId, userId); + } + + @Post('/:answer_id/attitudes') + @Guard('attitude', 'answer') + async updateAttitudeToAnswer( + @Param('question_id', ParseIntPipe) questionId: number, + @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, + @Body() { attitude_type: attitudeType }: AttitudeTypeDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + const attitudes = await this.answerService.setAttitudeToAnswer( + questionId, + answerId, + userId, + attitudeType, + ); + return { + code: 201, + message: 'You have expressed your attitude towards the answer', + data: { + attitudes, + }, + }; + } + + @Put('/:answer_id/favorite') + @Guard('favorite', 'answer') + async favoriteAnswer( + @Param('question_id', ParseIntPipe) questionId: number, + @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + await this.answerService.favoriteAnswer(questionId, answerId, userId); + return { + code: 200, + message: 'Answer favorited successfully.', + }; + } + + @Delete('/:answer_id/favorite') + @Guard('unfavorite', 'answer') + async unfavoriteAnswer( + @Param('question_id', ParseIntPipe) questionId: number, + @Param('answer_id', ParseIntPipe) @ResourceId() answerId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + await this.answerService.unfavoriteAnswer(questionId, answerId, userId); + } +} diff --git a/src/answer/answer.error.ts b/src/answer/answer.error.ts new file mode 100644 index 00000000..26ac3de7 --- /dev/null +++ b/src/answer/answer.error.ts @@ -0,0 +1,55 @@ +import { BaseError } from '../common/error/base-error'; + +export class AnswerNotFoundError extends BaseError { + constructor(public readonly id: number) { + super('AnswerNotFoundError', `Answer with id ${id} is not found.`, 404); + } +} + +export class AnswerNotFavoriteError extends BaseError { + constructor(public readonly id: number) { + super( + 'AnswerNotFavoriteError', + `Answer with id ${id} is not favorited.`, + 400, + ); + } +} + +export class AnswerAlreadyFavoriteError extends BaseError { + constructor(public readonly id: number) { + super( + 'AnswerAlreadyFavoriteError', + `Answer with id ${id} is already favorited.`, + 400, + ); + } +} + +export class AlreadyHasSameAttitudeError extends BaseError { + constructor( + public readonly userId: number, + public readonly id: number, + public readonly agree_type: number, + ) { + super( + 'AlreadyHasSameAttitudeError', + `Already has attitude ${agree_type} on answer ${id}.`, + 400, + ); + } +} + +export class QuestionAlreadyAnsweredError extends BaseError { + constructor( + public readonly userId: number, + public readonly questionId: number, + public readonly id: number | undefined, + ) { + super( + 'QuestionAlreadyAnsweredError', + `User ${userId} has answered the question ${questionId} with answer ${id}.`, + 400, + ); + } +} diff --git a/src/answer/answer.module.ts b/src/answer/answer.module.ts new file mode 100644 index 00000000..7deda8b7 --- /dev/null +++ b/src/answer/answer.module.ts @@ -0,0 +1,26 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { AttitudeModule } from '../attitude/attitude.module'; +import { AuthModule } from '../auth/auth.module'; +import { CommentsModule } from '../comments/comment.module'; +import { PrismaModule } from '../common/prisma/prisma.module'; +import { GroupsModule } from '../groups/groups.module'; +import { QuestionsModule } from '../questions/questions.module'; +import { UsersModule } from '../users/users.module'; +import { AnswerController } from './answer.controller'; +import { AnswerService } from './answer.service'; + +@Module({ + imports: [ + PrismaModule, + AuthModule, + forwardRef(() => UsersModule), + forwardRef(() => QuestionsModule), + forwardRef(() => CommentsModule), + forwardRef(() => GroupsModule), + AttitudeModule, + ], + providers: [AnswerService], + controllers: [AnswerController], + exports: [AnswerService], +}) +export class AnswerModule {} diff --git a/src/answer/answer.prisma b/src/answer/answer.prisma new file mode 100644 index 00000000..e0e582e6 --- /dev/null +++ b/src/answer/answer.prisma @@ -0,0 +1,82 @@ +import { Group } from "../groups/groups" +import { User } from "../users/users" +import { Question } from "../questions/questions" + +model Answer { + id Int @id(map: "PK_9232db17b63fb1e94f97e5c224f") @default(autoincrement()) + createdById Int @map("created_by_id") + questionId Int @map("question_id") + groupId Int? @map("group_id") + content String + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + group Group? @relation(fields: [groupId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_1887685ce6667b435b01c646a2c") + question Question @relation(fields: [questionId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_a4013f10cd6924793fbd5f0d637") + user User @relation(fields: [createdById], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_f636f6e852686173ea947f29045") + answerDeleteLog AnswerDeleteLog[] + answerFavoritedByUser AnswerFavoritedByUser[] + answerQueryLog AnswerQueryLog[] + answerUpdateLog AnswerUpdateLog[] + acceptedByQuestion Question? @relation("AcceptedAnswer") + + @@index([groupId], map: "IDX_1887685ce6667b435b01c646a2") + @@index([questionId], map: "IDX_a4013f10cd6924793fbd5f0d63") + @@index([createdById], map: "IDX_f636f6e852686173ea947f2904") + @@map("answer") +} + +model AnswerDeleteLog { + id Int @id(map: "PK_f1696d27f69ec9c6133a12aadcf") @default(autoincrement()) + deleterId Int? @map("deleter_id") + answerId Int @map("answer_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + answer Answer @relation(fields: [answerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_910393b814aac627593588c17fd") + user User? @relation(fields: [deleterId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_c2d0251df4669e17a57d6dbc06f") + + @@index([answerId], map: "IDX_910393b814aac627593588c17f") + @@index([deleterId], map: "IDX_c2d0251df4669e17a57d6dbc06") + @@map("answer_delete_log") +} + +model AnswerFavoritedByUser { + answerId Int @map("answer_id") + userId Int @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_9556368d270d73579a68db7e1bf") + answer Answer @relation(fields: [answerId], references: [id], onDelete: Cascade, map: "FK_c27a91d761c26ad612a0a356971") + + @@id([answerId, userId], map: "PK_5a857fe93c44fdb538ec5aa4771") + @@index([userId], map: "IDX_9556368d270d73579a68db7e1b") + @@index([answerId], map: "IDX_c27a91d761c26ad612a0a35697") + @@map("answer_favorited_by_user") +} + +model AnswerQueryLog { + id Int @id(map: "PK_4f65c4804d0693f458a716aa72c") @default(autoincrement()) + viewerId Int? @map("viewer_id") + answerId Int @map("answer_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + answer Answer @relation(fields: [answerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_71ed57d6bb340716f5e17043bbb") + user User? @relation(fields: [viewerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_f4b7cd859700f8928695b6c2bab") + + @@index([answerId], map: "IDX_71ed57d6bb340716f5e17043bb") + @@index([viewerId], map: "IDX_f4b7cd859700f8928695b6c2ba") + @@map("answer_query_log") +} + +model AnswerUpdateLog { + id Int @id(map: "PK_5ae381609b7ae9f2319fe26031f") @default(autoincrement()) + updaterId Int? @map("updater_id") + answerId Int @map("answer_id") + oldContent String @map("old_content") + newContent String @map("new_content") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User? @relation(fields: [updaterId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_0ef2a982b61980d95b5ae7f1a60") + answer Answer @relation(fields: [answerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_6f0964cf74c12678a86e49b23fe") + + @@index([updaterId], map: "IDX_0ef2a982b61980d95b5ae7f1a6") + @@index([answerId], map: "IDX_6f0964cf74c12678a86e49b23f") + @@map("answer_update_log") +} diff --git a/src/answer/answer.service.ts b/src/answer/answer.service.ts new file mode 100644 index 00000000..ccc0cff6 --- /dev/null +++ b/src/answer/answer.service.ts @@ -0,0 +1,639 @@ +// +// In this file, in the parameters of all methods except those whose name is ended with 'AcrossQuestions' +// an answer id should always be after a question id, which can be used as the sharding key in the future. +// + +import { Inject, Injectable, forwardRef } from '@nestjs/common'; +import { + AttitudableType, + AttitudeType, + CommentCommentabletypeEnum, +} from '@prisma/client'; +import { AttitudeStateDto } from '../attitude/DTO/attitude-state.dto'; +import { AttitudeService } from '../attitude/attitude.service'; +import { CommentsService } from '../comments/comment.service'; +import { PageDto } from '../common/DTO/page-response.dto'; +import { PageHelper } from '../common/helper/page.helper'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { GroupsService } from '../groups/groups.service'; +import { QuestionNotFoundError } from '../questions/questions.error'; +import { QuestionsService } from '../questions/questions.service'; +import { UserIdNotFoundError } from '../users/users.error'; +import { UsersService } from '../users/users.service'; +import { AnswerDto } from './DTO/answer.dto'; +import { + AnswerAlreadyFavoriteError, + AnswerNotFavoriteError, + AnswerNotFoundError, + QuestionAlreadyAnsweredError, +} from './answer.error'; + +@Injectable() +export class AnswerService { + constructor( + @Inject(forwardRef(() => UsersService)) + private readonly usersService: UsersService, + @Inject(forwardRef(() => QuestionsService)) + private readonly questionsService: QuestionsService, + @Inject(forwardRef(() => CommentsService)) + private readonly commentsService: CommentsService, + @Inject(forwardRef(() => GroupsService)) + private readonly groupsService: GroupsService, + private readonly attitudeService: AttitudeService, + private readonly prismaService: PrismaService, + ) {} + + async createAnswer( + questionId: number, + createdById: number, + content: string, + ): Promise { + const existingAnswerId = await this.getAnswerIdOfCreatedBy( + questionId, + createdById, + ); + if (existingAnswerId != null) { + throw new QuestionAlreadyAnsweredError( + createdById, + questionId, + existingAnswerId, + ); + } + + const answer = await this.prismaService.answer.create({ + data: { + questionId, + createdById, + content, + createdAt: new Date(), + }, + }); + return answer.id; + } + + async getQuestionAnswers( + questionId: number, + pageStart: number | undefined, + pageSize: number, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise<[AnswerDto[], PageDto]> { + if (!pageStart) { + const currPage = await this.prismaService.answer.findMany({ + where: { + questionId, + }, + orderBy: { id: 'asc' }, + take: pageSize + 1, + }); + const currDto = await Promise.all( + currPage.map(async (entity) => { + return this.getAnswerDto( + questionId, + entity.id, + viewerId, + ip, + userAgent, + ); + }), + ); + return PageHelper.PageStart(currDto, pageSize, (answer) => answer.id); + } else { + const start = await this.prismaService.answer.findUnique({ + where: { + id: pageStart, + }, + }); + if (!start) { + throw new AnswerNotFoundError(pageStart); + } + const prevPage = await this.prismaService.answer.findMany({ + where: { + questionId, + id: { lt: pageStart }, + }, + orderBy: { id: 'desc' }, + take: pageSize, + }); + const currPage = await this.prismaService.answer.findMany({ + where: { + questionId, + id: { gte: pageStart }, + }, + orderBy: { id: 'asc' }, + take: pageSize + 1, + }); + const currDto = await Promise.all( + currPage.map(async (entity) => { + return this.getAnswerDto( + questionId, + entity.id, + viewerId, + ip, + userAgent, + ); + }), + ); + return PageHelper.PageMiddle( + prevPage, + currDto, + pageSize, + (answer) => answer.id, + (answer) => answer.id, + ); + } + } + + async getUserAnsweredAnswersAcrossQuestions( + userId: number, + pageStart: number | undefined, + pageSize: number, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise<[AnswerDto[], PageDto]> { + if ((await this.usersService.isUserExists(userId)) == false) + throw new UserIdNotFoundError(userId); + if (!pageStart) { + const currPage = await this.prismaService.answer.findMany({ + where: { + createdById: userId, + }, + orderBy: { id: 'asc' }, + take: pageSize + 1, + }); + const currDto = await Promise.all( + currPage.map(async (entity) => { + return this.getAnswerDto( + entity.questionId, + entity.id, + viewerId, + ip, + userAgent, + ); + }), + ); + return PageHelper.PageStart(currDto, pageSize, (answer) => answer.id); + } else { + const prevPage = await this.prismaService.answer.findMany({ + where: { + createdById: userId, + id: { lt: pageStart }, + }, + orderBy: { id: 'desc' }, + take: pageSize, + }); + const currPage = await this.prismaService.answer.findMany({ + where: { + createdById: userId, + id: { gte: pageStart }, + }, + orderBy: { id: 'asc' }, + take: pageSize + 1, + }); + const currDto = await Promise.all( + currPage.map(async (entity) => { + return this.getAnswerDto( + entity.questionId, + entity.id, + viewerId, + ip, + userAgent, + ); + }), + ); + return PageHelper.PageMiddle( + prevPage, + currDto, + pageSize, + (answer) => answer.id, + (answer) => answer.id, + ); + } + } + + // questionId is reserved for sharding + getViewCountOfAnswer(questionId: number, answerId: number): Promise { + return this.prismaService.answerQueryLog.count({ + where: { + answerId, + }, + }); + } + + // questionId is reserved for sharding + async isFavorite( + questionId: number, + answerId: number, + userId: number | undefined, + ): Promise { + if (userId == undefined) return false; + const answer = await this.prismaService.answer.findUnique({ + where: { + id: answerId, + answerFavoritedByUser: { + some: { userId }, + }, + }, + }); + return answer != null; + } + + async getAnswerDto( + questionId: number, + answerId: number, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise { + const answer = await this.prismaService.answer.findUnique({ + where: { + questionId, + id: answerId, + }, + include: { + answerFavoritedByUser: true, + }, + }); + if (!answer) { + throw new AnswerNotFoundError(answerId); + } + + const authorDtoPromise = this.usersService.getUserDtoById( + answer.createdById, + viewerId, + ip, + userAgent, + ); + const attitudeStatusDtoPromise = this.attitudeService.getAttitudeStatusDto( + AttitudableType.ANSWER, + answerId, + viewerId, + ); + const viewCountPromise = this.getViewCountOfAnswer(questionId, answerId); + const commentCountPromise = this.commentsService.countCommentsByCommentable( + CommentCommentabletypeEnum.ANSWER, + answerId, + ); + const isFavoritePromise = this.isFavorite(questionId, answerId, viewerId); + const groupDtoPromise = + answer.groupId == undefined + ? Promise.resolve(undefined) + : this.groupsService.getGroupDtoById( + viewerId, + answer.groupId, + ip, + userAgent, + ); + + const [ + authorDto, + attitudeStatusDto, + viewCount, + commentCount, + isFavorite, + groupDto, + ] = await Promise.all([ + authorDtoPromise, + attitudeStatusDtoPromise, + viewCountPromise, + commentCountPromise, + isFavoritePromise, + groupDtoPromise, + ]); + + if (viewerId != undefined && ip != undefined && userAgent != undefined) { + await this.prismaService.answerQueryLog.create({ + data: { + answerId, + viewerId, + ip, + userAgent, + createdAt: new Date(), + }, + }); + } + + return { + id: answer.id, + question_id: answer.questionId, + content: answer.content, + author: authorDto, + created_at: answer.createdAt.getTime(), + updated_at: answer.updatedAt.getTime(), + attitudes: attitudeStatusDto, + favorite_count: answer.answerFavoritedByUser.length, + view_count: viewCount, + comment_count: commentCount, + is_favorite: isFavorite, + is_group: answer.groupId != undefined, + group: groupDto, + }; + } + + async updateAnswer( + questionId: number, + answerId: number, + content: string, + updaterId: number, + ): Promise { + const answer = await this.prismaService.answer.findUnique({ + where: { + questionId, + id: answerId, + }, + }); + if (!answer) { + throw new AnswerNotFoundError(answerId); + } + + const oldContent = answer.content; + await this.prismaService.answer.update({ + where: { + questionId, + id: answerId, + }, + data: { + content, + }, + }); + + await this.prismaService.answerUpdateLog.create({ + data: { + updaterId, + answerId, + oldContent, + newContent: content, + createdAt: new Date(), + }, + }); + } + + async deleteAnswer( + questionId: number, + answerId: number, + deleterId: number, + ): Promise { + if ((await this.isAnswerExists(questionId, answerId)) == false) { + throw new AnswerNotFoundError(answerId); + } + + await this.prismaService.answer.update({ + where: { + questionId, + id: answerId, + }, + data: { + deletedAt: new Date(), + }, + }); + + await this.prismaService.answerDeleteLog.create({ + data: { + deleterId, + answerId, + createdAt: new Date(), + }, + }); + } + + async getFavoriteAnswers( + userId: number, + pageStart: number, // undefined if from start + pageSize: number, + viewerId: number | undefined, // optional + ip: string, + userAgent: string | undefined, // optional + ): Promise<[AnswerDto[], PageDto]> { + if ((await this.usersService.isUserExists(userId)) == false) + throw new UserIdNotFoundError(userId); + if (!pageStart) { + const currPage = await this.prismaService.answer.findMany({ + where: { answerFavoritedByUser: { some: { userId } } }, + orderBy: { id: 'asc' }, + take: pageSize + 1, + }); + const currDto = await Promise.all( + currPage.map(async (entity) => { + return this.getAnswerDto( + entity.questionId, + entity.id, + viewerId, + ip, + userAgent, + ); + }), + ); + return PageHelper.PageStart(currDto, pageSize, (answer) => answer.id); + } else { + const prevPage = await this.prismaService.answer.findMany({ + where: { + answerFavoritedByUser: { some: { userId } }, + id: { lt: pageStart }, + }, + orderBy: { id: 'desc' }, + take: pageSize, + }); + const currPage = await this.prismaService.answer.findMany({ + where: { + answerFavoritedByUser: { some: { userId } }, + id: { gte: pageStart }, + }, + orderBy: { id: 'asc' }, + take: pageSize + 1, + }); + const currDto = await Promise.all( + currPage.map(async (entity) => { + return this.getAnswerDto( + entity.questionId, + entity.id, + viewerId, + ip, + userAgent, + ); + }), + ); + return PageHelper.PageMiddle( + prevPage, + currDto, + pageSize, + (answer) => answer.id, + (answer) => answer.id, + ); + } + } + + async favoriteAnswer( + questionId: number, + answerId: number, + createdById: number, + ): Promise { + if ((await this.isAnswerExists(questionId, answerId)) == false) { + throw new AnswerNotFoundError(answerId); + } + /* istanbul ignore if */ + if ((await this.usersService.isUserExists(createdById)) == false) { + throw new UserIdNotFoundError(createdById); + } + const oleRelation = + await this.prismaService.answerFavoritedByUser.findUnique({ + where: { + answerId_userId: { + answerId, + userId: createdById, + }, + }, + }); + if (oleRelation != null) { + throw new AnswerAlreadyFavoriteError(answerId); + } + await this.prismaService.answerFavoritedByUser.create({ + data: { + answerId, + userId: createdById, + }, + }); + } + + async unfavoriteAnswer( + questionId: number, + answerId: number, + createdById: number, + ): Promise { + if ((await this.isAnswerExists(questionId, answerId)) == false) { + throw new AnswerNotFoundError(answerId); + } + + /* istanbul ignore if */ + if ((await this.usersService.isUserExists(createdById)) == false) { + throw new UserIdNotFoundError(createdById); + } + + const oleRelation = + await this.prismaService.answerFavoritedByUser.findUnique({ + where: { + answerId_userId: { + answerId, + userId: createdById, + }, + }, + }); + if (oleRelation == null) { + throw new AnswerNotFavoriteError(answerId); + } + await this.prismaService.answerFavoritedByUser.delete({ + where: { + answerId_userId: { + answerId, + userId: createdById, + }, + }, + }); + } + + async isAnswerExists(questionId: number, answerId: number): Promise { + return ( + (await this.prismaService.answer.count({ + where: { + questionId, + id: answerId, + }, + })) > 0 + ); + } + + async isAnswerExistsAcrossQuestions(answerId: number): Promise { + return ( + (await this.prismaService.answer.count({ + where: { + id: answerId, + }, + })) > 0 + ); + } + + async getCreatedById(questionId: number, answerId: number): Promise { + const answer = await this.prismaService.answer.findUnique({ + where: { + questionId, + id: answerId, + }, + }); + if (!answer) { + throw new AnswerNotFoundError(answerId); + } + return answer.createdById; + } + + async getCreatedByIdAcrossQuestions(answerId: number): Promise { + const answer = await this.prismaService.answer.findUnique({ + where: { + id: answerId, + }, + }); + if (!answer) { + throw new AnswerNotFoundError(answerId); + } + return answer.createdById; + } + + async countQuestionAnswers(questionId: number): Promise { + if ((await this.questionsService.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + return await this.prismaService.answer.count({ + where: { + questionId, + }, + }); + } + + async setAttitudeToAnswer( + questionId: number, + answerId: number, + userId: number, + attitude: AttitudeType, + ): Promise { + const answer = await this.prismaService.answer.findUnique({ + where: { + questionId, + id: answerId, + }, + }); + if (!answer) { + throw new AnswerNotFoundError(answerId); + } + await this.attitudeService.setAttitude( + userId, + AttitudableType.ANSWER, + answerId, + attitude, + ); + return this.attitudeService.getAttitudeStatusDto( + AttitudableType.ANSWER, + answerId, + userId, + ); + } + + async getAnswerCount(userId: number): Promise { + return await this.prismaService.answer.count({ + where: { + createdById: userId, + }, + }); + } + + async getAnswerIdOfCreatedBy( + questionId: number, + createdById: number, + ): Promise { + const answer = await this.prismaService.answer.findFirst({ + where: { + questionId, + createdById, + }, + }); + return answer?.id; // return undefined if answer == undefined + } +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 00000000..92faad63 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,68 @@ +import { RedisModule } from '@liaoliaots/nestjs-redis'; +import { Module, ValidationPipe } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { AnswerModule } from './answer/answer.module'; +import { AttachmentsModule } from './attachments/attachments.module'; +import { AvatarsModule } from './avatars/avatars.module'; +import { CommentsModule } from './comments/comment.module'; +import configuration from './common/config/configuration'; +import { BaseErrorExceptionFilter } from './common/error/error-filter'; +import { EnsureGuardInterceptor } from './common/interceptor/ensure-guard.interceptor'; +import { TokenValidateInterceptor } from './common/interceptor/token-validate.interceptor'; +import { GroupsModule } from './groups/groups.module'; +import { MaterialbundlesModule } from './materialbundles/materialbundles.module'; +import { MaterialsModule } from './materials/materials.module'; +import { QuestionsModule } from './questions/questions.module'; +import { UsersModule } from './users/users.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ load: [configuration] }), + UsersModule, + QuestionsModule, + AnswerModule, + GroupsModule, + AvatarsModule, + CommentsModule, + MaterialsModule, + ServeStaticModule.forRoot({ + rootPath: process.env.FILE_UPLOAD_PATH, + serveRoot: '/static', + }), + AttachmentsModule, + MaterialbundlesModule, + RedisModule.forRoot({ + config: { + host: process.env.REDIS_HOST ?? 'localhost', + port: parseInt(process.env.REDIS_PORT ?? '6379'), + username: process.env.REDIS_USERNAME ?? undefined, + password: process.env.REDIS_PASSWORD ?? undefined, + }, + }), + ], + controllers: [], + providers: [ + { + provide: APP_PIPE, + useValue: new ValidationPipe({ + transform: true, + disableErrorMessages: false, + }), + }, + { + provide: APP_FILTER, + useClass: BaseErrorExceptionFilter, + }, + { + provide: APP_INTERCEPTOR, + useClass: TokenValidateInterceptor, + }, + { + provide: APP_INTERCEPTOR, + useClass: EnsureGuardInterceptor, + }, + ], +}) +export class AppModule {} diff --git a/src/app.prisma b/src/app.prisma new file mode 100644 index 00000000..25e2fb81 --- /dev/null +++ b/src/app.prisma @@ -0,0 +1,19 @@ +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "debian-openssl-3.0.x", "debian-openssl-1.1.x"] +} + +// JSON Types Generator: This generator uses `prisma-json-types-generator` to automatically +// generate TypeScript types for JSON fields in the schema. This enhances type safety and +// developer experience by providing strong typing for JSON fields, which are otherwise +// treated as a generic object in TypeScript. +generator json { + provider = "prisma-json-types-generator" + namespace = "PrismaJson" + // clientOutput = "" + // (./ -> relative to schema, or an importable path to require() it) +} +datasource db { + provider = "postgresql" + url = env("PRISMA_DATABASE_URL") +} diff --git a/src/attachments/DTO/attachments.dto.ts b/src/attachments/DTO/attachments.dto.ts new file mode 100644 index 00000000..9ff25ca7 --- /dev/null +++ b/src/attachments/DTO/attachments.dto.ts @@ -0,0 +1,14 @@ +import { AttachmentType } from '@prisma/client'; +import { IsEnum } from 'class-validator'; + +export class attachmentTypeDto { + @IsEnum(AttachmentType) + type: AttachmentType; +} + +export class attachmentDto { + id: number; + type: string; + url: string; + meta: PrismaJson.metaType; +} diff --git a/src/attachments/DTO/get-attachment.dto.ts b/src/attachments/DTO/get-attachment.dto.ts new file mode 100644 index 00000000..0087acfb --- /dev/null +++ b/src/attachments/DTO/get-attachment.dto.ts @@ -0,0 +1,8 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { attachmentDto } from './attachments.dto'; + +export class getAttachmentResponseDto extends BaseResponseDto { + data: { + attachment: attachmentDto; + }; +} diff --git a/src/attachments/DTO/upload-attachment.dto.ts b/src/attachments/DTO/upload-attachment.dto.ts new file mode 100644 index 00000000..4959a464 --- /dev/null +++ b/src/attachments/DTO/upload-attachment.dto.ts @@ -0,0 +1,7 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class uploadAttachmentDto extends BaseResponseDto { + data: { + id: number; + }; +} diff --git a/src/attachments/attachments.controller.ts b/src/attachments/attachments.controller.ts new file mode 100644 index 00000000..9efafb89 --- /dev/null +++ b/src/attachments/attachments.controller.ts @@ -0,0 +1,76 @@ +/* + * Description: This file implements the AttachmentsController class, + * which is responsible for handling the requests to /attachments/... + * + * Author(s): + * nameisyui + * + */ + +import { + Body, + Controller, + Get, + Headers, + Param, + ParseIntPipe, + Post, + UploadedFile, + UseFilters, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { AuthService } from '../auth/auth.service'; +import { BaseErrorExceptionFilter } from '../common/error/error-filter'; +import { attachmentTypeDto } from './DTO/attachments.dto'; +import { getAttachmentResponseDto } from './DTO/get-attachment.dto'; +import { uploadAttachmentDto } from './DTO/upload-attachment.dto'; +import { AttachmentsService } from './attachments.service'; +import { AuthToken, Guard } from '../auth/guard.decorator'; + +@Controller('attachments') +export class AttachmentsController { + constructor( + private readonly attachmentsService: AttachmentsService, + private readonly authService: AuthService, + ) {} + + @Post() + @UseInterceptors(FileInterceptor('file')) + @Guard('create', 'attachment') + async uploadAttachment( + @Body() { type }: attachmentTypeDto, + @UploadedFile() file: Express.Multer.File, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + const attachmentId = await this.attachmentsService.uploadAttachment( + type, + file, + ); + return { + code: 201, + message: 'Attachment uploaded successfully', + data: { + id: attachmentId, + }, + }; + } + + @Get('/:attachmentId') + @Guard('query', 'attachment') + async getAttachmentDetail( + @Param('attachmentId', ParseIntPipe) id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + const attachment = await this.attachmentsService.getAttachment(id); + return { + code: 200, + message: 'Get Attachment successfully', + data: { + attachment, + }, + }; + } +} diff --git a/src/attachments/attachments.error.ts b/src/attachments/attachments.error.ts new file mode 100644 index 00000000..af73ed65 --- /dev/null +++ b/src/attachments/attachments.error.ts @@ -0,0 +1,24 @@ +/* + * Description: This file defines the errors related to attachments service. + * + * Author(s): + * nameisyui + * + */ + +import { BaseError } from '../common/error/base-error'; + +export class InvalidAttachmentTypeError extends BaseError { + constructor() { + super('InvalidAttachmentTypeError', 'Invalid attachment type', 400); + } +} +export class AttachmentNotFoundError extends BaseError { + constructor(attachmentId: number) { + super( + 'AttachmentNotFoundError', + `Attachment ${attachmentId} Not Found`, + 404, + ); + } +} diff --git a/src/attachments/attachments.module.ts b/src/attachments/attachments.module.ts new file mode 100644 index 00000000..00f1679f --- /dev/null +++ b/src/attachments/attachments.module.ts @@ -0,0 +1,85 @@ +/* + * Description: This file defines the attachments module + * + * Author(s): + * nameisyui + * + */ + +import { Module } from '@nestjs/common'; +import { AttachmentsService } from './attachments.service'; +import { AttachmentsController } from './attachments.controller'; +import { MulterModule } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { extname, join } from 'path'; +import { existsSync, mkdirSync } from 'fs'; +import { v4 as uuidv4 } from 'uuid'; +import { InvalidAttachmentTypeError } from './attachments.error'; +import { MimeTypeNotMatchError } from '../materials/materials.error'; +import { PrismaModule } from '../common/prisma/prisma.module'; +import { MaterialsModule } from '../materials/materials.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [configureMulterModule(), PrismaModule, MaterialsModule, AuthModule], + controllers: [AttachmentsController], + providers: [AttachmentsService], +}) +export class AttachmentsModule {} +function configureMulterModule() { + return MulterModule.register({ + storage: diskStorage({ + destination: (req, file, callback) => { + if (!process.env.FILE_UPLOAD_PATH) { + /* istanbul ignore next */ + throw new Error( + 'FILE_UPLOAD_PATH environment variable is not defined', + ); + } + const rootPath = process.env.FILE_UPLOAD_PATH; + const uploadPaths: { [key: string]: string } = { + image: join(rootPath, '/images'), + video: join(rootPath, '/videos'), + audio: join(rootPath, '/audios'), + file: join(rootPath, '/files'), + }; + const fileType = req.body.type; + const uploadPath = uploadPaths[fileType]; + /* istanbul ignore if */ + if (!existsSync(uploadPath)) { + mkdirSync(uploadPath, { recursive: true }); + } + callback(null, uploadPath); + }, + filename: (req, file, callback) => { + const randomName = uuidv4(); + callback(null, `${randomName}${extname(file.originalname)}`); + }, + }), + limits: { + fileSize: 1024 * 1024 * 1024, //1GB + }, + fileFilter(req, file, callback) { + const typeToMimeTypes: { [key: string]: string[] } = { + image: ['image'], + video: ['video'], + audio: ['audio'], + file: ['application', 'text', 'image', 'video', 'audio'], + }; + const allowedTypes = typeToMimeTypes[req.body.type]; + if (!allowedTypes) { + return callback(new InvalidAttachmentTypeError(), false); + } + const isAllowed = allowedTypes.some((type) => + file.mimetype.includes(type), + ); + if (!isAllowed) { + return callback( + new MimeTypeNotMatchError(file.mimetype, req.body.type), + false, + ); + } + callback(null, true); + }, + }); +} diff --git a/src/attachments/attachments.prisma b/src/attachments/attachments.prisma new file mode 100644 index 00000000..3b536f09 --- /dev/null +++ b/src/attachments/attachments.prisma @@ -0,0 +1,14 @@ +enum AttachmentType { + image + video + audio + file +} + +model Attachment { + id Int @id @default(autoincrement()) + type AttachmentType + url String + /// [metaType] + meta Json @db.Json +} diff --git a/src/attachments/attachments.service.ts b/src/attachments/attachments.service.ts new file mode 100644 index 00000000..a3968741 --- /dev/null +++ b/src/attachments/attachments.service.ts @@ -0,0 +1,50 @@ +/* + * Description: This file implements the AttachmentsService class, + * which is responsible for handling the business logic of attachments + * + * Author(s): + * nameisyui + * + */ + +import { Injectable } from '@nestjs/common'; +import { MaterialsService } from '../materials/materials.service'; +import { AttachmentType } from '@prisma/client'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { attachmentDto } from './DTO/attachments.dto'; +import { AttachmentNotFoundError } from './attachments.error'; + +@Injectable() +export class AttachmentsService { + constructor( + private readonly prismaService: PrismaService, + private readonly materialsService: MaterialsService, + ) {} + + async uploadAttachment( + type: AttachmentType, + file: Express.Multer.File, + ): Promise { + const meta = await this.materialsService.getMeta(type, file); + const newAttachment = await this.prismaService.attachment.create({ + data: { + url: `/static/${encodeURIComponent(type)}s/${encodeURIComponent(file.filename)}`, + type, + meta, + }, + }); + return newAttachment.id; + } + + async getAttachment(id: number): Promise { + const attachment = await this.prismaService.attachment.findUnique({ + where: { + id, + }, + }); + if (attachment == null) { + throw new AttachmentNotFoundError(id); + } + return attachment; + } +} diff --git a/src/attitude/DTO/attitude-state.dto.ts b/src/attitude/DTO/attitude-state.dto.ts new file mode 100644 index 00000000..9bd0af9a --- /dev/null +++ b/src/attitude/DTO/attitude-state.dto.ts @@ -0,0 +1,19 @@ +import { AttitudeType } from '@prisma/client'; + +export class AttitudeStateDto { + positive_count: number; + negative_count: number; + difference: number; // defined as (positive_count - negative_count) + user_attitude: AttitudeType; + + constructor( + positive_count: number, + negative_count: number, + user_attitude: AttitudeType, + ) { + this.positive_count = positive_count; + this.negative_count = negative_count; + this.difference = positive_count - negative_count; + this.user_attitude = user_attitude; + } +} diff --git a/src/attitude/DTO/attitude.dto.ts b/src/attitude/DTO/attitude.dto.ts new file mode 100644 index 00000000..47345a02 --- /dev/null +++ b/src/attitude/DTO/attitude.dto.ts @@ -0,0 +1,7 @@ +import { AttitudeType } from '@prisma/client'; +import { IsEnum } from 'class-validator'; + +export class AttitudeTypeDto { + @IsEnum(AttitudeType) + attitude_type: AttitudeType; +} diff --git a/src/attitude/DTO/update-attitude.dto.ts b/src/attitude/DTO/update-attitude.dto.ts new file mode 100644 index 00000000..42b8216d --- /dev/null +++ b/src/attitude/DTO/update-attitude.dto.ts @@ -0,0 +1,8 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { AttitudeStateDto } from './attitude-state.dto'; + +export class UpdateAttitudeResponseDto extends BaseResponseDto { + data: { + attitudes: AttitudeStateDto; + }; +} diff --git a/src/attitude/attitude.error.ts b/src/attitude/attitude.error.ts new file mode 100644 index 00000000..dd228694 --- /dev/null +++ b/src/attitude/attitude.error.ts @@ -0,0 +1,30 @@ +/* + * Description: This file defines the errors related to attitude service. + * All the errors in this file should extend BaseError. + * + * Author(s): + * Nictheboy Li + * + */ + +import { BaseError } from '../common/error/base-error'; + +export class InvalidAttitudeTypeError extends BaseError { + constructor(attitude: string) { + super( + 'InvalidAttitudeTypeError', + `Invalid attitude type: ${attitude}`, + 400, + ); + } +} + +export class InvalidAttitudableTypeError extends BaseError { + constructor(attitudableType: string) { + super( + 'InvalidAttitudableTypeError', + `Invalid attitudable type: ${attitudableType}`, + 400, + ); + } +} diff --git a/src/attitude/attitude.module.ts b/src/attitude/attitude.module.ts new file mode 100644 index 00000000..981e3fcd --- /dev/null +++ b/src/attitude/attitude.module.ts @@ -0,0 +1,19 @@ +/* + * Description: This file defines the attitude module. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Module, forwardRef } from '@nestjs/common'; +import { PrismaModule } from '../common/prisma/prisma.module'; +import { UsersModule } from '../users/users.module'; +import { AttitudeService } from './attitude.service'; + +@Module({ + imports: [PrismaModule, forwardRef(() => UsersModule)], + providers: [AttitudeService], + exports: [AttitudeService], +}) +export class AttitudeModule {} diff --git a/src/attitude/attitude.prisma b/src/attitude/attitude.prisma new file mode 100644 index 00000000..eb18708a --- /dev/null +++ b/src/attitude/attitude.prisma @@ -0,0 +1,57 @@ +// +// Description: This file defines the database stucture of attitude. +// +// Author(s): +// Nictheboy Li +// +// + +import { User } from "../users/users" + +enum AttitudableType { + COMMENT + QUESTION + ANSWER +} + +enum AttitudeType { + UNDEFINED + POSITIVE + NEGATIVE +} + +// Although UNDEFINED is supported, +// it should not be stored in database. +enum AttitudeTypeNotUndefined { + POSITIVE + NEGATIVE +} + +model Attitude { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int @map("user_id") + attitudableType AttitudableType @map("attitudable_type") + attitudableId Int @map("attitudable_id") + attitude AttitudeTypeNotUndefined + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt() @map("updated_at") + + @@unique([attitudableId, userId, attitudableType]) + @@index([userId]) + @@map("attitude") +} + +model AttitudeLog { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int @map("user_id") + attitudableType AttitudableType @map("attitudable_type") + attitudableId Int @map("attitudable_id") + attitude AttitudeType + createdAt DateTime @default(now()) @map("created_at") + + @@index([attitudableId, attitudableType]) + @@index([userId]) + @@map("attitude_log") +} diff --git a/src/attitude/attitude.service.ts b/src/attitude/attitude.service.ts new file mode 100644 index 00000000..27d8e901 --- /dev/null +++ b/src/attitude/attitude.service.ts @@ -0,0 +1,125 @@ +/* + * Description: This file implements the AttitudeService class. + * It is responsible for the business logic of attitude. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Inject, Injectable, forwardRef } from '@nestjs/common'; +import { + AttitudableType, + AttitudeType, + AttitudeTypeNotUndefined, +} from '@prisma/client'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { UserIdNotFoundError } from '../users/users.error'; +import { UsersService } from '../users/users.service'; +import { AttitudeStateDto } from './DTO/attitude-state.dto'; + +@Injectable() +export class AttitudeService { + constructor( + private readonly prismaService: PrismaService, + @Inject(forwardRef(() => UsersService)) + private readonly usersService: UsersService, + ) {} + + async setAttitude( + userId: number, + attitudableType: AttitudableType, + attitudableId: number, + attitude: AttitudeType, + ): Promise { + if ((await this.usersService.isUserExists(userId)) == false) { + throw new UserIdNotFoundError(userId); + } + const attitudeCondition = { + attitudableId_userId_attitudableType: { + userId, + attitudableType, + attitudableId, + }, + }; + await this.prismaService.attitudeLog.create({ + data: { + userId, + attitudableType, + attitudableId, + attitude, + }, + }); + if (attitude != AttitudeType.UNDEFINED) { + await this.prismaService.attitude.upsert({ + where: attitudeCondition, + update: { + attitude, + }, + create: { + userId, + attitudableType, + attitudableId, + attitude, + }, + }); + } else { + await this.prismaService.attitude.delete({ + where: attitudeCondition, + }); + } + } + + async getAttitude( + userId: number, + attitudableType: AttitudableType, + attitudableId: number, + ): Promise { + const attitude = await this.prismaService.attitude.findUnique({ + where: { + attitudableId_userId_attitudableType: { + userId, + attitudableType, + attitudableId, + }, + }, + }); + return attitude?.attitude ?? AttitudeType.UNDEFINED; + } + + async countAttitude( + attitudableType: AttitudableType, + attitudableId: number, + attitude: AttitudeTypeNotUndefined, + ): Promise { + return await this.prismaService.attitude.count({ + where: { + attitudableType, + attitudableId, + attitude, + }, + }); + } + + async getAttitudeStatusDto( + attitudableType: AttitudableType, + attitudableId: number, + userId?: number | undefined, + ): Promise { + const positiveCount = await this.countAttitude( + attitudableType, + attitudableId, + AttitudeType.POSITIVE, + ); + const negativeCount = await this.countAttitude( + attitudableType, + attitudableId, + AttitudeType.NEGATIVE, + ); + const userAttitude = + userId == null + ? AttitudeType.UNDEFINED + : await this.getAttitude(userId, attitudableType, attitudableId); + return new AttitudeStateDto(positiveCount, negativeCount, userAttitude); + } +} diff --git a/src/auth/auth.error.ts b/src/auth/auth.error.ts new file mode 100644 index 00000000..f4cbfa09 --- /dev/null +++ b/src/auth/auth.error.ts @@ -0,0 +1,96 @@ +/* + * Description: This file defines the errors that can be thrown by the AuthService. + * All the Errors in this file should extend BaseError. + * + * Author(s): + * Nictheboy Li + * + */ + +import { BaseError } from '../common/error/base-error'; +import { AuthorizedAction } from './definitions'; + +export class AuthenticationRequiredError extends BaseError { + constructor() { + super('AuthenticationRequiredError', 'Authentication required', 401); + } +} + +export class InvalidTokenError extends BaseError { + constructor() { + super('InvalidTokenError', 'Invalid token', 401); + } +} + +export class TokenExpiredError extends BaseError { + constructor() { + super('TokenExpiredError', 'Token expired', 401); + } +} + +export class PermissionDeniedError extends BaseError { + constructor( + public readonly action: AuthorizedAction, + public readonly resourceOwnerId?: number, + public readonly resourceType?: string, + public readonly resourceId?: number, + ) { + super( + 'PermissionDeniedError', + `The attempt to perform action '${action}' on resource (resourceOwnerId: ${resourceOwnerId}, resourceType: ${resourceType}, resourceId: ${resourceId}) is not permitted by the given token.`, + 403, + ); + } +} + +export class SessionExpiredError extends BaseError { + constructor() { + super('SessionExpiredError', 'Session expired', 401); + } +} + +export class SessionRevokedError extends BaseError { + constructor() { + super('SessionRevokedError', 'Session revoked', 401); + } +} + +export class RefreshTokenAlreadyUsedError extends BaseError { + constructor() { + super( + 'RefreshTokenAlreadyUsedError', + 'The refresh token has already been used. A refresh token can only be used once.', + 401, + ); + } +} + +export class NotRefreshTokenError extends BaseError { + constructor() { + super( + 'NotRefreshTokenError', + 'The token is not a refresh token. A refresh token is required.', + 401, + ); + } +} + +export class SudoRequiredError extends BaseError { + constructor() { + super( + 'SudoRequiredError', + 'This operation requires sudo mode verification', + 403, + ); + } +} + +export class InvalidCredentialsError extends BaseError { + constructor() { + super( + 'InvalidCredentialsError', + 'Invalid credentials provided for sudo mode', + 401, + ); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 00000000..88884dc5 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,38 @@ +/* + * Description: This file defines the auth module, which is used for + * authentication and authorization. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { PrismaModule } from '../common/prisma/prisma.module'; +import { AuthService } from './auth.service'; +import { SessionService } from './session.service'; + +@Module({ + imports: [ + ConfigModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('jwt.secret'), + // This expire date is meaningless, as we use + // a field in token payload to determine whether + // the token is expired. Thus, we set it to a + // very large value. + signOptions: { expiresIn: '361 days' }, + }), + inject: [ConfigService], + }), + PrismaModule, + ], + controllers: [], + providers: [AuthService, SessionService], + exports: [AuthService, SessionService], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 00000000..e4643898 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,262 @@ +/* + * Description: This file implements the auth service, which is used for + * authentication and authorization. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import Ajv from 'ajv'; +import { readFileSync } from 'fs'; +import path from 'node:path'; +import { + AuthenticationRequiredError, + InvalidTokenError, + PermissionDeniedError, + SudoRequiredError, + TokenExpiredError, +} from './auth.error'; +import { CustomAuthLogics } from './custom-auth-logic'; +import { Authorization, AuthorizedAction, TokenPayload } from './definitions'; + +@Injectable() +export class AuthService { + public static instance: AuthService; + public customAuthLogics: CustomAuthLogics = new CustomAuthLogics(); + + constructor(private readonly jwtService: JwtService) { + AuthService.instance = this; + const tokenPayloadSchemaRaw = readFileSync( + path.resolve(__dirname, '../../src/auth/token-payload.schema.json'), + 'utf8', + ); + const tokenPayloadSchema = JSON.parse(tokenPayloadSchemaRaw); + this.isTokenPayloadValidate = new Ajv().compile(tokenPayloadSchema); + } + + private isTokenPayloadValidate: (payload: any) => boolean; + + private decodePayload(data: any): TokenPayload { + const payload = data.payload; + if (payload == undefined || !this.isTokenPayloadValidate(payload)) { + throw new Error( + 'The token is valid, but the payload of the token is' + + ' not a TokenPayload object. This is ether a bug or a malicious attack.', + ); + } + return payload as TokenPayload; + } + + private encodePayload(payload: TokenPayload): any { + return { payload: payload }; + } + + // Sign a token for an authorization. + sign(authorization: Authorization, validSeconds: number = 60): string { + const now = Date.now(); + const payload: TokenPayload = { + authorization: authorization, + signedAt: now, + validUntil: now + validSeconds * 1000, + }; + return this.jwtService.sign(this.encodePayload(payload)); + } + + // Verify a token and decodes its payload. + // + // If the token is invalid, for example, malformed, missigned or expired, + // an exception will be thrown by jwtService.verify(). + // + // If the token is valid but the payload is not an Authorization object, + // TokenFormatError will be thrown. + // + // Parameters: + // token: both the pure jwt token and the one with "Bearer " or "bearer " are supported. + verify(token: string | undefined): Authorization { + if (token == undefined || token == '') + throw new AuthenticationRequiredError(); + if (token.indexOf('Bearer ') == 0) token = token.slice(7); + else if (token.indexOf('bearer ') == 0) token = token.slice(7); + + let result: any; + try { + result = this.jwtService.verify(token); + } catch { + throw new InvalidTokenError(); + } + + const payload = this.decodePayload(result); + + if (Date.now() > payload.validUntil) throw new TokenExpiredError(); + + return payload.authorization; + } + + // If the toke is invalid, or the operation is not permitted, an exception is thrown. + // + // If resourceOwnerId, resourceType or resourceId is undefined, it means the resource has + // no owner, type or id. Only the AuthorizedResource object whose ownedByUser, types + // or resourceIds is undefined or contains a undefined can matches such a resource which has + // no owner, type or id. + async audit( + token: string | undefined, + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + requireSudo: boolean = false, + ): Promise { + const authorization = this.verify(token); + + if (requireSudo) { + const hasSudo = this.checkSudoMode(authorization); + if (!hasSudo) { + throw new SudoRequiredError(); + } + } + + await this.auditWithoutToken( + authorization, + action, + resourceOwnerId, + resourceType, + resourceId, + ); + } + + // Do the same thing as audit(), but without a token. + async auditWithoutToken( + authorization: Authorization, + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + ): Promise { + // In many situations, the coders may forget to convert the string to number. + // So we do it here. + // Addition: We think this hides problems; so we remove it. + //if (typeof resourceOwnerId == "string") + // resourceOwnerId = Number.parseInt(resourceOwnerId as any as string); + //if (typeof resourceId == "string") + // resourceId = Number.parseInt(resourceId as any as string); + if (resourceOwnerId !== undefined && typeof resourceOwnerId != 'number') { + //Logger.error(typeof resourceOwnerId); + throw new Error('resourceOwnerId must be a number.'); + } + if (resourceId !== undefined && typeof resourceId != 'number') { + //Logger.error(typeof resourceId); + throw new Error('resourceId must be a number.'); + } + for (const permission of authorization.permissions) { + let actionMatches = + permission.authorizedActions === undefined ? true : false; + if (permission.authorizedActions !== undefined) { + for (const authorizedAction of permission.authorizedActions) { + if (authorizedAction === action) { + actionMatches = true; + } + } + } + if (actionMatches == false) continue; + // Now, action matches. + + if ( + (permission.authorizedResource.ownedByUser === undefined || + permission.authorizedResource.ownedByUser === resourceOwnerId) !== + true + ) + continue; + // Now, owner matches. + + let typeMatches = + permission.authorizedResource.types === undefined ? true : false; + if (permission.authorizedResource.types !== undefined) { + for (const authorizedType of permission.authorizedResource.types) { + if (authorizedType === resourceType) { + typeMatches = true; + } + } + } + if (typeMatches == false) continue; + // Now, type matches. + + let idMatches = + permission.authorizedResource.resourceIds === undefined ? true : false; + if (permission.authorizedResource.resourceIds !== undefined) { + for (const authorizedId of permission.authorizedResource.resourceIds) { + if (authorizedId === resourceId) { + idMatches = true; + } + } + } + if (idMatches == false) continue; + // Now, id matches. + + if (permission.customLogic !== undefined) { + const result = await this.customAuthLogics.invoke( + permission.customLogic, + authorization.userId, + action, + resourceOwnerId, + resourceType, + resourceId, + permission.customLogicData, + ); + if (result !== true) continue; + } + // Now, custom logic matches. + + // Action, owner, type and id matches, so the operation is permitted. + return; + } + throw new PermissionDeniedError( + action, + resourceOwnerId, + resourceType, + resourceId, + ); + } + + // Decode a token, WITHOUT verifying it. + decode(token: string | undefined): TokenPayload { + if (token == undefined || token == '') + throw new AuthenticationRequiredError(); + if (token.indexOf('Bearer ') == 0) token = token.slice(7); + else if (token.indexOf('bearer ') == 0) token = token.slice(7); + const result = this.jwtService.decode(token); + return this.decodePayload(result); + } + + checkSudoMode(authorization: Authorization): boolean { + const now = Date.now(); + return ( + authorization.sudoUntil !== undefined && authorization.sudoUntil > now + ); + } + + // 为现有 token 签发一个带有 sudo 权限的新 token + async issueSudoToken(token: string): Promise { + // 先验证 token 有效性 + this.verify(token); + // 再获取完整 payload + const payload = this.decode(token); + + // 添加 0-5 分钟随机偏移 + const baseValidSeconds = 15 * 60; + const randomOffset = Math.floor(Math.random() * 300); + const sudoValidSeconds = baseValidSeconds + randomOffset; + + const newAuthorization: Authorization = { + ...payload.authorization, + sudoUntil: Date.now() + sudoValidSeconds * 1000, + }; + + return this.sign( + newAuthorization, + (payload.validUntil - Date.now()) / 1000, + ); + } +} diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts new file mode 100644 index 00000000..fab16fe4 --- /dev/null +++ b/src/auth/auth.spec.ts @@ -0,0 +1,372 @@ +/* + * Description: This file provides additional tests to auth module. + * + * Author(s): + * Nictheboy Li + * + */ + +import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AppModule } from '../app.module'; +import { + AuthenticationRequiredError, + NotRefreshTokenError, + PermissionDeniedError, +} from './auth.error'; +import { AuthService } from './auth.service'; +import { Authorization, AuthorizedAction } from './definitions'; +import { SessionService } from './session.service'; +import { CustomAuthLogicHandler } from './custom-auth-logic'; + +describe('AuthService', () => { + let app: TestingModule; + let authService: AuthService; + let sessionService: SessionService; + let jwtService: JwtService; + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + authService = app.get(AuthService); + jwtService = app.get(JwtService); + sessionService = app.get(SessionService); + }); + afterAll(() => { + app.close(); + }); + it('should throw Error("The token is valid, but...")', () => { + const token = jwtService.sign({ + //authorization: { userId: 1, permissions: [] }, + signedAt: Date.now(), + validUntil: Date.now() + 1000, + }); + expect(() => authService.verify(token)).toThrow( + 'The token is valid, but the payload of the token is' + + ' not a TokenPayload object. This is ether a bug or a malicious attack.', + ); + }); + it('should throw Error("The token is valid, but...")', () => { + const token = jwtService.sign({ + authorization: { userId: 1, permissions: [] }, + //signedAt: Date.now(), + validUntil: Date.now() + 1000, + }); + expect(() => authService.verify(token)).toThrow( + 'The token is valid, but the payload of the token is' + + ' not a TokenPayload object. This is ether a bug or a malicious attack.', + ); + }); + it('should throw Error("The token is valid, but...")', () => { + const token = jwtService.sign({ + authorization: { userId: 1, permissions: [] }, + signedAt: Date.now(), + //validUntil: Date.now() + 1000, + }); + expect(() => authService.verify(token)).toThrow( + 'The token is valid, but the payload of the token is' + + ' not a TokenPayload object. This is ether a bug or a malicious attack.', + ); + }); + it('should throw Error("The token is valid, but...")', () => { + const token = jwtService.sign({ + authorization: { + userId: 1, + permissions: [ + { + authorizedActions: ['create'], + authorizedResource: { + resourceType: 'user', + resourceId: 1, + }, + }, + ], + }, + signedAt: Date.now(), + validUntil: Date.now() + 1000, + }); + expect(() => authService.verify(token)).toThrow( + 'The token is valid, but the payload of the token is' + + ' not a TokenPayload object. This is ether a bug or a malicious attack.', + ); + }); + it('should throw Error("The token is valid, but...")', () => { + const token = jwtService.sign({ + authorization: { + userId: 1, + permissions: [ + { + authorizedActions: ['create'], + authorizedResource: { + resourceType: 'user', + resourceId: 1, + ownedByUser: '1', + }, + }, + ], + }, + signedAt: Date.now(), + validUntil: Date.now() + 1000, + }); + expect(() => authService.verify(token)).toThrow( + 'The token is valid, but the payload of the token is' + + ' not a TokenPayload object. This is ether a bug or a malicious attack.', + ); + }); + it('should throw Error("resourceOwnerId must be a number.")', () => { + const token = authService.sign({ + userId: 0, + permissions: [], + }); + expect(async () => { + await authService.audit(token, 'other', '1' as any as number, 'type', 1); + }).rejects.toThrow('resourceOwnerId must be a number.'); + }); + it('should throw Error("resourceId must be a number.")', () => { + const token = authService.sign({ + userId: 0, + permissions: [], + }); + expect(async () => { + await authService.audit(token, 'other', 1, 'type', '1' as any as number); + }).rejects.toThrow('resourceId must be a number.'); + }); + it('should throw AuthenticationRequiredError()', () => { + expect( + async () => await authService.audit('', 'other', 1, 'type', 1), + ).rejects.toThrow(new AuthenticationRequiredError()); + expect( + async () => await authService.audit(undefined, 'other', 1, 'type', 1), + ).rejects.toThrow(new AuthenticationRequiredError()); + expect(() => authService.decode('')).toThrow( + new AuthenticationRequiredError(), + ); + expect(() => authService.decode(undefined)).toThrow( + new AuthenticationRequiredError(), + ); + }); + it('should pass audit', async () => { + const token = authService.sign({ + userId: 0, + permissions: [ + { + authorizedActions: ['query'], + authorizedResource: { + ownedByUser: 1, + types: undefined, + resourceIds: undefined, + }, + }, + ], + }); + await authService.audit(`bearer ${token}`, 'query', 1, 'type', 1); + }); + it('should throw PermissionDeniedError()', () => { + const token = authService.sign({ + userId: 0, + permissions: [ + { + authorizedActions: ['delete'], + authorizedResource: { + ownedByUser: undefined, + types: undefined, + resourceIds: [1, 2, 3], + }, + }, + ], + }); + expect( + async () => await authService.audit(token, 'delete', 1, 'type', 5), + ).rejects.toThrow(new PermissionDeniedError('delete', 1, 'type', 5)); + }); + it('should verify and decode successfully', () => { + const authorization: Authorization = { + userId: 0, + permissions: [], + }; + const token = authService.sign(authorization); + expect(authService.verify(token)).toEqual(authorization); + expect(authService.verify(`Bearer ${token}`)).toEqual(authorization); + expect(authService.verify(`bearer ${token}`)).toEqual(authorization); + expect(authService.decode(token).authorization).toEqual(authorization); + expect(authService.decode(`Bearer ${token}`).authorization).toEqual( + authorization, + ); + expect(authService.decode(`bearer ${token}`).authorization).toEqual( + authorization, + ); + }); + it('should throw NotRefreshTokenError()', async () => { + const token = authService.sign({ + userId: 0, + permissions: [], + }); + await expect(sessionService.refreshSession(token)).rejects.toThrow( + new NotRefreshTokenError(), + ); + await expect(sessionService.revokeSession(token)).rejects.toThrow( + new NotRefreshTokenError(), + ); + }); + it('should throw Error()', () => { + const token = authService.sign({ + userId: 0, + permissions: [ + { + authorizedActions: ['some_action'], + authorizedResource: { + types: ['user'], + }, + customLogic: 'some_logic', + }, + ], + }); + expect(async () => { + await authService.audit(token, 'some_action', 1, 'user', 1); + }).rejects.toThrow(new Error("Custom auth logic 'some_logic' not found.")); + }); + it('should register and invoke custom logic successfully', async () => { + let handler_called = false; + const handler: CustomAuthLogicHandler = async ( + userId: number, + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + ) => { + handler_called = true; + return true; + }; + authService.customAuthLogics.register('some_logic', handler); + const token = authService.sign({ + userId: 0, + permissions: [ + { + authorizedActions: ['some_action'], + authorizedResource: { + types: ['user'], + }, + customLogic: 'some_logic', + }, + ], + }); + await authService.audit(token, 'some_action', 1, 'user', 1); + expect(handler_called).toBe(true); + }); + it('should register and invoke custom logic successfully', async () => { + let handler_called = false; + const handler: CustomAuthLogicHandler = async ( + userId: number, + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + ) => { + handler_called = true; + return false; + }; + authService.customAuthLogics.register('another_logic', handler); + const token = authService.sign({ + userId: 0, + permissions: [ + { + authorizedActions: ['another_action'], + authorizedResource: { + types: ['user'], + }, + customLogic: 'another_logic', + }, + ], + }); + expect(async () => { + await authService.audit(token, 'another_action', 1, 'user', 1); + }).rejects.toThrow( + new PermissionDeniedError('another_action', 1, 'user', 1), + ); + expect(handler_called).toBe(true); + }); + it('should invoke custom logic and get additional data successfully', async () => { + let handler_called = false; + let data = { some: '' }; + const handler: CustomAuthLogicHandler = async ( + userId: number, + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + customLogicData?: any, + ) => { + handler_called = true; + data = customLogicData; + return false; + }; + authService.customAuthLogics.register('yet_another_logic', handler); + const token = authService.sign({ + userId: 0, + permissions: [ + { + authorizedActions: ['another_action'], + authorizedResource: { + types: ['user'], + }, + customLogic: 'yet_another_logic', + customLogicData: { some: 'data' }, + }, + ], + }); + expect(async () => { + await authService.audit(token, 'another_action', 1, 'user', 1); + }).rejects.toThrow( + new PermissionDeniedError('another_action', 1, 'user', 1), + ); + expect(handler_called).toBe(true); + expect(data).toEqual({ some: 'data' }); + }); + it('should always invoke custom logic successfully', async () => { + let handler_called = false; + const handler: CustomAuthLogicHandler = async ( + userId: number, + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + ) => { + handler_called = true; + return true; + }; + authService.customAuthLogics.register('yet_yet_another_logic', handler); + const token = authService.sign({ + userId: 0, + permissions: [ + { + authorizedActions: undefined, // all actions + authorizedResource: {}, // all resources + customLogic: 'yet_yet_another_logic', + }, + ], + }); + await authService.audit(token, 'some_action', 1, 'user', 1); + expect(handler_called).toBe(true); + handler_called = false; + await authService.audit(token, 'another_action', undefined, 'user', 1); + expect(handler_called).toBe(true); + handler_called = false; + await authService.audit( + token, + 'some_action', + 1, + 'another_resource', + undefined, + ); + expect(handler_called).toBe(true); + handler_called = false; + await authService.audit( + token, + 'another_action', + undefined, + 'another_resource', + undefined, + ); + expect(handler_called).toBe(true); + }); +}); diff --git a/src/auth/custom-auth-logic.ts b/src/auth/custom-auth-logic.ts new file mode 100644 index 00000000..af7e9ff3 --- /dev/null +++ b/src/auth/custom-auth-logic.ts @@ -0,0 +1,45 @@ +import { AuthorizedAction } from './definitions'; + +export type CustomAuthLogicHandler = ( + userId: number, + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + customLogicData?: any, +) => Promise; + +export class CustomAuthLogics { + private logics: Map = new Map(); + + register(name: string, handler: CustomAuthLogicHandler): void { + /* istanbul ignore if */ + if (this.logics.has(name)) { + throw new Error(`Custom auth logic '${name}' already exists.`); + } + this.logics.set(name, handler); + } + + invoke( + name: string, + userId: number, + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + customLogicData?: any, + ): Promise { + const handler = this.logics.get(name); + if (!handler) { + throw new Error(`Custom auth logic '${name}' not found.`); + } + return handler( + userId, + action, + resourceOwnerId, + resourceType, + resourceId, + customLogicData, + ); + } +} diff --git a/src/auth/definitions.ts b/src/auth/definitions.ts new file mode 100644 index 00000000..12c8e21e --- /dev/null +++ b/src/auth/definitions.ts @@ -0,0 +1,83 @@ +/* + * Description: This file defines the basic structures used in authorization. + * + * Author(s): + * Nictheboy Li + * + */ + +/* + +IMPORTANT NOTICE: + +If you have modified this file, please run the following linux command: + +./node_modules/.bin/ts-json-schema-generator \ + --path 'src/auth/definitions.ts' \ + --type 'TokenPayload' \ + > src/auth/token-payload.schema.json + +to update the schema file, which is used in validating the token payload. + +*/ + +export type AuthorizedAction = string; + +// This class is used as a filter. +// +// If all the conditions are undefined, it matches everything. +// This is DANGEROUS as you can imagine, and you should avoid +// such a powerful authorization. +// +// Once a condition is added, the audited resource should have the same +// attribute if it is authorized. +// +// The data field is reserved for future use. +// +// Examples: +// { ownedByUser: undefined, types: undefined, resourceId: undefined } +// matches every resource, including the resources that are not owned by any user. +// { ownedByUser: 123, types: undefined, resourceId: undefined } +// matches all the resources owned by user whose user id is 123. +// { ownedByUser: 123, types: ["users/profile"], resourceId: undefined } +// matches the profile of user whose id is 123. +// { ownedByUser: undefined, types: ["blog"], resourceId: [42, 95, 928] } +// matches blogs whose IDs are 42, 95 and 928. +// { ownedByUser: undefined, types: [], resourceId: undefined } +// matches nothing and is meaningless. +// +export class AuthorizedResource { + ownedByUser?: number; // owner's user id + types?: string[]; // resource type + resourceIds?: number[]; + data?: any; // additional data +} + +// The permission to perform all the actions listed in authorizedActions +// on all the resources that match the authorizedResource property. +// +// If authorizedActions is undefined, the permission is granted to perform +// all the actions on the resources that match the authorizedResource property. +// +// If customLogic is not undefined, the permission is granted only if the +// custom logic allows the specified action on the specified resource. +export class Permission { + authorizedActions?: AuthorizedAction[]; + authorizedResource: AuthorizedResource; + customLogic?: string; + customLogicData?: any; +} + +// The user, whose id is userId, is granted the permissions. +export interface Authorization { + userId: number; + permissions: Permission[]; + sudoUntil?: number; // sudo 模式过期时间戳 + username?: string; // 用户名,用于密码重置等场景 +} + +export class TokenPayload { + authorization: Authorization; + signedAt: number; // timestamp in milliseconds + validUntil: number; // timestamp in milliseconds +} diff --git a/src/auth/guard.decorator.ts b/src/auth/guard.decorator.ts new file mode 100644 index 00000000..adcbd4c4 --- /dev/null +++ b/src/auth/guard.decorator.ts @@ -0,0 +1,174 @@ +/* + * Description: This file implements the guard decorator that is used to protect resources. + * + * You need to use @ResourceId(), @AuthToken(), @ResourceOwnerIdGetter() and @CurrentUserOwnResource() + * to provide the necessary information for the guard decorator. + * + * You can lean how to use these things by reading controller's code. + * + * Author(s): + * Nictheboy Li + * + */ + +import { SetMetadata } from '@nestjs/common'; +import { AuthenticationRequiredError } from './auth.error'; +import { AuthService } from './auth.service'; +import { AuthorizedAction } from './definitions'; + +const RESOURCE_ID_METADATA_KEY = Symbol('resourceIdMetadata'); +const AUTH_TOKEN_METADATA_KEY = Symbol('authTokenMetadata'); +const RESOURCE_OWNER_ID_GETTER_METADATA_KEY = Symbol( + 'resourceOwnerIdGetterMetadata', +); +const CURRENT_USER_OWN_RESOURCE_METADATA_KEY = Symbol( + 'currentUserOwnResourceMetadata', +); +export const HAS_GUARD_DECORATOR_METADATA_KEY = Symbol( + 'hasGuardDecoratorMetadata', +); + +export function ResourceId() { + return function ( + target: Object, + propertyKey: string | symbol, + parameterIndex: number, + ) { + Reflect.defineMetadata( + RESOURCE_ID_METADATA_KEY, + parameterIndex, + target, + propertyKey, + ); + }; +} + +export function AuthToken() { + return function ( + target: Object, + propertyKey: string | symbol, + parameterIndex: number, + ) { + Reflect.defineMetadata( + AUTH_TOKEN_METADATA_KEY, + parameterIndex, + target, + propertyKey, + ); + }; +} + +// apply it only to (resourceId: number) => Promise +export function ResourceOwnerIdGetter(resourceType: string) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ) { + Reflect.defineMetadata( + RESOURCE_OWNER_ID_GETTER_METADATA_KEY, + resourceType, + target, + propertyKey, + ); + }; +} + +export function CurrentUserOwnResource() { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ) { + Reflect.defineMetadata( + CURRENT_USER_OWN_RESOURCE_METADATA_KEY, + true, + target, + propertyKey, + ); + }; +} + +export function Guard( + action: AuthorizedAction, + resourceType: string, + requireSudo: boolean = false, +) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ) { + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: any[]) { + const authTokenParamIdx: number | undefined = Reflect.getOwnMetadata( + AUTH_TOKEN_METADATA_KEY, + target, + propertyKey, + ); + const authToken = + authTokenParamIdx != undefined ? args[authTokenParamIdx] : undefined; + if (authToken == undefined) { + throw new AuthenticationRequiredError(); + } + + const resourceIdParamIdx: number | undefined = Reflect.getOwnMetadata( + RESOURCE_ID_METADATA_KEY, + target, + propertyKey, + ); + const resourceId = + resourceIdParamIdx != undefined ? args[resourceIdParamIdx] : undefined; + + let resourceOwnerId: number | undefined = undefined; + const currentUserOwnResource: true | undefined = Reflect.getMetadata( + CURRENT_USER_OWN_RESOURCE_METADATA_KEY, + target, + propertyKey, + ); + if (currentUserOwnResource != undefined) { + resourceOwnerId = AuthService.instance.verify(authToken).userId; + } else { + const methods = Object.getOwnPropertyNames(target).filter( + (prop) => typeof target[prop] === 'function', + ); + let ownerIdGetterName: string | undefined = undefined; + for (const method of methods) { + const metadata = Reflect.getMetadata( + RESOURCE_OWNER_ID_GETTER_METADATA_KEY, + target, + method, + ); + if (metadata === resourceType) { + ownerIdGetterName = method; + } + } + const resourceOwnerIdGetter = + ownerIdGetterName != undefined + ? target[ownerIdGetterName] + : undefined; + resourceOwnerId = + resourceId != undefined && resourceOwnerIdGetter != undefined + ? await resourceOwnerIdGetter.call(this, resourceId) + : undefined; + } + + await AuthService.instance.audit( + authToken, + action, + resourceOwnerId, + resourceType, + resourceId, + requireSudo, + ); + return originalMethod.apply(this, args); + }; + SetMetadata(HAS_GUARD_DECORATOR_METADATA_KEY, true)( + target, + propertyKey, + descriptor, + ); + return descriptor; + }; +} diff --git a/src/auth/session.prisma b/src/auth/session.prisma new file mode 100644 index 00000000..b84e6807 --- /dev/null +++ b/src/auth/session.prisma @@ -0,0 +1,21 @@ +model session { + id Int @id(map: "PK_f55da76ac1c3ac420f444d2ff11") @default(autoincrement()) + validUntil DateTime @db.Timestamptz(6) + revoked Boolean + userId Int + authorization String + lastRefreshedAt BigInt + createdAt DateTime @default(now()) @db.Timestamptz(6) + + @@index([userId], map: "IDX_3d2f174ef04fb312fdebd0ddc5") + @@index([validUntil], map: "IDX_bb46e87d5b3f1e55c625755c00") +} + +model session_refresh_log { + id Int @id(map: "PK_f8f46c039b0955a7df6ad6631d7") @default(autoincrement()) + sessionId Int + oldRefreshToken String + newRefreshToken String + accessToken String + createdAt DateTime @default(now()) @db.Timestamptz(6) +} diff --git a/src/auth/session.service.ts b/src/auth/session.service.ts new file mode 100644 index 00000000..62d62ec5 --- /dev/null +++ b/src/auth/session.service.ts @@ -0,0 +1,197 @@ +/* + * Description: This file implements the session service. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { + NotRefreshTokenError, + RefreshTokenAlreadyUsedError, + SessionExpiredError, + SessionRevokedError, +} from './auth.error'; +import { AuthService } from './auth.service'; +import { Authorization } from './definitions'; + +@Injectable() +export class SessionService { + constructor( + private readonly authService: AuthService, + private readonly prismaService: PrismaService, + ) {} + + private readonly defaultSessionValidSeconds = 60 * 60 * 24 * 30 * 12; + private readonly defaultRefreshTokenValidSeconds = 60 * 60 * 24 * 30; + private readonly defaultAccessTokenValidSeconds = 15 * 60; + + private getRefreshAuthorization( + userId: number, + sessionId: number, + ): Authorization { + return { + userId: userId, + permissions: [ + { + authorizedResource: { + ownedByUser: undefined, + types: ['auth/session:refresh', 'auth/session:revoke'], + resourceIds: [sessionId], + }, + authorizedActions: ['other'], + }, + ], + }; + } + + // Returns: + // The refresh token of the session. + async createSession( + userId: number, + authorization: Authorization, + // The refresh token is valid for refreshTokenValidSeconds seconds. + // By default, it is valid for defaultRefreshTokenValidSeconds seconds. + refreshTokenValidSeconds: number = this.defaultRefreshTokenValidSeconds, + sessionValidSeconds: number = this.defaultSessionValidSeconds, + ): Promise { + const session = await this.prismaService.session.create({ + data: { + userId: userId, + authorization: JSON.stringify(authorization), + validUntil: new Date(Date.now() + sessionValidSeconds * 1000), + revoked: false, + lastRefreshedAt: new Date().getTime(), + }, + }); + return this.authService.sign( + this.getRefreshAuthorization(userId, session.id), + refreshTokenValidSeconds, + ); + } + + // After refreshing the session, the old refresh token is revoked. + // Returns: + // item1: A new refresh token. + // item2: The access token of the session. + async refreshSession( + oldRefreshToken: string, + refreshTokenValidSeconds: number = this.defaultRefreshTokenValidSeconds, + accessTokenValidSeconds: number = this.defaultAccessTokenValidSeconds, + ): Promise<[string, string]> { + const auth = this.authService.verify(oldRefreshToken); + if ( + auth.permissions.length !== 1 || + auth.permissions[0].authorizedResource.resourceIds == undefined || + auth.permissions[0].authorizedResource.resourceIds.length !== 1 + ) { + throw new NotRefreshTokenError(); + } + const sessionId = auth.permissions[0].authorizedResource.resourceIds[0]; + await this.authService.audit( + oldRefreshToken, + 'other', + undefined, + 'auth/session:refresh', + sessionId, + ); + let session = await this.prismaService.session.findUnique({ + where: { id: sessionId }, + }); + /* istanbul ignore if */ + if (session == undefined) { + throw new Error( + `In an attempt to refresh session with id ${sessionId},\n` + + `the refresh token is valid, but the session does not exist.\n` + + `Here are three possible reasons:\n` + + `1. There is a bug in the code.\n` + + `2. The database is corrupted.\n` + + `3. We are under attack.\n` + + `token: ${oldRefreshToken}`, + ); + } + if (new Date() > session.validUntil) { + throw new SessionExpiredError(); + } + if (session.revoked) { + throw new SessionRevokedError(); + } + const oldRefreshTokenSignedAt = + this.authService.decode(oldRefreshToken).signedAt; + if (oldRefreshTokenSignedAt < session.lastRefreshedAt) { + throw new RefreshTokenAlreadyUsedError(); + } + const authorization = JSON.parse(session.authorization) as Authorization; + const refreshAuthorization = this.getRefreshAuthorization( + session.userId, + session.id, + ); + + // get the current time before signing to ensure + // sign time of a refreshToken >= lastRefreshedAt + const lastRefreshedAt = new Date().getTime(); + const newRefreshToken = this.authService.sign( + refreshAuthorization, + refreshTokenValidSeconds, + ); + const accessToken = this.authService.sign( + authorization, + accessTokenValidSeconds, + ); + + // Update lastRefreshedAt + session = await this.prismaService.session.update({ + where: { id: sessionId }, + data: { lastRefreshedAt: lastRefreshedAt }, + }); + + // Insert a log + await this.prismaService.sessionRefreshLog.create({ + data: { + sessionId: session.id, + oldRefreshToken: oldRefreshToken, + newRefreshToken: newRefreshToken, + accessToken: accessToken, + }, + }); + + return [newRefreshToken, accessToken]; + } + + async revokeSession(refreshToken: string): Promise { + const auth = this.authService.verify(refreshToken); + if ( + auth.permissions.length !== 1 || + auth.permissions[0].authorizedResource.resourceIds == undefined || + auth.permissions[0].authorizedResource.resourceIds.length !== 1 + ) { + throw new NotRefreshTokenError(); + } + const sessionId = auth.permissions[0].authorizedResource.resourceIds[0]; + await this.authService.audit( + refreshToken, + 'other', + undefined, + 'auth/session:revoke', + sessionId, + ); + const ret = await this.prismaService.session.update({ + where: { id: sessionId }, + data: { revoked: true }, + }); + /* istanbul ignore if */ + if (ret == undefined) { + throw new Error( + `In an attempt to revoke session with id ${sessionId},\n` + + `the refresh token is valid, but the session does not exist.\n` + + `Here are three possible reasons:\n` + + `1. There is a bug in the code.\n` + + `2. The database is corrupted.\n` + + `3. We are under attack.\n` + + `token: ${refreshToken}`, + ); + } + } +} diff --git a/src/auth/token-payload.schema.json b/src/auth/token-payload.schema.json new file mode 100644 index 00000000..0590ef9e --- /dev/null +++ b/src/auth/token-payload.schema.json @@ -0,0 +1,98 @@ +{ + "$ref": "#/definitions/TokenPayload", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Authorization": { + "additionalProperties": false, + "properties": { + "permissions": { + "items": { + "$ref": "#/definitions/Permission" + }, + "type": "array" + }, + "sudoUntil": { + "type": "number" + }, + "userId": { + "type": "number" + }, + "username": { + "type": "string" + } + }, + "required": [ + "userId", + "permissions" + ], + "type": "object" + }, + "AuthorizedAction": { + "type": "string" + }, + "AuthorizedResource": { + "additionalProperties": false, + "properties": { + "data": {}, + "ownedByUser": { + "type": "number" + }, + "resourceIds": { + "items": { + "type": "number" + }, + "type": "array" + }, + "types": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "Permission": { + "additionalProperties": false, + "properties": { + "authorizedActions": { + "items": { + "$ref": "#/definitions/AuthorizedAction" + }, + "type": "array" + }, + "authorizedResource": { + "$ref": "#/definitions/AuthorizedResource" + }, + "customLogic": { + "type": "string" + }, + "customLogicData": {} + }, + "required": [ + "authorizedResource" + ], + "type": "object" + }, + "TokenPayload": { + "additionalProperties": false, + "properties": { + "authorization": { + "$ref": "#/definitions/Authorization" + }, + "signedAt": { + "type": "number" + }, + "validUntil": { + "type": "number" + } + }, + "required": [ + "authorization", + "signedAt", + "validUntil" + ], + "type": "object" + } + } +} \ No newline at end of file diff --git a/src/auth/user-id.decorator.ts b/src/auth/user-id.decorator.ts new file mode 100644 index 00000000..1c709eff --- /dev/null +++ b/src/auth/user-id.decorator.ts @@ -0,0 +1,39 @@ +/* + * Description: This file implements a decorator that can be used to get the user id. + * It can be used just like the @Ip() decorator. + * + * It has two forms: + * 1. @UserId() userId: number | undefined + * 2. @UserId(true) userId: number + * Only the second one will throw an error if the user is not logged in. + * + * Author(s): + * Nictheboy Li + * + */ + +import { ExecutionContext, createParamDecorator } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { AuthenticationRequiredError } from './auth.error'; + +export const UserId = createParamDecorator( + (required: boolean = false, ctx: ExecutionContext): number | undefined => { + const request = ctx.switchToHttp().getRequest(); + const auth = request.headers.authorization; + let userId: number | undefined = undefined; + if (required) { + userId = AuthService.instance.verify(auth).userId; + /* istanbul ignore if */ + if (userId == undefined) { + throw new AuthenticationRequiredError(); + } + } else { + try { + userId = AuthService.instance.verify(auth).userId; + } catch { + // The user is not logged in. + } + } + return userId; + }, +); diff --git a/src/avatars/DTO/upload-avatar.dto.ts b/src/avatars/DTO/upload-avatar.dto.ts new file mode 100644 index 00000000..2d0eb6f8 --- /dev/null +++ b/src/avatars/DTO/upload-avatar.dto.ts @@ -0,0 +1,6 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +export class UploadAvatarResponseDto extends BaseResponseDto { + data: { + avatarId: number; + }; +} diff --git a/src/avatars/avatars.controller.ts b/src/avatars/avatars.controller.ts new file mode 100644 index 00000000..aced2c05 --- /dev/null +++ b/src/avatars/avatars.controller.ts @@ -0,0 +1,130 @@ +import { + Controller, + Get, + Headers, + Param, + ParseIntPipe, + Post, + Query, + Res, + StreamableFile, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { AvatarType } from '@prisma/client'; +import { Response } from 'express'; +import * as fs from 'fs'; +import { AuthToken, Guard, ResourceId } from '../auth/guard.decorator'; +import { getFileHash, getFileMimeType } from '../common/helper/file.helper'; +import { UploadAvatarResponseDto } from './DTO/upload-avatar.dto'; +import { + CorrespondentFileNotExistError, + InvalidAvatarTypeError, +} from './avatars.error'; +import { AvatarsService } from './avatars.service'; +import { NoAuth } from '../common/interceptor/token-validate.interceptor'; + +@Controller('/avatars') +export class AvatarsController { + constructor(private readonly avatarsService: AvatarsService) {} + + @Post() + @UseInterceptors(FileInterceptor('avatar')) + @Guard('create', 'avatar') + async createAvatar( + @UploadedFile() file: Express.Multer.File, + @Headers('Authorization') @AuthToken() auth: string, + ): Promise { + const avatar = await this.avatarsService.save(file.path, file.filename); + return { + code: 201, + message: 'Upload avatar successfully', + data: { + avatarId: avatar.id, + }, + }; + } + + @Get('/default') + @NoAuth() + async getDefaultAvatar( + @Headers('If-None-Match') ifNoneMatch: string, + @Res({ passthrough: true }) res: Response, + ) { + const defaultAvatarId = await this.avatarsService.getDefaultAvatarId(); + const avatarPath = await this.avatarsService.getAvatarPath(defaultAvatarId); + if (!fs.existsSync(avatarPath)) { + throw new CorrespondentFileNotExistError(defaultAvatarId); + } + + const fileMimeType = await getFileMimeType(avatarPath); + const fileHash = await getFileHash(avatarPath); + const fileStat = fs.statSync(avatarPath); + res.set({ + 'Cache-Control': 'public, max-age=31536000', + 'Content-Disposition': 'inline', + 'Content-Length': fileStat.size, + 'Content-Type': fileMimeType, + ETag: fileHash, + 'Last-Modified': fileStat.mtime.toUTCString(), + }); + if (ifNoneMatch === fileHash) { + res.status(304).end(); + return; + } + + const file = fs.createReadStream(avatarPath); + return new StreamableFile(file); + } + + @Get('/:id') + @NoAuth() + async getAvatar( + @Headers('If-None-Match') ifNoneMatch: string, + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Res({ passthrough: true }) res: Response, + ) { + const avatarPath = await this.avatarsService.getAvatarPath(id); + if (!fs.existsSync(avatarPath)) { + throw new CorrespondentFileNotExistError(id); + } + + const fileMimeType = await getFileMimeType(avatarPath); + const fileHash = await getFileHash(avatarPath); + const fileStat = fs.statSync(avatarPath); + res.set({ + 'Cache-Control': 'public, max-age=31536000', + 'Content-Disposition': 'inline', + 'Content-Length': fileStat.size, + 'Content-Type': fileMimeType, + ETag: fileHash, + 'Last-Modified': fileStat.mtime.toUTCString(), + }); + if (ifNoneMatch === fileHash) { + res.status(304).end(); + return; + } + + const file = fs.createReadStream(avatarPath); + return new StreamableFile(file); + } + + @Get() + @Guard('enumerate', 'avatar') + async getAvailableAvatarIds( + @Query('type') type: AvatarType = AvatarType.predefined, + @Headers('Authorization') @AuthToken() auth: string, + ) { + if (type == AvatarType.predefined) { + const avatarIds = await this.avatarsService.getPreDefinedAvatarIds(); + return { + code: 200, + message: 'Get available avatarIds successfully', + data: { + avatarIds, + }, + }; + } else throw new InvalidAvatarTypeError(type); + } +} diff --git a/src/avatars/avatars.error.ts b/src/avatars/avatars.error.ts new file mode 100644 index 00000000..f09d5edf --- /dev/null +++ b/src/avatars/avatars.error.ts @@ -0,0 +1,23 @@ +import { BaseError } from '../common/error/base-error'; + +export class AvatarNotFoundError extends BaseError { + constructor(public readonly avatarId: number) { + super('AvatarNotFoundError', `Avatar ${avatarId} Not Found`, 404); + } +} + +export class CorrespondentFileNotExistError extends BaseError { + constructor(public readonly avatarId: number) { + super( + 'CorrespondentFileNotExistError', + `File of Avatar ${avatarId} Not Found`, + 404, + ); + } +} + +export class InvalidAvatarTypeError extends BaseError { + constructor(public readonly avatarType: string) { + super('InvalidAvatarTypeError', `Invalid Avatar type: ${avatarType}`, 400); + } +} diff --git a/src/avatars/avatars.module.ts b/src/avatars/avatars.module.ts new file mode 100644 index 00000000..ea6b616c --- /dev/null +++ b/src/avatars/avatars.module.ts @@ -0,0 +1,59 @@ +import { Module } from '@nestjs/common'; +import { MulterModule } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { existsSync, mkdirSync } from 'node:fs'; +import { extname, join } from 'node:path'; +import { v4 as uuidv4 } from 'uuid'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../common/prisma/prisma.module'; +import { AvatarsController } from './avatars.controller'; +import { AvatarsService } from './avatars.service'; +@Module({ + imports: [ + MulterModule.register({ + storage: diskStorage({ + destination: (req, file, callback) => { + /* istanbul ignore if */ + if (!process.env.FILE_UPLOAD_PATH) { + return callback( + new Error('FILE_UPLOAD_PATH environment variable is not defined'), + 'error', + ); + } + const dest = join(process.env.FILE_UPLOAD_PATH, 'avatars'); + if (!existsSync(dest)) { + mkdirSync(dest, { recursive: true }); + } + return callback(null, dest); + }, + filename: (req, file, callback) => { + const randomName = uuidv4(); + callback(null, `${randomName}${extname(file.originalname)}`); + }, + }), + limits: { + fileSize: 10 * 1024 * 1024, + fieldNameSize: 50, + }, + fileFilter: (_, file, callback) => { + const allowedMimeTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + ]; + /* istanbul ignore if */ + if (!allowedMimeTypes.includes(file.mimetype)) { + return callback(new Error('Only image files are allowed!'), false); + } + callback(null, true); + }, + }), + AuthModule, + PrismaModule, + ], + controllers: [AvatarsController], + providers: [AvatarsService], + exports: [AvatarsService], +}) +export class AvatarsModule {} diff --git a/src/avatars/avatars.prisma b/src/avatars/avatars.prisma new file mode 100644 index 00000000..66ee7f93 --- /dev/null +++ b/src/avatars/avatars.prisma @@ -0,0 +1,21 @@ +import { GroupProfile } from "../groups/groups" +import { UserProfile } from "../users/users" + +enum AvatarType { + default + predefined + upload +} + +model Avatar { + id Int @id @default(autoincrement()) + url String @db.VarChar + name String @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + avatarType AvatarType @map("avatar_type") + usageCount Int @default(0) @map("usage_count") + GroupProfile GroupProfile[] + UserProfile UserProfile[] + + @@map("avatar") +} diff --git a/src/avatars/avatars.service.ts b/src/avatars/avatars.service.ts new file mode 100644 index 00000000..9df5c3bd --- /dev/null +++ b/src/avatars/avatars.service.ts @@ -0,0 +1,185 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Avatar, AvatarType } from '@prisma/client'; +import { Mutex } from 'async-mutex'; +import { readdirSync } from 'fs'; +import { join } from 'node:path'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { AvatarNotFoundError } from './avatars.error'; +@Injectable() +export class AvatarsService implements OnModuleInit { + constructor(private readonly prismaService: PrismaService) {} + private mutex = new Mutex(); + private initialized: boolean = false; + + async onModuleInit(): Promise { + await this.mutex.runExclusive(async () => { + if (!this.initialized) { + await this.initialize(); + this.initialized = true; + } + }); + } + private async initialize(): Promise { + const sourcePath = join(__dirname, '../resources/avatars'); + + const avatarFiles = readdirSync(sourcePath); + /* istanbul ignore if */ + if (!process.env.DEFAULT_AVATAR_NAME) { + throw new Error( + 'DEFAULT_AVATAR_NAME environment variable is not defined', + ); + } + const defaultAvatarName = process.env.DEFAULT_AVATAR_NAME; + + const defaultAvatarPath = join(sourcePath, defaultAvatarName); + + // Before one test run, the table is ether empty or has the default avatar + // so the test will not cover all branches. + // However, the test will cover all branches in the second run. + // So we ignore the coverage for this part. + /* istanbul ignore next */ + await this.prismaService.$transaction(async (prismaClient) => { + // Lock the table to prevent multiple initializations in testing + // The SQL syntax is for PostgreSQL, so it may need to be changed for other databases + prismaClient.$executeRaw`LOCK TABLE "avatar" IN ACCESS EXCLUSIVE MODE`; + const defaultAvatar = await this.prismaService.avatar.findFirst({ + where: { + avatarType: AvatarType.default, + }, + }); + + if (!defaultAvatar) { + await this.prismaService.avatar.create({ + data: { + url: defaultAvatarPath, + name: defaultAvatarName, + avatarType: AvatarType.default, + usageCount: 0, + createdAt: new Date(), + }, + }); + } + const predefinedAvatar = await this.prismaService.avatar.findFirst({ + where: { + avatarType: AvatarType.predefined, + }, + }); + if (!predefinedAvatar) { + const predefinedAvatars = avatarFiles.filter( + (file) => file !== defaultAvatarName, + ); + if (predefinedAvatars.length === 0) { + throw new Error('no predefined avatars found'); + } + await Promise.all( + predefinedAvatars.map(async (name) => { + const avatarPath = join(sourcePath, name); + await this.prismaService.avatar.create({ + data: { + url: avatarPath, + name, + avatarType: AvatarType.predefined, + usageCount: 0, + createdAt: new Date(), + }, + }); + }), + ); + } + }); + } + + save(path: string, filename: string): Promise { + return this.prismaService.avatar.create({ + data: { + url: path, + name: filename, + avatarType: AvatarType.upload, + usageCount: 0, + createdAt: new Date(), + }, + }); + } + async getOne(avatarId: number): Promise { + const file = await this.prismaService.avatar.findUnique({ + where: { + id: avatarId, + }, + }); + if (file == undefined) throw new AvatarNotFoundError(avatarId); + return file; + } + async getAvatarPath(avatarId: number): Promise { + const file = await this.prismaService.avatar.findUnique({ + where: { + id: avatarId, + }, + }); + if (file == undefined) throw new AvatarNotFoundError(avatarId); + return file.url; + } + + async getDefaultAvatarId(): Promise { + const defaultAvatar = await this.prismaService.avatar.findFirst({ + where: { + avatarType: AvatarType.default, + }, + }); + if (defaultAvatar == undefined) throw new Error('Default avatar not found'); + + const defaultAvatarId = defaultAvatar.id; + return defaultAvatarId; + } + + async getPreDefinedAvatarIds(): Promise { + const PreDefinedAvatars = await this.prismaService.avatar.findMany({ + where: { + avatarType: AvatarType.predefined, + }, + }); + const PreDefinedAvatarIds = PreDefinedAvatars.map( + (PreDefinedAvatars) => PreDefinedAvatars.id, + ); + return PreDefinedAvatarIds; + } + + async plusUsageCount(avatarId: number): Promise { + if ((await this.isAvatarExists(avatarId)) == false) + throw new AvatarNotFoundError(avatarId); + await this.prismaService.avatar.update({ + where: { + id: avatarId, + }, + data: { + usageCount: { + increment: 1, + }, + }, + }); + } + + async minusUsageCount(avatarId: number): Promise { + if ((await this.isAvatarExists(avatarId)) == false) + throw new AvatarNotFoundError(avatarId); + await this.prismaService.avatar.update({ + where: { + id: avatarId, + }, + data: { + usageCount: { + decrement: 1, + }, + }, + }); + } + + async isAvatarExists(avatarId: number): Promise { + return ( + (await this.prismaService.avatar.count({ + where: { + id: avatarId, + }, + })) > 0 + ); + } +} diff --git a/src/comments/DTO/comment.dto.ts b/src/comments/DTO/comment.dto.ts new file mode 100644 index 00000000..034a8f50 --- /dev/null +++ b/src/comments/DTO/comment.dto.ts @@ -0,0 +1,13 @@ +import { CommentCommentabletypeEnum } from '@prisma/client'; +import { AttitudeStateDto } from '../../attitude/DTO/attitude-state.dto'; +import { UserDto } from '../../users/DTO/user.dto'; + +export class CommentDto { + id: number; + commentable_id: number; + commentable_type: CommentCommentabletypeEnum; + content: string; + user: UserDto; + created_at: number; + attitudes: AttitudeStateDto; +} diff --git a/src/comments/DTO/create-comment.dto.ts b/src/comments/DTO/create-comment.dto.ts new file mode 100644 index 00000000..c9caf823 --- /dev/null +++ b/src/comments/DTO/create-comment.dto.ts @@ -0,0 +1,7 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class CreateCommentResponseDto extends BaseResponseDto { + data: { + id: number; + }; +} diff --git a/src/comments/DTO/get-comment-detail.dto.ts b/src/comments/DTO/get-comment-detail.dto.ts new file mode 100644 index 00000000..b0a55507 --- /dev/null +++ b/src/comments/DTO/get-comment-detail.dto.ts @@ -0,0 +1,8 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { CommentDto } from './comment.dto'; + +export class GetCommentDetailResponseDto extends BaseResponseDto { + data: { + comment: CommentDto; + }; +} diff --git a/src/comments/DTO/get-comments.dto.ts b/src/comments/DTO/get-comments.dto.ts new file mode 100644 index 00000000..1dfc29ce --- /dev/null +++ b/src/comments/DTO/get-comments.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { CommentDto } from './comment.dto'; + +export class GetCommentsResponseDto extends BaseResponseDto { + data: { + comments: CommentDto[]; + page: PageDto; + }; +} diff --git a/src/comments/DTO/update-comment.dto.ts b/src/comments/DTO/update-comment.dto.ts new file mode 100644 index 00000000..12eee716 --- /dev/null +++ b/src/comments/DTO/update-comment.dto.ts @@ -0,0 +1,6 @@ +import { IsNotEmpty } from 'class-validator'; + +export class UpdateCommentDto { + @IsNotEmpty() + content: string; +} diff --git a/src/comments/comment.controller.ts b/src/comments/comment.controller.ts new file mode 100644 index 00000000..94bc062c --- /dev/null +++ b/src/comments/comment.controller.ts @@ -0,0 +1,178 @@ +import { + Body, + Controller, + Delete, + Get, + Headers, + Ip, + Param, + ParseIntPipe, + Patch, + Post, + Query, + UseFilters, + UseInterceptors, +} from '@nestjs/common'; +import { AttitudeTypeDto } from '../attitude/DTO/attitude.dto'; +import { UpdateAttitudeResponseDto } from '../attitude/DTO/update-attitude.dto'; +import { AuthService } from '../auth/auth.service'; +import { + AuthToken, + CurrentUserOwnResource, + Guard, + ResourceId, + ResourceOwnerIdGetter, +} from '../auth/guard.decorator'; +import { UserId } from '../auth/user-id.decorator'; +import { BaseResponseDto } from '../common/DTO/base-response.dto'; +import { PageDto } from '../common/DTO/page.dto'; +import { BaseErrorExceptionFilter } from '../common/error/error-filter'; +import { TokenValidateInterceptor } from '../common/interceptor/token-validate.interceptor'; +import { CreateCommentResponseDto } from './DTO/create-comment.dto'; +import { GetCommentDetailResponseDto } from './DTO/get-comment-detail.dto'; +import { GetCommentsResponseDto } from './DTO/get-comments.dto'; +import { UpdateCommentDto } from './DTO/update-comment.dto'; +import { CommentsService } from './comment.service'; +import { parseCommentable } from './commentable.enum'; +@Controller('/comments') +export class CommentsController { + constructor( + private readonly commentsService: CommentsService, + private readonly authService: AuthService, + ) {} + + @ResourceOwnerIdGetter('comment') + async getCommentOwner(commentId: number): Promise { + return await this.commentsService.getCommentCreatedById(commentId); + } + + @Get('/:commentableType/:commentableId') + @Guard('enumerate', 'comment') + async getComments( + @Param('commentableType') commentableType: string, + @Param('commentableId', ParseIntPipe) commentableId: number, + @Query() + { page_start: pageStart, page_size: pageSize }: PageDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + ): Promise { + const [comments, page] = await this.commentsService.getComments( + parseCommentable(commentableType), + commentableId, + pageStart, + pageSize, + userId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Get comments successfully', + data: { + comments, + page, + }, + }; + } + + //! The static route `/:commentId/attitudes` should come + //! before the dynamic route `/:commentableType/:commentableId` + //! so that it is not overridden. + @Post('/:commentId/attitudes') + @Guard('attitude', 'comment') + async updateAttitudeToComment( + @Param('commentId', ParseIntPipe) @ResourceId() commentId: number, + @Body() { attitude_type: attitudeType }: AttitudeTypeDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + const attitudes = await this.commentsService.setAttitudeToComment( + commentId, + userId, + attitudeType, + ); + return { + code: 200, + message: 'You have expressed your attitude towards the comment', + data: { + attitudes, + }, + }; + } + + @Post('/:commentableType/:commentableId') + @Guard('create', 'comment') + @CurrentUserOwnResource() + async createComment( + @Param('commentableType') + commentableType: string, + @Param('commentableId', ParseIntPipe) commentableId: number, + @Body('content') content: string, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + const commentId = await this.commentsService.createComment( + parseCommentable(commentableType), + commentableId, + content, + userId, + ); + return { + code: 201, + message: 'Comment created successfully', + data: { + id: commentId, + }, + }; + } + + @Delete('/:commentId') + @Guard('delete', 'comment') + async deleteComment( + @Param('commentId', ParseIntPipe) @ResourceId() commentId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + await this.commentsService.deleteComment(commentId, userId); + } + + @Get('/:commentId') + @Guard('query', 'comment') + async getCommentDetail( + @Param('commentId', ParseIntPipe) @ResourceId() commentId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + ): Promise { + const comment = await this.commentsService.getCommentDto( + commentId, + userId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Details are as follows', + data: { + comment, + }, + }; + } + + @Patch('/:commentId') + @Guard('modify', 'comment') + async updateComment( + @Param('commentId', ParseIntPipe) @ResourceId() commentId: number, + @Body() { content }: UpdateCommentDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + await this.commentsService.updateComment(commentId, content); + return { + code: 200, + message: 'Comment updated successfully', + }; + } +} diff --git a/src/comments/comment.error.ts b/src/comments/comment.error.ts new file mode 100644 index 00000000..a48701cd --- /dev/null +++ b/src/comments/comment.error.ts @@ -0,0 +1,31 @@ +import { CommentCommentabletypeEnum } from '@prisma/client'; +import { BaseError } from '../common/error/base-error'; + +export class CommentableNotFoundError extends BaseError { + constructor( + public readonly commentableType: CommentCommentabletypeEnum, + public readonly commentableId: number, + ) { + super( + 'CommentableNotFoundError', + `${commentableType} ${commentableId} not found`, + 404, + ); + } +} + +export class InvalidCommentableTypeError extends BaseError { + constructor(public readonly commentableType: string) { + super( + 'InvalidCommentableTypeError', + `Invalid commentable type: ${commentableType}`, + 400, + ); + } +} + +export class CommentNotFoundError extends BaseError { + constructor(public readonly commentId: number) { + super('CommentNotFoundError', `Comment ID ${commentId} not found`, 404); + } +} diff --git a/src/comments/comment.module.ts b/src/comments/comment.module.ts new file mode 100644 index 00000000..1a380a95 --- /dev/null +++ b/src/comments/comment.module.ts @@ -0,0 +1,23 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { AnswerModule } from '../answer/answer.module'; +import { AttitudeModule } from '../attitude/attitude.module'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../common/prisma/prisma.module'; +import { QuestionsModule } from '../questions/questions.module'; +import { UsersModule } from '../users/users.module'; +import { CommentsController } from './comment.controller'; +import { CommentsService } from './comment.service'; +@Module({ + imports: [ + AuthModule, + UsersModule, + QuestionsModule, + forwardRef(() => AnswerModule), + AttitudeModule, + PrismaModule, + ], + controllers: [CommentsController], + providers: [CommentsService], + exports: [CommentsService], +}) +export class CommentsModule {} diff --git a/src/comments/comment.prisma b/src/comments/comment.prisma new file mode 100644 index 00000000..206f2cad --- /dev/null +++ b/src/comments/comment.prisma @@ -0,0 +1,53 @@ +import { User } from "../users/users" + +enum CommentCommentabletypeEnum { + ANSWER + COMMENT + QUESTION +} + +model Comment { + id Int @id(map: "PK_0b0e4bbc8415ec426f87f3a88e2") @default(autoincrement()) + commentableType CommentCommentabletypeEnum @map("commentable_type") + commentableId Int @map("commentable_id") + content String + createdById Int @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamp(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamp(6) + user User @relation(fields: [createdById], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_63ac916757350d28f05c5a6a4ba") + commentDeleteLog CommentDeleteLog[] + commentQueryLog CommentQueryLog[] + + @@index([commentableId], map: "IDX_525212ea7a75cba69724e42303") + @@index([createdById], map: "IDX_63ac916757350d28f05c5a6a4b") + @@map("comment") +} + +model CommentDeleteLog { + id Int @id(map: "PK_429889b4bdc646cb80ef8bc1814") @default(autoincrement()) + commentId Int @map("comment_id") + operatedById Int @map("operated_by_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User @relation(fields: [operatedById], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_53f0a8befcc12c0f7f2bab7584d") + comment Comment @relation(fields: [commentId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_66705ce7d7908554cff01b260ec") + + @@index([operatedById], map: "IDX_53f0a8befcc12c0f7f2bab7584") + @@index([commentId], map: "IDX_66705ce7d7908554cff01b260e") + @@map("comment_delete_log") +} + +model CommentQueryLog { + id Int @id(map: "PK_afbfb3d92cbf55c99cb6bdcd58f") @default(autoincrement()) + commentId Int @map("comment_id") + viewerId Int? @map("viewer_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + comment Comment @relation(fields: [commentId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_4020ff7fcffb2737e990f8bde5e") + user User? @relation(fields: [viewerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_4ead8566a6fa987264484b13d54") + + @@index([commentId], map: "IDX_4020ff7fcffb2737e990f8bde5") + @@index([viewerId], map: "IDX_4ead8566a6fa987264484b13d5") + @@map("comment_query_log") +} diff --git a/src/comments/comment.service.ts b/src/comments/comment.service.ts new file mode 100644 index 00000000..d313f3c8 --- /dev/null +++ b/src/comments/comment.service.ts @@ -0,0 +1,272 @@ +import { Inject, Injectable, forwardRef } from '@nestjs/common'; +import { + AttitudableType, + AttitudeType, + Comment, + CommentCommentabletypeEnum, +} from '@prisma/client'; +import { AnswerService } from '../answer/answer.service'; +import { AttitudeStateDto } from '../attitude/DTO/attitude-state.dto'; +import { AttitudeService } from '../attitude/attitude.service'; +import { PageDto } from '../common/DTO/page-response.dto'; +import { PageHelper } from '../common/helper/page.helper'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { QuestionsService } from '../questions/questions.service'; +import { UsersService } from '../users/users.service'; +import { CommentDto } from './DTO/comment.dto'; +import { + CommentNotFoundError, + CommentableNotFoundError, +} from './comment.error'; + +@Injectable() +export class CommentsService { + constructor( + private readonly attitudeService: AttitudeService, + private readonly usersService: UsersService, + @Inject(forwardRef(() => AnswerService)) + private readonly answerService: AnswerService, + private readonly questionService: QuestionsService, + private readonly prismaService: PrismaService, + ) {} + + private async ensureCommentableExists( + commentableType: CommentCommentabletypeEnum, + commentableId: number, + ): Promise { + switch (commentableType) { + case CommentCommentabletypeEnum.ANSWER: + if ( + (await this.answerService.isAnswerExistsAcrossQuestions( + commentableId, + )) == false + ) + throw new CommentableNotFoundError(commentableType, commentableId); + break; + case CommentCommentabletypeEnum.COMMENT: + if ((await this.isCommentExists(commentableId)) == false) + throw new CommentableNotFoundError(commentableType, commentableId); + break; + case CommentCommentabletypeEnum.QUESTION: + if ( + (await this.questionService.isQuestionExists(commentableId)) == false + ) + throw new CommentableNotFoundError(commentableType, commentableId); + break; + default: + throw new Error( + `CommentService.ensureCommentableExists() does not support commentable type ${commentableType}`, + ); + } + } + + async findCommentOrThrow(commentId: number): Promise { + const comment = await this.prismaService.comment.findUnique({ + where: { + id: commentId, + }, + }); + if (comment == null) throw new CommentNotFoundError(commentId); + return comment; + } + + async isCommentExists(commentId: number): Promise { + const count = await this.prismaService.comment.count({ + where: { + id: commentId, + }, + }); + return count > 0; + } + + async createComment( + commentableType: CommentCommentabletypeEnum, + commentableId: number, + content: string, + createdById: number, + ): Promise { + await this.ensureCommentableExists(commentableType, commentableId); + const result = await this.prismaService.comment.create({ + data: { + commentableType, + commentableId, + content, + createdById, + }, + }); + return result.id; + } + + async deleteComment(commentId: number, operatedById: number): Promise { + if ((await this.isCommentExists(commentId)) == false) + throw new CommentNotFoundError(commentId); + await this.prismaService.comment.update({ + where: { + id: commentId, + }, + data: { + deletedAt: new Date(), + }, + }); + await this.prismaService.commentDeleteLog.create({ + data: { + commentId, + operatedById, + }, + }); + } + + async getCommentDto( + commentId: number, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise { + const comment = await this.findCommentOrThrow(commentId); + await this.prismaService.commentQueryLog.create({ + data: { + commentId, + viewerId, + ip, + userAgent, + }, + }); + return { + id: comment.id, + content: comment.content, + commentable_id: comment.commentableId, + commentable_type: comment.commentableType, + user: await this.usersService.getUserDtoById( + comment.createdById, + viewerId, + ip, + userAgent, + ), + created_at: comment.createdAt.getTime(), + attitudes: await this.attitudeService.getAttitudeStatusDto( + AttitudableType.COMMENT, + commentId, + viewerId, + ), + }; + } + + async getComments( + commentableType: CommentCommentabletypeEnum, + commentableId: number, + pageStart: number | undefined, + pageSize: number = 20, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise<[CommentDto[], PageDto]> { + if (pageStart == undefined) { + const comments = await this.prismaService.comment.findMany({ + where: { + commentableType, + commentableId, + }, + orderBy: { + createdAt: 'desc', + }, + take: pageSize + 1, + }); + const commentDtos = await Promise.all( + comments.map((comment) => + this.getCommentDto(comment.id, viewerId, ip, userAgent), + ), + ); + return PageHelper.PageStart(commentDtos, pageSize, (i) => i.id); + } else { + const start = await this.findCommentOrThrow(pageStart); + const prev = await this.prismaService.comment.findMany({ + where: { + commentableType, + commentableId, + createdAt: { + gt: start.createdAt, + }, + }, + orderBy: { + createdAt: 'asc', + }, + take: pageSize, + }); + const curr = await this.prismaService.comment.findMany({ + where: { + commentableType, + commentableId, + createdAt: { + lte: start.createdAt, + }, + }, + orderBy: { + createdAt: 'desc', + }, + take: pageSize + 1, + }); + const currDtos = await Promise.all( + curr.map((comment) => + this.getCommentDto(comment.id, viewerId, ip, userAgent), + ), + ); + return PageHelper.PageMiddle( + prev, + currDtos, + pageSize, + (i) => i.id, + (i) => i.id, + ); + } + } + + async setAttitudeToComment( + commentId: number, + userId: number, + attitudeType: AttitudeType, + ): Promise { + if ((await this.isCommentExists(commentId)) == false) + throw new CommentNotFoundError(commentId); + await this.attitudeService.setAttitude( + userId, + AttitudableType.COMMENT, + commentId, + attitudeType, + ); + return await this.attitudeService.getAttitudeStatusDto( + AttitudableType.COMMENT, + commentId, + userId, + ); + } + + async getCommentCreatedById(commentId: number): Promise { + const comment = await this.findCommentOrThrow(commentId); + return comment.createdById; + } + + async updateComment(commentId: number, content: string): Promise { + if ((await this.isCommentExists(commentId)) == false) + throw new CommentNotFoundError(commentId); + await this.prismaService.comment.update({ + where: { + id: commentId, + }, + data: { + content, + }, + }); + } + + async countCommentsByCommentable( + commentableType: CommentCommentabletypeEnum, + commentableId: number, + ): Promise { + return await this.prismaService.comment.count({ + where: { + commentableType, + commentableId, + }, + }); + } +} diff --git a/src/comments/commentable.enum.ts b/src/comments/commentable.enum.ts new file mode 100644 index 00000000..6fa4f86d --- /dev/null +++ b/src/comments/commentable.enum.ts @@ -0,0 +1,18 @@ +import { CommentCommentabletypeEnum } from '@prisma/client'; +import { InvalidCommentableTypeError } from './comment.error'; + +export function parseCommentable( + commentable: string, +): CommentCommentabletypeEnum { + commentable = commentable.toUpperCase(); + switch (commentable) { + case 'ANSWER': + return CommentCommentabletypeEnum.ANSWER; + case 'COMMENT': + return CommentCommentabletypeEnum.COMMENT; + case 'QUESTION': + return CommentCommentabletypeEnum.QUESTION; + default: + throw new InvalidCommentableTypeError(commentable); + } +} diff --git a/src/common/DTO/base-response.dto.ts b/src/common/DTO/base-response.dto.ts new file mode 100644 index 00000000..414a6213 --- /dev/null +++ b/src/common/DTO/base-response.dto.ts @@ -0,0 +1,23 @@ +/* + * Description: This file defines the base respond DTO. + * All the respond DTOs should extend this class. + * + * Author(s): + * Nictheboy Li + * + */ + +import { IsInt, IsString } from 'class-validator'; + +export class BaseResponseDto { + constructor(code: number, message: string) { + this.code = code; + this.message = message; + } + + @IsInt() + code: number; + + @IsString() + message: string; +} diff --git a/src/common/DTO/page-response.dto.ts b/src/common/DTO/page-response.dto.ts new file mode 100644 index 00000000..bca51bc5 --- /dev/null +++ b/src/common/DTO/page-response.dto.ts @@ -0,0 +1,29 @@ +/* + * Description: This file defines the DTO for paged respond. + * + * Author(s): + * Nictheboy Li + * + */ + +import { IsNumber } from 'class-validator'; + +export class PageDto { + @IsNumber() + page_start: number; + + @IsNumber() + page_size: number; + + @IsNumber() + has_prev: boolean; + + @IsNumber() + prev_start?: number; + + @IsNumber() + has_more: boolean; + + @IsNumber() + next_start?: number; +} diff --git a/src/common/DTO/page.dto.ts b/src/common/DTO/page.dto.ts new file mode 100644 index 00000000..4abfb3a8 --- /dev/null +++ b/src/common/DTO/page.dto.ts @@ -0,0 +1,24 @@ +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional } from 'class-validator'; +import { GroupQueryType } from '../../groups/groups.service'; + +export class PageDto { + @IsOptional() + @IsInt() + @Type(() => Number) + page_start?: number; + + @IsOptional() + @IsInt() + @Type(() => Number) + page_size: number = 20; +} + +export class PageWithKeywordDto extends PageDto { + q: string; +} + +export class GroupPageDto extends PageWithKeywordDto { + @IsEnum(GroupQueryType) + type: GroupQueryType = GroupQueryType.Recommend; +} diff --git a/src/common/config/configuration.ts b/src/common/config/configuration.ts new file mode 100644 index 00000000..25d97d14 --- /dev/null +++ b/src/common/config/configuration.ts @@ -0,0 +1,57 @@ +import { ConfigService } from '@nestjs/config'; +import { ElasticsearchModuleOptions } from '@nestjs/elasticsearch'; + +export default () => { + return { + port: parseInt(process.env.PORT || '3000', 10), + elasticsearch: { + node: process.env.ELASTICSEARCH_NODE, + maxRetries: parseInt(process.env.ELASTICSEARCH_MAX_RETRIES || '3', 10), + requestTimeout: parseInt( + process.env.ELASTICSEARCH_REQUEST_TIMEOUT || '30000', + 10, + ), + pingTimeout: parseInt( + process.env.ELASTICSEARCH_PING_TIMEOUT || '30000', + 10, + ), + sniffOnStart: process.env.ELASTICSEARCH_SNIFF_ON_START === 'true', + auth: { + username: process.env.ELASTICSEARCH_AUTH_USERNAME, + password: process.env.ELASTICSEARCH_AUTH_PASSWORD, + }, + }, + jwt: { + secret: process.env.JWT_SECRET, + // expiresIn: process.env.JWT_EXPIRES_IN, + }, + cookieBasePath: process.env.COOKIE_BASE_PATH || '/', + frontendBaseUrl: process.env.FRONTEND_BASE_URL || '', + passwordResetPath: + process.env.PASSWORD_RESET_PREFIX || + '/account/recover/password/verify?token=', + webauthn: { + rpName: process.env.WEB_AUTHN_RP_NAME || 'Cheese Community', + rpID: process.env.WEB_AUTHN_RP_ID || 'localhost', + origin: process.env.WEB_AUTHN_ORIGIN || 'http://localhost:7777', + }, + totp: { + appName: process.env.APP_NAME || 'Cheese Community', + encryptionKey: process.env.TOTP_ENCRYPTION_KEY || process.env.APP_KEY, + backupCodesCount: parseInt( + process.env.TOTP_BACKUP_CODES_COUNT || '10', + 10, + ), + window: parseInt(process.env.TOTP_WINDOW || '1', 10), // 验证窗口,默认前后1个时间窗口 + }, + }; +}; + +export function elasticsearchConfigFactory( + configService: ConfigService, +): ElasticsearchModuleOptions { + const config = configService.get('elasticsearch'); + if (config == undefined) + throw new Error('Elasticsearch configuration not found'); + return config; +} diff --git a/src/common/config/elasticsearch.module.ts b/src/common/config/elasticsearch.module.ts new file mode 100644 index 00000000..100afb96 --- /dev/null +++ b/src/common/config/elasticsearch.module.ts @@ -0,0 +1,9 @@ +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ElasticsearchModule } from '@nestjs/elasticsearch'; +import { elasticsearchConfigFactory } from './configuration'; + +export const ConfiguredElasticsearchModule = ElasticsearchModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: elasticsearchConfigFactory, +}); diff --git a/src/common/config/x-forwarded-for.middleware.ts b/src/common/config/x-forwarded-for.middleware.ts new file mode 100644 index 00000000..b6941ffc --- /dev/null +++ b/src/common/config/x-forwarded-for.middleware.ts @@ -0,0 +1,18 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +@Injectable() +export class XForwardedForMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction) { + const xForwardedFor = req.headers['x-forwarded-for']; + const ip = + (Array.isArray(xForwardedFor) ? xForwardedFor[0] : xForwardedFor) || + req.connection.remoteAddress; + Object.defineProperty(req, 'ip', { + value: ip, + writable: false, + configurable: false, + }); + next(); + } +} diff --git a/src/common/error/base-error.ts b/src/common/error/base-error.ts new file mode 100644 index 00000000..656ef3a1 --- /dev/null +++ b/src/common/error/base-error.ts @@ -0,0 +1,29 @@ +/* + * Description: This file defines the base error class. + * + * All client errors, which is defined as errors caused by improper input, + * should extend this class, because the error handler + * will check if the error is an instance of BaseError, and if so, + * it will return the error message and status code to the client. + * Otherwise, it will return a generic error message and status code 500. + * + * However, if the error is thrown because of an impossible situation, + * which indicates that there might be a bug in the code, curruption + * in the database, or even a security issue, then the error should + * not extends this class, because the error handler will not log the + * client error to the console, but it will log all other errors. + * + * Author(s): + * Nictheboy Li + * + */ + +export class BaseError extends Error { + constructor( + public readonly name: string, // The name of the error, e.g. 'InvalidTokenError' + public readonly message: string, // The message of the error, e.g. 'Invalid token' + public readonly statusCode: number, // The HTTP status code of the error, e.g. 401 + ) { + super(message); + } +} diff --git a/src/common/error/error-filter.ts b/src/common/error/error-filter.ts new file mode 100644 index 00000000..ecf72d2b --- /dev/null +++ b/src/common/error/error-filter.ts @@ -0,0 +1,64 @@ +/* + * Description: This file defines the error filter. + * It handles the errors unhandled by the controllers + * + * Author(s): + * Nictheboy Li + * + */ + +import { + ArgumentsHost, + BadRequestException, + Catch, + ExceptionFilter, + Logger, +} from '@nestjs/common'; +import { Response } from 'express'; +import { BaseError } from './base-error'; + +/* + +Usage: + +@Controller('/...') +@UseFilters(new BaseErrorExceptionFilter()) +export class YourControllerClass() { + ... +} + +See users.controller.ts as an example. + +*/ + +@Catch() +export class BaseErrorExceptionFilter implements ExceptionFilter { + catch(exception: Error, host: ArgumentsHost) { + // console.log(exception); // for debug + const context = host.switchToHttp(); + const response = context.getResponse(); + if (exception instanceof BaseError) { + const status = exception.statusCode; + response.status(status).json({ + code: status, + message: `${exception.name}: ${exception.message}`, + }); + } else { + /* istanbul ignore else */ + // Above is a hint for istanbul to ignore the else branch + // where error is logged and 'Internal Server Error' is returned. + if (exception instanceof BadRequestException) { + response.status(400).json({ + code: 400, + message: `${exception.name}: ${exception.message}`, + }); + } else { + Logger.error(exception.stack); + response.status(500).json({ + code: 500, + message: 'Internal Server Error', + }); + } + } + } +} diff --git a/src/common/helper/file.helper.ts b/src/common/helper/file.helper.ts new file mode 100644 index 00000000..7e8ce511 --- /dev/null +++ b/src/common/helper/file.helper.ts @@ -0,0 +1,26 @@ +import crypto from 'crypto'; +import * as fs from 'fs'; +import mime from 'mime-types'; + +export function getFileHash(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + stream + .on('data', (data) => { + hash.update(data); + }) + .on('end', () => { + resolve(hash.digest('hex')); + stream.destroy(); + }) + .on('error', (error) => { + reject(error); + stream.destroy(); + }); + }); +} + +export async function getFileMimeType(filePath: string): Promise { + return mime.lookup(filePath) || 'application/octet-stream'; +} diff --git a/src/common/helper/page.helper.ts b/src/common/helper/page.helper.ts new file mode 100644 index 00000000..18946f12 --- /dev/null +++ b/src/common/helper/page.helper.ts @@ -0,0 +1,152 @@ +/* + * Description: This file implements the PageHelper class. + * Its an utility class for generating the PageResponseDto data. + * + * Author(s): + * Nictheboy Li + * + */ + +import { PageDto } from '../DTO/page-response.dto'; + +export class PageHelper { + // Used when you do + // + // SELECT ... FROM ... + // WHERE ... + // AND id >= (firstId) + // LIMIT (pageSize + 1) + // ORDER BY id ASC + // + // in SQL. + static PageStart( + data: TData[], + pageSize: number, + idGetter: (item: TData) => number, + ): [TData[], PageDto] { + return PageHelper.PageInternal(data, pageSize, false, 0, idGetter); + } + + // Used when you do both + // + // SELECT ... FROM ... + // WHERE ... + // AND id < (firstId) + // LIMIT (pageSize) + // ORDER BY id DESC + // + // and + // + // SELECT ... FROM ... + // WHERE ... + // AND id >= (firstId) + // LIMIT (pageSize + 1) + // ORDER BY id ASC + // + // in SQL. + static PageMiddle( + prev: TPrev[], + data: TData[], + pageSize: number, + idGetterPrev: (item: TPrev) => number, + idGetter: (item: TData) => number, + ): [TData[], PageDto] { + let has_prev = false; + let prev_start = 0; + if (prev.length > 0) { + has_prev = true; + // Since prev.length > 0, prev.at(-1) is not undefined. + prev_start = idGetterPrev(prev.at(-1)!); + } + return PageHelper.PageInternal( + data, + pageSize, + has_prev, + prev_start, + idGetter, + ); + } + + // Used when you do + // + // SELECT ... FROM ... + // WHERE ... + // LIMIT 1000 + // ORDER BY id ASC + // + // in SQL. + static PageFromAll( + allData: TData[], + pageStart: number | undefined, + pageSize: number, + idGetter: (item: TData) => number, + // nullable + // Something like '() => { throw new TopicNotFoundError(pageStart); }' + // If pageStart is not found in allData, this function will be called. + errorIfNotFound?: (pageStart: number) => void, + ): [TData[], PageDto] { + if (pageStart == undefined) { + const data = allData.slice(0, pageSize + 1); + return PageHelper.PageStart(data, pageSize, idGetter); + } else { + const pageStartIndex = allData.findIndex((r) => idGetter(r) == pageStart); + if (pageStartIndex == -1) { + /* istanbul ignore if */ + // Above is a hint for istanbul to ignore this if-statement. + if (errorIfNotFound == undefined) + return this.PageStart([], pageSize, idGetter); + else errorIfNotFound(pageStart); + } + const prev = allData.slice(0, pageStartIndex).slice(-pageSize).reverse(); + const data = allData.slice(pageStartIndex, pageStartIndex + pageSize + 1); + return PageHelper.PageMiddle(prev, data, pageSize, idGetter, idGetter); + } + } + + private static PageInternal( + data: TData[], + pageSize: number, + hasPrev: boolean, + prevStart: number, + idGetter: (item: TData) => number, + ): [TData[], PageDto] { + if (data.length == 0 || pageSize < 0) { + return [ + [], + { + page_start: 0, + page_size: 0, + has_prev: hasPrev, + prev_start: prevStart, + has_more: false, + next_start: 0, + }, + ]; + } else if (data.length > pageSize) { + return [ + data.slice(0, pageSize), + { + page_start: idGetter(data[0]), + page_size: pageSize, + has_prev: hasPrev, + prev_start: prevStart, + has_more: true, + // Since data.length > pageSize >= 0, data.at(-1) is not undefined. + next_start: idGetter(data.at(-1)!), + }, + ]; + } else { + return [ + data, + { + page_start: idGetter(data[0]), + page_size: data.length, + has_prev: hasPrev, + prev_start: prevStart, + has_more: false, + next_start: 0, + }, + ]; + } + } +} diff --git a/src/common/helper/where.helper.ts b/src/common/helper/where.helper.ts new file mode 100644 index 00000000..f15ec88b --- /dev/null +++ b/src/common/helper/where.helper.ts @@ -0,0 +1,43 @@ +import { SortOrder, SortPattern } from '../pipe/parse-sort-pattern.pipe'; + +function isSortOrder(sort: SortPattern | SortOrder): sort is SortOrder { + return typeof sort === 'string'; +} + +export function getPrevWhereBySort( + sort: SortPattern, + cursor: { [key in keyof T]: any }, +) { + const prevWhere: any = {}; + for (const key in sort) { + if (isSortOrder(sort[key])) { + prevWhere[key] = + sort[key] === 'asc' ? { lt: cursor[key] } : { gt: cursor[key] }; + } else { + prevWhere[key] = getPrevWhereBySort( + sort[key] as SortPattern, + cursor[key], + ); + } + } + return prevWhere; +} + +export function getCurrWhereBySort( + sort: SortPattern, + cursor: { [key in keyof T]: any }, +) { + const currWhere: any = {}; + for (const key in sort) { + if (isSortOrder(sort[key])) { + currWhere[key] = + sort[key] === 'asc' ? { gte: cursor[key] } : { lte: cursor[key] }; + } else { + currWhere[key] = getCurrWhereBySort( + sort[key] as SortPattern, + cursor[key], + ); + } + } + return currWhere; +} diff --git a/src/common/interceptor/ensure-guard.interceptor.ts b/src/common/interceptor/ensure-guard.interceptor.ts new file mode 100644 index 00000000..ebc9f5d7 --- /dev/null +++ b/src/common/interceptor/ensure-guard.interceptor.ts @@ -0,0 +1,44 @@ +/* + * Description: An interceptor that will ensure the presence of a guard or NoAuth decorator. + * This interceptor is used globally to avoid forgetting to add a guard. + * + * Author(s): + * Nictheboy Li + * + */ + +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { ModuleRef, Reflector } from '@nestjs/core'; +import { Observable } from 'rxjs'; +import { AuthService } from '../../auth/auth.service'; +import { NoAuth } from './token-validate.interceptor'; +import { HAS_GUARD_DECORATOR_METADATA_KEY } from '../../auth/guard.decorator'; + +// See: https://docs.nestjs.com/interceptors +// See: https://stackoverflow.com/questions/63618612/nestjs-use-service-inside-interceptor-not-global-interceptor + +@Injectable() +export class EnsureGuardInterceptor implements NestInterceptor { + constructor(private readonly reflector: Reflector) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const noAuth = this.reflector.get(NoAuth, context.getHandler()); + const hasGuard = + this.reflector.get( + HAS_GUARD_DECORATOR_METADATA_KEY, + context.getHandler(), + ) ?? false; + /* istanbul ignore if */ + if (!hasGuard && !noAuth) { + throw new Error( + 'EnsureGuardInterceptor: Neither Guard nor NoAuth decorator found', + ); + } + return next.handle(); + } +} diff --git a/src/common/interceptor/token-validate.interceptor.ts b/src/common/interceptor/token-validate.interceptor.ts new file mode 100644 index 00000000..83778195 --- /dev/null +++ b/src/common/interceptor/token-validate.interceptor.ts @@ -0,0 +1,52 @@ +/* + * Description: An interceptor that will validate access token as long as there is one. + * + * Author(s): + * Nictheboy Li + * + */ + +/* + * Use this interceptor in all controllers to validate the token. This is due to a need of the front-end: + * + * "Some endpoints can still be accessed without a login, but their response is related to the currently logged in user. + * In this case it should always be verified that the token carried by the request has not expired." -- HuanCheng65@Github + * + * If the back-end don't do so, then the front-end will get no error and a respond same as the one user will get with out logging in. + * Our solution is to always validate access token as long as there is one in the http header. + * + * See: https://github.com/SageSeekerSociety/cheese-backend/issues/85 + * + */ + +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Observable } from 'rxjs'; +import { AuthService } from '../../auth/auth.service'; + +// See: https://docs.nestjs.com/interceptors +// See: https://stackoverflow.com/questions/63618612/nestjs-use-service-inside-interceptor-not-global-interceptor + +export const NoAuth = Reflector.createDecorator(); + +@Injectable() +export class TokenValidateInterceptor implements NestInterceptor { + constructor(private reflector: Reflector) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const noAuth = this.reflector.get(NoAuth, context.getHandler()); + if (noAuth) { + return next.handle(); + } + const token = context.switchToHttp().getRequest().headers['authorization']; + if (token != undefined) { + AuthService.instance.verify(token); + } + return next.handle(); + } +} diff --git a/src/common/pipe/parse-sort-pattern.pipe.spec.ts b/src/common/pipe/parse-sort-pattern.pipe.spec.ts new file mode 100644 index 00000000..55b7f1b4 --- /dev/null +++ b/src/common/pipe/parse-sort-pattern.pipe.spec.ts @@ -0,0 +1,27 @@ +import { ParseSortPatternPipe } from './parse-sort-pattern.pipe'; + +describe('ParseSortPatternPipePipe', () => { + it('should be defined', () => { + expect(new ParseSortPatternPipe()).toBeDefined(); + }); + it('should parse pattern correctly', () => { + const pipe = new ParseSortPatternPipe(); + expect(pipe.transform('field1,-field2')).toEqual({ + field1: 'asc', + field2: 'desc', + }); + expect(pipe.transform('+field1,-field2')).toEqual({ + field1: 'asc', + field2: 'desc', + }); + expect(pipe.transform('-field.subfield')).toEqual({ + field: { subfield: 'desc' }, + }); + }); + it('should throw an error when trying to sort by not allowed field', () => { + const pipe = new ParseSortPatternPipe({ allowedFields: ['field1'] }); + expect(() => pipe.transform('field1,-field2')).toThrow( + "Field 'field2' is not allowed to be sorted.", + ); + }); +}); diff --git a/src/common/pipe/parse-sort-pattern.pipe.ts b/src/common/pipe/parse-sort-pattern.pipe.ts new file mode 100644 index 00000000..8e23ffa1 --- /dev/null +++ b/src/common/pipe/parse-sort-pattern.pipe.ts @@ -0,0 +1,99 @@ +import { + ArgumentMetadata, + HttpStatus, + Injectable, + Optional, + PipeTransform, +} from '@nestjs/common'; +import { + ErrorHttpStatusCode, + HttpErrorByCode, +} from '@nestjs/common/utils/http-error-by-code.util'; + +export type SortOrder = 'asc' | 'desc'; + +export type SortPattern = { [key: string]: SortOrder | SortPattern }; + +export interface ParseSortPatternPipeOptions { + errorHttpStatusCode?: ErrorHttpStatusCode; + exceptionFactory?: (error: string) => any; + optional?: boolean; + allowedFields?: string[]; +} + +/** + * Parse sort pattern pipe + * Example1: ?sort=field1,-field2 => { field1: 'asc', field2: 'desc' } + * Example2: ?sort=+field1,-field2 => { field1: 'asc', field2: 'desc' } + * Example3: ?sort=-field.subfield => { 'field': { 'subfield': 'desc' } } + */ +@Injectable() +export class ParseSortPatternPipe + implements PipeTransform +{ + private readonly exceptionFactory: (error: string) => any; + + private static isNil(val: any): val is null | undefined { + return val === null || val === undefined; + } + + constructor( + @Optional() private readonly options?: ParseSortPatternPipeOptions, + ) { + this.options = this.options || {}; + const { errorHttpStatusCode = HttpStatus.BAD_REQUEST, exceptionFactory } = + this.options; + this.exceptionFactory = + exceptionFactory || + ((error) => new HttpErrorByCode[errorHttpStatusCode](error)); + } + + private static getSortPattern( + fields: string[], + order: SortOrder, + ): SortPattern | SortOrder { + if (fields.length === 0) { + return order; + } + const pattern = ParseSortPatternPipe.getSortPattern(fields.slice(1), order); + const result: SortPattern = {}; + result[fields[0]] = pattern; + return result; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + transform(value: string, _metadata?: ArgumentMetadata): SortPattern { + if (ParseSortPatternPipe.isNil(value) && this.options?.optional) { + return value; + } + try { + return this.parseSortPattern(value); + } catch (error) { + throw this.exceptionFactory(`Invalid sort pattern: ${error}`); + } + } + + isAllowedField(field: string): boolean { + return ( + !this.options?.allowedFields || this.options.allowedFields.includes(field) + ); + } + + parseSortPattern(value: string): SortPattern { + const sortPattern: SortPattern = {}; + if (value) { + const items = value.split(','); + for (const item of items) { + const order = item.startsWith('-') ? 'desc' : 'asc'; + const field = item.replace(/^[+-]/, ''); + if (!this.isAllowedField(field)) { + throw `Field '${field}' is not allowed to be sorted.`; + } + const fields = field.split('.'); + const pattern = ParseSortPatternPipe.getSortPattern(fields, order); + Object.assign(sortPattern, pattern); + } + } + return sortPattern; + } +} diff --git a/src/common/pipe/snake-case-to-camel-case.pipe.spec.ts b/src/common/pipe/snake-case-to-camel-case.pipe.spec.ts new file mode 100644 index 00000000..7d9db90e --- /dev/null +++ b/src/common/pipe/snake-case-to-camel-case.pipe.spec.ts @@ -0,0 +1,52 @@ +import { SnakeCaseToCamelCasePipe } from './snake-case-to-camel-case.pipe'; + +describe('SnakeCaseToCamelCasePipe', () => { + it('should be defined', () => { + expect(new SnakeCaseToCamelCasePipe()).toBeDefined(); + }); + it('should transform pattern correctly', () => { + const pipe = new SnakeCaseToCamelCasePipe(); + expect(pipe.transform('snake_case')).toBe('snakeCase'); + expect(pipe.transform('snake_case_long')).toBe('snakeCaseLong'); + expect(pipe.transform('snake')).toBe('snake'); + }); + it('should treat empty carefully', () => { + const pipe = new SnakeCaseToCamelCasePipe(); + expect(pipe.transform('')).toBe(''); + expect(pipe.transform(undefined)).toBe(''); + }); + it('should ignore prefix', () => { + const pipe = new SnakeCaseToCamelCasePipe({ + prefixIgnorePattern: 'prefix_', + }); + expect(pipe.transform('prefix_snake_case')).toBe('prefix_snakeCase'); + expect(pipe.transform('prefix_snake_case_long')).toBe( + 'prefix_snakeCaseLong', + ); + expect(pipe.transform('prefix_snake')).toBe('prefix_snake'); + }); + it('should ignore complex prefix regex pattern', () => { + const pipe = new SnakeCaseToCamelCasePipe({ prefixIgnorePattern: '[+-]' }); + expect(pipe.transform('+snake_case')).toBe('+snakeCase'); + expect(pipe.transform('-snake_case')).toBe('-snakeCase'); + expect(pipe.transform('+snake')).toBe('+snake'); + expect(pipe.transform('-snake')).toBe('-snake'); + }); + it('should ignore suffix', () => { + const pipe = new SnakeCaseToCamelCasePipe({ + suffixIgnorePattern: '_suffix', + }); + expect(pipe.transform('snake_case_suffix')).toBe('snakeCase_suffix'); + expect(pipe.transform('snake_case_long_suffix')).toBe( + 'snakeCaseLong_suffix', + ); + expect(pipe.transform('snake_suffix')).toBe('snake_suffix'); + }); + it('should ignore complex suffix regex pattern', () => { + const pipe = new SnakeCaseToCamelCasePipe({ suffixIgnorePattern: '[+-]' }); + expect(pipe.transform('snake_case+')).toBe('snakeCase+'); + expect(pipe.transform('snake_case-')).toBe('snakeCase-'); + expect(pipe.transform('snake+')).toBe('snake+'); + expect(pipe.transform('snake-')).toBe('snake-'); + }); +}); diff --git a/src/common/pipe/snake-case-to-camel-case.pipe.ts b/src/common/pipe/snake-case-to-camel-case.pipe.ts new file mode 100644 index 00000000..e237dd69 --- /dev/null +++ b/src/common/pipe/snake-case-to-camel-case.pipe.ts @@ -0,0 +1,39 @@ +import { PipeTransform } from '@nestjs/common'; + +interface SnakeCaseToCamelCasePipeParameters { + prefixIgnorePattern?: string; + suffixIgnorePattern?: string; +} + +export class SnakeCaseToCamelCasePipe + implements PipeTransform +{ + constructor(private readonly arg: SnakeCaseToCamelCasePipeParameters = {}) {} + transform(value: string | undefined): string { + // Generated by Gtihub Copilot + // Modified by nictheboy + let prefix = ''; + let suffix = ''; + if (this.arg.prefixIgnorePattern) { + prefix = + value?.match(new RegExp(`^${this.arg.prefixIgnorePattern}`))?.[0] ?? ''; + value = + value?.replace(new RegExp(`^${this.arg.prefixIgnorePattern}`), '') ?? + ''; + } + if (this.arg.suffixIgnorePattern) { + suffix = + value?.match(new RegExp(`${this.arg.suffixIgnorePattern}$`))?.[0] ?? ''; + value = + value?.replace(new RegExp(`${this.arg.suffixIgnorePattern}$`), '') ?? + ''; + } + return ( + prefix + + (value?.replace(/([-_][a-z])/g, (group) => + group.toUpperCase().replace('-', '').replace('_', ''), + ) ?? '') + + suffix + ); + } +} diff --git a/src/common/prisma/prisma.module.ts b/src/common/prisma/prisma.module.ts new file mode 100644 index 00000000..e0504e39 --- /dev/null +++ b/src/common/prisma/prisma.module.ts @@ -0,0 +1,18 @@ +/* + * Description: This file defines PrismaModule, the NestJs module that provides PrismaService. + * PrismaService provided by this module supports soft delete. + * + * + * Author(s): + * Nictheboy Li + * + */ + +import { Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/src/common/prisma/prisma.service.ts b/src/common/prisma/prisma.service.ts new file mode 100644 index 00000000..8f89947a --- /dev/null +++ b/src/common/prisma/prisma.service.ts @@ -0,0 +1,63 @@ +/* + * Description: This file defines PrismaService, which extends PrismaClient. + * Extensions are added to PrismaService to support soft delete. + * + * + * Author(s): + * Nictheboy Li + * + */ + +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { UseSoftDelete } from './soft-delete.decorator'; + +// Please register all models that support soft delete below. +// A model with soft delete must have a field named "deletedAt" of type "DateTime?" in the database. +@PrismaDecorator(UseSoftDelete('Answer')) +@PrismaDecorator(UseSoftDelete('Comment')) +@PrismaDecorator(UseSoftDelete('Group')) +@PrismaDecorator(UseSoftDelete('GroupProfile')) +@PrismaDecorator(UseSoftDelete('GroupMembership')) +@PrismaDecorator(UseSoftDelete('GroupQuestionRelationship')) +@PrismaDecorator(UseSoftDelete('Question')) +@PrismaDecorator(UseSoftDelete('QuestionTopicRelation')) +@PrismaDecorator(UseSoftDelete('QuestionFollowerRelation')) +@PrismaDecorator(UseSoftDelete('Topics')) +@PrismaDecorator(UseSoftDelete('User')) +@PrismaDecorator(UseSoftDelete('UserProfile')) +@PrismaDecorator(UseSoftDelete('UserFollowingRelationship')) +export class PrismaClientExtended extends PrismaClient { + withoutExtensions() { + return this; + } +} + +export function PrismaDecorator( + prismaWrapper: (client: PrismaClientExtended) => PrismaClientExtended, +) { + return ( + constructor: typeof PrismaClientExtended, + ): typeof PrismaClientExtended => { + const cls = class { + constructor() { + const innerClient = new constructor(); + const client = prismaWrapper(innerClient); + (client as any).withoutExtensions = () => + innerClient.withoutExtensions(); + return client; + } + }; + return cls as typeof PrismaClientExtended; + }; +} + +@Injectable() +export class PrismaService + extends PrismaClientExtended + implements OnModuleInit +{ + async onModuleInit() { + await this.$connect(); + } +} diff --git a/src/common/prisma/soft-delete.decorator.ts b/src/common/prisma/soft-delete.decorator.ts new file mode 100644 index 00000000..d6e617f9 --- /dev/null +++ b/src/common/prisma/soft-delete.decorator.ts @@ -0,0 +1,28 @@ +/* + * Description: An decorator that will extend prisma service to support soft delete + * This decorator is only used in src/common/prisma/prisma.service.ts + * + * Author(s): + * Nictheboy Li + * + */ + +import { PrismaClientExtended } from './prisma.service'; + +export function UseSoftDelete( + model: string, +): (p: PrismaClientExtended) => PrismaClientExtended { + model = model.charAt(0).toLowerCase() + model.slice(1); + return (client) => { + return client.$extends({ + query: { + [model]: { + $allOperations({ args, query }: any) { + if (args.where) args.where = { ...args.where, deletedAt: null }; + return query(args); + }, + }, + } as any, + }) as PrismaClientExtended; + }; +} diff --git a/src/email/email-rule.service.ts b/src/email/email-rule.service.ts new file mode 100644 index 00000000..7a53cba2 --- /dev/null +++ b/src/email/email-rule.service.ts @@ -0,0 +1,42 @@ +/* + * Description: This file implements the EmailRuleService class. + * It is used to determine whether a given email address is valid. + * + * Author(s): + * Nictheboy Li + * + */ + +// It should be noticed that although the check rules are written in code NOW, +// it is still a good practice to write them in the configuration file, or even database. +// We plan to do so in the future. + +import { Injectable } from '@nestjs/common'; +import { isEmail } from 'class-validator'; +import { InvalidEmailAddressError } from '../users/users.error'; +import { EmailPolicyViolationError } from './email.error'; + +@Injectable() +export class EmailRuleService { + constructor() {} + + // support only @ruc.edu.cn currently + readonly emailSuffix = '@ruc.edu.cn'; + + async isEmailSuffixSupported(email: string): Promise { + return email.endsWith(this.emailSuffix); + } + + get emailSuffixRule(): string { + return `Only ${this.emailSuffix} is supported currently.`; + } + + async emailPolicyEnsure(email: string): Promise { + if (isEmail(email) == false) throw new InvalidEmailAddressError(email); + + // Double check the email policy + // Although the email policy is checked in UsersService, it is still not a bad thing to check it here. + if ((await this.isEmailSuffixSupported(email)) == false) + throw new EmailPolicyViolationError(email); + } +} diff --git a/src/email/email.error.ts b/src/email/email.error.ts new file mode 100644 index 00000000..bede000c --- /dev/null +++ b/src/email/email.error.ts @@ -0,0 +1,16 @@ +/* + * Description: This file defines the errors related to email service. + * All the errors in this file should extend BaseError. + * + * Author(s): + * Nictheboy Li + * + */ + +import { BaseError } from '../common/error/base-error'; + +export class EmailPolicyViolationError extends BaseError { + constructor(public readonly email: string) { + super('EmailPolicyViolationError', `Email policy violation: ${email}`, 422); + } +} diff --git a/src/email/email.module.ts b/src/email/email.module.ts new file mode 100644 index 00000000..35e4362b --- /dev/null +++ b/src/email/email.module.ts @@ -0,0 +1,49 @@ +/* + * Description: This file defines the users module, which is used to send emails. + * + * Author(s): + * Nictheboy Li + * + */ + +import { MailerModule } from '@nestjs-modules/mailer'; +import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; +import { Module } from '@nestjs/common'; +import { join } from 'path'; +import { EmailRuleService } from './email-rule.service'; +import { EmailService } from './email.service'; +import { ConfigModule } from '@nestjs/config'; + +@Module({ + imports: [ + MailerModule.forRoot({ + //See: https://notiz.dev/blog/send-emails-with-nestjs + transport: { + host: process.env.EMAIL_SMTP_HOST, + port: parseInt( + process.env.EMAIL_SMTP_PORT || + (process.env.EMAIL_SMTP_SSL_ENABLE === 'true' ? '587' : '25'), + ), + secure: process.env.EMAIL_SMTP_SSL_ENABLE, + auth: { + user: process.env.EMAIL_SMTP_USERNAME, + pass: process.env.EMAIL_SMTP_PASSWORD, + }, + }, + defaults: { + from: process.env.EMAIL_DEFAULT_FROM, + }, + template: { + dir: join(__dirname, '..', 'resources', 'email-templates'), + adapter: new HandlebarsAdapter(), // or new PugAdapter() or new EjsAdapter() + options: { + strict: true, + }, + }, + }), + ConfigModule, + ], + providers: [EmailService, EmailRuleService], + exports: [EmailService, EmailRuleService], +}) +export class EmailModule {} diff --git a/src/email/email.service.ts b/src/email/email.service.ts new file mode 100644 index 00000000..708b7315 --- /dev/null +++ b/src/email/email.service.ts @@ -0,0 +1,54 @@ +/* + * Description: This file implements the EmailService class. + * It checks email address and then sends emails. + * + * Author(s): + * Nictheboy Li + * + */ + +import { MailerService } from '@nestjs-modules/mailer'; +import { Injectable } from '@nestjs/common'; +import { EmailRuleService } from './email-rule.service'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class EmailService { + constructor( + private readonly mailerService: MailerService, + private readonly emailRuleService: EmailRuleService, + private readonly configService: ConfigService, + ) {} + + async sendPasswordResetEmail( + email: string, + username: string, + token: string, + ): Promise { + await this.emailRuleService.emailPolicyEnsure(email); + await this.mailerService.sendMail({ + to: email, + subject: 'Password Reset', + template: './password-reset.english.hbs', + context: { + username, + resetUrl: + this.configService.get('frontendBaseUrl') + + this.configService.get('passwordResetPath') + + token, + }, + }); + } + + async sendRegisterCode(email: string, code: string): Promise { + await this.emailRuleService.emailPolicyEnsure(email); + await this.mailerService.sendMail({ + to: email, + subject: 'Register Code', + template: './register-code.english.hbs', + context: { + code, + }, + }); + } +} diff --git a/src/email/email.spec.ts b/src/email/email.spec.ts new file mode 100644 index 00000000..1b497bcc --- /dev/null +++ b/src/email/email.spec.ts @@ -0,0 +1,47 @@ +/* + * Description: This file provide unit tests for email module. + * + * It is NOT ENABLED by default, and can be enabled by setting EMAILTEST_ENABLE to true. + * You need to set EMAILTEST_RECEIVER to let the test know where to send the email. + * + * We recommend you to manually check whether you have received the email. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { AppModule } from '../app.module'; +import { EmailService } from './email.service'; + +if (process.env.EMAILTEST_ENABLE == 'true') { + const receiver = process.env.EMAILTEST_RECEIVER as string; + describe('Email Module', () => { + let app: TestingModule; + let emailService: EmailService; + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + emailService = app.get(EmailService); + }); + afterAll(async () => { + await app.close(); + }); + + it('should send a password reset email', async () => { + await emailService.sendPasswordResetEmail( + receiver, + 'test_username', + 'a_jwt_token_that_is_very_very_very_very_very_long', + ); + }); + + it('should send a register code email', async () => { + await emailService.sendRegisterCode(receiver, '123456'); + }); + }); +} else { + it('Email test is disabled', () => {}); +} diff --git a/src/groups/DTO/create-group.dto.ts b/src/groups/DTO/create-group.dto.ts new file mode 100644 index 00000000..8e9b2875 --- /dev/null +++ b/src/groups/DTO/create-group.dto.ts @@ -0,0 +1,12 @@ +import { IsInt, IsString } from 'class-validator'; + +export class CreateGroupDto { + @IsString() + readonly name: string; + + @IsString() + readonly intro: string; + + @IsInt() + readonly avatarId: number; +} diff --git a/src/groups/DTO/get-group-members.dto.ts b/src/groups/DTO/get-group-members.dto.ts new file mode 100644 index 00000000..c3b77dd4 --- /dev/null +++ b/src/groups/DTO/get-group-members.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { UserDto } from '../../users/DTO/user.dto'; + +export class GetGroupMembersResponseDto extends BaseResponseDto { + data: { + members: UserDto[]; + page?: PageDto; + }; +} diff --git a/src/groups/DTO/get-group-questions.dto.ts b/src/groups/DTO/get-group-questions.dto.ts new file mode 100644 index 00000000..34bdb5cb --- /dev/null +++ b/src/groups/DTO/get-group-questions.dto.ts @@ -0,0 +1,7 @@ +import { PageDto } from '../../common/DTO/page-response.dto'; +import { QuestionDto } from '../../questions/DTO/question.dto'; + +export class GetGroupQuestionsResultDto { + questions: QuestionDto[]; + page: PageDto; +} diff --git a/src/groups/DTO/get-groups.dto.ts b/src/groups/DTO/get-groups.dto.ts new file mode 100644 index 00000000..9907dbf0 --- /dev/null +++ b/src/groups/DTO/get-groups.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { GroupDto } from './group.dto'; + +export class GetGroupsResponseDto extends BaseResponseDto { + data: { + groups: GroupDto[]; + page: PageDto; + }; +} diff --git a/src/groups/DTO/get-members.dto.ts b/src/groups/DTO/get-members.dto.ts new file mode 100644 index 00000000..8b8f0220 --- /dev/null +++ b/src/groups/DTO/get-members.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { UserDto } from '../../users/DTO/user.dto'; + +export class GetGroupMembersResponseDto extends BaseResponseDto { + data: { + members: UserDto[]; + page: PageDto; + }; +} diff --git a/src/groups/DTO/get-questions.dto.ts b/src/groups/DTO/get-questions.dto.ts new file mode 100644 index 00000000..789a2169 --- /dev/null +++ b/src/groups/DTO/get-questions.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { QuestionDto } from '../../questions/DTO/question.dto'; + +export class GetGroupQuestionsResponseDto extends BaseResponseDto { + data: { + questions: QuestionDto[]; + page: PageDto; + }; +} diff --git a/src/groups/DTO/group.dto.ts b/src/groups/DTO/group.dto.ts new file mode 100644 index 00000000..446fec23 --- /dev/null +++ b/src/groups/DTO/group.dto.ts @@ -0,0 +1,24 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { UserDto } from '../../users/DTO/user.dto'; + +export class GroupDto { + id: number; + name: string; + intro: string; + avatarId: number; + owner: UserDto; + created_at: number; // timestamp + updated_at: number; // timestamp + member_count: number; + question_count: number; + answer_count: number; + is_member: boolean; + is_owner: boolean; + is_public: boolean; +} + +export class GroupResponseDto extends BaseResponseDto { + data: { + group: GroupDto; + }; +} diff --git a/src/groups/DTO/join-group.dto.ts b/src/groups/DTO/join-group.dto.ts new file mode 100644 index 00000000..f2c40c1c --- /dev/null +++ b/src/groups/DTO/join-group.dto.ts @@ -0,0 +1,17 @@ +import { IsString } from 'class-validator'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class JoinGroupDto { + @IsString() + readonly intro: string; +} + +export class JoinGroupResultDto { + member_count: number; + is_member: boolean; + is_waiting: boolean; +} + +export class JoinGroupResponseDto extends BaseResponseDto { + data: JoinGroupResultDto; +} diff --git a/src/groups/DTO/quit-group.dto.ts b/src/groups/DTO/quit-group.dto.ts new file mode 100644 index 00000000..997d28ec --- /dev/null +++ b/src/groups/DTO/quit-group.dto.ts @@ -0,0 +1,7 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class QuitGroupResponseDto extends BaseResponseDto { + data: { + member_count: number; + }; +} diff --git a/src/groups/DTO/update-group.dto.ts b/src/groups/DTO/update-group.dto.ts new file mode 100644 index 00000000..6f9c9eba --- /dev/null +++ b/src/groups/DTO/update-group.dto.ts @@ -0,0 +1,14 @@ +import { IsInt, IsString } from 'class-validator'; + +export class UpdateGroupDto { + @IsString() + readonly name: string; + + @IsString() + readonly intro: string; + + @IsInt() + readonly avatarId: number; + + // todo: add cover +} diff --git a/src/groups/groups.controller.ts b/src/groups/groups.controller.ts new file mode 100644 index 00000000..51efb106 --- /dev/null +++ b/src/groups/groups.controller.ts @@ -0,0 +1,267 @@ +/* + * Description: This file implements the groups controller. + * It is responsible for handling the requests to /groups/... + * + * Author(s): + * Andy Lee + * + */ + +import { + Body, + Controller, + Delete, + Get, + Headers, + Ip, + Param, + ParseIntPipe, + Post, + Put, + Query, + UseFilters, + UseInterceptors, +} from '@nestjs/common'; +import { AuthService } from '../auth/auth.service'; +import { BaseResponseDto } from '../common/DTO/base-response.dto'; +import { GroupPageDto } from '../common/DTO/page.dto'; +import { BaseErrorExceptionFilter } from '../common/error/error-filter'; +import { + NoAuth, + TokenValidateInterceptor, +} from '../common/interceptor/token-validate.interceptor'; +import { CreateGroupDto } from './DTO/create-group.dto'; +import { GetGroupsResponseDto } from './DTO/get-groups.dto'; +import { GetGroupMembersResponseDto } from './DTO/get-members.dto'; +import { GetGroupQuestionsResponseDto } from './DTO/get-questions.dto'; +import { GroupResponseDto } from './DTO/group.dto'; +import { JoinGroupDto, JoinGroupResponseDto } from './DTO/join-group.dto'; +import { QuitGroupResponseDto } from './DTO/quit-group.dto'; +import { UpdateGroupDto } from './DTO/update-group.dto'; +import { GroupsService } from './groups.service'; + +@Controller('/groups') +@UseFilters(BaseErrorExceptionFilter) +@UseInterceptors(TokenValidateInterceptor) +export class GroupsController { + constructor( + private readonly authService: AuthService, + private readonly groupsService: GroupsService, + ) {} + + @Post('/') + @NoAuth() + async createGroup( + @Body() { name, intro, avatarId }: CreateGroupDto, + @Headers('Authorization') auth: string | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + const userId = this.authService.verify(auth).userId; + const group = await this.groupsService.createGroup( + name, + userId, + intro, + avatarId, + userId, + ip, + userAgent, + ); + return { + code: 201, + message: 'Group created successfully', + data: { group }, + }; + } + + @Get('/') + @NoAuth() + async getGroups( + @Query() { q: key, page_start, page_size, type }: GroupPageDto, + @Headers('Authorization') auth: string | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + let userId: number | undefined; + try { + userId = this.authService.verify(auth).userId; + } catch { + // The user is not logged in. + } + const [groups, page] = await this.groupsService.getGroups( + userId, + key, + page_start, + page_size, + type, + ip, + userAgent, + ); + return { + code: 200, + message: 'Groups fetched successfully.', + data: { + groups, + page, + }, + }; + } + + @Get('/:id') + @NoAuth() + async getGroupDetail( + @Param('id', ParseIntPipe) id: number, + @Headers('Authorization') auth: string | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + let userId: number | undefined; + try { + userId = this.authService.verify(auth).userId; + } catch { + // The user is not logged in. + } + const group = await this.groupsService.getGroupDtoById( + userId, + id, + ip, + userAgent, + ); + return { + code: 200, + message: 'Group fetched successfully.', + data: { group }, + }; + } + + @Put('/:id') + @NoAuth() + async updateGroup( + @Param('id', ParseIntPipe) id: number, + @Headers('Authorization') auth: string | undefined, + @Body() req: UpdateGroupDto, + ): Promise { + const userId = this.authService.verify(auth).userId; + await this.groupsService.updateGroup( + userId, + id, + req.name, + req.intro, + req.avatarId, + ); + return { + code: 200, + message: 'Group updated successfully.', + }; + } + + @Delete('/:id') + @NoAuth() + async deleteGroup( + @Param('id', ParseIntPipe) id: number, + @Headers('Authorization') auth: string | undefined, + ): Promise { + const userId = this.authService.verify(auth).userId; + await this.groupsService.deleteGroup(userId, id); + } + + @Get('/:id/members') + @NoAuth() + async getGroupMembers( + @Param('id', ParseIntPipe) id: number, + @Query() { page_start, page_size }: GroupPageDto, + @Headers('Authorization') auth: string | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + let userId: number | undefined; + try { + userId = this.authService.verify(auth).userId; + } catch { + // The user is not logged in. + } + const [members, page] = await this.groupsService.getGroupMembers( + id, + page_start, + page_size, + userId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Group members fetched successfully.', + data: { + members, + page, + }, + }; + } + + @Post('/:id/members') + @NoAuth() + async joinGroup( + @Param('id', ParseIntPipe) groupId: number, + @Body() { intro }: JoinGroupDto, + @Headers('Authorization') auth: string | undefined, + ): Promise { + const userId = this.authService.verify(auth).userId; + const joinResult = await this.groupsService.joinGroup( + userId, + groupId, + intro, + ); + return { + code: 201, + message: 'Joined group successfully.', + data: joinResult, + }; + } + + @Delete('/:id/members') + @NoAuth() + async quitGroup( + @Param('id', ParseIntPipe) groupId: number, + @Headers('Authorization') auth: string | undefined, + ): Promise { + const userId = this.authService.verify(auth).userId; + const member_count = await this.groupsService.quitGroup(userId, groupId); + return { + code: 200, + message: 'Quit group successfully.', + data: { member_count }, + }; + } + + @Get('/:id/questions') + @NoAuth() + async getGroupQuestions( + @Param('id', ParseIntPipe) id: number, + @Query() { page_start, page_size }: GroupPageDto, + @Headers('Authorization') auth: string | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + let userId: number | undefined; + try { + userId = this.authService.verify(auth).userId; + } catch { + // The user is not logged in. + } + const getGroupQuestionsResult = await this.groupsService.getGroupQuestions( + id, + page_start, + page_size, + userId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Group questions fetched successfully.', + data: getGroupQuestionsResult, + }; + } + + // todo: group targets +} diff --git a/src/groups/groups.error.ts b/src/groups/groups.error.ts new file mode 100644 index 00000000..a02884d2 --- /dev/null +++ b/src/groups/groups.error.ts @@ -0,0 +1,67 @@ +/* + * Description: This file defines the errors related to groups service. + * All the errors should extend `BaseError`. + * + * Author(s): + * Andy Lee + * + */ + +import { BaseError } from '../common/error/base-error'; + +export class InvalidGroupNameError extends BaseError { + constructor( + public readonly groupName: string, + public readonly rule: string, + ) { + super( + 'InvalidGroupNameError', + `Invalid group name ${groupName} since ${rule}`, + 422, + ); + } +} + +export class GroupNameAlreadyUsedError extends BaseError { + constructor(public readonly groupName: string) { + super( + 'GroupNameAlreadyUsedError', + `Group name ${groupName} already used`, + 409, + ); + } +} + +export class GroupNotFoundError extends BaseError { + constructor(public readonly groupId: number) { + super('GroupNotFoundError', `Group with id ${groupId} not found`, 404); + } +} + +export class CannotDeleteGroupError extends BaseError { + constructor(public readonly groupId: number) { + super('CannotDeleteGroupError', `Cannot delete group ${groupId}`, 403); + } +} + +export class GroupAlreadyJoinedError extends BaseError { + constructor(public readonly groupId: number) { + super('GroupAlreadyJoinedError', `Group ${groupId} already joined`, 409); + } +} + +export class GroupNotJoinedError extends BaseError { + constructor(public readonly groupId: number) { + super('GroupNotJoinedError', `Group ${groupId} not joined`, 409); + } +} + +export class GroupProfileNotFoundError extends BaseError { + constructor(public readonly groupId: number) { + super( + 'GroupProfileNotFoundError', + `Group ${groupId}'s profile not found`, + 404, + ); + } +} diff --git a/src/groups/groups.module.ts b/src/groups/groups.module.ts new file mode 100644 index 00000000..fb3883d0 --- /dev/null +++ b/src/groups/groups.module.ts @@ -0,0 +1,32 @@ +/* + * Description: This file defines the groups module. + * + * Author(s): + * Andy Lee + * + */ + +import { Module, forwardRef } from '@nestjs/common'; +import { AnswerModule } from '../answer/answer.module'; +import { AuthModule } from '../auth/auth.module'; +import { AvatarsModule } from '../avatars/avatars.module'; +import { PrismaModule } from '../common/prisma/prisma.module'; +import { QuestionsModule } from '../questions/questions.module'; +import { UsersModule } from '../users/users.module'; +import { GroupsController } from './groups.controller'; +import { GroupsService } from './groups.service'; + +@Module({ + imports: [ + AuthModule, + forwardRef(() => UsersModule), + forwardRef(() => QuestionsModule), + forwardRef(() => AnswerModule), + AvatarsModule, + PrismaModule, + ], + controllers: [GroupsController], + providers: [GroupsService], + exports: [GroupsService], +}) +export class GroupsModule {} diff --git a/src/groups/groups.prisma b/src/groups/groups.prisma new file mode 100644 index 00000000..4d7f141b --- /dev/null +++ b/src/groups/groups.prisma @@ -0,0 +1,80 @@ +import { User } from "../users/users" +import { Question } from "../questions/questions" +import { Avatar } from "../avatars/avatars" +import { Answer } from "../answer/answer" + +model Group { + id Int @id(map: "PK_256aa0fda9b1de1a73ee0b7106b") @default(autoincrement()) + name String @unique(map: "IDX_8a45300fd825918f3b40195fbd") @db.VarChar + createdAt DateTime @default(dbgenerated("('now'::text)::timestamp(3) with time zone")) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + answer Answer[] + groupMembership GroupMembership[] + groupProfile GroupProfile? + groupQuestionRelationship GroupQuestionRelationship[] + groupTarget GroupTarget[] + + @@map("group") +} + +model GroupMembership { + id Int @id(map: "PK_b631623cf04fa74513b975e7059") @default(autoincrement()) + groupId Int @map("group_id") + memberId Int @map("member_id") + role String @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + user User @relation(fields: [memberId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_7d88d00d8617a802b698c0cd609") + group Group @relation(fields: [groupId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_b1411f07fafcd5ad93c6ee16424") + + @@index([memberId], map: "IDX_7d88d00d8617a802b698c0cd60") + @@index([groupId], map: "IDX_b1411f07fafcd5ad93c6ee1642") + @@map("group_membership") +} + +model GroupProfile { + id Int @id(map: "PK_2a62b59d1bf8a3191c992e8daf4") @default(autoincrement()) + intro String @db.VarChar + avatarId Int? @map("avatar_id") + avatar Avatar? @relation(fields: [avatarId], references: [id]) + groupId Int @unique(map: "REL_7359ba99cc116d00cf74e048ed") @map("group_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + group Group @relation(fields: [groupId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_7359ba99cc116d00cf74e048edd") + + @@map("group_profile") +} + +model GroupQuestionRelationship { + id Int @id(map: "PK_47ee7be0b0f0e51727012382922") @default(autoincrement()) + groupId Int @map("group_id") + questionId Int @unique(map: "REL_5b1232271bf29d99456fcf39e7") @map("question_id") + createdAt DateTime @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + question Question @relation(fields: [questionId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_5b1232271bf29d99456fcf39e75") + group Group @relation(fields: [groupId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_b31bf3b3688ec41daaced89a0ab") + + @@index([groupId], map: "IDX_b31bf3b3688ec41daaced89a0a") + @@map("group_question_relationship") +} + +model GroupTarget { + id Int @id(map: "PK_f1671a42b347bd96ce6595f91ee") @default(autoincrement()) + groupId Int @map("group_id") + name String @db.VarChar + intro String @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + startedAt DateTime @map("started_at") @db.Date + endedAt DateTime @map("ended_at") @db.Date + attendanceFrequency String @map("attendance_frequency") @db.VarChar + group Group @relation(fields: [groupId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_19d57f140124c5100e8e1ca3088") + + @@index([groupId], map: "IDX_19d57f140124c5100e8e1ca308") + @@map("group_target") +} diff --git a/src/groups/groups.service.ts b/src/groups/groups.service.ts new file mode 100644 index 00000000..131c734d --- /dev/null +++ b/src/groups/groups.service.ts @@ -0,0 +1,755 @@ +/* + * Description: This file implements the groups service. + * It is responsible for the business logic of questions. + * + * Author(s): + * Andy Lee + * + */ + +import { + Inject, + Injectable, + Logger, + NotImplementedException, + forwardRef, +} from '@nestjs/common'; +import { Group, GroupProfile } from '@prisma/client'; +import { AnswerService } from '../answer/answer.service'; +import { AvatarNotFoundError } from '../avatars/avatars.error'; +import { AvatarsService } from '../avatars/avatars.service'; +import { PageDto } from '../common/DTO/page-response.dto'; +import { PageHelper } from '../common/helper/page.helper'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { QuestionNotFoundError } from '../questions/questions.error'; +import { QuestionsService } from '../questions/questions.service'; +import { UserDto } from '../users/DTO/user.dto'; +import { UserIdNotFoundError } from '../users/users.error'; +import { UsersService } from '../users/users.service'; +import { GetGroupQuestionsResultDto } from './DTO/get-group-questions.dto'; +import { GroupDto } from './DTO/group.dto'; +import { JoinGroupResultDto } from './DTO/join-group.dto'; +import { + CannotDeleteGroupError, + GroupAlreadyJoinedError, + GroupNameAlreadyUsedError, + GroupNotFoundError, + GroupNotJoinedError, + GroupProfileNotFoundError, + InvalidGroupNameError, +} from './groups.error'; + +export enum GroupQueryType { + Recommend = 'recommend', + Hot = 'hot', + New = 'new', +} + +@Injectable() +export class GroupsService { + constructor( + private usersService: UsersService, + @Inject(forwardRef(() => QuestionsService)) + private questionsService: QuestionsService, + @Inject(forwardRef(() => AnswerService)) + private answerService: AnswerService, + private avatarsService: AvatarsService, + private prismaService: PrismaService, + ) {} + + private isValidGroupName(name: string): boolean { + return /^[a-zA-Z0-9_\-\u4e00-\u9fa5]{1,16}$/.test(name); + } + + get groupNameRule(): string { + return 'Group display name must be 1-16 characters long and can only contain letters, numbers, underscores, hyphens and Chinese characters.'; + } + + async isGroupExists(groupId: number): Promise { + return ( + (await this.prismaService.group.findUnique({ + where: { + id: groupId, + }, + })) != undefined + ); + } + + async isGroupNameExists(groupName: string): Promise { + const ret = await this.prismaService.group.count({ + where: { + name: groupName, + }, + }); + if (ret > 1) + Logger.error(`Group name ${groupName} is used more than once.`); + return ret > 0; + } + + async createGroup( + name: string, + userId: number, + intro: string, + avatarId: number, + operatorId: number, + ip: string, + userAgent: string | undefined, + ): Promise { + if (!this.isValidGroupName(name)) { + throw new InvalidGroupNameError(name, this.groupNameRule); + } + if (await this.isGroupNameExists(name)) { + // todo: create log? + throw new GroupNameAlreadyUsedError(name); + } + if ((await this.avatarsService.isAvatarExists(avatarId)) == false) { + throw new AvatarNotFoundError(avatarId); + } + const group = await this.prismaService.group.create({ + data: { + name, + createdAt: new Date(), + }, + }); + await this.avatarsService.plusUsageCount(avatarId); + const groupProfile = await this.prismaService.groupProfile.create({ + data: { + intro, + avatarId, + createdAt: new Date(), + groupId: group.id, + updatedAt: new Date(), + }, + }); + + await this.prismaService.groupMembership.create({ + data: { + memberId: userId, + groupId: group.id, + role: 'owner', + createdAt: new Date(), + }, + }); + + const userDto = await this.usersService.getUserDtoById( + userId, + operatorId, + ip, + userAgent, + ); + + return { + id: group.id, + name: group.name, + intro: groupProfile.intro, + avatarId: avatarId, + owner: userDto, + created_at: group.createdAt.getTime(), + updated_at: group.updatedAt.getTime(), + member_count: 1, + question_count: 0, + answer_count: 0, + is_member: true, + is_owner: true, + is_public: true, + }; + } + + async getGroups( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + userId: number | undefined, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + keyword: string | undefined, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + page_start_id: number | undefined, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + page_size: number, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + order_type: GroupQueryType, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ip: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + userAgent: string | undefined, + ): Promise<[GroupDto[], PageDto]> { + throw new NotImplementedException(); + /* + let queryBuilder = this.groupsRepository.createQueryBuilder('group'); + + if (keyword) { + queryBuilder = queryBuilder.where('group.name LIKE :key', { + key: `%${keyword}%`, + }); + } + + let prevEntity = undefined, + currEntity = undefined; + if (!page_start_id) { + switch (order_type) { + case GroupQueryType.Recommend: + queryBuilder = queryBuilder.addSelect( + 'getRecommendationScore(group)', + 'score', + ); + break; + case GroupQueryType.Hot: + queryBuilder = queryBuilder.addSelect( + 'getGroupHotness(group)', + 'hotness', + ); + break; + case GroupQueryType.New: + queryBuilder = queryBuilder.orderBy('group.createdAt', 'DESC'); + break; + } + currEntity = await queryBuilder.limit(page_size + 1).getMany(); + const currDTOs = await Promise.all( + currEntity.map((entity) => + this.getGroupDtoById(userId, entity.id, ip, userAgent), + ), + ); + return PageHelper.PageStart(currDTOs, page_size, (group) => group.id); + } else { + const referenceGroup = await this.groupsRepository.findOneBy({ + id: page_start_id, + }); + if (!referenceGroup) { + throw new GroupNotFoundError(page_start_id); + } + + let referenceValue; + switch (order_type) { + case GroupQueryType.Recommend: { + referenceValue = getRecommendationScore(referenceGroup); + queryBuilder = queryBuilder.addSelect( + 'getRecommendationScore(group)', + 'score', + ); + break; + } + case GroupQueryType.Hot: { + referenceValue = getGroupHotness(referenceGroup); + queryBuilder = queryBuilder.addSelect( + 'getGroupHotness(group)', + 'hotness', + ); + break; + } + case GroupQueryType.New: { + break; + } + } + + switch (order_type) { + case GroupQueryType.Recommend: + prevEntity = await queryBuilder + .andWhere('recommendation_score > :referenceValue', { + referenceValue, + }) + .limit(page_size) + .getMany(); + currEntity = await queryBuilder + .andWhere('recommendation_score <= :referenceValue', { + referenceValue, + }) + .limit(page_size + 1) + .getMany(); + break; + case GroupQueryType.Hot: + prevEntity = await queryBuilder + .andWhere('hotness > :referenceValue', { referenceValue }) + .limit(page_size) + .getMany(); + currEntity = await queryBuilder + .andWhere('hotness <= :referenceValue', { referenceValue }) + .limit(page_size + 1) + .getMany(); + break; + case GroupQueryType.New: { + const queryBuilderCopy = queryBuilder.clone(); + + prevEntity = await queryBuilder + .orderBy('id', 'ASC') + .andWhere('id > :page_start_id', { page_start_id }) + .limit(page_size) + .getMany(); + + currEntity = await queryBuilderCopy + .orderBy('id', 'DESC') + .andWhere('id <= :page_start_id', { page_start_id }) + .limit(page_size + 1) + .getMany(); + break; + } + } + const currDTOs = await Promise.all( + currEntity.map((entity) => + this.getGroupDtoById(userId, entity.id, ip, userAgent), + ), + ); + return PageHelper.PageMiddle( + prevEntity, + currDTOs, + page_size, + (group) => group.id, + (group) => group.id, + ); + } + */ + } + + async getGroupDtoById( + userId: number | undefined, + groupId: number, + ip: string, + userAgent: string | undefined, + ): Promise { + const group = await this.prismaService.group.findUnique({ + where: { + id: groupId, + }, + include: { + groupProfile: true, + }, + }); + if (group == undefined) { + throw new GroupNotFoundError(groupId); + } + + const ownership = await this.prismaService.groupMembership.findFirst({ + where: { + groupId, + role: 'owner', + }, + }); + if (ownership == undefined) { + throw new Error(`Group ${groupId} has no owner.`); + } + const ownerId = ownership.memberId; + const ownerDto = await this.usersService.getUserDtoById( + ownerId, + userId, + ip, + userAgent, + ); + + const member_count = await this.prismaService.groupMembership.count({ + where: { + groupId, + }, + }); + const questions = + await this.prismaService.groupQuestionRelationship.findMany({ + where: { + groupId, + }, + }); + const question_count = questions.length; + const answer_count_promises = questions.map((question) => + this.answerService.countQuestionAnswers(question.id), + ); + const answer_count = (await Promise.all(answer_count_promises)).reduce( + (a, b) => a + b, + 0, + ); + + const is_member = userId + ? (await this.prismaService.groupMembership.findFirst({ + where: { + groupId, + memberId: userId, + }, + })) != undefined + : false; + const is_owner = userId ? ownerId == userId : false; + + return { + id: group.id, + name: group.name, + intro: + group.groupProfile?.intro ?? 'This user does not have an introduction.', + avatarId: + group.groupProfile?.avatarId ?? + (await this.avatarsService.getDefaultAvatarId()), + owner: ownerDto, + created_at: group.createdAt.getTime(), + updated_at: group.updatedAt.getTime(), + member_count, + question_count, + answer_count, + is_member, + is_owner, + is_public: true, // todo: implement + }; + } + async getGroupProfile(groupId: number): Promise { + const profile = await this.prismaService.groupProfile.findFirst({ + where: { + groupId, + }, + }); + if (profile == undefined) throw new GroupProfileNotFoundError(groupId); + return profile; + } + async updateGroup( + userId: number, + groupId: number, + name: string, + intro: string, + avatarId: number, + ): Promise { + const group = await this.prismaService.group.findUnique({ + where: { + id: groupId, + }, + include: { + groupProfile: true, + }, + }); + if (group == undefined) { + throw new GroupNotFoundError(groupId); + } + + const userMembership = await this.prismaService.groupMembership.findFirst({ + where: { + groupId, + memberId: userId, + role: { in: ['owner', 'admin'] }, + }, + }); + if (userMembership == undefined) { + throw new CannotDeleteGroupError(groupId); + } + + if (!this.isValidGroupName(name)) { + throw new InvalidGroupNameError(name, this.groupNameRule); + } + if (await this.isGroupNameExists(name)) { + // todo: create log? + throw new GroupNameAlreadyUsedError(name); + } + if ((await this.avatarsService.isAvatarExists(avatarId)) == false) { + throw new AvatarNotFoundError(avatarId); + } + const preAvatarId = group.groupProfile?.avatarId; + if (preAvatarId) await this.avatarsService.minusUsageCount(preAvatarId); + await this.avatarsService.plusUsageCount(avatarId); + await this.prismaService.group.update({ + where: { + id: groupId, + }, + data: { + name, + }, + }); + await this.prismaService.groupProfile.update({ + where: { + groupId, + }, + data: { + intro, + avatarId, + }, + }); + } + + async deleteGroup(userId: number, groupId: number): Promise { + if ((await this.isGroupExists(groupId)) == false) + throw new GroupNotFoundError(groupId); + + const owner = await this.prismaService.groupMembership.findFirst({ + where: { + groupId, + memberId: userId, + role: 'owner', + }, + }); + if (owner == undefined) { + throw new CannotDeleteGroupError(groupId); + } + + await this.prismaService.group.update({ + where: { + id: groupId, + }, + data: { + deletedAt: new Date(), + }, + }); + await this.prismaService.groupProfile.update({ + where: { + groupId, + }, + data: { + deletedAt: new Date(), + }, + }); + await this.prismaService.groupMembership.updateMany({ + where: { + groupId, + }, + data: { + deletedAt: new Date(), + }, + }); + } + + async isUserWithinGroup(userId: number, groupId: number): Promise { + return ( + (await this.prismaService.groupMembership.findFirst({ + where: { + memberId: userId, + groupId, + }, + })) != undefined + ); + } + + async joinGroup( + userId: number, + groupId: number, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + intro: string, + ): Promise { + if ((await this.isGroupExists(groupId)) == false) + throw new GroupNotFoundError(groupId); + + if (await this.isUserWithinGroup(userId, groupId)) + throw new GroupAlreadyJoinedError(groupId); + + await this.prismaService.groupMembership.create({ + data: { + groupId, + memberId: userId, + role: 'member', + createdAt: new Date(), + }, + }); + + const member_count = await this.prismaService.groupMembership.count({ + where: { + groupId, + }, + }); + const is_member = true; + const is_waiting = false; // todo: pending logic + return { member_count, is_member, is_waiting }; + } + + async quitGroup(userId: number, groupId: number): Promise { + if ((await this.isGroupExists(groupId)) == false) + throw new GroupNotFoundError(groupId); + + if ((await this.isUserWithinGroup(userId, groupId)) == false) + throw new GroupNotJoinedError(groupId); + + await this.prismaService.groupMembership.updateMany({ + where: { + groupId, + memberId: userId, + }, + data: { + deletedAt: new Date(), + }, + }); + const member_count = await this.prismaService.groupMembership.count({ + where: { + groupId, + }, + }); + return member_count; + } + + async getGroupMembers( + groupId: number, + firstMemberId: number | undefined, + page_size: number, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise<[UserDto[], PageDto]> { + if ((await this.isGroupExists(groupId)) == false) + throw new GroupNotFoundError(groupId); + + if (!firstMemberId) { + const entity = await this.prismaService.groupMembership.findMany({ + where: { + groupId, + }, + orderBy: { + id: 'asc', + }, + take: page_size + 1, + }); + const DTOs = await Promise.all( + entity.map((entity) => + this.usersService.getUserDtoById( + entity.memberId, + viewerId, + ip, + userAgent, + ), + ), + ); + return PageHelper.PageStart(DTOs, page_size, (user) => user.id); + } else { + // firstMemberId is not undefined + const firstMember = await this.prismaService + .withoutExtensions() + .groupMembership.findFirst({ + where: { + groupId, + memberId: firstMemberId, + }, + }); + // ! first member may be deleted while the request on sending + // ! so we need to include deleted members to get the correct reference value + // ! i.e. member joined time(id) here + + if (firstMember == undefined) { + throw new UserIdNotFoundError(firstMemberId); + } + const firstMemberJoinedId = firstMember.id; + + const prevEntity = this.prismaService.groupMembership.findMany({ + where: { + groupId, + id: { lt: firstMemberJoinedId }, + }, + take: page_size, + orderBy: { + id: 'desc', + }, + }); + const currEntity = this.prismaService.groupMembership.findMany({ + where: { + groupId, + id: { gte: firstMemberJoinedId }, + }, + take: page_size + 1, + orderBy: { + id: 'asc', + }, + }); + const currDTOs = await Promise.all( + (await currEntity).map((entity) => + this.usersService.getUserDtoById( + entity.memberId, + viewerId, + ip, + userAgent, + ), + ), + ); + return PageHelper.PageMiddle( + await prevEntity, + currDTOs, + page_size, + (user) => user.memberId, + (user) => user.id, + ); + } + } + + async getGroupQuestions( + groupId: number, + page_start_id: number | undefined, + page_size: number, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise { + if (page_start_id) { + const referenceRelationship = + await this.prismaService.groupQuestionRelationship.findFirst({ + where: { + questionId: page_start_id, + groupId, + }, + }); + if (!referenceRelationship) { + throw new QuestionNotFoundError(page_start_id); + } + const referenceValue = referenceRelationship.createdAt; + const prev = await this.prismaService.groupQuestionRelationship.findMany({ + where: { + groupId, + createdAt: { gt: referenceValue }, + }, + orderBy: { + createdAt: 'desc', + }, + take: page_size, + }); + const curr = await this.prismaService.groupQuestionRelationship.findMany({ + where: { + groupId, + createdAt: { lte: referenceValue }, + }, + orderBy: { + createdAt: 'desc', + }, + take: page_size + 1, + }); + const DTOs = await Promise.all( + curr.map((relationship) => + this.questionsService.getQuestionDto( + relationship.questionId, + viewerId, + ip, + userAgent, + ), + ), + ); + const [retDTOs, page] = PageHelper.PageMiddle( + prev, + DTOs, + page_size, + (relationship) => relationship.questionId, + (questionDto) => questionDto.id, + ); + return { + questions: retDTOs, + page, + }; + } else { + const curr = await this.prismaService.groupQuestionRelationship.findMany({ + where: { + groupId, + }, + orderBy: { + createdAt: 'desc', + }, + take: page_size + 1, + }); + const DTOs = await Promise.all( + curr.map((relationship) => + this.questionsService.getQuestionDto( + relationship.questionId, + viewerId, + ip, + userAgent, + ), + ), + ); + const [retDTOs, page] = PageHelper.PageStart( + DTOs, + page_size, + (questionDto) => questionDto.id, + ); + return { + questions: retDTOs, + page, + }; + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function getRecommendationScore(referenceGroup: Group): number { + throw new Error('Function getRecommendationScore not implemented.'); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function getGroupHotness(referenceGroup: Group): number { + throw new Error('Function getGroupHotness not implemented.'); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 00000000..fa89c3c1 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,57 @@ +import { NestFactory } from '@nestjs/core'; +import { RedisStore } from 'connect-redis'; +import session from 'express-session'; +import Redis from 'ioredis'; +import { AppModule } from './app.module'; +import { XForwardedForMiddleware } from './common/config/x-forwarded-for.middleware'; +export const IS_DEV = process.env.NODE_ENV !== 'production'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + const corsOptions = { + origin: + process.env.CORS_ORIGINS?.split(',') ?? + (IS_DEV ? ['http://localhost:3000'] : []), + methods: process.env.CORS_METHODS ?? 'GET,POST,PUT,PATCH,DELETE', + allowedHeaders: process.env.CORS_HEADERS ?? 'Content-Type,Authorization', + credentials: process.env.CORS_CREDENTIALS === 'true', + }; + app.enableCors(corsOptions); + + const redis = new Redis({ + host: process.env.REDIS_HOST ?? 'localhost', + port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : 6379, + username: process.env.REDIS_USERNAME ?? undefined, + password: process.env.REDIS_PASSWORD ?? undefined, + }); + + await redis.ping(); + + let redisStore = new RedisStore({ + client: redis, + prefix: 'cheese-session:', + }); + + app.use( + session({ + store: redisStore, + secret: process.env.SESSION_SECRET ?? 'secret', + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + sameSite: 'strict', + }, + }), + ); + + if (process.env.TRUST_X_FORWARDED_FOR === 'true') + app.use(new XForwardedForMiddleware().use); + + if (!process.env.PORT) + throw new Error('PORT environment variable is not defined'); + + await app.listen(process.env.PORT); +} +bootstrap(); diff --git a/src/materialbundles/DTO/create-materialbundle.dto.ts b/src/materialbundles/DTO/create-materialbundle.dto.ts new file mode 100644 index 00000000..8e6ac9b7 --- /dev/null +++ b/src/materialbundles/DTO/create-materialbundle.dto.ts @@ -0,0 +1,17 @@ +import { IsArray, IsInt, IsString } from 'class-validator'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class createMaterialBundleRequestDto { + @IsString() + title: string; + @IsString() + content: string; + @IsArray() + @IsInt({ each: true }) + materials: number[]; +} +export class createMaterialBundleResponseDto extends BaseResponseDto { + data: { + id: number; + }; +} diff --git a/src/materialbundles/DTO/get-materialbundle.dto.ts b/src/materialbundles/DTO/get-materialbundle.dto.ts new file mode 100644 index 00000000..5ca07228 --- /dev/null +++ b/src/materialbundles/DTO/get-materialbundle.dto.ts @@ -0,0 +1,24 @@ +import { IsEnum, IsOptional } from 'class-validator'; +import { PageWithKeywordDto } from '../../common/DTO/page.dto'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { materialBundleDto } from './materialbundle.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; + +enum sortRule { + Rating = 'rating', + Download = 'download', + Newest = 'newest', + Empty = '', +} +export class getMaterialBundleListDto extends PageWithKeywordDto { + @IsOptional() + @IsEnum(sortRule) + sort: sortRule; +} + +export class getMaterialBundlesResponseDto extends BaseResponseDto { + data: { + materials: materialBundleDto[]; + page: PageDto; + }; +} diff --git a/src/materialbundles/DTO/materialbundle.dto.ts b/src/materialbundles/DTO/materialbundle.dto.ts new file mode 100644 index 00000000..7ce6acad --- /dev/null +++ b/src/materialbundles/DTO/materialbundle.dto.ts @@ -0,0 +1,24 @@ +import { float } from '@elastic/elasticsearch/lib/api/types'; +import { UserDto } from '../../users/DTO/user.dto'; +import { materialDto } from '../../materials/DTO/material.dto'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class materialBundleDto { + id: number; + title: string; + content: string; + creator: UserDto | null; + created_at: number; // timestamp + updated_at: number; // timestamp + rating: float; + rating_count: number; + my_rating: number | undefined; + comments_count: number; + materials: materialDto[]; +} + +export class BundleResponseDto extends BaseResponseDto { + data: { + materialBundle: materialBundleDto; + }; +} diff --git a/src/materialbundles/DTO/update-materialbundle.dto.ts b/src/materialbundles/DTO/update-materialbundle.dto.ts new file mode 100644 index 00000000..b592f7d3 --- /dev/null +++ b/src/materialbundles/DTO/update-materialbundle.dto.ts @@ -0,0 +1,21 @@ +import { + IsArray, + IsInt, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; + +export class updateMaterialBundleDto { + @IsOptional() + @IsString() + title: string; + @IsOptional() + @IsString() + content: string; + @IsOptional() + @IsArray() + @IsNotEmpty() + @IsInt({ each: true }) + materials: number[]; +} diff --git a/src/materialbundles/materialbundles.controller.ts b/src/materialbundles/materialbundles.controller.ts new file mode 100644 index 00000000..5fbbc5bc --- /dev/null +++ b/src/materialbundles/materialbundles.controller.ts @@ -0,0 +1,175 @@ +/* + * Description: This file implements the MaterialbundlesController class, + * which is responsible for handling the requests to /material-bundles + * + * Author(s): + * nameisyui + * + */ + +import { + Body, + Controller, + Delete, + Get, + Headers, + Ip, + Param, + ParseIntPipe, + Patch, + Post, + Query, + UseFilters, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { AuthService } from '../auth/auth.service'; +import { + createMaterialBundleRequestDto, + createMaterialBundleResponseDto, +} from './DTO/create-materialbundle.dto'; +//import { getMaterialBundleListDto } from './DTO/get-materialbundle.dto'; +import { + AuthToken, + CurrentUserOwnResource, + Guard, + ResourceId, + ResourceOwnerIdGetter, +} from '../auth/guard.decorator'; +import { UserId } from '../auth/user-id.decorator'; +import { BaseResponseDto } from '../common/DTO/base-response.dto'; +import { BaseErrorExceptionFilter } from '../common/error/error-filter'; +import { + getMaterialBundleListDto, + getMaterialBundlesResponseDto, +} from './DTO/get-materialbundle.dto'; +import { BundleResponseDto } from './DTO/materialbundle.dto'; +import { updateMaterialBundleDto } from './DTO/update-materialbundle.dto'; +import { MaterialbundlesService } from './materialbundles.service'; + +@Controller('/material-bundles') +export class MaterialbundlesController { + constructor( + private readonly materialbundlesService: MaterialbundlesService, + private readonly authService: AuthService, + ) {} + + @ResourceOwnerIdGetter('material-bundle') + async getMaterialBundleOwner( + materialBundleId: number, + ): Promise { + return this.materialbundlesService.getMaterialBundleCreatorId( + materialBundleId, + ); + } + + @Post() + @Guard('create', 'material-bundle') + @CurrentUserOwnResource() + async createMaterialBundle( + @Headers('Authorization') @AuthToken() auth: string | undefined, + @Body() + { title, content, materials }: createMaterialBundleRequestDto, + @UserId(true) userId: number, + ): Promise { + const bundleId = await this.materialbundlesService.createBundle( + title, + content, + materials, + userId, + ); + return { + code: 201, + message: 'MaterialBundle created successfully', + data: { + id: bundleId, + }, + }; + } + + @Get() + @Guard('enumerate', 'material-bundle') + async getMaterialBundleList( + @Query() + { + q, + page_start: pageStart, + page_size: pageSize, + sort, + }: getMaterialBundleListDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + @UserId() viewerId: number | undefined, + ): Promise { + const [bundles, page] = await this.materialbundlesService.getBundles( + q, + pageStart, + pageSize, + sort.toString(), + viewerId, + ip, + userAgent, + ); + return { + code: 200, + message: 'get material bundles successfully', + data: { + materials: bundles, + page, + }, + }; + } + @Get('/:materialBundleId') + @Guard('query', 'material-bundle') + async getMaterialBundleDetail( + @Param('materialBundleId', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + @UserId() viewerId: number | undefined, + ): Promise { + const materialBundle = await this.materialbundlesService.getBundleDetail( + id, + viewerId, + ip, + userAgent, + ); + return { + code: 200, + message: 'get material bundle detail successfully', + data: { + materialBundle, + }, + }; + } + @Patch('/:materialBundleId') + @Guard('modify', 'material-bundle') + async updateMaterialBundle( + @Param('materialBundleId', ParseIntPipe) @ResourceId() id: number, + @Body() { title, content, materials }: updateMaterialBundleDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + await this.materialbundlesService.updateMaterialBundle( + id, + userId, + title, + content, + materials, + ); + return { + code: 200, + message: 'Materialbundle updated successfully', + }; + } + @Delete('/:materialBundleId') + @Guard('delete', 'material-bundle') + async deleteMaterialBundle( + @Param('materialBundleId', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ) { + await this.materialbundlesService.deleteMaterialBundle(id, userId); + } +} diff --git a/src/materialbundles/materialbundles.error.ts b/src/materialbundles/materialbundles.error.ts new file mode 100644 index 00000000..85eae693 --- /dev/null +++ b/src/materialbundles/materialbundles.error.ts @@ -0,0 +1,33 @@ +/* + * Description: This file defines the errors related to material bundles service. + * + * Author(s): + * nameisyui + * + */ + +import { BaseError } from '../common/error/base-error'; + +export class BundleNotFoundError extends BaseError { + constructor(bundleId: number) { + super('BundleNotFoundError', `Bundle ${bundleId} Not Found`, 404); + } +} + +export class UpdateBundleDeniedError extends BaseError { + constructor(bundleId: number) { + super('UpdateBundleDeniedError', `Can Not Update Bundle ${bundleId}`, 403); + } +} + +export class DeleteBundleDeniedError extends BaseError { + constructor(bundleId: number) { + super('DeleteBundleDeniedError', `Can Not Delete Bundle ${bundleId}`, 403); + } +} + +export class KeywordTooLongError extends BaseError { + constructor() { + super('KeywordTooLongError', 'Keyword length should not exceed 100', 400); + } +} diff --git a/src/materialbundles/materialbundles.module.ts b/src/materialbundles/materialbundles.module.ts new file mode 100644 index 00000000..d0782da9 --- /dev/null +++ b/src/materialbundles/materialbundles.module.ts @@ -0,0 +1,22 @@ +/* + * Description: This file defines the material bundles module + * + * Author(s): + * nameisyui + * + */ + +import { Module } from '@nestjs/common'; +import { MaterialbundlesService } from './materialbundles.service'; +import { MaterialbundlesController } from './materialbundles.controller'; +import { MaterialsModule } from '../materials/materials.module'; +import { PrismaModule } from '../common/prisma/prisma.module'; +import { UsersModule } from '../users/users.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [MaterialsModule, PrismaModule, UsersModule, AuthModule], + controllers: [MaterialbundlesController], + providers: [MaterialbundlesService], +}) +export class MaterialbundlesModule {} diff --git a/src/materialbundles/materialbundles.prisma b/src/materialbundles/materialbundles.prisma new file mode 100644 index 00000000..17a8842b --- /dev/null +++ b/src/materialbundles/materialbundles.prisma @@ -0,0 +1,19 @@ +import { User } from "../users/users" +import { MaterialbundlesRelation } from "../materials/materials" + +model MaterialBundle { + id Int @id @default(autoincrement()) + title String + content String + creator User @relation(fields: [creatorId], references: [id]) + creatorId Int + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt() @map("updated_at") @db.Timestamptz(6) + rating Float @default(0) + ratingCount Int @default(0) @map("rating_count") + myRating Float? @map("my_rating") + commentsCount Int @default(0) @map("comments_count") + materials MaterialbundlesRelation[] + + @@map("material_bundle") +} diff --git a/src/materialbundles/materialbundles.service.ts b/src/materialbundles/materialbundles.service.ts new file mode 100644 index 00000000..9fcfc687 --- /dev/null +++ b/src/materialbundles/materialbundles.service.ts @@ -0,0 +1,341 @@ +/* + * Description: This file implements the MaterialbundlesService class, + * which is responsible for handling the business logic of material bundles + * + * Author(s): + * nameisyui + * + */ + +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { materialBundleDto } from './DTO/materialbundle.dto'; +import { UserDto } from '../users/DTO/user.dto'; +import { UsersService } from '../users/users.service'; +import { MaterialsService } from '../materials/materials.service'; +import { MaterialNotFoundError } from '../materials/materials.error'; +import { + BundleNotFoundError, + DeleteBundleDeniedError, + UpdateBundleDeniedError, + KeywordTooLongError, +} from './materialbundles.error'; +import { PageDto } from '../common/DTO/page-response.dto'; +import { PageHelper } from '../common/helper/page.helper'; +import { Prisma } from '@prisma/client'; +import { + getPrevWhereBySort, + getCurrWhereBySort, +} from '../common/helper/where.helper'; +import { SortPattern } from '../common/pipe/parse-sort-pattern.pipe'; + +@Injectable() +export class MaterialbundlesService { + constructor( + private readonly prismaService: PrismaService, + private readonly usersService: UsersService, + private readonly materialsService: MaterialsService, + ) {} + + async createBundle( + title: string, + content: string, + materialIds: number[], + creatorId: number, + ): Promise { + for (const i in materialIds) { + const material = await this.prismaService.material.findUnique({ + where: { + id: materialIds[i], + }, + }); + if (!material) { + throw new MaterialNotFoundError(materialIds[i]); + } + } + const materialBundleCreateArray = materialIds.map((materialId) => ({ + material: { + connect: { + id: materialId, + }, + }, + })); + const newBundle = await this.prismaService.materialBundle.create({ + data: { + title, + content, + creatorId, + materials: { + create: materialBundleCreateArray, + }, + }, + }); + return newBundle.id; + } + async getBundles( + keywords: string, + firstBundleId: number | undefined, // if from start + pageSize: number, + sort: string, + viewerId: number | undefined, // optional + ip: string, + userAgent: string | undefined, // optional + ): Promise<[materialBundleDto[], PageDto]> { + if (keywords.length > 100) throw new KeywordTooLongError(); + const take = pageSize + 1; + let sortPattern: SortPattern; + if (sort === 'rating') { + sortPattern = { rating: 'desc' }; + } else if (sort === 'download') { + //to do + sortPattern = { downloads: 'desc' }; + } else if (sort === 'newest') { + sortPattern = { createdAt: 'desc' }; + } else { + sortPattern = { id: 'asc' }; + } + if (firstBundleId == undefined) { + const bundles = await this.prismaService.materialBundle.findMany({ + take, + where: { + ...this.buildWhereCondition(keywords), + }, + orderBy: sortPattern, + }); + const DTOs = await Promise.all( + bundles.map((r) => { + return this.getBundleDetail(r.id, viewerId, ip, userAgent); + }), + ); + return PageHelper.PageStart(DTOs, pageSize, (i) => i.id); + } else { + const cursor = await this.prismaService.materialBundle.findUnique({ + where: { id: firstBundleId }, + }); + if (cursor == undefined) { + throw new BundleNotFoundError(firstBundleId); + } + const prev = await this.prismaService.materialBundle.findMany({ + where: { + ...getPrevWhereBySort(sortPattern, cursor), + ...this.buildWhereCondition(keywords), + }, + orderBy: sortPattern, + take: pageSize, + }); + + const curr = await this.prismaService.materialBundle.findMany({ + where: { + ...getCurrWhereBySort(sortPattern, cursor), + ...this.buildWhereCondition(keywords), + }, + orderBy: sortPattern, + take: pageSize + 1, + }); + const currDtos = await Promise.all( + curr.map((r) => this.getBundleDetail(r.id, viewerId, ip, userAgent)), + ); + return PageHelper.PageMiddle( + prev, + currDtos, + pageSize, + (i) => i.id, + (i) => i.id, + ); + } + } + private buildWhereCondition(query: string): Prisma.MaterialBundleWhereInput { + if (!query) { + return {}; + } + + const conditions: Prisma.MaterialBundleWhereInput[] = []; + const regex = /(\w+)(:>=|:<=|:>|:<|:)(\w+)/g; + let match; + + // Parsing special conditions like rating:>=4 + while ((match = regex.exec(query)) !== null) { + const [, field, operator, value] = match; + let condition: Prisma.MaterialBundleWhereInput; + switch (operator) { + case ':>=': + condition = { [field]: { gte: parseFloat(value) } }; + break; + case ':<=': + condition = { [field]: { lte: parseFloat(value) } }; + break; + case ':>': + condition = { [field]: { gt: parseFloat(value) } }; + break; + case ':<': + condition = { [field]: { lt: parseFloat(value) } }; + break; + case ':': + condition = { [field]: { contains: value, mode: 'insensitive' } }; + break; + default: + // This should never happen + throw new Error(`Invalid operator: ${operator}`); + } + + conditions.push(condition); + } + // If no special conditions were matched, treat the entire query as a keyword search + if (conditions.length === 0) { + conditions.push({ + OR: [ + { title: { contains: query, mode: 'insensitive' } }, + { content: { contains: query, mode: 'insensitive' } }, + ], + }); + } + return conditions.length > 0 ? { AND: conditions } : {}; + } + + async getBundleDetail( + bundleId: number, + viewerId: number | undefined, // optional + ip: string, + userAgent: string | undefined, // optional + ): Promise { + const bundle = await this.prismaService.materialBundle.findUnique({ + where: { + id: bundleId, + }, + }); + if (!bundle) { + throw new BundleNotFoundError(bundleId); + } + let userDto: UserDto | null = null; // For case that user is deleted. + try { + userDto = await this.usersService.getUserDtoById( + bundle.creatorId, + viewerId, + ip, + userAgent, + ); + } catch (e) { + // If user is null, it means that one user created this, but the user + // does not exist now. This is NOT a data integrity problem, since user can be + // deleted. So we just return a null and not throw an error. + } + const materialIds = ( + await this.prismaService.materialbundlesRelation.findMany({ + where: { + bundleId: bundle.id, + }, + }) + ).map((i) => i.materialId); + const materialDtos = await Promise.all( + materialIds.map((id) => + this.materialsService.getMaterial(id, viewerId, ip, userAgent), + ), + ); + const myRating = bundle.myRating == null ? undefined : bundle.myRating; + return { + id: bundle.id, + title: bundle.title, + content: bundle.content, + creator: userDto, + created_at: bundle.createdAt.getTime(), + updated_at: bundle.updatedAt.getTime(), + rating: bundle.rating, + rating_count: bundle.ratingCount, + my_rating: myRating, + comments_count: bundle.commentsCount, + materials: materialDtos, + }; + } + async updateMaterialBundle( + bundleId: number, + userId: number, + title: string | undefined, + content: string | undefined, + materials: number[] | undefined, + ) { + const bundle = await this.prismaService.materialBundle.findUnique({ + where: { + id: bundleId, + }, + include: { + materials: true, + }, + }); + if (!bundle) throw new BundleNotFoundError(bundleId); + if (bundle.creatorId !== userId) + throw new UpdateBundleDeniedError(bundleId); + const data: any = {}; + if (title !== undefined) { + data.title = title; + } + if (content !== undefined) { + data.content = content; + } + if (materials !== undefined) { + for (const materialId of materials) { + const material = await this.prismaService.material.findUnique({ + where: { id: materialId }, + }); + if (!material) { + throw new MaterialNotFoundError(materialId); + } + } + await this.prismaService.$transaction(async () => { + const currentMaterialIds = bundle.materials.map((i) => i.materialId); + const materialIdsToAdd = materials.filter( + (id) => !currentMaterialIds.includes(id), + ); + const materialIdsToRemove = currentMaterialIds.filter( + (id) => !materials.includes(id), + ); + if (materialIdsToRemove.length > 0) { + // delete if not exist in updated bundle + await this.prismaService.materialbundlesRelation.deleteMany({ + where: { + bundleId: bundleId, + materialId: { in: materialIdsToRemove }, + }, + }); + } + if (materialIdsToAdd.length > 0) { + data.materials = { + create: materialIdsToAdd.map((id) => ({ + material: { + connect: { id }, + }, + })), + }; + } + await this.prismaService.materialBundle.update({ + where: { id: bundleId }, + data: data, + }); + }); + } + } + async deleteMaterialBundle(id: number, userId: number) { + const bundle = await this.prismaService.materialBundle.findUnique({ + where: { + id, + }, + }); + if (!bundle) throw new BundleNotFoundError(id); + if (bundle.creatorId !== userId) throw new DeleteBundleDeniedError(id); + await this.prismaService.materialBundle.delete({ + where: { + id, + }, + }); + return; + } + + async getMaterialBundleCreatorId(bundleId: number): Promise { + const bundle = await this.prismaService.materialBundle.findUnique({ + where: { + id: bundleId, + }, + }); + if (!bundle) throw new BundleNotFoundError(bundleId); + return bundle.creatorId; + } +} diff --git a/src/materials/DTO/get-material.dto.ts b/src/materials/DTO/get-material.dto.ts new file mode 100644 index 00000000..e8f6f339 --- /dev/null +++ b/src/materials/DTO/get-material.dto.ts @@ -0,0 +1,8 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { materialDto } from './material.dto'; + +export class GetMaterialResponseDto extends BaseResponseDto { + data: { + material: materialDto; + }; +} diff --git a/src/materials/DTO/material.dto.ts b/src/materials/DTO/material.dto.ts new file mode 100644 index 00000000..d816d3aa --- /dev/null +++ b/src/materials/DTO/material.dto.ts @@ -0,0 +1,18 @@ +import { MaterialType } from '@prisma/client'; +import { IsEnum } from 'class-validator'; +import { UserDto } from '../../users/DTO/user.dto'; + +export class MaterialTypeDto { + @IsEnum(MaterialType) + type: MaterialType; +} +export class materialDto { + id: number; + type: MaterialType; + uploader: UserDto; + created_at: number; // timestamp + expires: number | undefined; + download_count: number; + url: string; + meta: PrismaJson.metaType; +} diff --git a/src/materials/DTO/upload-material.dto.ts b/src/materials/DTO/upload-material.dto.ts new file mode 100644 index 00000000..50c0a6d7 --- /dev/null +++ b/src/materials/DTO/upload-material.dto.ts @@ -0,0 +1,14 @@ +import { IsIn, IsString } from 'class-validator'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class UploadMaterialRequestDto { + @IsString() + @IsIn(['file', 'image', 'video', 'audio']) + type: string; +} + +export class UploadMaterialResponseDto extends BaseResponseDto { + data: { + id: number; + }; +} diff --git a/src/materials/materials.controller.ts b/src/materials/materials.controller.ts new file mode 100644 index 00000000..aa4b95fe --- /dev/null +++ b/src/materials/materials.controller.ts @@ -0,0 +1,96 @@ +/* + * Description: This file implements the MaterialsController class, + * which is responsible for handling the requests to /materials + * + * Author(s): + * nameisyui + * + */ + +import { + Body, + Controller, + Delete, + Get, + Headers, + Ip, + Param, + ParseIntPipe, + Post, + UploadedFile, + UseFilters, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { AuthService } from '../auth/auth.service'; +import { BaseErrorExceptionFilter } from '../common/error/error-filter'; +import { GetMaterialResponseDto } from './DTO/get-material.dto'; +import { MaterialTypeDto } from './DTO/material.dto'; +import { UploadMaterialResponseDto } from './DTO/upload-material.dto'; +import { MaterialsService } from './materials.service'; +import { AuthToken, Guard, ResourceId } from '../auth/guard.decorator'; +import { UserId } from '../auth/user-id.decorator'; + +@Controller('/materials') +export class MaterialsController { + constructor( + private readonly materialsService: MaterialsService, + private readonly authService: AuthService, + ) {} + + @Post() + @UseInterceptors(FileInterceptor('file')) + @Guard('create', 'material') + async uploadMaterial( + @Body() { type: materialType }: MaterialTypeDto, + @UploadedFile() file: Express.Multer.File, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) uploaderId: number, + ): Promise { + const materialId = await this.materialsService.uploadMaterial( + materialType, + file, + uploaderId, + ); + return { + code: 200, + message: 'Material upload successfully', + data: { + id: materialId, + }, + }; + } + + @Get('/:materialId') + @Guard('query', 'material') + async getMaterialDetail( + @Param('materialId', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + @UserId() viewerId: number | undefined, + ): Promise { + const material = await this.materialsService.getMaterial( + id, + viewerId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Get Material successfully', + data: { + material, + }, + }; + } + @Delete('/:materialId') // to do + async deleteMaterial() //@Param('materialId') id: number, + //@Headers('Authorization') @AuthToken() auth: string | undefined, + : Promise { + /* istanbul ignore next */ + throw new Error('deleteMaterial method is not implemented yet.'); + } +} diff --git a/src/materials/materials.error.ts b/src/materials/materials.error.ts new file mode 100644 index 00000000..aff86e26 --- /dev/null +++ b/src/materials/materials.error.ts @@ -0,0 +1,35 @@ +/* + * Description: This file defines the errors related to material service. + * + * Author(s): + * nameisyui + * + */ + +import { BaseError } from '../common/error/base-error'; + +export class InvalidMaterialTypeError extends BaseError { + constructor() { + super('InvalidMaterialTypeError', 'Invalid material type', 400); + } +} + +export class MaterialNotFoundError extends BaseError { + constructor(materialId: number) { + super('MaterialNotFoundError', `Material ${materialId} Not Found`, 404); + } +} +export class MetaDataParseError extends BaseError { + /* istanbul ignore next */ + constructor(metaType: string) { + super('MetaDataParseError', `${metaType} meta parse fail`, 400); + } +} +export class MimeTypeNotMatchError extends BaseError { + constructor( + public readonly mimetype: string, + public readonly materialtype: string, + ) { + super('MimeTypeNotMatchError', `${mimetype} is not ${materialtype}`, 422); + } +} diff --git a/src/materials/materials.module.ts b/src/materials/materials.module.ts new file mode 100644 index 00000000..46a8d53c --- /dev/null +++ b/src/materials/materials.module.ts @@ -0,0 +1,88 @@ +/* + * Description: This file defines the material module + * + * Author(s): + * nameisyui + * + */ + +import { Module } from '@nestjs/common'; +import { MulterModule } from '@nestjs/platform-express'; +import { existsSync, mkdirSync } from 'fs'; +import { diskStorage } from 'multer'; +import { extname, join } from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../common/prisma/prisma.module'; +import { MaterialsController } from './materials.controller'; +import { + InvalidMaterialTypeError, + MimeTypeNotMatchError, +} from './materials.error'; +import { MaterialsService } from './materials.service'; +import { UsersModule } from '../users/users.module'; +@Module({ + imports: [configureMulterModule(), PrismaModule, AuthModule, UsersModule], + controllers: [MaterialsController], + providers: [MaterialsService], + exports: [MaterialsService], +}) +export class MaterialsModule {} + +function configureMulterModule() { + return MulterModule.register({ + storage: diskStorage({ + destination: (req, file, callback) => { + if (!process.env.FILE_UPLOAD_PATH) { + /* istanbul ignore next */ + throw new Error( + 'FILE_UPLOAD_PATH environment variable is not defined', + ); + } + const rootPath = process.env.FILE_UPLOAD_PATH; + const uploadPaths: { [key: string]: string } = { + image: join(rootPath, '/images'), + video: join(rootPath, '/videos'), + audio: join(rootPath, '/audios'), + file: join(rootPath, '/files'), + }; + const fileType = req.body.type; + const uploadPath = uploadPaths[fileType]; + /* istanbul ignore if */ + if (!existsSync(uploadPath)) { + mkdirSync(uploadPath, { recursive: true }); + } + callback(null, uploadPath); + }, + filename: (req, file, callback) => { + const randomName = uuidv4(); + callback(null, `${randomName}${extname(file.originalname)}`); + }, + }), + limits: { + fileSize: 1024 * 1024 * 1024, //1GB + }, + fileFilter(req, file, callback) { + const typeToMimeTypes: { [key: string]: string[] } = { + image: ['image'], + video: ['video'], + audio: ['audio'], + file: ['application', 'text'], + }; + const allowedTypes = typeToMimeTypes[req.body.type]; + if (!allowedTypes) { + return callback(new InvalidMaterialTypeError(), false); + } + const isAllowed = allowedTypes.some((type) => + file.mimetype.includes(type), + ); + if (!isAllowed) { + return callback( + new MimeTypeNotMatchError(file.mimetype, req.body.type), + false, + ); + } + callback(null, true); + }, + }); +} diff --git a/src/materials/materials.prisma b/src/materials/materials.prisma new file mode 100644 index 00000000..879aec1d --- /dev/null +++ b/src/materials/materials.prisma @@ -0,0 +1,32 @@ +import { User } from "../users/users" +import {MaterialBundle} from "../materialbundles/materialbundles" +enum MaterialType { + image + file + audio + video +} + +model Material { + id Int @id @default(autoincrement()) + type MaterialType + url String + name String + uploader User @relation(fields: [uploaderId], references: [id]) + uploaderId Int + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + expires Int? + downloadCount Int @default(0) @map("download_count") + materialBundles MaterialbundlesRelation[] + /// [metaType] + meta Json @db.Json +} + +model MaterialbundlesRelation { + material Material @relation(fields: [materialId], references: [id]) + materialId Int + bundle MaterialBundle @relation(fields: [bundleId], references: [id], onDelete: Cascade) + bundleId Int + + @@id([materialId, bundleId]) +} diff --git a/src/materials/materials.service.ts b/src/materials/materials.service.ts new file mode 100644 index 00000000..1731046f --- /dev/null +++ b/src/materials/materials.service.ts @@ -0,0 +1,200 @@ +/* + * Description: This file implements the MaterialsService class, + * which is responsible for handling the business logic of material + * + * Author(s): + * nameisyui + * + */ + +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { MaterialType } from '@prisma/client'; +import ffmpeg from 'fluent-ffmpeg'; +import path, { join } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { promisify } from 'node:util'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { MaterialNotFoundError, MetaDataParseError } from './materials.error'; +import { materialDto } from './DTO/material.dto'; +import { UsersService } from '../users/users.service'; +import md5 from 'md5'; +@Injectable() +export class MaterialsService implements OnModuleInit { + private ffprobeAsync: (file: string) => Promise; + constructor( + private readonly prismaService: PrismaService, + private readonly userService: UsersService, + ) { + this.ffprobeAsync = promisify(ffmpeg.ffprobe); + } + async onModuleInit(): Promise { + return new Promise((resolve, reject) => { + ffmpeg.getAvailableCodecs((err) => { + /* istanbul ignore if */ + if (err) { + reject(new Error('FFmpeg not found on system.')); + } else { + resolve(); + } + }); + }); + } + async getImageMetadata( + filePath: string, + ): Promise<{ width: number; height: number }> { + const metadata = await this.ffprobeAsync(filePath); + const width = metadata.streams[0].width; + const height = metadata.streams[0].height; + /* istanbul ignore if */ + if (!width || !height) { + throw new MetaDataParseError('image'); + } + return { width, height }; + } + async getVideoMetadata(filePath: string): Promise<{ + width: number; + height: number; + duration: number; + thumbnail: string; + }> { + const metadata = await this.ffprobeAsync(filePath); + const width = metadata.streams[0].width; + const height = metadata.streams[0].height; + const duration = metadata.format.duration; + /* istanbul ignore if */ + if (!width || !height || !duration) { + throw new MetaDataParseError('video'); + } + const rootPath = process.env.FILE_UPLOAD_PATH!; + const videoName = path.parse(filePath).name; + ffmpeg(filePath) + .screenshots({ + timestamps: ['00:00:01'], + folder: join(rootPath, '/images'), + filename: `${videoName}-thumbnail.jpg`, + size: '320x240', + }) + + .on('end', () => { + /* istanbul ignore next */ + }) + .on('error', (err: Error) => { + /* istanbul ignore next */ + throw Error('Error generating thumbnail:' + err); + }); + const thumbnail = `/static/images/${encodeURIComponent(videoName)}-thumbnail.jpg`; + return { width, height, duration, thumbnail }; + } + async getAudioMetadata(filePath: string): Promise<{ duration: number }> { + const metadata = await this.ffprobeAsync(filePath); + const duration = metadata.format.duration; + /* istanbul ignore if */ + if (!duration) { + throw new MetaDataParseError('audio'); + } + return { duration }; + } + + async getMeta( + type: string, + file: Express.Multer.File, + ): Promise { + let meta: PrismaJson.metaType; + + const buf = readFileSync(file.path); + const hash = md5(buf); + + if (type === MaterialType.image) { + const metadata = await this.getImageMetadata(file.path); + meta = { + size: file.size, + name: file.filename, + mime: file.mimetype, + hash, + width: metadata.width, + height: metadata.height, + thumbnail: 'thumbnail', //todo + }; + } else if (type === MaterialType.video) { + const metadata = await this.getVideoMetadata(file.path); + meta = { + size: file.size, + name: file.filename, + mime: file.mimetype, + hash, + width: metadata.width, + height: metadata.height, + duration: metadata.duration, + thumbnail: metadata.thumbnail, //todo + }; + } else if (type === MaterialType.audio) { + const metadata = await this.getAudioMetadata(file.path); + meta = { + size: file.size, + name: file.filename, + mime: file.mimetype, + hash, + duration: metadata.duration, + }; + } else { + meta = { + size: file.size, + name: file.filename, + mime: file.mimetype, + hash, + }; + } + return meta; + } + + async uploadMaterial( + type: MaterialType, + file: Express.Multer.File, + uploaderId: number, + ): Promise { + const meta = await this.getMeta(type, file); + const newMaterial = await this.prismaService.material.create({ + data: { + url: `/static/${encodeURIComponent(type)}s/${encodeURIComponent(file.filename)}`, + type, + name: file.filename, + uploaderId, + meta, + }, + }); + return newMaterial.id; + } + + async getMaterial( + materialId: number, + viewerId: number | undefined, // optional + ip: string, + userAgent: string | undefined, // optional + ): Promise { + const material = await this.prismaService.material.findUnique({ + where: { + id: materialId, + }, + }); + if (material == null) { + throw new MaterialNotFoundError(materialId); + } + const uploaderDto = await this.userService.getUserDtoById( + material.uploaderId, + viewerId, + ip, + userAgent, + ); + const expires = material.expires == null ? undefined : material.expires; + return { + id: material.id, + type: material.type, + uploader: uploaderDto, + created_at: material.createdAt.getTime(), + expires, + download_count: material.downloadCount, + url: material.url, + meta: material.meta, + }; + } +} diff --git a/src/materials/metatype.ts b/src/materials/metatype.ts new file mode 100644 index 00000000..cbfbfb18 --- /dev/null +++ b/src/materials/metatype.ts @@ -0,0 +1,40 @@ +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace PrismaJson { + type fileMeta = { + size: number; + name: string; + mime: string; + hash: string; + }; + type imageMeta = { + size: number; + name: string; + mime: string; + hash: string; + height: number; + width: number; + thumbnail: string; + }; + type audioMeta = { + size: number; + name: string; + mime: string; + hash: string; + duration: number; + }; + type videoMeta = { + size: number; + name: string; + mime: string; + hash: string; + duration: number; + height: number; + width: number; + thumbnail: string; + }; + + type metaType = imageMeta | videoMeta | audioMeta | fileMeta; + } +} +export {}; diff --git a/src/materials/resources/test.jpg b/src/materials/resources/test.jpg new file mode 100644 index 00000000..269e82e3 Binary files /dev/null and b/src/materials/resources/test.jpg differ diff --git a/src/materials/resources/test.mp3 b/src/materials/resources/test.mp3 new file mode 100644 index 00000000..c4727bd4 Binary files /dev/null and b/src/materials/resources/test.mp3 differ diff --git a/src/materials/resources/test.mp4 b/src/materials/resources/test.mp4 new file mode 100644 index 00000000..33fb4e07 Binary files /dev/null and b/src/materials/resources/test.mp4 differ diff --git a/src/materials/resources/test.pdf b/src/materials/resources/test.pdf new file mode 100644 index 00000000..c6c9bc22 Binary files /dev/null and b/src/materials/resources/test.pdf differ diff --git a/src/questions/DTO/add-question.dto.ts b/src/questions/DTO/add-question.dto.ts new file mode 100644 index 00000000..27b11652 --- /dev/null +++ b/src/questions/DTO/add-question.dto.ts @@ -0,0 +1,43 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { BOUNTY_LIMIT } from '../questions.error'; + +export class AddQuestionRequestDto { + @IsString() + title: string; + + @IsString() + content: string; + + @IsInt() + type: number; + + @IsArray() + @IsInt({ each: true }) + topics: number[]; + + @IsInt() + @IsOptional() + groupId?: number; + + @IsInt() + @Min(0, { message: 'Bounty can not be negative' }) + @Max(BOUNTY_LIMIT, { message: 'Bounty is too high' }) + @IsOptional() + @Type(() => Number) + bounty: number = 0; +} + +export class AddQuestionResponseDto extends BaseResponseDto { + data: { + id: number; + }; +} diff --git a/src/questions/DTO/follow-unfollow-question.dto.ts b/src/questions/DTO/follow-unfollow-question.dto.ts new file mode 100644 index 00000000..02a550f3 --- /dev/null +++ b/src/questions/DTO/follow-unfollow-question.dto.ts @@ -0,0 +1,13 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class FollowQuestionResponseDto extends BaseResponseDto { + data: { + follow_count: number; + }; +} + +export class UnfollowQuestionResponseDto extends BaseResponseDto { + data: { + follow_count: number; + }; +} diff --git a/src/questions/DTO/get-invitation-detail.dto.ts b/src/questions/DTO/get-invitation-detail.dto.ts new file mode 100644 index 00000000..90bd49c4 --- /dev/null +++ b/src/questions/DTO/get-invitation-detail.dto.ts @@ -0,0 +1,8 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { QuestionInvitationDto } from './question-invitation.dto'; + +export class GetQuestionInvitationDetailResponseDto extends BaseResponseDto { + data: { + invitation: QuestionInvitationDto; + }; +} diff --git a/src/questions/DTO/get-question-follower.dto.ts b/src/questions/DTO/get-question-follower.dto.ts new file mode 100644 index 00000000..1f70762a --- /dev/null +++ b/src/questions/DTO/get-question-follower.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { UserDto } from '../../users/DTO/user.dto'; + +export class GetQuestionFollowerResponseDto extends BaseResponseDto { + data: { + users: UserDto[]; + page: PageDto; + }; +} diff --git a/src/questions/DTO/get-question-invitation.dto.ts b/src/questions/DTO/get-question-invitation.dto.ts new file mode 100644 index 00000000..548ef2e8 --- /dev/null +++ b/src/questions/DTO/get-question-invitation.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { QuestionInvitationDto } from './question-invitation.dto'; + +export class GetQuestionInvitationsResponseDto extends BaseResponseDto { + data: { + invitations: QuestionInvitationDto[]; + page: PageDto; + }; +} diff --git a/src/questions/DTO/get-question-recommendations.dto.ts b/src/questions/DTO/get-question-recommendations.dto.ts new file mode 100644 index 00000000..dd9796d2 --- /dev/null +++ b/src/questions/DTO/get-question-recommendations.dto.ts @@ -0,0 +1,8 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { UserDto } from '../../users/DTO/user.dto'; + +export class GetQuestionRecommendationsResponseDto extends BaseResponseDto { + data: { + users: UserDto[]; + }; +} diff --git a/src/questions/DTO/get-question.dto.ts b/src/questions/DTO/get-question.dto.ts new file mode 100644 index 00000000..b17862b3 --- /dev/null +++ b/src/questions/DTO/get-question.dto.ts @@ -0,0 +1,8 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { QuestionDto } from './question.dto'; + +export class GetQuestionResponseDto extends BaseResponseDto { + data: { + question: QuestionDto; + }; +} diff --git a/src/questions/DTO/invite-user-answer.dto.ts b/src/questions/DTO/invite-user-answer.dto.ts new file mode 100644 index 00000000..89fa8611 --- /dev/null +++ b/src/questions/DTO/invite-user-answer.dto.ts @@ -0,0 +1,13 @@ +import { IsInt } from 'class-validator'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class InviteUsersAnswerRequestDto { + @IsInt() + user_id: number; +} + +export class InviteUsersAnswerResponseDto extends BaseResponseDto { + data: { + invitationId: number; + }; +} diff --git a/src/questions/DTO/question-invitation.dto.ts b/src/questions/DTO/question-invitation.dto.ts new file mode 100644 index 00000000..c0198d3d --- /dev/null +++ b/src/questions/DTO/question-invitation.dto.ts @@ -0,0 +1,10 @@ +import { UserDto } from '../../users/DTO/user.dto'; + +export class QuestionInvitationDto { + id: number; + question_id: number; + user: UserDto; + created_at: number; // timestamp + updated_at: number; // timestamp + is_answered: boolean; +} diff --git a/src/questions/DTO/question.dto.ts b/src/questions/DTO/question.dto.ts new file mode 100644 index 00000000..e90b7857 --- /dev/null +++ b/src/questions/DTO/question.dto.ts @@ -0,0 +1,26 @@ +import { AnswerDto } from '../../answer/DTO/answer.dto'; +import { AttitudeStateDto } from '../../attitude/DTO/attitude-state.dto'; +import { GroupDto } from '../../groups/DTO/group.dto'; +import { TopicDto } from '../../topics/DTO/topic.dto'; +import { UserDto } from '../../users/DTO/user.dto'; +export class QuestionDto { + id: number; + title: string; + content: string; + author: UserDto | null; + type: number; + topics: TopicDto[]; + created_at: number; // timestamp + updated_at: number; // timestamp + attitudes: AttitudeStateDto; + is_follow: boolean; + my_answer_id: number | null | undefined; + answer_count: number; + comment_count: number; + follow_count: number; + view_count: number; + group: GroupDto | null; + bounty: number; + bounty_start_at?: number; // timestamp + accepted_answer: AnswerDto | null; +} diff --git a/src/questions/DTO/search-question.dto.ts b/src/questions/DTO/search-question.dto.ts new file mode 100644 index 00000000..ea738220 --- /dev/null +++ b/src/questions/DTO/search-question.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { QuestionDto } from './question.dto'; + +export class SearchQuestionResponseDto extends BaseResponseDto { + data: { + questions: QuestionDto[]; + page: PageDto; + }; +} diff --git a/src/questions/DTO/set-bounty.dto.ts b/src/questions/DTO/set-bounty.dto.ts new file mode 100644 index 00000000..6122dd9b --- /dev/null +++ b/src/questions/DTO/set-bounty.dto.ts @@ -0,0 +1,13 @@ +import { Type } from 'class-transformer'; +import { IsInt, Max, Min } from 'class-validator'; +import { BOUNTY_LIMIT } from '../questions.error'; + +export class SetBountyDto { + @IsInt() + @Min(1, { message: 'Bounty must be positive when setting' }) + @Max(BOUNTY_LIMIT, { + message: 'Bounty is too high', + }) + @Type(() => Number) + bounty: number; +} diff --git a/src/questions/DTO/update-question.dto.ts b/src/questions/DTO/update-question.dto.ts new file mode 100644 index 00000000..8cf43ddf --- /dev/null +++ b/src/questions/DTO/update-question.dto.ts @@ -0,0 +1,16 @@ +import { IsArray, IsInt, IsString } from 'class-validator'; + +export class UpdateQuestionRequestDto { + @IsString() + title: string; + + @IsString() + content: string; + + @IsInt() + type: number; + + @IsArray() + @IsInt({ each: true }) + topics: number[]; +} diff --git a/src/questions/questions.controller.ts b/src/questions/questions.controller.ts new file mode 100644 index 00000000..d7e1a31a --- /dev/null +++ b/src/questions/questions.controller.ts @@ -0,0 +1,436 @@ +/* + * Description: This file implements the questions controller. + * It is responsible for handling the requests to /questions/... + * However, it's not responsible for /questions/{id}/answers/... + * + * Author(s): + * Nictheboy Li + * Andy Lee + * HuanCheng65 + * + */ + +import { + Body, + Controller, + Delete, + Get, + Headers, + Ip, + Param, + ParseIntPipe, + Post, + Put, + Query, + UseFilters, + UseInterceptors, +} from '@nestjs/common'; +import { AttitudeTypeDto } from '../attitude/DTO/attitude.dto'; +import { UpdateAttitudeResponseDto } from '../attitude/DTO/update-attitude.dto'; +import { AuthService } from '../auth/auth.service'; +import { UserId } from '../auth/user-id.decorator'; +import { BaseResponseDto } from '../common/DTO/base-response.dto'; +import { PageDto, PageWithKeywordDto } from '../common/DTO/page.dto'; +import { BaseErrorExceptionFilter } from '../common/error/error-filter'; +import { TokenValidateInterceptor } from '../common/interceptor/token-validate.interceptor'; +import { + ParseSortPatternPipe, + SortPattern, +} from '../common/pipe/parse-sort-pattern.pipe'; +import { SnakeCaseToCamelCasePipe } from '../common/pipe/snake-case-to-camel-case.pipe'; +import { + AddQuestionRequestDto, + AddQuestionResponseDto, +} from './DTO/add-question.dto'; +import { + FollowQuestionResponseDto, + UnfollowQuestionResponseDto, +} from './DTO/follow-unfollow-question.dto'; +import { GetQuestionInvitationDetailResponseDto } from './DTO/get-invitation-detail.dto'; +import { GetQuestionFollowerResponseDto } from './DTO/get-question-follower.dto'; +import { GetQuestionInvitationsResponseDto } from './DTO/get-question-invitation.dto'; +import { GetQuestionRecommendationsResponseDto } from './DTO/get-question-recommendations.dto'; +import { GetQuestionResponseDto } from './DTO/get-question.dto'; +import { + InviteUsersAnswerRequestDto, + InviteUsersAnswerResponseDto, +} from './DTO/invite-user-answer.dto'; +import { SearchQuestionResponseDto } from './DTO/search-question.dto'; +import { SetBountyDto } from './DTO/set-bounty.dto'; +import { UpdateQuestionRequestDto } from './DTO/update-question.dto'; +import { QuestionsService } from './questions.service'; +import { + AuthToken, + CurrentUserOwnResource, + Guard, + ResourceId, + ResourceOwnerIdGetter, +} from '../auth/guard.decorator'; + +@Controller('/questions') +export class QuestionsController { + constructor( + readonly questionsService: QuestionsService, + readonly authService: AuthService, + ) {} + + @ResourceOwnerIdGetter('question') + async getQuestionOwner(id: number): Promise { + return this.questionsService.getQuestionCreatedById(id); + } + + @Get('/') + @Guard('enumerate', 'question') + async searchQuestion( + @Query() + { q, page_start: pageStart, page_size: pageSize }: PageWithKeywordDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() searcherId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + ): Promise { + const [questions, pageRespond] = + await this.questionsService.searchQuestions( + q ?? '', + pageStart, + pageSize, + searcherId, + ip, + userAgent, + ); + return { + code: 200, + message: 'OK', + data: { + questions, + page: pageRespond, + }, + }; + } + + @Post('/') + @Guard('create', 'question') + @CurrentUserOwnResource() + async addQuestion( + @Body() + { title, content, type, topics, groupId, bounty }: AddQuestionRequestDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + const questionId = await this.questionsService.addQuestion( + userId, + title, + content, + type, + topics, + groupId, + bounty, + ); + return { + code: 201, + message: 'Created', + data: { + id: questionId, + }, + }; + } + + @Get('/:id') + @Guard('query', 'question') + async getQuestion( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + ): Promise { + const questionDto = await this.questionsService.getQuestionDto( + id, + userId, + ip, + userAgent, + ); + return { + code: 200, + message: 'OK', + data: { + question: questionDto, + }, + }; + } + + @Put('/:id') + @Guard('modify', 'question') + async updateQuestion( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Body() { title, content, type, topics }: UpdateQuestionRequestDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + await this.questionsService.updateQuestion( + id, + title, + content, + type, + topics, + this.authService.verify(auth).userId, + ); + return { + code: 200, + message: 'OK', + }; + } + + @Delete('/:id') + @Guard('delete', 'question') + async deleteQuestion( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + await this.questionsService.deleteQuestion(id); + } + + @Get('/:id/followers') + @Guard('enumerate-followers', 'question') + async getQuestionFollowers( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Query() + { page_start: pageStart, page_size: pageSize }: PageDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + ): Promise { + const [followers, pageRespond] = + await this.questionsService.getQuestionFollowers( + id, + pageStart, + pageSize, + userId, + ip, + userAgent, + ); + return { + code: 200, + message: 'OK', + data: { + users: followers, + page: pageRespond, + }, + }; + } + + @Post('/:id/followers') + @Guard('follow', 'question') + async followQuestion( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + await this.questionsService.followQuestion(userId, id); + return { + code: 201, + message: 'OK', + data: { + follow_count: await this.questionsService.getFollowCountOfQuestion(id), + }, + }; + } + + @Delete('/:id/followers') + @Guard('unfollow', 'question') + async unfollowQuestion( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + await this.questionsService.unfollowQuestion(userId, id); + return { + code: 200, + message: 'OK', + data: { + follow_count: await this.questionsService.getFollowCountOfQuestion(id), + }, + }; + } + + @Post('/:id/attitudes') + @Guard('attitude', 'question') + async updateAttitudeToQuestion( + @Param('id', ParseIntPipe) @ResourceId() questionId: number, + @Body() { attitude_type: attitudeType }: AttitudeTypeDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + const attitudes = await this.questionsService.setAttitudeToQuestion( + questionId, + userId, + attitudeType, + ); + return { + code: 201, + message: 'You have expressed your attitude towards the question', + data: { + attitudes, + }, + }; + } + + @Get('/:id/invitations') + @Guard('enumerate-invitations', 'question') + async getQuestionInvitations( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Query() + { page_start: pageStart, page_size: pageSize }: PageDto, + @Query( + 'sort', + new SnakeCaseToCamelCasePipe({ prefixIgnorePattern: '[+-]' }), + new ParseSortPatternPipe({ + optional: true, + allowedFields: ['createdAt'], + }), + ) + sort: SortPattern = { createdAt: 'desc' }, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + ): Promise { + const [invitations, page] = + await this.questionsService.getQuestionInvitations( + id, + sort, + pageStart, + pageSize, + userId, + ip, + userAgent, + ); + return { + code: 200, + message: 'OK', + data: { + invitations, + page, + }, + }; + } + + @Post('/:id/invitations') + @Guard('invite', 'question') + async inviteUserAnswerQuestion( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Body() { user_id: invitedUserId }: InviteUsersAnswerRequestDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + const inviteId = await this.questionsService.inviteUsersToAnswerQuestion( + id, + invitedUserId, + ); + return { + code: 201, + message: 'Invited', + data: { + invitationId: inviteId, + }, + }; + } + + @Delete('/:id/invitations/:invitation_id') + @Guard('uninvite', 'question') + async cancelInvition( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Param('invitation_id', ParseIntPipe) invitationId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + await this.questionsService.cancelInvitation(id, invitationId); + return { + code: 204, + message: 'successfully cancelled', + }; + } + + //! The static route `/:id/invitations/recommendations` should come + //! before the dynamic route `/:id/invitations/:invitation_id` + //! so that it is not overridden. + @Get('/:id/invitations/recommendations') + @Guard('query-invitation-recommendations', 'question') + async getRecommendations( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Query('page_size') + pageSize: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + ): Promise { + const users = + await this.questionsService.getQuestionInvitationRecommendations( + id, + pageSize, + userId, + ip, + userAgent, + ); + return { + code: 200, + message: 'successfully', + data: { + users, + }, + }; + } + + @Get('/:id/invitations/:invitation_id') + @Guard('query-invitation', 'question') + async getInvitationDetail( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Param('invitation_id', ParseIntPipe) invitationId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + ): Promise { + const invitationDto = await this.questionsService.getQuestionInvitationDto( + id, + invitationId, + userId, + ip, + userAgent, + ); + return { + code: 200, + message: 'successfully', + data: { + invitation: invitationDto, + }, + }; + } + + @Put('/:id/bounty') + @Guard('set-bounty', 'question') + async setBounty( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @Body() { bounty }: SetBountyDto, + ): Promise { + await this.questionsService.setBounty(id, bounty); + return { + code: 200, + message: 'OK', + }; + } + + @Put('/:id/acceptance') + @Guard('accept-answer', 'question') + async acceptAnswer( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Query('answer_id', ParseIntPipe) answer_id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + await this.questionsService.acceptAnswer(id, answer_id); + return { + code: 200, + message: 'OK', + }; + } +} diff --git a/src/questions/questions.error.ts b/src/questions/questions.error.ts new file mode 100644 index 00000000..6693dec6 --- /dev/null +++ b/src/questions/questions.error.ts @@ -0,0 +1,96 @@ +/* + * Description: This file defines the errors related to questions service. + * All the errors in this file should extend BaseError. + * + * Author(s): + * Nictheboy Li + * Andy Lee + * HuanCheng65 + * + */ + +import { BaseError } from '../common/error/base-error'; +export const BOUNTY_LIMIT = 20; +export class QuestionNotFoundError extends BaseError { + constructor(id: number) { + super('QuestionNotFoundError', `Question with id ${id} is not found.`, 404); + } +} + +export class QuestionAlreadyFollowedError extends BaseError { + constructor(id: number) { + super( + 'QuestionAlreadyFollowedError', + `Question with id ${id} is already followed.`, + 400, + ); + } +} + +export class QuestionNotFollowedYetError extends BaseError { + constructor(id: number) { + super( + 'QuestionNotFollowedYetError', + `Question with id ${id} is not followed yet.`, + 400, + ); + } +} + +export class QuestionInvitationNotFoundError extends BaseError { + constructor(id: number) { + super( + 'QuestionInvitationNotFoundError', + `Question invitation with id ${id} is not found.`, + 400, + ); + } +} +export class QuestionNotHasThisTopicError extends BaseError { + constructor(id: number, topicId: number) { + super( + 'QuestionNotHasThisTopicError', + `Question with id ${id} does not have topic with id ${topicId}.`, + 400, + ); + } +} + +export class BountyOutOfLimitError extends BaseError { + constructor(bounty: number) { + super( + 'BountyOutOfLimitError', + `Bounty ${bounty} is outside the limit of 0 and ${BOUNTY_LIMIT}.`, + 400, + ); + } +} +export class UserAlreadyInvitedError extends BaseError { + constructor(id: number) { + super( + 'UserAlreadyInvitedError', + `User with id ${id} is already invited.`, + 400, + ); + } +} + +export class AlreadyAnsweredError extends BaseError { + constructor(id: number) { + super( + 'AlreadyAnsweredError', + `User with id ${id} has already answered the question.`, + 400, + ); + } +} + +export class BountyNotBiggerError extends BaseError { + constructor(id: number, bounty: number) { + super( + 'BountyNotBiggerError', + `Bounty ${bounty} is not bigger than the current bounty of question ${id}.`, + 400, + ); + } +} diff --git a/src/questions/questions.es-doc.ts b/src/questions/questions.es-doc.ts new file mode 100644 index 00000000..74e9d930 --- /dev/null +++ b/src/questions/questions.es-doc.ts @@ -0,0 +1,5 @@ +export class QuestionElasticsearchDocument { + id: number; + title: string; + content: string; +} diff --git a/src/questions/questions.es.prisma b/src/questions/questions.es.prisma new file mode 100644 index 00000000..5e0899e4 --- /dev/null +++ b/src/questions/questions.es.prisma @@ -0,0 +1,11 @@ +import { Question } from "questions" + +model QuestionElasticsearchRelation { + id Int @id @default(autoincrement()) + question Question @relation(fields: [questionId], references: [id], map: "fk_question_elasticsearch_relation_question_id") + questionId Int @unique + elasticsearchId String + + @@index([questionId]) + @@index([elasticsearchId]) +} diff --git a/src/questions/questions.invitation.prisma b/src/questions/questions.invitation.prisma new file mode 100644 index 00000000..7f672721 --- /dev/null +++ b/src/questions/questions.invitation.prisma @@ -0,0 +1,15 @@ +import { User } from "../users/users" +import { Question } from "../questions/questions" + +model QuestionInvitationRelation { + id Int @id @default(autoincrement()) + questionId Int + question Question @relation(fields: [questionId], references: [id]) + userId Int + user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([questionId]) + @@index([userId]) +} diff --git a/src/questions/questions.module.ts b/src/questions/questions.module.ts new file mode 100644 index 00000000..0e7b6858 --- /dev/null +++ b/src/questions/questions.module.ts @@ -0,0 +1,36 @@ +/* + * Description: This file defines the questions module. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Module, forwardRef } from '@nestjs/common'; +import { AnswerModule } from '../answer/answer.module'; +import { AttitudeModule } from '../attitude/attitude.module'; +import { AuthModule } from '../auth/auth.module'; +import { ConfiguredElasticsearchModule } from '../common/config/elasticsearch.module'; +import { PrismaModule } from '../common/prisma/prisma.module'; +import { GroupsModule } from '../groups/groups.module'; +import { TopicsModule } from '../topics/topics.module'; +import { UsersModule } from '../users/users.module'; +import { QuestionsController } from './questions.controller'; +import { QuestionsService } from './questions.service'; + +@Module({ + imports: [ + ConfiguredElasticsearchModule, + PrismaModule, + AuthModule, + forwardRef(() => UsersModule), + TopicsModule, + forwardRef(() => AttitudeModule), + forwardRef(() => GroupsModule), + forwardRef(() => AnswerModule), + ], + controllers: [QuestionsController], + providers: [QuestionsService], + exports: [QuestionsService], +}) +export class QuestionsModule {} diff --git a/src/questions/questions.prisma b/src/questions/questions.prisma new file mode 100644 index 00000000..0fc93fd7 --- /dev/null +++ b/src/questions/questions.prisma @@ -0,0 +1,98 @@ +import { GroupQuestionRelationship } from "../groups/groups" +import { User } from "../users/users" +import { Topic } from "../topics/topics" +import { Answer } from "../answer/answer" +import { QuestionElasticsearchRelation } from "questions.es" +import { QuestionInvitationRelation } from "questions.invitation" + +model Question { + id Int @id(map: "PK_21e5786aa0ea704ae185a79b2d5") @default(autoincrement()) + createdById Int @map("created_by_id") + title String + content String + type Int + groupId Int? @map("group_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + answer Answer[] + groupQuestionRelationship GroupQuestionRelationship? + user User @relation(fields: [createdById], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_187915d8eaa010cde8b053b35d5") + bounty Int @default(0) + bountyStartAt DateTime? @map("bounty_start_at") @db.Timestamptz(6) + acceptedAnswer Answer? @relation("AcceptedAnswer", fields: [acceptedAnswerId], references: [id]) + acceptedAnswerId Int? @unique() @map("accepted_answer_id") + questionFollowerRelation QuestionFollowerRelation[] + questionQueryLog QuestionQueryLog[] + questionTopicRelation QuestionTopicRelation[] + questionInvitationRelation QuestionInvitationRelation[] + questionElasticsearchRelation QuestionElasticsearchRelation? + + @@index([createdById], map: "IDX_187915d8eaa010cde8b053b35d") + @@index([title, content], map: "IDX_8b24620899a8556c3f22f52145") + @@index([groupId], map: "IDX_ac7c68d428ab7ffd2f4752eeaa") + @@map("question") +} + +model QuestionFollowerRelation { + id Int @id(map: "PK_5f5ce2e314f975612a13d601362") @default(autoincrement()) + questionId Int @map("question_id") + followerId Int @map("follower_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + user User @relation(fields: [followerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_21a30245c4a32d5ac67da809010") + question Question @relation(fields: [questionId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_6544f7f7579bf88e3c62f995f8a") + + @@index([followerId], map: "IDX_21a30245c4a32d5ac67da80901") + @@index([questionId], map: "IDX_6544f7f7579bf88e3c62f995f8") + @@map("question_follower_relation") +} + +model QuestionQueryLog { + id Int @id(map: "PK_2876061262a774e4aba4daaaae4") @default(autoincrement()) + viewerId Int? @map("viewer_id") + questionId Int @map("question_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User? @relation(fields: [viewerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_8ce4bcc67caf0406e6f20923d4d") + question Question @relation(fields: [questionId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_a0ee1672e103ed0a0266f217a3f") + + @@index([viewerId], map: "IDX_8ce4bcc67caf0406e6f20923d4") + @@index([questionId], map: "IDX_a0ee1672e103ed0a0266f217a3") + @@map("question_query_log") +} + +model QuestionSearchLog { + id Int @id(map: "PK_6f41b41474cf92c67a7da97384c") @default(autoincrement()) + keywords String @db.VarChar + firstQuestionId Int? @map("first_question_id") + pageSize Int @map("page_size") + result String @db.VarChar + duration Float + searcherId Int? @map("searcher_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User? @relation(fields: [searcherId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_13c7e9fd7403cc5a87ab6524bc4") + + @@index([searcherId], map: "IDX_13c7e9fd7403cc5a87ab6524bc") + @@index([keywords], map: "IDX_2fbe3aa9f62233381aefeafa00") + @@map("question_search_log") +} + +model QuestionTopicRelation { + id Int @id(map: "PK_c50ec8a9ac6c3007f0861e4a383") @default(autoincrement()) + questionId Int @map("question_id") + topicId Int @map("topic_id") + createdById Int @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + user User @relation(fields: [createdById], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_d439ea68a02c1e7ea9863fc3df1") + topic Topic @relation(fields: [topicId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_dd4b9a1b83559fa38a3a50463fd") + question Question @relation(fields: [questionId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_fab99c5e4fc380d9b7f9abbbb02") + + @@index([topicId], map: "IDX_dd4b9a1b83559fa38a3a50463f") + @@index([questionId], map: "IDX_fab99c5e4fc380d9b7f9abbbb0") + @@map("question_topic_relation") +} diff --git a/src/questions/questions.service.ts b/src/questions/questions.service.ts new file mode 100644 index 00000000..4cfceeb4 --- /dev/null +++ b/src/questions/questions.service.ts @@ -0,0 +1,1178 @@ +/* + * Description: This file implements the QuestionsService class. + * It is responsible for the business logic of questions. + * + * Author(s): + * Nictheboy Li + * Andy Lee + * HuanCheng65 + * + */ + +import { Inject, Injectable, Logger, forwardRef } from '@nestjs/common'; +import { ElasticsearchService } from '@nestjs/elasticsearch'; +import { + AttitudableType, + AttitudeType, + CommentCommentabletypeEnum, + PrismaClient, + Question, + QuestionInvitationRelation, + User, +} from '@prisma/client'; +import { AnswerNotFoundError } from '../answer/answer.error'; +import { AnswerService } from '../answer/answer.service'; +import { AttitudeStateDto } from '../attitude/DTO/attitude-state.dto'; +import { AttitudeService } from '../attitude/attitude.service'; +import { PageDto } from '../common/DTO/page-response.dto'; +import { PageHelper } from '../common/helper/page.helper'; +import { + getCurrWhereBySort, + getPrevWhereBySort, +} from '../common/helper/where.helper'; +import { SortPattern } from '../common/pipe/parse-sort-pattern.pipe'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { GroupsService } from '../groups/groups.service'; +import { TopicDto } from '../topics/DTO/topic.dto'; +import { TopicNotFoundError } from '../topics/topics.error'; +import { TopicsService } from '../topics/topics.service'; +import { UserDto } from '../users/DTO/user.dto'; +import { UserIdNotFoundError } from '../users/users.error'; +import { UsersService } from '../users/users.service'; +import { QuestionInvitationDto } from './DTO/question-invitation.dto'; +import { QuestionDto } from './DTO/question.dto'; +import { + AlreadyAnsweredError, + BOUNTY_LIMIT, + BountyNotBiggerError, + BountyOutOfLimitError, + QuestionAlreadyFollowedError, + QuestionInvitationNotFoundError, + QuestionNotFollowedYetError, + QuestionNotFoundError, + QuestionNotHasThisTopicError, + UserAlreadyInvitedError, +} from './questions.error'; +import { QuestionElasticsearchDocument } from './questions.es-doc'; + +@Injectable() +export class QuestionsService { + constructor( + @Inject(forwardRef(() => UsersService)) + private readonly userService: UsersService, + private readonly topicService: TopicsService, + @Inject(forwardRef(() => AttitudeService)) + private readonly attitudeService: AttitudeService, + @Inject(forwardRef(() => GroupsService)) + private readonly groupService: GroupsService, + @Inject(forwardRef(() => AnswerService)) + private readonly answerService: AnswerService, + private readonly elasticSearchService: ElasticsearchService, + private readonly prismaService: PrismaService, + ) {} + + async addTopicToQuestion( + questionId: number, + topicId: number, + createdById: number, + // for transaction + omitQuestionExistsCheck: boolean = false, + prismaClient: PrismaClient | undefined = this.prismaService, + ): Promise { + if ( + !omitQuestionExistsCheck && + (await this.isQuestionExists(questionId)) == false + ) + throw new QuestionNotFoundError(questionId); + if ((await this.topicService.isTopicExists(topicId)) == false) + throw new TopicNotFoundError(topicId); + if ((await this.userService.isUserExists(createdById)) == false) + throw new UserIdNotFoundError(createdById); + await prismaClient.questionTopicRelation.create({ + data: { + questionId, + topicId, + createdById, + createdAt: new Date(), + }, + }); + } + + async deleteTopicFromQuestion( + questionId: number, + topicId: number, + // for transaction + prismaClient: PrismaClient | undefined = this.prismaService, + ): Promise { + const ret = await prismaClient.questionTopicRelation.updateMany({ + where: { + topicId, + questionId, + }, + data: { + deletedAt: new Date(), + }, + }); + if (ret.count == 0) + throw new QuestionNotHasThisTopicError(questionId, topicId); + /* istanbul ignore if */ + if (ret.count > 1) + Logger.error( + `More than one question-topic relation deleted. questionId: ${questionId}, topicId: ${topicId}`, + ); + } + + // returns: question id + async addQuestion( + askerUserId: number, + title: string, + content: string, + type: number, + topicIds: number[], + groupId?: number, + bounty: number = 0, + ): Promise { + /* istanbul ignore if */ + if (bounty < 0 || bounty > BOUNTY_LIMIT) + throw new BountyOutOfLimitError(bounty); + + for (const topicId of topicIds) { + const topicExists = await this.topicService.isTopicExists(topicId); + if (topicExists == false) throw new TopicNotFoundError(topicId); + } + + // const nonExistTopicId = topicIds.find(async (topicId) => { + // const exist = await this.topicService.isTopicExists(topicId); + // return !exist; + // }); + // if (nonExistTopicId) throw new TopicNotFoundError(nonExistTopicId); + + // TODO: Validate groupId. + + let question: Question; + await this.prismaService.$transaction( + async (prismaClient) => { + question = await prismaClient.question.create({ + data: { + createdById: askerUserId, + title, + content, + type, + groupId, + bounty, + bountyStartAt: bounty ? new Date() : undefined, + createdAt: new Date(), + }, + }); + for (const topicId of topicIds) { + await this.addTopicToQuestion( + question.id, + topicId, + askerUserId, + true, + prismaClient as PrismaClient, // for transaction + ); + } + }, + { maxWait: 60000, timeout: 60000 }, + ); + + /* istanbul ignore if */ + if (question! == undefined) + throw new Error( + "Impossible: variable 'question' is undefined after transaction.", + ); + const esIndexResult = + await this.elasticSearchService.index({ + index: 'questions', + document: { + id: question.id, + title: question.title, + content: question.content, + }, + }); + await this.prismaService.questionElasticsearchRelation.create({ + data: { + elasticsearchId: esIndexResult._id, + question: { connect: { id: question.id } }, + }, + }); + return question.id; + } + + async hasFollowedQuestion( + userId: number | undefined, + questionId: number, + ): Promise { + if (userId == undefined) return false; + return ( + (await this.prismaService.questionFollowerRelation.count({ + where: { + followerId: userId, + questionId, + }, + })) > 0 + ); + } + + // returns: a list of topicId + async getTopicDtosOfQuestion( + questionId: number, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise { + const relations = await this.prismaService.questionTopicRelation.findMany({ + where: { + questionId, + }, + }); + return await Promise.all( + relations.map((relation) => + this.topicService.getTopicDtoById( + relation.topicId, + viewerId, + ip, + userAgent, + ), + ), + ); + } + + async getFollowCountOfQuestion(questionId: number): Promise { + return await this.prismaService.questionFollowerRelation.count({ + where: { + questionId, + }, + }); + } + + async getViewCountOfQuestion(questionId: number): Promise { + return await this.prismaService.questionQueryLog.count({ + where: { + questionId, + }, + }); + } + + async getQuestionDto( + questionId: number, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise { + const question = await this.prismaService.question.findUnique({ + where: { + id: questionId, + }, + include: { acceptedAnswer: true }, + }); + if (question == undefined) throw new QuestionNotFoundError(questionId); + + let userDto: UserDto | null = null; // For case that user is deleted. + try { + userDto = await this.userService.getUserDtoById( + question.createdById, + viewerId, + ip, + userAgent, + ); + } catch (e) { + // If user is null, it means that one user created this question, but the user + // does not exist now. This is NOT a data integrity problem, since user can be + // deleted. So we just return a null and not throw an error. + } + const topicsPromise = this.getTopicDtosOfQuestion( + questionId, + viewerId, + ip, + userAgent, + ); + const hasFollowedPromise = this.hasFollowedQuestion(viewerId, questionId); + const followCountPromise = this.getFollowCountOfQuestion(questionId); + const viewCountPromise = this.getViewCountOfQuestion(questionId); + const myAnswerIdPromise = + viewerId == undefined + ? Promise.resolve(undefined) // If the viewer is not logged in, then the field should be missing. + : this.answerService.getAnswerIdOfCreatedBy(questionId, viewerId); // If the viewer is logged in, then the field should be a number or null. + const attitudeDtoPromise = this.attitudeService.getAttitudeStatusDto( + AttitudableType.QUESTION, + questionId, + viewerId, + ); + const answerCountPromise = this.prismaService.answer.count({ + where: { + questionId, + }, + }); + const commentCountPromise = this.prismaService.comment.count({ + where: { + commentableType: CommentCommentabletypeEnum.QUESTION, + commentableId: questionId, + }, + }); + const groupDtoPromise = + question.groupId == undefined + ? Promise.resolve(null) + : this.groupService.getGroupDtoById( + undefined, + question.groupId, + ip, + userAgent, + ); + const acceptedAnswerDtoPromise = + question.acceptedAnswer == undefined + ? Promise.resolve(null) + : this.answerService.getAnswerDto( + questionId, + question.acceptedAnswer.id, + viewerId, + ip, + userAgent, + ); + + const [ + topics, + hasFollowed, + followCount, + viewCount, + myAnswerId, + attitudeDto, + answerCount, + commentCount, + groupDto, + acceptedAnswerDto, + ] = await Promise.all([ + topicsPromise, + hasFollowedPromise, + followCountPromise, + viewCountPromise, + myAnswerIdPromise, + attitudeDtoPromise, + answerCountPromise, + commentCountPromise, + groupDtoPromise, + acceptedAnswerDtoPromise, + ]); + if (viewerId != undefined && ip != undefined) { + // TODO: is checking all fields necessary? This is only a temporary solution to meet the not-null constraint. + // TODO: userAgent maybe null when testing + await this.prismaService.questionQueryLog.create({ + data: { + viewerId, + questionId, + ip, + userAgent: userAgent ?? '', + createdAt: new Date(), + }, + }); + } + return { + id: question.id, + title: question.title, + content: question.content, + author: userDto, + type: question.type, + topics, + created_at: question.createdAt.getTime(), + updated_at: question.updatedAt.getTime(), + is_follow: hasFollowed, + my_answer_id: myAnswerId, + answer_count: answerCount, + comment_count: commentCount, + follow_count: followCount, + attitudes: attitudeDto, + view_count: viewCount, + group: groupDto, + bounty: question.bounty, + bounty_start_at: question.bountyStartAt?.getTime(), + accepted_answer: acceptedAnswerDto, + }; + } + + async searchQuestions( + keywords: string, + firstQuestionId: number | undefined, // if from start + pageSize: number, + searcherId: number | undefined, // optional + ip: string, + userAgent: string | undefined, // optional + ): Promise<[QuestionDto[], PageDto]> { + const timeBegin = Date.now(); + const result = !keywords + ? { hits: { hits: [] } } + : await this.elasticSearchService.search({ + index: 'questions', + size: 1000, + body: { + query: { + multi_match: { + query: keywords, + fields: ['title', 'content'], + }, + }, + }, + }); + const allQuestionEsDocs = result.hits.hits + .filter((h) => h._source != undefined) + .map((h) => h._source) as QuestionElasticsearchDocument[]; + const [questionEsDocs, page] = PageHelper.PageFromAll( + allQuestionEsDocs, + firstQuestionId, + pageSize, + (i) => i.id, + (firstQuestionId) => { + throw new QuestionNotFoundError(firstQuestionId); + }, + ); + const questions = await Promise.all( + questionEsDocs.map((questionId) => + this.getQuestionDto(questionId.id, searcherId, ip, userAgent), + ), + ); + await this.prismaService.questionSearchLog.create({ + data: { + keywords, + firstQuestionId: firstQuestionId, + pageSize, + result: questionEsDocs.map((t) => t.id).join(','), + duration: (Date.now() - timeBegin) / 1000, + searcherId, + ip, + userAgent: userAgent ?? '', + createdAt: new Date(), + }, + }); + return [questions, page]; + } + + async updateQuestion( + questionId: number, + title: string, + content: string, + type: number, + topicIds: number[], + updateById: number, + ): Promise { + if ((await this.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + await this.prismaService.$transaction( + async (prismaClient) => { + // const questionRepository = entityManager.getRepository(Question); + // question.title = title; + // question.content = content; + // question.type = type; + // await questionRepository.save(question); + await prismaClient.question.update({ + where: { + id: questionId, + }, + data: { + title, + content, + type, + }, + }); + const oldTopicIds = ( + await prismaClient.questionTopicRelation.findMany({ + where: { + questionId, + }, + }) + ).map((r) => r.topicId); + const toDelete = oldTopicIds.filter((id) => !topicIds.includes(id)); + const toAdd = topicIds.filter((id) => !oldTopicIds.includes(id)); + for (const id of toDelete) { + await this.deleteTopicFromQuestion( + questionId, + id, + prismaClient as PrismaClient, + ); + } + for (const id of toAdd) { + await this.addTopicToQuestion( + questionId, + id, + updateById, + false, + prismaClient as PrismaClient, + ); + } + const esRelation = + await this.prismaService.questionElasticsearchRelation.findUnique({ + where: { questionId }, + }); + + /* istanbul ignore if */ + if (esRelation == null) + throw new Error( + `Question with id ${questionId} exists, ` + + `but there is no record of its elaticsearch id. ` + + `This is impossible if the program works well. ` + + `It might be caused by a bug, a database migration problem, ` + + `or that the database has corrupted.`, + ); + const questionEsDocNew: QuestionElasticsearchDocument = { + id: questionId, + title: title, + content: content, + }; + await this.elasticSearchService.update({ + index: 'questions', + id: esRelation.elasticsearchId, + doc: questionEsDocNew, + }); + }, + { maxWait: 60000, timeout: 60000 }, + ); + } + + async getQuestionCreatedById(questionId: number): Promise { + const question = await this.prismaService.question.findUnique({ + where: { + id: questionId, + }, + }); + if (question == undefined) throw new QuestionNotFoundError(questionId); + return question.createdById; + } + + async deleteQuestion(questionId: number): Promise { + if ((await this.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + const esRelation = + await this.prismaService.questionElasticsearchRelation.findUnique({ + where: { questionId }, + }); + + /* istanbul ignore if */ + if (esRelation == null) + throw new Error( + `Question with id ${questionId} exists, ` + + `but there is no record of its elaticsearch id. ` + + `This is impossible if the program works well. ` + + `It might be caused by a bug, a database migration problem, ` + + `or that the database has corrupted.`, + ); + await this.elasticSearchService.delete({ + index: 'questions', + id: esRelation.elasticsearchId, + }); + await this.prismaService.questionElasticsearchRelation.delete({ + where: { questionId }, + }); + // await this.questionRepository.softDelete({ id: questionId }); + await this.prismaService.question.update({ + where: { id: questionId }, + data: { + deletedAt: new Date(), + }, + }); + } + + async followQuestion(followerId: number, questionId: number): Promise { + if ((await this.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + if ((await this.userService.isUserExists(followerId)) == false) + throw new UserIdNotFoundError(followerId); + + if ( + (await this.prismaService.questionFollowerRelation.count({ + where: { + followerId, + questionId, + }, + })) > 0 + ) + throw new QuestionAlreadyFollowedError(questionId); + + await this.prismaService.questionFollowerRelation.create({ + data: { + followerId, + questionId, + createdAt: new Date(), + }, + }); + } + + async unfollowQuestion( + followerId: number, + questionId: number, + ): Promise { + if ((await this.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + if ((await this.userService.isUserExists(followerId)) == false) + throw new UserIdNotFoundError(followerId); + + if ( + (await this.prismaService.questionFollowerRelation.count({ + where: { + followerId, + questionId, + }, + })) == 0 + ) + throw new QuestionNotFollowedYetError(questionId); + const ret = await this.prismaService.questionFollowerRelation.updateMany({ + where: { + followerId, + questionId, + }, + data: { + deletedAt: new Date(), + }, + }); + /* istanbul ignore if */ + if (ret.count == 0) throw new QuestionNotFollowedYetError(questionId); + /* istanbul ignore if */ + if (ret.count > 1) + throw new Error( + `More than one question-follower relation deleted. followerId: ${followerId}, questionId: ${questionId}`, + ); + } + + async getFollowedQuestions( + followerId: number, + firstQuestionId: number | undefined, // if from start + pageSize: number, + viewerId: number | undefined, // optional + ip: string, + userAgent: string | undefined, // optional + ): Promise<[QuestionDto[], PageDto]> { + if ((await this.userService.isUserExists(followerId)) == false) + throw new UserIdNotFoundError(followerId); + if (firstQuestionId == undefined) { + const relations = + await this.prismaService.questionFollowerRelation.findMany({ + where: { + followerId, + }, + take: pageSize + 1, + orderBy: { + questionId: 'asc', + }, + }); + const DTOs = await Promise.all( + relations.map((r) => { + return this.getQuestionDto(r.questionId, viewerId, ip, userAgent); + }), + ); + return PageHelper.PageStart(DTOs, pageSize, (item) => item.id); + } else { + const prevPromise = this.prismaService.questionFollowerRelation.findMany({ + where: { + followerId, + questionId: { + lt: firstQuestionId, + }, + }, + take: pageSize, + orderBy: { + questionId: 'desc', + }, + }); + const currPromise = this.prismaService.questionFollowerRelation.findMany({ + where: { + followerId, + questionId: { + gte: firstQuestionId, + }, + }, + take: pageSize + 1, + orderBy: { + questionId: 'asc', + }, + }); + const [prev, curr] = await Promise.all([prevPromise, currPromise]); + const currDTOs = await Promise.all( + curr.map((record) => + this.getQuestionDto(record.questionId, viewerId, ip, userAgent), + ), + ); + return PageHelper.PageMiddle( + prev, + currDTOs, + pageSize, + (i) => i.questionId, + (i) => i.id, + ); + } + } + + async getQuestionFollowers( + questionId: number, + firstFollowerId: number | undefined, // if from start + pageSize: number, + viewerId: number | undefined, // optional + ip: string, + userAgent: string | undefined, // optional + ): Promise<[UserDto[], PageDto]> { + if (firstFollowerId == undefined) { + const relations = + await this.prismaService.questionFollowerRelation.findMany({ + where: { + questionId, + }, + take: pageSize + 1, + orderBy: { + followerId: 'asc', + }, + }); + const DTOs = await Promise.all( + relations.map((r) => { + return this.userService.getUserDtoById( + r.followerId, + viewerId, + ip, + userAgent, + ); + }), + ); + return PageHelper.PageStart(DTOs, pageSize, (item) => item.id); + } else { + const prevRelationshipsPromise = + this.prismaService.questionFollowerRelation.findMany({ + where: { + questionId, + followerId: { + lt: firstFollowerId, + }, + }, + take: pageSize, + orderBy: { + followerId: 'desc', + }, + }); + const queriedRelationsPromise = + this.prismaService.questionFollowerRelation.findMany({ + where: { + questionId, + followerId: { + gte: firstFollowerId, + }, + }, + take: pageSize + 1, + orderBy: { + followerId: 'asc', + }, + }); + const DTOs = await Promise.all( + (await queriedRelationsPromise).map((r) => { + return this.userService.getUserDtoById( + r.followerId, + viewerId, + ip, + userAgent, + ); + }), + ); + const prev = await prevRelationshipsPromise; + return PageHelper.PageMiddle( + prev, + DTOs, + pageSize, + (i) => i.followerId, + (i) => i.id, + ); + } + } + + // returns: + // invitation id + async inviteUsersToAnswerQuestion( + questionId: number, + userId: number, + ): Promise { + if ((await this.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + if ((await this.userService.isUserExists(userId)) == false) + throw new UserIdNotFoundError(userId); + const haveBeenAnswered = await this.answerService.getAnswerIdOfCreatedBy( + questionId, + userId, + ); + if (haveBeenAnswered) { + throw new AlreadyAnsweredError(userId); + } + const haveBeenInvited = + await this.prismaService.questionInvitationRelation.findFirst({ + where: { + questionId: questionId, + userId: userId, + }, + }); + if (haveBeenInvited) { + throw new UserAlreadyInvitedError(userId); + } + + const invitation = + await this.prismaService.questionInvitationRelation.create({ + data: { + questionId, + userId, + }, + }); + return invitation.id; + } + + async getQuestionInvitations( + questionId: number, + sort: SortPattern, + pageStart: number | undefined, + pageSize: number | undefined = 20, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise<[QuestionInvitationDto[], PageDto]> { + const record2dto = async ( + invitation: QuestionInvitationRelation, + ): Promise => { + return { + id: invitation.id, + question_id: invitation.questionId, + user: await this.userService.getUserDtoById( + invitation.userId, + viewerId, + ip, + userAgent, + ), + created_at: invitation.createdAt.getTime(), + updated_at: invitation.updatedAt.getTime(), + is_answered: await this.isQuestionAnsweredBy( + questionId, + invitation.userId, + ), + }; + }; + + if ((await this.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + if (pageStart == undefined) { + const invitations = + await this.prismaService.questionInvitationRelation.findMany({ + where: { questionId }, + orderBy: sort, + take: pageSize + 1, + }); + const invitationDtos: QuestionInvitationDto[] = await Promise.all( + invitations.map(record2dto), + ); + return PageHelper.PageStart(invitationDtos, pageSize, (i) => i.id); + } else { + const cursor = + await this.prismaService.questionInvitationRelation.findUnique({ + where: { id: pageStart }, + }); + if (cursor == undefined) + throw new QuestionInvitationNotFoundError(pageStart); + const prev = await this.prismaService.questionInvitationRelation.findMany( + { + where: { + questionId, + ...getPrevWhereBySort(sort, cursor), + }, + orderBy: sort, + take: pageSize, + }, + ); + const curr = await this.prismaService.questionInvitationRelation.findMany( + { + where: { + questionId, + ...getCurrWhereBySort(sort, cursor), + }, + orderBy: sort, + take: pageSize + 1, + }, + ); + const currDtos = await Promise.all(curr.map(record2dto)); + return PageHelper.PageMiddle( + prev, + currDtos, + pageSize, + (i) => i.id, + (i) => i.id, + ); + } + } + + async getQuestionInvitationRecommendations( + questionId: number, + pageSize = 5, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise { + if ((await this.isQuestionExists(questionId)) == false) { + throw new QuestionNotFoundError(questionId); + } + // No sql injection here: + // "The method is implemented as a tagged template, which allows you to pass a template literal where you can easily + // insert your variables. In turn, Prisma Client creates prepared statements that are safe from SQL injections." + // See: https://www.prisma.io/docs/orm/prisma-client/queries/raw-database-access/raw-queries + const randomUserEntities = await this.prismaService.$queryRaw` + SELECT * FROM "user" WHERE id NOT IN ( + SELECT "user_id" FROM question_invitation_relation + WHERE "question_id" = ${questionId} + ) + ORDER BY RANDOM() + LIMIT ${pageSize} + `; + // const randomUserEntities = await this.prismaService.user.findMany({ + // take: pageSize, + // orderBy: { + // id: 'asc', + // }, //TODO + // where: { + // NOT: { + // QuestionInvitationRelation: { + // some: { + // questionId, + // }, + // } + // } + // } + // }); + + const userDtos = await Promise.all( + randomUserEntities.map((entity) => + this.userService.getUserDtoById(entity.id, viewerId, ip, userAgent), + ), + ); + + return userDtos; + } + async getQuestionInvitationDto( + questionId: number, + invitationId: number, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise { + if ((await this.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + const invitation = + await this.prismaService.questionInvitationRelation.findFirst({ + where: { id: invitationId, questionId }, + }); + if (!invitation) { + throw new QuestionInvitationNotFoundError(invitationId); + } + const userdto = await this.userService.getUserDtoById( + invitation.userId, + viewerId, + ip, + userAgent, + ); + return { + id: invitation.id, + question_id: invitation.questionId, + user: userdto, + created_at: invitation.createdAt.getTime(), + updated_at: invitation.updatedAt.getTime(), + is_answered: await this.isQuestionAnsweredBy( + questionId, + invitation.userId, + ), + }; + } + + async cancelInvitation( + questionId: number, + invitationId: number, + ): Promise { + if ((await this.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + const invitation = + await this.prismaService.questionInvitationRelation.findFirst({ + where: { id: invitationId, questionId }, + }); + if (!invitation) { + throw new QuestionInvitationNotFoundError(invitationId); + } + await this.prismaService.questionInvitationRelation.delete({ + where: { + id: invitationId, + }, + }); + } + + async isQuestionExists(questionId: number): Promise { + return ( + (await this.prismaService.question.count({ + where: { + id: questionId, + }, + })) > 0 + ); + } + + async setAttitudeToQuestion( + questionId: number, + userId: number, + attitudeType: AttitudeType, + ): Promise { + if ((await this.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + await this.attitudeService.setAttitude( + userId, + AttitudableType.QUESTION, + questionId, + attitudeType, + ); + return this.attitudeService.getAttitudeStatusDto( + AttitudableType.QUESTION, + questionId, + userId, + ); + } + + async isQuestionAnsweredBy( + questionId: number, + userId: number | undefined, + ): Promise { + if (userId == undefined) return false; + return ( + (await this.answerService.getAnswerIdOfCreatedBy(questionId, userId)) != + undefined + ); + } + + async getInvitedById( + questionId: number, + invitationId: number, + ): Promise { + if ((await this.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + const invitation = + await this.prismaService.questionInvitationRelation.findUnique({ + where: { + questionId, + id: invitationId, + }, + }); + if (invitation == undefined) + throw new QuestionInvitationNotFoundError(invitationId); + return invitation.userId; + } + + async setBounty(questionId: number, bounty: number): Promise { + /* istanbul ignore if */ + if (bounty < 0 || bounty > BOUNTY_LIMIT) + throw new BountyOutOfLimitError(bounty); + + if ((await this.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + + const oldBounty = ( + await this.prismaService.question.findUniqueOrThrow({ + where: { id: questionId }, + }) + ).bounty; + if (!(bounty > oldBounty)) { + throw new BountyNotBiggerError(questionId, bounty); + } + + await this.prismaService.question.update({ + where: { id: questionId }, + data: { + bounty, + bountyStartAt: new Date(), + }, + }); + } + + async acceptAnswer(questionId: number, answerId: number): Promise { + if ((await this.isQuestionExists(questionId)) == false) + throw new QuestionNotFoundError(questionId); + if ( + (await this.answerService.isAnswerExists(questionId, answerId)) == false + ) + throw new AnswerNotFoundError(answerId); + + await this.prismaService.question.update({ + where: { id: questionId }, + data: { + acceptedAnswer: { + connect: { + id: answerId, + }, + }, + }, + }); + } + + getQuestionCount(userId: number): Promise { + return this.prismaService.question.count({ + where: { + createdById: userId, + }, + }); + } + + async getUserAskedQuestions( + userId: number, + pageStart: number | undefined, + pageSize: number, + viewerId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise<[QuestionDto[], PageDto]> { + if ((await this.userService.isUserExists(userId)) == false) + throw new UserIdNotFoundError(userId); + if (!pageStart) { + const currPage = await this.prismaService.question.findMany({ + where: { + createdById: userId, + }, + orderBy: { + id: 'asc', + }, + take: pageSize + 1, + }); + const currDto = await Promise.all( + currPage.map(async (entity) => { + return this.getQuestionDto(entity.id, viewerId, ip, userAgent); + }), + ); + return PageHelper.PageStart(currDto, pageSize, (answer) => answer.id); + } else { + const prevPage = await this.prismaService.question.findMany({ + where: { + createdById: userId, + id: { + lt: pageStart, + }, + }, + orderBy: { + id: 'desc', + }, + take: pageSize, + }); + const currPage = await this.prismaService.question.findMany({ + where: { + createdById: userId, + id: { + gte: pageStart, + }, + }, + orderBy: { + id: 'asc', + }, + take: pageSize + 1, + }); + const currDto = await Promise.all( + currPage.map(async (entity) => { + return this.getQuestionDto(entity.id, viewerId, ip, userAgent); + }), + ); + return PageHelper.PageMiddle( + prevPage, + currDto, + pageSize, + (answer) => answer.id, + (answer) => answer.id, + ); + } + } +} diff --git a/src/questions/questions.spec.ts b/src/questions/questions.spec.ts new file mode 100644 index 00000000..57d06422 --- /dev/null +++ b/src/questions/questions.spec.ts @@ -0,0 +1,135 @@ +/* + * Description: This file provide additional tests to questions module. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { AppModule } from '../app.module'; +import { TopicNotFoundError } from '../topics/topics.error'; +import { TopicsService } from '../topics/topics.service'; +import { UserIdNotFoundError } from '../users/users.error'; +import { UsersService } from '../users/users.service'; +import { QuestionNotFoundError } from './questions.error'; +import { QuestionsService } from './questions.service'; + +describe('Questions Module', () => { + const randomString = Math.floor(Math.random() * 10000000000).toString(); + let app: TestingModule; + let questionsService: QuestionsService; + let topicsService: TopicsService; + let usersService: UsersService; + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + questionsService = app.get(QuestionsService); + topicsService = app.get(TopicsService); + usersService = app.get(UsersService); + }); + afterAll(async () => { + await app.close(); + }); + + it('should wait until user with id 1 exists', async () => { + /* eslint-disable no-constant-condition */ + while (true) { + try { + await usersService.getUserDtoById(1, 1, '127.0.0.1', 'some user agent'); + } catch (e) { + // wait one second + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue; + } + break; + } + }); + + let questionId: number; + let topicId1: number; + let topicId2: number; + + it('should add topic to question', async () => { + topicId1 = ( + await topicsService.addTopic(randomString + ' unit test topic 1', 1) + ).id; + topicId2 = ( + await topicsService.addTopic(randomString + ' unit test topic 2', 1) + ).id; + questionId = await questionsService.addQuestion( + 1, + 'unit test question', + 'unit test question description', + 0, + [topicId1], + ); + const questionDto1 = await questionsService.getQuestionDto( + questionId, + 1, + '127.0.0.1', + 'some user agent', + ); + expect(questionDto1.topics.length).toBe(1); + expect(questionDto1.topics).toContainEqual({ + id: topicId1, + name: `${randomString} unit test topic 1`, + }); + await questionsService.addTopicToQuestion(questionId, topicId2, 1); + const questionDto2 = await questionsService.getQuestionDto( + questionId, + 1, + '127.0.0.1', + 'some user agent', + ); + expect(questionDto2.topics.length).toBe(2); + expect(questionDto2.topics).toContainEqual({ + id: topicId1, + name: `${randomString} unit test topic 1`, + }); + expect(questionDto2.topics).toContainEqual({ + id: topicId2, + name: `${randomString} unit test topic 2`, + }); + }); + + it('should throw UserIdNotFoundError', async () => { + await expect( + questionsService.followQuestion(-1, questionId), + ).rejects.toThrow(new UserIdNotFoundError(-1)); + await expect( + questionsService.unfollowQuestion(-1, questionId), + ).rejects.toThrow(new UserIdNotFoundError(-1)); + }); + + it('should throw QuestionNotFoundError', async () => { + await expect( + questionsService.addTopicToQuestion(-1, topicId1, 1), + ).rejects.toThrow(new QuestionNotFoundError(-1)); + }); + + it('should throw TopicNotFoundError', async () => { + await expect( + questionsService.addTopicToQuestion(questionId, -1, 1), + ).rejects.toThrow(new TopicNotFoundError(-1)); + }); + + it('should throw UserIdNotFoundError', async () => { + await expect( + questionsService.addTopicToQuestion(questionId, topicId1, -1), + ).rejects.toThrow(new UserIdNotFoundError(-1)); + }); + + it('should throw QuestionNotFoundError', async () => { + await expect( + questionsService.updateQuestion(-1, 'title', 'content', 0, [], 1), + ).rejects.toThrow(new QuestionNotFoundError(-1)); + await expect(questionsService.deleteQuestion(-1)).rejects.toThrow( + new QuestionNotFoundError(-1), + ); + await expect(questionsService.unfollowQuestion(1, -1)).rejects.toThrow( + new QuestionNotFoundError(-1), + ); + }); +}); diff --git a/src/resources/avatars/default.jpg b/src/resources/avatars/default.jpg new file mode 100644 index 00000000..1f997a97 Binary files /dev/null and b/src/resources/avatars/default.jpg differ diff --git a/src/resources/avatars/pre1.jpg b/src/resources/avatars/pre1.jpg new file mode 100644 index 00000000..77610056 Binary files /dev/null and b/src/resources/avatars/pre1.jpg differ diff --git a/src/resources/avatars/pre2.jpg b/src/resources/avatars/pre2.jpg new file mode 100644 index 00000000..0bb09f1e Binary files /dev/null and b/src/resources/avatars/pre2.jpg differ diff --git a/src/resources/avatars/pre3.jpg b/src/resources/avatars/pre3.jpg new file mode 100644 index 00000000..d1d7be7a Binary files /dev/null and b/src/resources/avatars/pre3.jpg differ diff --git a/src/resources/email-templates/password-reset.english.hbs b/src/resources/email-templates/password-reset.english.hbs new file mode 100644 index 00000000..f1e5bbd4 --- /dev/null +++ b/src/resources/email-templates/password-reset.english.hbs @@ -0,0 +1,3 @@ +

Hello, {{ username }}. You are trying to reset your password.

+

Please use the following link to reset your password:

+

{{ resetUrl }}

diff --git a/src/resources/email-templates/register-code.english.hbs b/src/resources/email-templates/register-code.english.hbs new file mode 100644 index 00000000..b5764aab --- /dev/null +++ b/src/resources/email-templates/register-code.english.hbs @@ -0,0 +1,2 @@ +

Wellcome to Cheese Society!

+

Your register code is: {{ code }}

diff --git a/src/topics/DTO/add-topic.dto.ts b/src/topics/DTO/add-topic.dto.ts new file mode 100644 index 00000000..c5325aa8 --- /dev/null +++ b/src/topics/DTO/add-topic.dto.ts @@ -0,0 +1,13 @@ +import { IsString } from 'class-validator'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class AddTopicRequestDto { + @IsString() + name: string; +} + +export class AddTopicResponseDto extends BaseResponseDto { + data: { + id: number; + }; +} diff --git a/src/topics/DTO/get-topic.dto.ts b/src/topics/DTO/get-topic.dto.ts new file mode 100644 index 00000000..32f0e9dc --- /dev/null +++ b/src/topics/DTO/get-topic.dto.ts @@ -0,0 +1,8 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { TopicDto } from './topic.dto'; + +export class GetTopicResponseDto extends BaseResponseDto { + data: { + topic: TopicDto; + }; +} diff --git a/src/topics/DTO/search-topic.dto.ts b/src/topics/DTO/search-topic.dto.ts new file mode 100644 index 00000000..312d84cc --- /dev/null +++ b/src/topics/DTO/search-topic.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { TopicDto } from './topic.dto'; + +export class SearchTopicResponseDto extends BaseResponseDto { + data: { + topics: TopicDto[]; + page: PageDto; + }; +} diff --git a/src/topics/DTO/topic.dto.ts b/src/topics/DTO/topic.dto.ts new file mode 100644 index 00000000..b2972725 --- /dev/null +++ b/src/topics/DTO/topic.dto.ts @@ -0,0 +1,4 @@ +export class TopicDto { + id: number; + name: string; +} diff --git a/src/topics/topics.controller.ts b/src/topics/topics.controller.ts new file mode 100644 index 00000000..c5533a4b --- /dev/null +++ b/src/topics/topics.controller.ts @@ -0,0 +1,109 @@ +/* + * Description: This file implements the topics controller. + * It is responsible for handling the requests to /topics/... + * + * Author(s): + * Nictheboy Li + * + */ + +import { + Body, + Controller, + Get, + Headers, + Ip, + Param, + ParseIntPipe, + Post, + Query, + UseFilters, + UseInterceptors, +} from '@nestjs/common'; +import { AuthService } from '../auth/auth.service'; +import { PageWithKeywordDto } from '../common/DTO/page.dto'; +import { BaseErrorExceptionFilter } from '../common/error/error-filter'; +import { TokenValidateInterceptor } from '../common/interceptor/token-validate.interceptor'; +import { AddTopicRequestDto, AddTopicResponseDto } from './DTO/add-topic.dto'; +import { GetTopicResponseDto } from './DTO/get-topic.dto'; +import { SearchTopicResponseDto } from './DTO/search-topic.dto'; +import { TopicsService } from './topics.service'; +import { UserId } from '../auth/user-id.decorator'; +import { AuthToken, Guard, ResourceId } from '../auth/guard.decorator'; + +@Controller('/topics') +export class TopicsController { + constructor( + private readonly topicsService: TopicsService, + private readonly authService: AuthService, + ) {} + + @Get('/') + @Guard('enumerate', 'topic') + async searchTopics( + @Query() + { q, page_start: pageStart, page_size: pageSize }: PageWithKeywordDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() searcherId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + ): Promise { + const [topics, pageRespond] = await this.topicsService.searchTopics( + q, + pageStart, + pageSize, + searcherId, + ip, + userAgent, + ); + return { + code: 200, + message: 'OK', + data: { + topics: topics, + page: pageRespond, + }, + }; + } + + @Post('/') + @Guard('create', 'topic') + async addTopic( + @Body() { name }: AddTopicRequestDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + const topic = await this.topicsService.addTopic(name, userId); + return { + code: 201, + message: 'OK', + data: { + id: topic.id, + }, + }; + } + + @Get('/:id') + @Guard('query', 'topic') + async getTopic( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() userId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string, + ): Promise { + const topic = await this.topicsService.getTopicDtoById( + id, + userId, + ip, + userAgent, + ); + return { + code: 200, + message: 'OK', + data: { + topic, + }, + }; + } +} diff --git a/src/topics/topics.error.ts b/src/topics/topics.error.ts new file mode 100644 index 00000000..8def1682 --- /dev/null +++ b/src/topics/topics.error.ts @@ -0,0 +1,26 @@ +/* + * Description: This file defines the errors related to topics service. + * All the errors in this file should extend BaseError. + * + * Author(s): + * Nictheboy Li + * + */ + +import { BaseError } from '../common/error/base-error'; + +export class TopicAlreadyExistsError extends BaseError { + constructor(topicName: string) { + super( + 'TopicAlreadyExistsError', + `Topic '${topicName}' already exists.`, + 409, + ); + } +} + +export class TopicNotFoundError extends BaseError { + constructor(topicId: number) { + super('TopicNotFoundError', `Topic with id '${topicId}' not found.`, 404); + } +} diff --git a/src/topics/topics.es-doc.ts b/src/topics/topics.es-doc.ts new file mode 100644 index 00000000..34c84f5b --- /dev/null +++ b/src/topics/topics.es-doc.ts @@ -0,0 +1,4 @@ +export class TopicElasticsearchDocument { + id: number; + name: string; +} diff --git a/src/topics/topics.module.ts b/src/topics/topics.module.ts new file mode 100644 index 00000000..d4e43809 --- /dev/null +++ b/src/topics/topics.module.ts @@ -0,0 +1,22 @@ +/* + * Description: This file defines the topics module. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ConfiguredElasticsearchModule } from '../common/config/elasticsearch.module'; +import { PrismaModule } from '../common/prisma/prisma.module'; +import { TopicsController } from './topics.controller'; +import { TopicsService } from './topics.service'; + +@Module({ + imports: [PrismaModule, ConfiguredElasticsearchModule, AuthModule], + controllers: [TopicsController], + providers: [TopicsService], + exports: [TopicsService], +}) +export class TopicsModule {} diff --git a/src/topics/topics.prisma b/src/topics/topics.prisma new file mode 100644 index 00000000..f8462e30 --- /dev/null +++ b/src/topics/topics.prisma @@ -0,0 +1,34 @@ +import { QuestionTopicRelation } from "../questions/questions" +import { User } from "../users/users" + +model Topic { + id Int @id(map: "PK_33aa4ecb4e4f20aa0157ea7ef61") @default(autoincrement()) + name String @unique(map: "idx_topic_name_unique") @db.VarChar + createdById Int @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + questionTopicRelation QuestionTopicRelation[] + user User @relation(fields: [createdById], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_59d7548ea797208240417106e2d") + + @@index([createdById], map: "IDX_59d7548ea797208240417106e2") + @@index([name], map: "idx_topic_name_ft") + @@map("topic") +} + +model TopicSearchLog { + id Int @id(map: "PK_41a432f5f993017b2502c73c78e") @default(autoincrement()) + keywords String @db.VarChar + firstTopicId Int? @map("first_topic_id") + pageSize Int @map("page_size") + result String @db.VarChar + duration Float + searcherId Int? @map("searcher_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User? @relation(fields: [searcherId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_fe1e75b8b625499f0119faaba5b") + + @@index([keywords], map: "IDX_85c1844b4fa3e29b1b8dfaeac6") + @@index([searcherId], map: "IDX_fe1e75b8b625499f0119faaba5") + @@map("topic_search_log") +} diff --git a/src/topics/topics.service.ts b/src/topics/topics.service.ts new file mode 100644 index 00000000..a10cac46 --- /dev/null +++ b/src/topics/topics.service.ts @@ -0,0 +1,139 @@ +/* + * Description: This file implements the TopicsService class. + * It is responsible for the business logic of topics. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Injectable } from '@nestjs/common'; +import { ElasticsearchService } from '@nestjs/elasticsearch'; +import { Topic } from '@prisma/client'; +import { PageDto } from '../common/DTO/page-response.dto'; +import { PageHelper } from '../common/helper/page.helper'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { TopicDto } from './DTO/topic.dto'; +import { TopicAlreadyExistsError, TopicNotFoundError } from './topics.error'; +import { TopicElasticsearchDocument } from './topics.es-doc'; + +@Injectable() +export class TopicsService { + constructor( + private readonly prismaService: PrismaService, + private readonly elasticsearchService: ElasticsearchService, + ) {} + + async addTopic(topicName: string, userId: number): Promise { + if (await this.isTopicNameExists(topicName)) + throw new TopicAlreadyExistsError(topicName); + const result = await this.prismaService.topic.create({ + data: { + name: topicName, + createdById: userId, + }, + }); + await this.elasticsearchService.index({ + index: 'topics', + document: { + id: result.id, + name: result.name, + }, + }); + return { + id: result.id, + name: result.name, + }; + } + + async searchTopics( + keywords: string, + pageStart: number | undefined, + pageSize: number, + searcherId: number | undefined, + ip: string, + userAgent: string | undefined, // optional + ): Promise<[TopicDto[], PageDto]> { + const timeBegin = Date.now(); + const result = !keywords + ? { hits: { hits: [] } } + : await this.elasticsearchService.search({ + index: 'topics', + size: 1000, + body: { + query: { + match: { name: keywords }, + }, + }, + }); + const allData = result.hits.hits + .filter((h) => h._source != undefined) + .map((h) => h._source) as TopicElasticsearchDocument[]; + const [data, page] = PageHelper.PageFromAll( + allData, + pageStart, + pageSize, + (i) => i.id, + (pageStart) => { + throw new TopicNotFoundError(pageStart); + }, + ); + await this.prismaService.topicSearchLog.create({ + data: { + keywords: keywords, + firstTopicId: pageStart, + pageSize: pageSize, + result: data.map((t) => t.id).join(','), + duration: (Date.now() - timeBegin) / 1000, + searcherId: searcherId, + ip: ip, + userAgent: userAgent, + }, + }); + return [data, page]; + } + + async findTopicRecordOrThrow(topicId: number): Promise { + const topic = await this.prismaService.topic.findUnique({ + where: { + id: topicId, + }, + }); + if (topic == undefined) throw new TopicNotFoundError(topicId); + return topic; + } + + async getTopicDtoById( + topicId: number, + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + viewerId: number | undefined, + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + ip: string, + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + userAgent: string | undefined, + ): Promise { + const topic = await this.findTopicRecordOrThrow(topicId); + return { + id: topic.id, + name: topic.name, + }; + } + + async isTopicExists(topicId: number): Promise { + const count = await this.prismaService.topic.count({ + where: { + id: topicId, + }, + }); + return count > 0; + } + + async isTopicNameExists(topicName: string): Promise { + const count = await this.prismaService.topic.count({ + where: { + name: topicName, + }, + }); + return count > 0; + } +} diff --git a/src/users/DTO/change-password.dto.ts b/src/users/DTO/change-password.dto.ts new file mode 100644 index 00000000..21608515 --- /dev/null +++ b/src/users/DTO/change-password.dto.ts @@ -0,0 +1,12 @@ +import { IsString } from 'class-validator'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class ChangePasswordRequestDto { + @IsString() + srpSalt: string; // 新密码的 SRP 凭证 + + @IsString() + srpVerifier: string; // 新密码的 SRP 凭证 +} + +export class ChangePasswordResponseDto extends BaseResponseDto {} diff --git a/src/users/DTO/follow-unfollow.dto.ts b/src/users/DTO/follow-unfollow.dto.ts new file mode 100644 index 00000000..ec59948e --- /dev/null +++ b/src/users/DTO/follow-unfollow.dto.ts @@ -0,0 +1,13 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class FollowResponseDto extends BaseResponseDto { + data: { + follow_count: number; + }; +} + +export class UnfollowResponseDto extends BaseResponseDto { + data: { + follow_count: number; + }; +} diff --git a/src/users/DTO/get-answered-answers.dto.ts b/src/users/DTO/get-answered-answers.dto.ts new file mode 100644 index 00000000..ac77dab8 --- /dev/null +++ b/src/users/DTO/get-answered-answers.dto.ts @@ -0,0 +1,10 @@ +import { AnswerDto } from '../../answer/DTO/answer.dto'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; + +export class GetAnsweredAnswersResponseDto extends BaseResponseDto { + data: { + answers: AnswerDto[]; + page: PageDto; + }; +} diff --git a/src/users/DTO/get-asked-questions.dto.ts b/src/users/DTO/get-asked-questions.dto.ts new file mode 100644 index 00000000..5c96fe9c --- /dev/null +++ b/src/users/DTO/get-asked-questions.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { QuestionDto } from '../../questions/DTO/question.dto'; + +export class GetAskedQuestionsResponseDto extends BaseResponseDto { + data: { + questions: QuestionDto[]; + page: PageDto; + }; +} diff --git a/src/users/DTO/get-followed-questions.dto.ts b/src/users/DTO/get-followed-questions.dto.ts new file mode 100644 index 00000000..71103968 --- /dev/null +++ b/src/users/DTO/get-followed-questions.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { QuestionDto } from '../../questions/DTO/question.dto'; + +export class GetFollowedQuestionsResponseDto extends BaseResponseDto { + data: { + questions: QuestionDto[]; + page: PageDto; + }; +} diff --git a/src/users/DTO/get-followers.dto.ts b/src/users/DTO/get-followers.dto.ts new file mode 100644 index 00000000..45a8925c --- /dev/null +++ b/src/users/DTO/get-followers.dto.ts @@ -0,0 +1,10 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { PageDto } from '../../common/DTO/page-response.dto'; +import { UserDto } from './user.dto'; + +export class GetFollowersResponseDto extends BaseResponseDto { + data: { + users: UserDto[]; + page: PageDto; + }; +} diff --git a/src/users/DTO/get-user.dto.ts b/src/users/DTO/get-user.dto.ts new file mode 100644 index 00000000..c80c7467 --- /dev/null +++ b/src/users/DTO/get-user.dto.ts @@ -0,0 +1,8 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { UserDto } from './user.dto'; + +export class GetUserResponseDto extends BaseResponseDto { + data: { + user: UserDto; + }; +} diff --git a/src/users/DTO/login.dto.ts b/src/users/DTO/login.dto.ts new file mode 100644 index 00000000..5ee7a382 --- /dev/null +++ b/src/users/DTO/login.dto.ts @@ -0,0 +1,21 @@ +import { IsString } from 'class-validator'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { UserDto } from './user.dto'; + +export class LoginRequestDto { + @IsString() + username: string; + + @IsString() + password: string; +} + +export class LoginResponseDto extends BaseResponseDto { + data: { + user?: UserDto; + accessToken?: string; + requires2FA?: boolean; + tempToken?: string; + usedBackupCode?: boolean; + }; +} diff --git a/src/users/DTO/passkey.dto.ts b/src/users/DTO/passkey.dto.ts new file mode 100644 index 00000000..33268361 --- /dev/null +++ b/src/users/DTO/passkey.dto.ts @@ -0,0 +1,59 @@ +import { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from '@simplewebauthn/server'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { UserDto } from './user.dto'; + +// Registration DTOs +export class PasskeyRegistrationOptionsResponseDto extends BaseResponseDto { + data: { + options: PublicKeyCredentialCreationOptionsJSON; + }; +} + +export class PasskeyRegistrationVerifyRequestDto { + response: RegistrationResponseJSON; +} + +export class PasskeyRegistrationVerifyResponseDto extends BaseResponseDto {} + +// Authentication DTOs +export class PasskeyAuthenticationOptionsRequestDto { + userId?: number; +} + +export class PasskeyAuthenticationOptionsResponseDto extends BaseResponseDto { + data: { + options: PublicKeyCredentialRequestOptionsJSON; + }; +} + +export class PasskeyAuthenticationVerifyRequestDto { + response: AuthenticationResponseJSON; +} + +export class PasskeyAuthenticationVerifyResponseDto extends BaseResponseDto { + data: { + user: UserDto; + accessToken: string; + }; +} + +// Passkey Management DTOs +export interface PasskeyInfo { + id: string; + createdAt: Date; + deviceType: string; + backedUp: boolean; +} + +export class GetPasskeysResponseDto extends BaseResponseDto { + data: { + passkeys: PasskeyInfo[]; + }; +} + +export class DeletePasskeyResponseDto extends BaseResponseDto {} diff --git a/src/users/DTO/refresh-token.dto.ts b/src/users/DTO/refresh-token.dto.ts new file mode 100644 index 00000000..beb81ff4 --- /dev/null +++ b/src/users/DTO/refresh-token.dto.ts @@ -0,0 +1,3 @@ +import { LoginResponseDto } from './login.dto'; + +export class RefreshTokenResponseDto extends LoginResponseDto {} diff --git a/src/users/DTO/register.dto.ts b/src/users/DTO/register.dto.ts new file mode 100644 index 00000000..4f4aa69b --- /dev/null +++ b/src/users/DTO/register.dto.ts @@ -0,0 +1,36 @@ +import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { LoginResponseDto } from './login.dto'; + +export class RegisterRequestDto { + @IsString() + username: string; + + @IsString() + nickname: string; + + @IsString() + @IsOptional() + srpSalt?: string; + + @IsString() + @IsOptional() + srpVerifier?: string; + + @IsString() + email: string; + + @IsString() + emailCode: string; + + // 可选的传统密码字段,仅用于测试 + @IsString() + @IsOptional() + password?: string; + + // 是否使用传统认证方式,仅用于测试 + @IsBoolean() + @IsOptional() + isLegacyAuth?: boolean; +} + +export class RegisterResponseDto extends LoginResponseDto {} diff --git a/src/users/DTO/reset-password.dto.ts b/src/users/DTO/reset-password.dto.ts new file mode 100644 index 00000000..e84dc162 --- /dev/null +++ b/src/users/DTO/reset-password.dto.ts @@ -0,0 +1,22 @@ +import { IsString } from 'class-validator'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class ResetPasswordRequestRequestDto { + @IsString() + email: string; +} + +export class ResetPasswordRequestDto extends BaseResponseDto {} + +export class ResetPasswordVerifyRequestDto { + @IsString() + token: string; + + @IsString() + srpSalt: string; + + @IsString() + srpVerifier: string; +} + +export class ResetPasswordVerifyResponseDto extends BaseResponseDto {} diff --git a/src/users/DTO/send-email-verify-code.dto.ts b/src/users/DTO/send-email-verify-code.dto.ts new file mode 100644 index 00000000..5bdf6950 --- /dev/null +++ b/src/users/DTO/send-email-verify-code.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class SendEmailVerifyCodeRequestDto { + @IsString() + email: string; +} + +export class SendEmailVerifyCodeResponseDto extends BaseResponseDto {} diff --git a/src/users/DTO/srp.dto.ts b/src/users/DTO/srp.dto.ts new file mode 100644 index 00000000..61902f4c --- /dev/null +++ b/src/users/DTO/srp.dto.ts @@ -0,0 +1,33 @@ +/* + * Description: This file defines the DTOs for SRP authentication. + */ + +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; +import { UserDto } from '../../users/DTO/user.dto'; + +export class SrpInitRequestDto { + username: string; + clientPublicEphemeral: string; +} + +export class SrpInitResponseDto extends BaseResponseDto { + data: { + salt: string; + serverPublicEphemeral: string; + }; +} + +export class SrpVerifyRequestDto { + username: string; + clientProof: string; +} + +export class SrpVerifyResponseDto extends BaseResponseDto { + data: { + serverProof: string; + accessToken: string; + requires2FA: boolean; + tempToken?: string; + user?: UserDto; + }; +} diff --git a/src/users/DTO/sudo.dto.ts b/src/users/DTO/sudo.dto.ts new file mode 100644 index 00000000..1e860533 --- /dev/null +++ b/src/users/DTO/sudo.dto.ts @@ -0,0 +1,28 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class VerifySudoRequestDto { + method: 'password' | 'srp' | 'passkey' | 'totp'; + credentials: { + // 传统密码凭据 + password?: string; + // SRP 凭据 + clientPublicEphemeral?: string; + clientProof?: string; + // Passkey 凭据 + passkeyResponse?: any; + // TOTP 凭据 + code?: string; + }; +} + +export class VerifySudoResponseDto extends BaseResponseDto { + data: { + accessToken: string; + // 如果是 SRP 方式,需要返回这些字段用于完成握手 + salt?: string; + serverPublicEphemeral?: string; + serverProof?: string; + // 如果是传统密码验证且触发了 SRP 升级,返回此字段 + srpUpgraded?: boolean; + }; +} diff --git a/src/users/DTO/totp.dto.ts b/src/users/DTO/totp.dto.ts new file mode 100644 index 00000000..ab245c17 --- /dev/null +++ b/src/users/DTO/totp.dto.ts @@ -0,0 +1,73 @@ +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class Enable2FARequestDto { + code?: string; + secret?: string; +} + +export class Disable2FARequestDto { + code: string; +} + +export class Verify2FARequestDto { + temp_token: string; + code: string; +} + +export class GenerateBackupCodesRequestDto { + code: string; +} + +export class TOTPSetupResponseDto extends BaseResponseDto { + data: { + secret?: string; + otpauth_url?: string; + backup_codes?: string[]; + }; +} + +export class BackupCodesResponseDto extends BaseResponseDto { + data: { + backup_codes: string[]; + }; +} + +export class Enable2FAResponseDto extends BaseResponseDto { + data: { + secret: string; + otpauth_url: string; + qrcode: string; + backup_codes: string[]; + }; +} + +export class Disable2FAResponseDto extends BaseResponseDto { + data: { + success: boolean; + }; +} + +export class GenerateBackupCodesResponseDto extends BaseResponseDto { + data: { + backup_codes: string[]; + }; +} + +export class Get2FAStatusResponseDto extends BaseResponseDto { + data: { + enabled: boolean; + has_passkey: boolean; + always_required: boolean; + }; +} + +export class Update2FASettingsRequestDto { + always_required: boolean; +} + +export class Update2FASettingsResponseDto extends BaseResponseDto { + data: { + success: boolean; + always_required: boolean; + }; +} diff --git a/src/users/DTO/update-user.dto.ts b/src/users/DTO/update-user.dto.ts new file mode 100644 index 00000000..4fc6b423 --- /dev/null +++ b/src/users/DTO/update-user.dto.ts @@ -0,0 +1,15 @@ +import { IsInt, IsString } from 'class-validator'; +import { BaseResponseDto } from '../../common/DTO/base-response.dto'; + +export class UpdateUserRequestDto { + @IsString() + nickname: string; + + @IsString() + intro: string; + + @IsInt() + avatarId: number; +} + +export class UpdateUserResponseDto extends BaseResponseDto {} diff --git a/src/users/DTO/user.dto.ts b/src/users/DTO/user.dto.ts new file mode 100644 index 00000000..1f32054b --- /dev/null +++ b/src/users/DTO/user.dto.ts @@ -0,0 +1,12 @@ +export class UserDto { + id: number; + username: string; + nickname: string; + avatarId: number; + intro: string; + follow_count: number; + fans_count: number; + question_count: number; + answer_count: number; + is_follow: boolean; +} diff --git a/src/users/role-permission.service.ts b/src/users/role-permission.service.ts new file mode 100644 index 00000000..0e7ba573 --- /dev/null +++ b/src/users/role-permission.service.ts @@ -0,0 +1,197 @@ +import { Injectable } from '@nestjs/common'; +import { Authorization } from '../auth/definitions'; + +@Injectable() +export class RolePermissionService { + async getAuthorizationForUserWithRole( + userId: number, + roleName: string, + ): Promise { + switch (roleName) { + case 'standard-user': + return await this.getAuthorizationForStandardUser(userId); + /* istanbul ignore next */ + default: + throw new Error(`Role ${roleName} is not supported.`); + } + } + + private async getAuthorizationForStandardUser( + userId: number, + ): Promise { + return { + userId: userId, + permissions: [ + { + authorizedActions: [ + 'query', + 'follow', + 'unfollow', + 'enumerate-followers', + 'enumerate-answers', + 'enumerate-questions', + 'enumerate-followed-users', + 'enumerate-followed-questions', + ], + authorizedResource: { + ownedByUser: undefined, + types: ['user'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['verify-sudo'], + authorizedResource: { + ownedByUser: undefined, + types: ['user'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['modify-2fa'], + authorizedResource: { + ownedByUser: userId, + types: ['user'], + resourceIds: [userId], + }, + }, + { + authorizedActions: [ + 'enumerate-passkeys', + 'register-passkey', + 'delete-passkey', + ], + authorizedResource: { + ownedByUser: userId, + types: ['user'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['modify-profile'], + authorizedResource: { + ownedByUser: userId, + types: ['user'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['query', 'enumerate'], + authorizedResource: { + ownedByUser: undefined, + types: ['question', 'answer', 'comment'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['create', 'delete', 'modify'], + authorizedResource: { + ownedByUser: userId, + types: ['question', 'answer', 'comment'], + resourceIds: undefined, + }, + }, + { + authorizedActions: [ + 'query', + 'query-invitation-recommendations', + 'query-invitation', + 'enumerate', + 'enumerate-answers', + 'enumerate-followers', + 'enumerate-invitations', + 'follow', + 'unfollow', + 'invite', + 'uninvite', + ], + authorizedResource: { + ownedByUser: undefined, + types: ['question'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['accept-answer', 'set-bounty'], + authorizedResource: { + ownedByUser: userId, + types: ['question'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['query', 'favorite', 'unfavorite'], + authorizedResource: { + ownedByUser: undefined, + types: ['answer'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['attitude'], + authorizedResource: { + ownedByUser: undefined, + types: ['comment', 'question', 'answer'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['create', 'query'], + authorizedResource: { + ownedByUser: undefined, + types: ['attachment', 'material'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['query', 'enumerate'], + authorizedResource: { + ownedByUser: undefined, + types: ['material-bundle'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['create', 'modify', 'delete'], + authorizedResource: { + ownedByUser: undefined, + types: ['material-bundle'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['create', 'query', 'enumerate'], + authorizedResource: { + ownedByUser: undefined, + types: ['topic'], + resourceIds: undefined, + }, + }, + { + authorizedActions: ['create', 'enumerate'], + authorizedResource: { + ownedByUser: undefined, + types: ['avatar'], + resourceIds: undefined, + }, + }, + { + authorizedActions: [], + authorizedResource: { + ownedByUser: undefined, + types: ['group'], + resourceIds: undefined, + }, + }, + { + authorizedActions: [], + authorizedResource: { + ownedByUser: userId, + types: ['group'], + resourceIds: undefined, + }, + }, + ], + }; + } +} diff --git a/src/users/srp.service.spec.ts b/src/users/srp.service.spec.ts new file mode 100644 index 00000000..a3efc836 --- /dev/null +++ b/src/users/srp.service.spec.ts @@ -0,0 +1,210 @@ +/* + * Description: This file provides unit tests for SRP service. + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import * as srpClient from 'secure-remote-password/client'; +import * as srp from 'secure-remote-password/server'; +import { AppModule } from '../app.module'; +import { SrpService } from './srp.service'; + +// Mock secure-remote-password modules +jest.mock('secure-remote-password/client', () => ({ + generateSalt: jest.fn(() => 'test-salt'), + derivePrivateKey: jest.fn(() => 'test-private-key'), + deriveVerifier: jest.fn(() => 'test-verifier'), +})); + +jest.mock('secure-remote-password/server', () => ({ + generateEphemeral: jest.fn(() => ({ + secret: 'server-secret', + public: 'server-public', + })), + deriveSession: jest.fn(() => ({ + key: 'shared-key', + proof: 'server-proof', + })), +})); + +describe('SRP Service', () => { + let app: TestingModule; + let srpService: SrpService; + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + srpService = app.get(SrpService); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('generateSrpCredentials', () => { + it('should generate salt and verifier', async () => { + const username = 'testuser'; + const password = 'testpass'; + + const result = await srpService.generateSrpCredentials( + username, + password, + ); + + expect(srpClient.generateSalt).toHaveBeenCalled(); + expect(srpClient.derivePrivateKey).toHaveBeenCalledWith( + 'test-salt', + username, + password, + ); + expect(srpClient.deriveVerifier).toHaveBeenCalledWith('test-private-key'); + expect(result).toEqual({ + salt: 'test-salt', + verifier: 'test-verifier', + }); + }); + }); + + describe('createServerSession', () => { + it('should create server session with ephemeral keys', async () => { + const username = 'testuser'; + const salt = 'test-salt'; + const verifier = 'test-verifier'; + const clientPublicEphemeral = 'client-public'; + + const result = await srpService.createServerSession( + username, + salt, + verifier, + clientPublicEphemeral, + ); + + expect(srp.generateEphemeral).toHaveBeenCalledWith(verifier); + expect(result).toEqual({ + serverEphemeral: { + secret: 'server-secret', + public: 'server-public', + }, + }); + }); + }); + + describe('verifyClient', () => { + it('should verify client proof and return server proof', async () => { + const serverSecretEphemeral = 'server-secret'; + const clientPublicEphemeral = 'client-public'; + const salt = 'test-salt'; + const username = 'testuser'; + const verifier = 'test-verifier'; + const clientProof = 'client-proof'; + + const result = await srpService.verifyClient( + serverSecretEphemeral, + clientPublicEphemeral, + salt, + username, + verifier, + clientProof, + ); + + expect(srp.deriveSession).toHaveBeenCalledWith( + serverSecretEphemeral, + clientPublicEphemeral, + salt, + username, + verifier, + clientProof, + ); + expect(result).toEqual({ + success: true, + serverProof: 'server-proof', + }); + }); + + it('should return failure when verification fails', async () => { + jest.spyOn(srp, 'deriveSession').mockImplementationOnce(() => { + throw new Error('Verification failed'); + }); + + const result = await srpService.verifyClient( + 'server-secret', + 'client-public', + 'salt', + 'username', + 'verifier', + 'invalid-proof', + ); + + expect(result).toEqual({ + success: false, + serverProof: '', + }); + }); + }); + + describe('isUserSrpUpgraded', () => { + it('should return true for upgraded user', async () => { + jest + .spyOn(srpService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce({ + srpUpgraded: true, + } as any); + + const result = await srpService.isUserSrpUpgraded(1); + expect(result).toBe(true); + }); + + it('should return false for non-upgraded user', async () => { + jest + .spyOn(srpService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce({ + srpUpgraded: false, + } as any); + + const result = await srpService.isUserSrpUpgraded(1); + expect(result).toBe(false); + }); + + it('should return false for non-existent user', async () => { + jest + .spyOn(srpService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce(null); + + const result = await srpService.isUserSrpUpgraded(1); + expect(result).toBe(false); + }); + }); + + describe('upgradeUserToSrp', () => { + it('should upgrade user to SRP', async () => { + const updateSpy = jest + .spyOn(srpService['prismaService'].user, 'update') + .mockResolvedValueOnce({} as any); + + await srpService.upgradeUserToSrp(1, 'testuser', 'testpass'); + + expect(updateSpy).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { + srpSalt: 'test-salt', + srpVerifier: 'test-verifier', + srpUpgraded: true, + }, + }); + }); + + it('should handle upgrade failure', async () => { + jest + .spyOn(srpService['prismaService'].user, 'update') + .mockRejectedValueOnce(new Error('Update failed')); + + await expect( + srpService.upgradeUserToSrp(1, 'testuser', 'testpass'), + ).rejects.toThrow('Update failed'); + }); + }); +}); diff --git a/src/users/srp.service.ts b/src/users/srp.service.ts new file mode 100644 index 00000000..2d4ff932 --- /dev/null +++ b/src/users/srp.service.ts @@ -0,0 +1,122 @@ +/* + * Description: This file implements the SRP service. + * It is responsible for handling SRP protocol operations. + */ + +import { Injectable } from '@nestjs/common'; +import * as srpClient from 'secure-remote-password/client'; +import * as srp from 'secure-remote-password/server'; +import { PrismaService } from '../common/prisma/prisma.service'; + +@Injectable() +export class SrpService { + constructor(private readonly prismaService: PrismaService) {} + + /** + * 为新用户生成 SRP salt 和 verifier + */ + async generateSrpCredentials( + username: string, + password: string, + ): Promise<{ + salt: string; + verifier: string; + }> { + const salt = srpClient.generateSalt(); + const privateKey = srpClient.derivePrivateKey(salt, username, password); + const verifier = srpClient.deriveVerifier(privateKey); + + return { + salt, + verifier, + }; + } + + /** + * 创建 SRP 服务器会话 + */ + async createServerSession( + username: string, + salt: string, + verifier: string, + clientPublicEphemeral: string, + ): Promise<{ + serverEphemeral: { public: string; secret: string }; + }> { + const serverEphemeral = srp.generateEphemeral(verifier); + + return { + serverEphemeral, + }; + } + + /** + * 验证客户端的证明 + */ + async verifyClient( + serverSecretEphemeral: string, + clientPublicEphemeral: string, + salt: string, + username: string, + verifier: string, + clientProof: string, + ): Promise<{ + success: boolean; + serverProof: string; + }> { + try { + const serverSession = srp.deriveSession( + serverSecretEphemeral, + clientPublicEphemeral, + salt, + username, + verifier, + clientProof, + ); + + return { + success: true, + serverProof: serverSession.proof, + }; + } catch (error) { + return { + success: false, + serverProof: '', + }; + } + } + + /** + * 验证用户是否已经升级到 SRP + */ + async isUserSrpUpgraded(userId: number): Promise { + const user = await this.prismaService.user.findUnique({ + where: { id: userId }, + select: { srpUpgraded: true }, + }); + return user?.srpUpgraded ?? false; + } + + /** + * 为现有用户升级到 SRP + */ + async upgradeUserToSrp( + userId: number, + username: string, + password: string, + ): Promise { + const { salt, verifier } = await this.generateSrpCredentials( + username, + password, + ); + + await this.prismaService.user.update({ + where: { id: userId }, + data: { + srpSalt: salt, + srpVerifier: verifier, + srpUpgraded: true, + }, + }); + } +} diff --git a/src/users/totp.service.spec.ts b/src/users/totp.service.spec.ts new file mode 100644 index 00000000..4cbd40a3 --- /dev/null +++ b/src/users/totp.service.spec.ts @@ -0,0 +1,232 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import bcrypt from 'bcryptjs'; +import { authenticator } from 'otplib'; +import { InvalidTokenError } from '../auth/auth.error'; +import { AuthService } from '../auth/auth.service'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { TOTPService } from './totp.service'; + +describe('TOTPService', () => { + let service: TOTPService; + let prisma: PrismaService; + let authService: AuthService; + + const mockPrisma = { + user: { + findUnique: jest.fn(), + update: jest.fn(), + }, + userBackupCode: { + create: jest.fn(), + deleteMany: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + + const mockAuthService = { + sign: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TOTPService, + { + provide: PrismaService, + useValue: mockPrisma, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockImplementation((key) => ({ + appName: 'TestApp', + backupCodesCount: 5, + window: 1, + encryptionKey: 'test-encryption-key-1234567890abc', + })), + }, + }, + { + provide: AuthService, + useValue: mockAuthService, + }, + ], + }).compile(); + + service = module.get(TOTPService); + prisma = module.get(PrismaService); + authService = module.get(AuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Core Functionality', () => { + it('should generate TOTP secret', async () => { + const secret = await service.generateTOTPSecret(); + expect(secret).toHaveLength(16); + }); + + it('should generate valid TOTP URI', () => { + const uri = service.generateTOTPUri('SECRET123', 'testuser'); + expect(uri).toContain('otpauth://totp/TestApp:testuser'); + expect(uri).toContain('secret=SECRET123'); + }); + }); + + describe('2FA Lifecycle', () => { + const mockUser = { + id: 1, + totpEnabled: false, + totpSecret: null, + }; + + beforeEach(() => { + mockUser.totpEnabled = false; + mockUser.totpSecret = null; + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.user.update.mockImplementation(({ data }) => { + Object.assign(mockUser, data); + return mockUser; + }); + }); + + it('should enable 2FA with valid token', async () => { + const testSecret = authenticator.generateSecret(); + const testToken = authenticator.generate(testSecret); + + const backupCodes = await service.enable2FA(1, testSecret, testToken); + + expect(backupCodes).toHaveLength(5); + expect(mockPrisma.userBackupCode.create).toHaveBeenCalledTimes(5); + expect(mockUser.totpEnabled).toBe(true); + expect(mockUser.totpSecret).toBeDefined(); + }); + + it('should throw when enabling 2FA with invalid token', async () => { + const testSecret = authenticator.generateSecret(); + const invalidToken = '000000'; + + await expect( + service.enable2FA(1, testSecret, invalidToken), + ).rejects.toThrowError('Invalid TOTP token'); + }); + + it('should disable 2FA and clear backup codes', async () => { + mockUser.totpEnabled = true; + await service.disable2FA(1); + + expect(mockPrisma.userBackupCode.deleteMany).toHaveBeenCalled(); + expect(mockUser.totpEnabled).toBe(false); + expect(mockUser.totpSecret).toBeNull(); + }); + }); + + describe('Verification Process', () => { + const encryptedSecret = 'encrypted-test-secret'; + const mockUser = { + id: 1, + totpEnabled: true, + totpSecret: encryptedSecret, + }; + + beforeEach(() => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + jest + .spyOn(service as any, 'decryptSecret') + .mockReturnValue('decrypted-secret'); + mockPrisma.userBackupCode.findMany.mockResolvedValue([]); + }); + + it('should verify valid TOTP code', async () => { + const testToken = authenticator.generate('decrypted-secret'); + const result = await service.verify2FA(1, testToken); + + expect(result.isValid).toBe(true); + expect(result.usedBackupCode).toBe(false); + }); + + it('should verify valid backup code', async () => { + const testCode = 'backup123'; + const hashedCode = bcrypt.hashSync(testCode, 10); + + mockPrisma.userBackupCode.findMany.mockResolvedValue([ + { + id: 1, + codeHash: hashedCode, + used: false, + }, + ]); + + const result = await service.verify2FA(1, testCode); + + expect(result.isValid).toBe(true); + expect(result.usedBackupCode).toBe(true); + expect(mockPrisma.userBackupCode.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { used: true }, + }); + }); + + it('should return false for invalid code', async () => { + const result = await service.verify2FA(1, 'invalid-code'); + expect(result.isValid).toBe(false); + }); + }); + + describe('Backup Codes', () => { + it('should generate new backup codes', async () => { + const codes = await service.generateAndSaveBackupCodes(1); + expect(codes).toHaveLength(5); + expect(mockPrisma.userBackupCode.deleteMany).toHaveBeenCalled(); + expect(mockPrisma.userBackupCode.create).toHaveBeenCalledTimes(5); + }); + }); + + describe('Edge Cases', () => { + it('should throw when enabling 2FA for non-existent user', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + await expect(service.enable2FA(999, 'secret', 'token')).rejects.toThrow( + 'User not found', + ); + }); + + it('should throw when disabling 2FA for non-enabled user', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 1, + totpEnabled: false, + }); + await expect(service.disable2FA(1)).rejects.toThrow('2FA is not enabled'); + }); + }); + + describe('Token Generation', () => { + it('should generate sudo token with valid 2FA', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 1, + totpEnabled: true, + totpSecret: 'encrypted-secret', + }); + jest + .spyOn(service as any, 'decryptSecret') + .mockReturnValue('decrypted-secret'); + authenticator.verify = jest.fn().mockReturnValue(true); + + const token = await service.verify2FAAndGenerateToken(1, 'valid-token'); + expect(authService.sign).toHaveBeenCalled(); + }); + + it('should throw InvalidTokenError with invalid 2FA', async () => { + jest + .spyOn(service, 'verify2FA') + .mockRejectedValueOnce(new InvalidTokenError()); + + await expect( + service.verify2FAAndGenerateToken(1, 'invalid-token'), + ).rejects.toThrow(InvalidTokenError); + }); + }); +}); diff --git a/src/users/totp.service.ts b/src/users/totp.service.ts new file mode 100644 index 00000000..05594ebf --- /dev/null +++ b/src/users/totp.service.ts @@ -0,0 +1,315 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import bcrypt from 'bcryptjs'; +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'crypto'; +import { authenticator } from 'otplib'; +import { InvalidTokenError } from '../auth/auth.error'; +import { AuthService } from '../auth/auth.service'; +import { PrismaService } from '../common/prisma/prisma.service'; + +const ENCRYPTION_KEY_SALT = 'cheese-totp-secret-'; +const ENCRYPTION_ALGORITHM = 'aes-256-gcm'; + +@Injectable() +export class TOTPService { + private readonly encryptionKey: Buffer; + private readonly backupCodesCount: number; + private readonly appName: string; + private readonly window: number; + + constructor( + private readonly configService: ConfigService, + private readonly authService: AuthService, + private readonly prismaService: PrismaService, + ) { + // 从配置中获取参数 + const config = this.configService.get('totp'); + this.appName = config.appName; + this.backupCodesCount = config.backupCodesCount; + this.window = config.window; + + // 使用固定的盐值和配置的密钥生成加密密钥 + const encryptionKey = + config.encryptionKey || 'your-fallback-key-at-least-32-chars-long'; + this.encryptionKey = Buffer.from( + createHash('sha256') + .update(ENCRYPTION_KEY_SALT + encryptionKey) + .digest(), + ); + + // 配置 authenticator + authenticator.options = { + window: this.window, + }; + } + + private encryptSecret(secret: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv(ENCRYPTION_ALGORITHM, this.encryptionKey, iv); + + const encrypted = Buffer.concat([ + cipher.update(secret, 'utf8'), + cipher.final(), + ]); + + const authTag = cipher.getAuthTag(); + + // 将 IV、认证标签和加密数据拼接在一起,并进行 Base64 编码 + return Buffer.concat([iv, authTag, encrypted]).toString('base64'); + } + + private decryptSecret(encryptedData: string): string { + const data = Buffer.from(encryptedData, 'base64'); + + // 从拼接的数据中提取各个部分 + const iv = data.slice(0, 12); + const authTag = data.slice(12, 28); + const encrypted = data.slice(28); + + const decipher = createDecipheriv( + ENCRYPTION_ALGORITHM, + this.encryptionKey, + iv, + ); + decipher.setAuthTag(authTag); + + return Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]).toString('utf8'); + } + + private hashBackupCode(code: string): string { + return bcrypt.hashSync(code, 10); + } + + private verifyBackupCode(code: string, hashedCode: string): boolean { + return bcrypt.compareSync(code, hashedCode); + } + + async generateTOTPSecret(): Promise { + return authenticator.generateSecret(); + } + + generateTOTPUri(secret: string, username: string): string { + return `otpauth://totp/${this.appName}:${username}?secret=${secret}&issuer=${this.appName}`; + } + + async generateBackupCodes(): Promise { + const codes: string[] = []; + for (let i = 0; i < this.backupCodesCount; i++) { + const code = randomBytes(4).toString('hex'); + codes.push(code); + } + return codes; + } + + async enable2FA( + userId: number, + secret: string, + token: string, + ): Promise { + const user = await this.prismaService.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new Error('User not found'); + } + + if (user.totpEnabled) { + throw new Error('2FA is already enabled'); + } + + // 验证用户提供的 token 是否正确 + if (!this.verifyTOTP(secret, token)) { + throw new Error('Invalid TOTP token'); + } + + // 生成备份码 + const backupCodes = await this.generateBackupCodes(); + const hashedBackupCodes = backupCodes.map((code) => + this.hashBackupCode(code), + ); + + // 创建备份码 + await Promise.all( + hashedBackupCodes.map((codeHash) => + this.prismaService.userBackupCode.create({ + data: { + userId, + codeHash, + used: false, + }, + }), + ), + ); + + // 更新用户的 TOTP 设置 + await this.prismaService.user.update({ + where: { id: userId }, + data: { + totpSecret: this.encryptSecret(secret), + totpEnabled: true, + }, + }); + + return backupCodes; + } + + async generateAndSaveBackupCodes(userId: number): Promise { + // 生成新的备份码 + const backupCodes = await this.generateBackupCodes(); + const hashedBackupCodes = backupCodes.map((code) => + this.hashBackupCode(code), + ); + + // 删除旧的备份码 + await this.prismaService.userBackupCode.deleteMany({ + where: { userId }, + }); + + // 保存新的备份码 + await Promise.all( + hashedBackupCodes.map((codeHash) => + this.prismaService.userBackupCode.create({ + data: { + userId, + codeHash, + used: false, + }, + }), + ), + ); + + return backupCodes; + } + + async disable2FA(userId: number): Promise { + const user = await this.prismaService.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new Error('User not found'); + } + + if (!user.totpEnabled) { + throw new Error('2FA is not enabled'); + } + + // 删除所有备份码 + await this.prismaService.userBackupCode.deleteMany({ + where: { userId }, + }); + + // 禁用 2FA + await this.prismaService.user.update({ + where: { id: userId }, + data: { + totpSecret: null, + totpEnabled: false, + }, + }); + } + + verifyTOTP(secret: string, token: string): boolean { + return authenticator.verify({ token, secret }); + } + + async verify2FA( + userId: number, + token: string, + ): Promise<{ isValid: boolean; usedBackupCode: boolean }> { + const user = await this.prismaService.user.findUnique({ + where: { id: userId }, + }); + + if (!user || !user.totpEnabled || !user.totpSecret) { + return { isValid: false, usedBackupCode: false }; + } + + // 检查是否是备用代码 + const backupCodes = await this.prismaService.userBackupCode.findMany({ + where: { + userId, + used: false, + }, + }); + + // 遍历所有未使用的备用码进行验证 + for (const backupCode of backupCodes) { + if (this.verifyBackupCode(token, backupCode.codeHash)) { + // 标记备用代码为已使用 + await this.prismaService.userBackupCode.update({ + where: { id: backupCode.id }, + data: { used: true }, + }); + return { isValid: true, usedBackupCode: true }; + } + } + + // 验证 TOTP 代码 + const decryptedSecret = this.decryptSecret(user.totpSecret); + const isValid = authenticator.verify({ + token, + secret: decryptedSecret, + }); + + return { isValid, usedBackupCode: false }; + } + + async verify2FAAndGenerateToken( + userId: number, + token: string, + ): Promise { + const isValid = await this.verify2FA(userId, token); + if (!isValid) { + throw new InvalidTokenError(); + } + + const user = await this.prismaService.user.findUnique({ + where: { id: userId }, + }); + if (!user) { + throw new Error('User not found'); + } + + // Generate a new access token with sudo permissions + return this.authService.sign({ + userId: user.id, + permissions: [], + sudoUntil: Date.now() + 15 * 60 * 1000, // 15 minutes + }); + } + + /** + * 生成临时令牌,用于 2FA 验证 + */ + generateTempToken(userId: number): string { + return this.authService.sign( + { + userId: userId, + permissions: [ + { + authorizedActions: ['verify'], + authorizedResource: { + ownedByUser: userId, + types: ['users/totp:verify'], + resourceIds: undefined, + data: { + validUntil: Date.now() + 5 * 60 * 1000, + }, + }, + }, + ], + }, + 300, + ); + } +} diff --git a/src/users/user-challenge.repository.ts b/src/users/user-challenge.repository.ts new file mode 100644 index 00000000..8bbc0b60 --- /dev/null +++ b/src/users/user-challenge.repository.ts @@ -0,0 +1,40 @@ +import { RedisService } from '@liaoliaots/nestjs-redis'; +import { Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; + +@Injectable() +export class UserChallengeRepository { + private readonly redisClient: Redis; + + constructor(private readonly redisService: RedisService) { + // 获取 Redis 客户端,若不存在则抛出异常 + this.redisClient = this.redisService.getOrThrow(); + } + + // 构造 Redis key,格式:user:{userId}:challenge + private getKey(userId: number): string { + return `user:${userId}:challenge`; + } + + // 设置用户的 challenge,并设置过期时间(单位:秒) + async setChallenge( + userId: number, + challenge: string, + ttlSeconds: number, + ): Promise { + const key = this.getKey(userId); + await this.redisClient.set(key, challenge, 'EX', ttlSeconds); + } + + // 获取用户的 challenge + async getChallenge(userId: number): Promise { + const key = this.getKey(userId); + return await this.redisClient.get(key); + } + + // 删除用户的 challenge + async deleteChallenge(userId: number): Promise { + const key = this.getKey(userId); + await this.redisClient.del(key); + } +} diff --git a/src/users/users-permission.service.ts b/src/users/users-permission.service.ts new file mode 100644 index 00000000..59059ffc --- /dev/null +++ b/src/users/users-permission.service.ts @@ -0,0 +1,67 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { PermissionDeniedError } from '../auth/auth.error'; +import { AuthService } from '../auth/auth.service'; +import { Authorization, AuthorizedAction } from '../auth/definitions'; +import { RolePermissionService } from './role-permission.service'; + +@Injectable() +export class UsersPermissionService implements OnModuleInit { + constructor( + private readonly authService: AuthService, + private readonly rolePermissionService: RolePermissionService, + ) {} + + onModuleInit() { + this.authService.customAuthLogics.register( + 'role-based', + async ( + userId: number, + action: AuthorizedAction, + resourceOwnerId?: number, + resourceType?: string, + resourceId?: number, + customLogicData?: any, + ): Promise => { + const authorization = + await this.rolePermissionService.getAuthorizationForUserWithRole( + userId, + customLogicData.role, + ); + try { + await this.authService.auditWithoutToken( + authorization, + action, + resourceOwnerId, + resourceType, + resourceId, + ); + } catch (e) { + if (e instanceof PermissionDeniedError) { + return false; + } + /* istanbul ignore next */ + throw e; + } + return true; + }, + ); + } + + // Although this method is not async now, + // it may become async in the future. + async getAuthorizationForUser(userId: number): Promise { + return { + userId: userId, + permissions: [ + { + authorizedActions: undefined, // forward all actions + authorizedResource: {}, // forward all resources + customLogic: 'role-based', + customLogicData: { + role: 'standard-user', + }, + }, + ], + }; + } +} diff --git a/src/users/users-register-request.service.ts b/src/users/users-register-request.service.ts new file mode 100644 index 00000000..e6310e83 --- /dev/null +++ b/src/users/users-register-request.service.ts @@ -0,0 +1,65 @@ +/* + * Description: This file implements the UsersRegisterRequestService class. + * It is responsible for managing user register requests. + * + * Author(s): + * Nictheboy Li + * + */ + +// We plan to use redis or something else to store verification codes +// instead of storing them in database. +// +// This change is planed to be implemented in the future. + +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../common/prisma/prisma.service'; + +@Injectable() +export class UsersRegisterRequestService { + constructor(private readonly prismaService: PrismaService) {} + + private readonly registerCodeValidSeconds = 10 * 60; // 10 minutes + + private isCodeExpired(createdAt: Date): boolean { + return ( + new Date().getTime() - createdAt.getTime() > + this.registerCodeValidSeconds * 1000 + ); + } + + async createRequest(email: string, code: string): Promise { + await this.prismaService.userRegisterRequest.create({ + data: { + email: email, + code: code, + }, + }); + } + + async verifyRequest(email: string, code: string): Promise { + // Determine whether the email code is correct. + const records = await this.prismaService.userRegisterRequest.findMany({ + where: { email }, + }); + for (const record of records) { + if (this.isCodeExpired(record.createdAt)) { + await this.prismaService.userRegisterRequest.delete({ + where: { id: record.id }, + }); + continue; + } + // For code that is netheir expired nor matched, just ignore it. + if (record.code == code) { + // Both email and code are correct, and the code is not expired. + // The register request is valid, maybe not successful, but valid. + // Thus, the code is used and should be deleted. + await this.prismaService.userRegisterRequest.delete({ + where: { id: record.id }, + }); + return true; + } + } + return false; + } +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts new file mode 100644 index 00000000..b00b1687 --- /dev/null +++ b/src/users/users.controller.ts @@ -0,0 +1,1210 @@ +/* + * Description: This file implements the users controller. + * It is responsible for handling the requests to /users/... + * + * Author(s): + * Nictheboy Li + * + */ + +import { + Body, + Controller, + Delete, + forwardRef, + Get, + Headers, + HttpCode, + Inject, + Ip, + Param, + ParseIntPipe, + Patch, + Post, + Put, + Query, + Req, + Res, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request, Response } from 'express'; +import path from 'node:path'; +import qrcode from 'qrcode'; +import { AnswerService } from '../answer/answer.service'; +import { AuthenticationRequiredError } from '../auth/auth.error'; +import { AuthService } from '../auth/auth.service'; +import { + AuthToken, + Guard, + ResourceId, + ResourceOwnerIdGetter, +} from '../auth/guard.decorator'; +import { SessionService } from '../auth/session.service'; +import { UserId } from '../auth/user-id.decorator'; +import { BaseResponseDto } from '../common/DTO/base-response.dto'; +import { PageDto } from '../common/DTO/page.dto'; +import { NoAuth } from '../common/interceptor/token-validate.interceptor'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { QuestionsService } from '../questions/questions.service'; +import { + ChangePasswordRequestDto, + ChangePasswordResponseDto, +} from './DTO/change-password.dto'; +import { + FollowResponseDto, + UnfollowResponseDto, +} from './DTO/follow-unfollow.dto'; +import { GetAnsweredAnswersResponseDto } from './DTO/get-answered-answers.dto'; +import { GetAskedQuestionsResponseDto } from './DTO/get-asked-questions.dto'; +import { GetFollowedQuestionsResponseDto } from './DTO/get-followed-questions.dto'; +import { GetFollowersResponseDto } from './DTO/get-followers.dto'; +import { GetUserResponseDto } from './DTO/get-user.dto'; +import { LoginRequestDto, LoginResponseDto } from './DTO/login.dto'; +import { + DeletePasskeyResponseDto, + GetPasskeysResponseDto, + PasskeyAuthenticationOptionsRequestDto, + PasskeyAuthenticationOptionsResponseDto, + PasskeyAuthenticationVerifyRequestDto, + PasskeyAuthenticationVerifyResponseDto, + PasskeyRegistrationOptionsResponseDto, + PasskeyRegistrationVerifyRequestDto, + PasskeyRegistrationVerifyResponseDto, +} from './DTO/passkey.dto'; +import { RefreshTokenResponseDto } from './DTO/refresh-token.dto'; +import { RegisterRequestDto, RegisterResponseDto } from './DTO/register.dto'; +import { + ResetPasswordRequestDto, + ResetPasswordRequestRequestDto, + ResetPasswordVerifyRequestDto, + ResetPasswordVerifyResponseDto, +} from './DTO/reset-password.dto'; +import { + SendEmailVerifyCodeRequestDto, + SendEmailVerifyCodeResponseDto, +} from './DTO/send-email-verify-code.dto'; +import { + SrpInitRequestDto, + SrpInitResponseDto, + SrpVerifyRequestDto, + SrpVerifyResponseDto, +} from './DTO/srp.dto'; +import { VerifySudoRequestDto, VerifySudoResponseDto } from './DTO/sudo.dto'; +import { + Disable2FARequestDto, + Disable2FAResponseDto, + Enable2FARequestDto, + Enable2FAResponseDto, + GenerateBackupCodesRequestDto, + GenerateBackupCodesResponseDto, + Get2FAStatusResponseDto, + Update2FASettingsRequestDto, + Update2FASettingsResponseDto, + Verify2FARequestDto, +} from './DTO/totp.dto'; +import { + UpdateUserRequestDto, + UpdateUserResponseDto, +} from './DTO/update-user.dto'; +import { UserDto } from './DTO/user.dto'; +import { TOTPService } from './totp.service'; +import { + PasskeyNotFoundError, + TOTPRequiredError, + UserIdNotFoundError, + UsernameNotFoundError, +} from './users.error'; +import { UsersService } from './users.service'; + +declare module 'express-session' { + interface SessionData { + passkeyChallenge?: string; + srpSession?: { + serverSecretEphemeral: string; + clientPublicEphemeral: string; + }; + } +} + +@Controller('/users') +export class UsersController { + constructor( + private readonly usersService: UsersService, + private readonly authService: AuthService, + private readonly sessionService: SessionService, + private readonly prismaService: PrismaService, + private readonly totpService: TOTPService, + @Inject(forwardRef(() => AnswerService)) + private readonly answerService: AnswerService, + @Inject(forwardRef(() => QuestionsService)) + private readonly questionsService: QuestionsService, + private readonly configService: ConfigService, + ) {} + + @ResourceOwnerIdGetter('user') + async getUserOwner(userId: number): Promise { + return userId; + } + + @Post('/verify/email') + @NoAuth() + async sendRegisterEmailCode( + @Body() { email }: SendEmailVerifyCodeRequestDto, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + await this.usersService.sendRegisterEmailCode(email, ip, userAgent); + return { + code: 201, + message: 'Send email successfully.', + }; + } + + @Post('/') + @NoAuth() + async register( + @Body() + { + username, + nickname, + srpSalt, + srpVerifier, + email, + emailCode, + password, + isLegacyAuth, + }: RegisterRequestDto, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + @Req() req: Request, + @Res() res: Response, + ): Promise { + const userDto = await this.usersService.register( + username, + nickname, + srpSalt, + srpVerifier, + email, + emailCode, + ip, + userAgent, + password, + isLegacyAuth, + ); + + // 如果是传统认证方式,并且在测试环境下,自动登录 + if ( + isLegacyAuth && + password && + (process.env.NODE_ENV === 'test' || + process.env.NODE_ENV === 'development') + ) { + const [, refreshToken] = await this.usersService.login( + username, + password, + ip, + userAgent, + isLegacyAuth, + ); + const [newRefreshToken, accessToken] = + await this.sessionService.refreshSession(refreshToken); + const newRefreshTokenExpire = new Date( + this.authService.decode(newRefreshToken).validUntil, + ); + + const data: RegisterResponseDto = { + code: 201, + message: 'Register successfully.', + data: { + user: userDto, + accessToken, + }, + }; + + return res + .cookie('REFRESH_TOKEN', newRefreshToken, { + httpOnly: true, + sameSite: 'strict', + path: path.posix.join( + this.configService.get('cookieBasePath')!, + 'users/auth', + ), + expires: new Date(newRefreshTokenExpire), + }) + .json(data); + } + + // 如果是 SRP 方式,自动创建会话 + if (srpSalt && srpVerifier) { + // 直接创建会话,因为我们信任注册时提供的 verifier + const accessToken = await this.usersService.createSessionForNewUser( + userDto.id, + ); + const [refreshToken, newAccessToken] = + await this.sessionService.refreshSession(accessToken); + const refreshTokenExpire = new Date( + this.authService.decode(refreshToken).validUntil, + ); + + const data: RegisterResponseDto = { + code: 201, + message: 'Register successfully.', + data: { + user: userDto, + accessToken: newAccessToken, + }, + }; + + return res + .cookie('REFRESH_TOKEN', refreshToken, { + httpOnly: true, + sameSite: 'strict', + path: path.posix.join( + this.configService.get('cookieBasePath')!, + 'users/auth', + ), + expires: refreshTokenExpire, + }) + .json(data); + } + + // 如果执行到这里,说明请求参数不完整 + throw new Error( + 'Invalid registration parameters: either legacy auth or SRP credentials must be provided', + ); + } + + @Post('/auth/login') + @NoAuth() + async login( + @Body() { username, password }: LoginRequestDto, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + @Res() res: Response, + ): Promise { + try { + const [userDto, refreshToken] = await this.usersService.login( + username, + password, + ip, + userAgent, + ); + const [newRefreshToken, accessToken] = + await this.sessionService.refreshSession(refreshToken); + const newRefreshTokenExpire = new Date( + this.authService.decode(newRefreshToken).validUntil, + ); + const data: LoginResponseDto = { + code: 201, + message: 'Login successfully.', + data: { + user: userDto, + accessToken, + requires2FA: false, + }, + }; + return res + .cookie('REFRESH_TOKEN', newRefreshToken, { + httpOnly: true, + sameSite: 'strict', + path: path.posix.join( + this.configService.get('cookieBasePath')!, + 'users/auth', + ), + expires: new Date(newRefreshTokenExpire), + }) + .json(data); + } catch (e) { + if (e instanceof TOTPRequiredError) { + const data: LoginResponseDto = { + code: 401, + message: e.message, + data: { + requires2FA: true, + tempToken: e.tempToken, + }, + }; + return res.json(data); + } + throw e; + } + } + + @Post('/auth/refresh-token') + @NoAuth() + async refreshToken( + @Headers('cookie') cookieHeader: string, + @Res() res: Response, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + if (cookieHeader == undefined) { + throw new AuthenticationRequiredError(); + } + const cookies = cookieHeader.split(';').map((cookie) => cookie.trim()); + const refreshTokenCookie = cookies.find((cookie) => + cookie.startsWith('REFRESH_TOKEN='), + ); + if (refreshTokenCookie == undefined) { + throw new AuthenticationRequiredError(); + } + const refreshToken = refreshTokenCookie.split('=')[1]; + const [newRefreshToken, accessToken] = + await this.sessionService.refreshSession(refreshToken); + const newRefreshTokenExpire = new Date( + this.authService.decode(newRefreshToken).validUntil, + ); + const decodedAccessToken = this.authService.decode(accessToken); + const userDto = await this.usersService.getUserDtoById( + decodedAccessToken.authorization.userId, + decodedAccessToken.authorization.userId, + ip, + userAgent, + ); + const data: RefreshTokenResponseDto = { + code: 201, + message: 'Refresh token successfully.', + data: { + accessToken: accessToken, + user: userDto, + }, + }; + return res + .cookie('REFRESH_TOKEN', newRefreshToken, { + httpOnly: true, + sameSite: 'strict', + path: path.posix.join( + this.configService.get('cookieBasePath')!, + 'users/auth', + ), + expires: new Date(newRefreshTokenExpire), + }) + .json(data); + } + + @Post('/auth/logout') + @NoAuth() + async logout( + @Headers('cookie') cookieHeader: string, + ): Promise { + if (cookieHeader == undefined) { + throw new AuthenticationRequiredError(); + } + const cookies = cookieHeader.split(';').map((cookie) => cookie.trim()); + const refreshTokenCookie = cookies.find((cookie) => + cookie.startsWith('REFRESH_TOKEN='), + ); + if (refreshTokenCookie == undefined) { + throw new AuthenticationRequiredError(); + } + const refreshToken = refreshTokenCookie.split('=')[1]; + await this.sessionService.revokeSession(refreshToken); + return { + code: 201, + message: 'Logout successfully.', + }; + } + + @Post('/recover/password/request') + @NoAuth() + async sendResetPasswordEmail( + @Body() { email }: ResetPasswordRequestRequestDto, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + await this.usersService.sendResetPasswordEmail(email, ip, userAgent); + return { + code: 201, + message: 'Send email successfully.', + }; + } + + @Post('/recover/password/verify') + @NoAuth() + async verifyAndResetPassword( + @Body() { token, srpSalt, srpVerifier }: ResetPasswordVerifyRequestDto, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + await this.usersService.verifyAndResetPassword( + token, + srpSalt, + srpVerifier, + ip, + userAgent, + ); + return { + code: 201, + message: 'Reset password successfully.', + }; + } + + @Get('/:id') + @Guard('query', 'user') + async getUser( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() viewerId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + const user = await this.usersService.getUserDtoById( + id, + viewerId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Query user successfully.', + data: { + user: user, + }, + }; + } + + @Put('/:id') + @Guard('modify-profile', 'user') + async updateUser( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Body() { nickname, intro, avatarId }: UpdateUserRequestDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + await this.usersService.updateUserProfile(id, nickname, intro, avatarId); + return { + code: 200, + message: 'Update user successfully.', + }; + } + + @Post('/:id/followers') + @Guard('follow', 'user') + async followUser( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + await this.usersService.addFollowRelationship(userId, id); + return { + code: 201, + message: 'Follow user successfully.', + data: { + follow_count: await this.usersService.getFollowingCount(userId), + }, + }; + } + + @Delete('/:id/followers') + @Guard('unfollow', 'user') + async unfollowUser( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId(true) userId: number, + ): Promise { + await this.usersService.deleteFollowRelationship(userId, id); + return { + code: 200, + message: 'Unfollow user successfully.', + data: { + follow_count: await this.usersService.getFollowingCount(userId), + }, + }; + } + + @Get('/:id/followers') + @Guard('enumerate-followers', 'user') + async getFollowers( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Query() + { page_start: pageStart, page_size: pageSize }: PageDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() viewerId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + if (pageSize == undefined || pageSize == 0) pageSize = 20; + const [followers, page] = await this.usersService.getFollowers( + id, + pageStart, + pageSize, + viewerId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Query followers successfully.', + data: { + users: followers, + page: page, + }, + }; + } + + @Get('/:id/follow/users') + @Guard('enumerate-followed-users', 'user') + async getFollowees( + @Param('id', ParseIntPipe) @ResourceId() id: number, + @Query() + { page_start: pageStart, page_size: pageSize }: PageDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() viewerId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + if (pageSize == undefined || pageSize == 0) pageSize = 20; + const [followees, page] = await this.usersService.getFollowees( + id, + pageStart, + pageSize, + viewerId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Query followees successfully.', + data: { + users: followees, + page: page, + }, + }; + } + + @Get('/:id/questions') + @Guard('enumerate-questions', 'user') + async getUserAskedQuestions( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Query() + { page_start: pageStart, page_size: pageSize }: PageDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() viewerId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + if (pageSize == undefined || pageSize == 0) pageSize = 20; + const [questions, page] = await this.questionsService.getUserAskedQuestions( + userId, + pageStart, + pageSize, + viewerId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Query asked questions successfully.', + data: { + questions, + page, + }, + }; + } + + @Get('/:id/answers') + @Guard('enumerate-answers', 'user') + async getUserAnsweredAnswers( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Query() + { page_start: pageStart, page_size: pageSize }: PageDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + @UserId() viewerId: number | undefined, + ): Promise { + if (pageSize == undefined || pageSize == 0) pageSize = 20; + const [answers, page] = + await this.answerService.getUserAnsweredAnswersAcrossQuestions( + userId, + pageStart, + pageSize, + viewerId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Query asked questions successfully.', + data: { + answers, + page, + }, + }; + } + + @Get('/:id/follow/questions') + @Guard('enumerate-followed-questions', 'user') + async getFollowedQuestions( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Query() + { page_start: pageStart, page_size: pageSize }: PageDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + @UserId() viewerId: number | undefined, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + ): Promise { + if (pageSize == undefined || pageSize == 0) pageSize = 20; + const [questions, page] = await this.questionsService.getFollowedQuestions( + userId, + pageStart, + pageSize, + viewerId, + ip, + userAgent, + ); + return { + code: 200, + message: 'Query followed questions successfully.', + data: { + questions, + page, + }, + }; + } + + // Passkey Registration + @Post('/:id/passkeys/options') + @Guard('register-passkey', 'user', true) + async getPasskeyRegistrationOptions( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + const options = + await this.usersService.generatePasskeyRegistrationOptions(userId); + return { + code: 200, + message: 'Generated registration options successfully.', + data: { + options: options as any, // Type assertion to fix compatibility issue + }, + }; + } + + @Post('/:id/passkeys') + @Guard('register-passkey', 'user', true) + async verifyPasskeyRegistration( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Body() { response }: PasskeyRegistrationVerifyRequestDto, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + await this.usersService.verifyPasskeyRegistration(userId, response); + return { + code: 201, + message: 'Passkey registered successfully.', + }; + } + + // Passkey Authentication + @Post('/auth/passkey/options') + @NoAuth() + async getPasskeyAuthenticationOptions( + @Body() { userId }: PasskeyAuthenticationOptionsRequestDto, + @Req() req: Request, + ): Promise { + const options = + await this.usersService.generatePasskeyAuthenticationOptions(req, userId); + req.session.passkeyChallenge = options.challenge; + return { + code: 200, + message: 'Generated authentication options successfully.', + data: { + options: options as any, // Type assertion to fix compatibility issue + }, + }; + } + + @Post('/auth/passkey/verify') + @NoAuth() + async verifyPasskeyAuthentication( + @Req() req: Request, + @Body() { response }: PasskeyAuthenticationVerifyRequestDto, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + @Res() res: Response, + ): Promise { + const verified = await this.usersService.verifyPasskeyAuthentication( + req, + response, + ); + if (!verified) { + throw new PasskeyNotFoundError(response.id); + } + + const passkey = await this.prismaService.passkey.findFirst({ + where: { + credentialId: response.id, + }, + }); + + if (!passkey) { + throw new PasskeyNotFoundError(response.id); + } + + const [userDto, refreshToken] = (await this.usersService.handlePasskeyLogin( + passkey.userId, + ip, + userAgent, + )) as [UserDto, string]; // Type assertion to fix compatibility issue + + const [newRefreshToken, accessToken] = + await this.sessionService.refreshSession(refreshToken); + const newRefreshTokenExpire = new Date( + this.authService.decode(newRefreshToken).validUntil, + ); + + const data: PasskeyAuthenticationVerifyResponseDto = { + code: 201, + message: 'Authentication successful.', + data: { + user: userDto, + accessToken, + }, + }; + + return res + .cookie('REFRESH_TOKEN', newRefreshToken, { + httpOnly: true, + sameSite: 'strict', + path: path.posix.join( + this.configService.get('cookieBasePath')!, + 'users/auth', + ), + expires: new Date(newRefreshTokenExpire), + }) + .json(data); + } + + // Passkey Management + @Get('/:id/passkeys') + @Guard('enumerate-passkeys', 'user') + async getUserPasskeys( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + const passkeys = await this.usersService.getUserPasskeys(userId); + return { + code: 200, + message: 'Query passkeys successfully.', + data: { + passkeys: passkeys.map((p) => ({ + id: p.credentialId, + createdAt: p.createdAt, + deviceType: p.deviceType, + backedUp: p.backedUp, + })), + }, + }; + } + + @Delete('/:id/passkeys/:credentialId') + @Guard('delete-passkey', 'user', true) + async deletePasskey( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Param('credentialId') credentialId: string, + @Headers('Authorization') @AuthToken() auth: string | undefined, + ): Promise { + await this.usersService.deletePasskey(userId, credentialId); + return { + code: 200, + message: 'Delete passkey successfully.', + }; + } + + @Post('/auth/sudo') + @Guard('verify-sudo', 'user') + async verifySudo( + @Req() req: Request, + @Headers('Authorization') @AuthToken() auth: string, + @Body() body: VerifySudoRequestDto, + ): Promise { + // 验证并获取新 token + const result = await this.usersService.verifySudo( + req, + auth, + body.method, + body.credentials, + ); + + let message = 'Sudo mode activated successfully'; + if (result.serverProof) { + message = 'SRP verification successful'; + } else if (result.srpUpgraded) { + message = 'Password verification successful and account upgraded to SRP'; + } + + return { + code: 200, + message, + data: result, + }; + } + + @Post('auth/verify-2fa') + @NoAuth() + async verify2FA( + @Body() dto: Verify2FARequestDto, + @Ip() ip: string, + @Headers('user-agent') userAgent: string | undefined, + @Res() res: Response, + ): Promise { + const [userDto, refreshToken, usedBackupCode] = + await this.usersService.verifyTOTPAndLogin( + dto.temp_token, + dto.code, + ip, + userAgent, + ); + const [newRefreshToken, accessToken] = + await this.sessionService.refreshSession(refreshToken); + const newRefreshTokenExpire = new Date( + this.authService.decode(newRefreshToken).validUntil, + ); + const data: LoginResponseDto = { + code: 201, + message: usedBackupCode + ? 'Login successfully. Note: This backup code has expired. Please generate a new backup code for future use.' + : 'Login successfully.', + data: { + user: userDto, + accessToken, + requires2FA: false, + usedBackupCode: usedBackupCode, + }, + }; + return res + .cookie('REFRESH_TOKEN', newRefreshToken, { + httpOnly: true, + sameSite: 'strict', + path: path.posix.join( + this.configService.get('cookieBasePath')!, + 'users/auth', + ), + expires: new Date(newRefreshTokenExpire), + }) + .json(data); + } + + // 2FA 管理接口 + @Post(':id/2fa/enable') + @Guard('modify-2fa', 'user', true) + async enable2FA( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Body() dto: Enable2FARequestDto, + @Headers('Authorization') @AuthToken() auth: string, + ): Promise { + // 如果是初始化阶段 + if (!dto.code) { + const secret = await this.totpService.generateTOTPSecret(); + const user = await this.usersService.findUserRecordOrThrow(userId); + const otpauthUrl = this.totpService.generateTOTPUri( + secret, + user.username, + ); + + // 生成二维码 + const qrcodeData = await qrcode.toDataURL(otpauthUrl); + + return { + code: 200, + message: 'TOTP secret generated successfully', + data: { + secret, + otpauth_url: otpauthUrl, + qrcode: qrcodeData, + backup_codes: [], // 初始化阶段不生成备份码 + }, + }; + } + + // 如果是确认阶段,需要前端传入之前生成的 secret + if (!dto.secret) { + throw new Error('Secret is required for confirmation'); + } + + // 验证并启用 2FA + const backupCodes = await this.totpService.enable2FA( + userId, + dto.secret, + dto.code, + ); + + // 生成二维码(虽然这个阶段前端可能不需要了,但为了保持 API 一致性还是返回) + const user = await this.usersService.findUserRecordOrThrow(userId); + const otpauthUrl = this.totpService.generateTOTPUri( + dto.secret, + user.username, + ); + const qrcodeData = await qrcode.toDataURL(otpauthUrl); + + return { + code: 201, + message: '2FA enabled successfully', + data: { + secret: dto.secret, + otpauth_url: otpauthUrl, + qrcode: qrcodeData, + backup_codes: backupCodes, + }, + }; + } + + @Post(':id/2fa/disable') + @HttpCode(200) + @Guard('modify-2fa', 'user', true) + async disable2FA( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Body() dto: Disable2FARequestDto, + @Headers('Authorization') @AuthToken() auth: string, + ): Promise { + await this.totpService.disable2FA(userId); + return { + code: 200, + message: '2FA disabled successfully', + data: { + success: true, + }, + }; + } + + @Post(':id/2fa/backup-codes') + @Guard('modify-2fa', 'user', true) + async generateBackupCodes( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Body() dto: GenerateBackupCodesRequestDto, + @Headers('Authorization') @AuthToken() auth: string, + ): Promise { + // 生成新的备份码并保存 + const backupCodes = + await this.totpService.generateAndSaveBackupCodes(userId); + + return { + code: 201, + message: 'New backup codes generated successfully', + data: { + backup_codes: backupCodes, + }, + }; + } + + @Get(':id/2fa/status') + @Guard('query', 'user') + async get2FAStatus( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Headers('Authorization') @AuthToken() auth: string, + ): Promise { + const user = await this.prismaService.user.findUnique({ + where: { id: userId }, + select: { + totpEnabled: true, + totpAlwaysRequired: true, + passkeys: { + select: { id: true }, + }, + }, + }); + + if (!user) { + throw new UserIdNotFoundError(userId); + } + + return { + code: 200, + message: 'Get 2FA status successfully', + data: { + enabled: user.totpEnabled, + has_passkey: user.passkeys.length > 0, + always_required: user.totpAlwaysRequired, + }, + }; + } + + @Put(':id/2fa/settings') + @Guard('modify-2fa', 'user', true) + async update2FASettings( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Body() dto: Update2FASettingsRequestDto, + @Headers('Authorization') @AuthToken() auth: string, + ): Promise { + await this.prismaService.user.update({ + where: { id: userId }, + data: { + totpAlwaysRequired: dto.always_required, + }, + }); + + return { + code: 200, + message: '2FA settings updated successfully', + data: { + success: true, + always_required: dto.always_required, + }, + }; + } + + @Post('/auth/srp/init') + @NoAuth() + async srpInit( + @Body() { username, clientPublicEphemeral }: SrpInitRequestDto, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + @Req() req: Request, + ): Promise { + const result = await this.usersService.handleSrpInit( + username, + clientPublicEphemeral, + ); + + // 将服务器的私密临时值存储在 session 中 + req.session.srpSession = { + serverSecretEphemeral: result.serverSecretEphemeral, + clientPublicEphemeral, + }; + + return { + code: 200, + message: 'SRP initialization successful.', + data: { + salt: result.salt, + serverPublicEphemeral: result.serverPublicEphemeral, + }, + }; + } + + @Post('/auth/srp/verify') + @NoAuth() + async srpVerify( + @Body() { username, clientProof }: SrpVerifyRequestDto, + @Ip() ip: string, + @Headers('User-Agent') userAgent: string | undefined, + @Req() req: Request, + @Res() res: Response, + ): Promise { + const sessionState = req.session.srpSession; + if (!sessionState) { + throw new Error('SRP session not found. Please initialize first.'); + } + + const result = await this.usersService.handleSrpVerify( + username, + sessionState.clientPublicEphemeral, + clientProof, + sessionState.serverSecretEphemeral, + ip, + userAgent, + ); + + // 清除 session 中的 SRP 状态 + delete req.session.srpSession; + + if (result.requires2FA) { + const data: SrpVerifyResponseDto = { + code: 200, + message: 'SRP verification successful, 2FA required.', + data: { + serverProof: result.serverProof, + accessToken: '', + requires2FA: true, + tempToken: result.tempToken, + user: result.user, + }, + }; + return res.json(data); + } + + // 如果不需要 2FA,设置 refresh token cookie + const [refreshToken, accessToken] = + await this.sessionService.refreshSession(result.accessToken); + + const refreshTokenExpire = new Date( + this.authService.decode(refreshToken).validUntil, + ); + + const data: SrpVerifyResponseDto = { + code: 200, + message: 'SRP verification successful.', + data: { + serverProof: result.serverProof, + accessToken, + requires2FA: false, + user: result.user, + }, + }; + + return res + .cookie('REFRESH_TOKEN', refreshToken, { + httpOnly: true, + sameSite: 'strict', + path: path.posix.join( + this.configService.get('cookieBasePath')!, + 'users/auth', + ), + expires: refreshTokenExpire, + }) + .json(data); + } + + @Get('/auth/methods/:username') + @NoAuth() + async getAuthMethods(@Param('username') username: string): Promise<{ + code: number; + message: string; + data: { + supports_srp: boolean; + supports_passkey: boolean; + supports_2fa: boolean; + requires_2fa: boolean; + }; + }> { + try { + const user = + await this.usersService.findUserRecordByUsernameOrThrow(username); + + const hasPasskeys = + (await this.prismaService.passkey.count({ + where: { userId: user.id }, + })) > 0; + + return { + code: 200, + message: 'Authentication methods retrieved successfully.', + data: { + supports_srp: user.srpUpgraded, + supports_passkey: hasPasskeys, + supports_2fa: user.totpEnabled, + requires_2fa: user.totpAlwaysRequired, + }, + }; + } catch (error) { + if (error instanceof UsernameNotFoundError) { + // 如果用户不存在,返回所有方法都不支持 + return { + code: 200, + message: 'User not found, no authentication methods available.', + data: { + supports_srp: false, + supports_passkey: false, + supports_2fa: false, + requires_2fa: false, + }, + }; + } + throw error; + } + } + + @Patch('/:id/password') + @Guard('modify-profile', 'user', true) // 需要 sudo 模式 + async changePassword( + @Param('id', ParseIntPipe) @ResourceId() userId: number, + @Body() { srpSalt, srpVerifier }: ChangePasswordRequestDto, + @Headers('Authorization') @AuthToken() auth: string, + ): Promise { + await this.usersService.changePassword(userId, srpSalt, srpVerifier); + + return { + code: 200, + message: 'Password changed successfully', + }; + } +} diff --git a/src/users/users.error.ts b/src/users/users.error.ts new file mode 100644 index 00000000..bb8f4ce7 --- /dev/null +++ b/src/users/users.error.ts @@ -0,0 +1,237 @@ +/* + * Description: This file defines the errors related to users service. + * All the errors in this file should extend BaseError. + * + * Author(s): + * Nictheboy Li + * + */ + +import { BaseError } from '../common/error/base-error'; + +export class InvalidEmailAddressError extends BaseError { + constructor(public readonly email: string) { + super( + 'InvalidEmailAddressError', + `Invalid email address: ${email}. Email should look like someone@example.com`, + 422, + ); + } +} + +export class InvalidEmailSuffixError extends BaseError { + constructor( + public readonly email: string, + public readonly rule: string, + ) { + super( + 'InvalidEmailSuffixError', + `Invalid email suffix: ${email}. ${rule}`, + 422, + ); + } +} + +export class EmailAlreadyRegisteredError extends BaseError { + constructor(public readonly email: string) { + super( + 'EmailAlreadyRegisteredError', + `Email already registered: ${email}`, + 409, + ); + } +} + +export class EmailSendFailedError extends BaseError { + constructor(public readonly email: string) { + super('EmailSendFailedError', `Failed to send email to ${email}`, 500); + } +} + +export class InvalidUsernameError extends BaseError { + constructor( + public readonly username: string, + public readonly rule: string, + ) { + super( + 'InvalidUsernameError', + `Invalid username: ${username}. ${rule}`, + 422, + ); + } +} + +export class InvalidNicknameError extends BaseError { + constructor( + public readonly nickname: string, + public readonly rule: string, + ) { + super( + 'InvalidNicknameError', + `Invalid nickname: ${nickname}. ${rule}`, + 422, + ); + } +} + +export class InvalidPasswordError extends BaseError { + constructor(public readonly rule: string) { + super('InvalidPasswordError', `Invalid password. ${rule}`, 422); + } +} + +export class UsernameAlreadyRegisteredError extends BaseError { + constructor(public readonly username: string) { + super( + 'UsernameAlreadyRegisteredError', + `Username already registered: ${username}`, + 409, + ); + } +} + +export class CodeNotMatchError extends BaseError { + constructor( + public readonly email: string, + public readonly code: string, + ) { + super('CodeNotMatchError', `Code not match: ${email}, ${code}`, 422); + } +} + +export class UserIdNotFoundError extends BaseError { + constructor(public readonly userId: number) { + super('UserIdNotFoundError', `User with id ${userId} not found`, 404); + } +} + +export class UsernameNotFoundError extends BaseError { + constructor(public readonly username: string) { + super( + 'UsernameNotFoundError', + `User with username ${username} not found`, + 404, + ); + } +} + +export class PasswordNotMatchError extends BaseError { + constructor(public readonly username: string) { + super( + 'PasswordNotMatchError', + `Password not match for user ${username}`, + 401, + ); + } +} + +export class EmailNotFoundError extends BaseError { + constructor(public readonly email: string) { + super('EmailNotFoundError', `Email not found: ${email}`, 404); + } +} + +export class UserNotFollowedYetError extends BaseError { + constructor(public readonly followeeId: number) { + super( + 'UserNotFollowedYetError', + `User with id ${followeeId} is not followed yet.`, + 422, + ); + } +} + +export class FollowYourselfError extends BaseError { + constructor() { + super('FollowYourselfError', 'Cannot follow yourself.', 422); + } +} + +export class UserAlreadyFollowedError extends BaseError { + constructor(public readonly followeeId: number) { + super( + 'UserAlreadyFollowedError', + `User with id ${followeeId} already followed.`, + 422, + ); + } +} + +export class UpdateAvatarError extends BaseError { + constructor() { + super('UpdateAvatarError', 'Can not use avatar loaded by others.', 403); + } +} + +export class ChallengeNotFoundError extends BaseError { + constructor() { + super('ChallengeNotFoundError', 'Challenge not found', 404); + } +} + +export class PasskeyVerificationFailedError extends BaseError { + constructor() { + super('PasskeyVerificationFailedError', 'Passkey verification failed', 400); + } +} + +export class PasskeyNotFoundError extends BaseError { + constructor(credentialId: string) { + super( + 'PasskeyNotFoundError', + `Passkey not found. ID: ${credentialId.substring(0, 8)}...`, + 404, + ); + } +} + +export class TOTPRequiredError extends BaseError { + constructor( + username: string, + public readonly tempToken: string, + ) { + super( + 'TOTPRequiredError', + `2FA verification required for user '${username}'`, + 401, + ); + } +} + +export class TOTPInvalidError extends BaseError { + constructor() { + super('TOTPInvalidError', 'Invalid 2FA code', 400); + } +} + +export class TOTPTempTokenInvalidError extends BaseError { + constructor() { + super( + 'TOTPTempTokenInvalidError', + 'Invalid or expired temporary token for 2FA verification', + 400, + ); + } +} + +export class SrpNotUpgradedError extends BaseError { + constructor(username: string) { + super( + 'SrpNotUpgradedError', + `User ${username} has not been upgraded to SRP authentication.`, + 401, + ); + } +} + +export class SrpVerificationError extends BaseError { + constructor() { + super('SrpVerificationError', 'SRP verification failed.', 401); + } +} + +export class InvalidPublicKeyError extends BaseError { + constructor() { + super('InvalidPublicKeyError', 'Invalid public key provided.', 422); + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts new file mode 100644 index 00000000..694e814b --- /dev/null +++ b/src/users/users.module.ts @@ -0,0 +1,48 @@ +/* + * Description: This file defines the users module. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Module, forwardRef } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AnswerModule } from '../answer/answer.module'; +import { AuthModule } from '../auth/auth.module'; +import { AvatarsModule } from '../avatars/avatars.module'; +import { PrismaModule } from '../common/prisma/prisma.module'; +import { EmailModule } from '../email/email.module'; +import { QuestionsModule } from '../questions/questions.module'; +import { RolePermissionService } from './role-permission.service'; +import { SrpService } from './srp.service'; +import { TOTPService } from './totp.service'; +import { UserChallengeRepository } from './user-challenge.repository'; +import { UsersPermissionService } from './users-permission.service'; +import { UsersRegisterRequestService } from './users-register-request.service'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + imports: [ + PrismaModule, + ConfigModule, + EmailModule, + AuthModule, + AvatarsModule, + forwardRef(() => AnswerModule), + forwardRef(() => QuestionsModule), + ], + controllers: [UsersController], + providers: [ + UsersService, + UsersPermissionService, + UsersRegisterRequestService, + RolePermissionService, + UserChallengeRepository, + TOTPService, + SrpService, + ], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/src/users/users.prisma b/src/users/users.prisma new file mode 100644 index 00000000..b6082a4a --- /dev/null +++ b/src/users/users.prisma @@ -0,0 +1,198 @@ +import { GroupMembership } from "../groups/groups" +import { Question, QuestionFollowerRelation, QuestionQueryLog, QuestionSearchLog, QuestionTopicRelation } from "../questions/questions" +import { Topic, TopicSearchLog } from "../topics/topics" +import { Avatar } from "../avatars/avatars" +import { Answer, AnswerDeleteLog, AnswerFavoritedByUser, AnswerQueryLog, AnswerUpdateLog } from "../answer/answer" +import { Attitude } from "../attitude/attitude" +import { Comment, CommentDeleteLog, CommentQueryLog } from "../comments/comment" +import { AttitudeLog } from "../attitude/attitude" +import { QuestionInvitationRelation } from "../questions/questions.invitation" +import { Material } from "../materials/materials" +import { MaterialBundle } from "../materialbundles/materialbundles" + +model User { + id Int @id(map: "PK_cace4a159ff9f2512dd42373760") @default(autoincrement()) + username String @unique(map: "IDX_78a916df40e02a9deb1c4b75ed") @db.VarChar + hashedPassword String? @map("hashed_password") @db.VarChar + srpSalt String? @map("srp_salt") @db.VarChar(500) + srpVerifier String? @map("srp_verifier") @db.VarChar(1000) + srpUpgraded Boolean @default(false) @map("srp_upgraded") + email String @unique(map: "IDX_e12875dfb3b1d92d7d7c5377e2") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + lastPasswordChangedAt DateTime @default(now()) @map("last_password_changed_at") @db.Timestamptz(6) + answer Answer[] + answerDeleteLog AnswerDeleteLog[] + answerFavoritedByUser AnswerFavoritedByUser[] + answerQueryLog AnswerQueryLog[] + answerUpdateLog AnswerUpdateLog[] + comment Comment[] + commentDeleteLog CommentDeleteLog[] + commentQueryLog CommentQueryLog[] + groupMembership GroupMembership[] + question Question[] + questionFollowerRelation QuestionFollowerRelation[] + questionQueryLog QuestionQueryLog[] + questionSearchLog QuestionSearchLog[] + questionTopicRelation QuestionTopicRelation[] + topic Topic[] + topicSearchLog TopicSearchLog[] + userFollowingRelationshipUserFollowingRelationshipFollowerIdTouser UserFollowingRelationship[] @relation("user_following_relationship_followerIdTouser") + userFollowingRelationshipUserFollowingRelationshipFolloweeIdTouser UserFollowingRelationship[] @relation("user_following_relationship_followeeIdTouser") + userLoginLog UserLoginLog[] + userProfile UserProfile? + userProfileQueryLogUserProfileQueryLogViewerIdTouser UserProfileQueryLog[] @relation("user_profile_query_log_viewerIdTouser") + userProfileQueryLogUserProfileQueryLogVieweeIdTouser UserProfileQueryLog[] @relation("user_profile_query_log_vieweeIdTouser") + attitude Attitude[] + attitudeLog AttitudeLog[] + questionInvitationRelation QuestionInvitationRelation[] + Material Material[] + MaterialBundle MaterialBundle[] + passkeys Passkey[] + totpSecret String? @db.VarChar(64) @map("totp_secret") + totpEnabled Boolean @default(false) @map("totp_enabled") + totpAlwaysRequired Boolean @default(false) @map("totp_always_required") + backupCodes UserBackupCode[] + + @@map("user") +} + +model UserFollowingRelationship { + id Int @id(map: "PK_3b0199015f8814633fc710ff09d") @default(autoincrement()) + followeeId Int @map("followee_id") + followerId Int @map("follower_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + userUserFollowingRelationshipFollowerIdTouser User @relation("user_following_relationship_followerIdTouser", fields: [followerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_868df0c2c3a138ee54d2a515bce") + userUserFollowingRelationshipFolloweeIdTouser User @relation("user_following_relationship_followeeIdTouser", fields: [followeeId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_c78831eeee179237b1482d0c6fb") + + @@index([followerId], map: "IDX_868df0c2c3a138ee54d2a515bc") + @@index([followeeId], map: "IDX_c78831eeee179237b1482d0c6f") + @@map("user_following_relationship") +} + +model UserLoginLog { + id Int @id(map: "PK_f8db79b1af1f385db4f45a2222e") @default(autoincrement()) + userId Int @map("user_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User @relation(fields: [userId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_66c592c7f7f20d1214aba2d0046") + + @@index([userId], map: "IDX_66c592c7f7f20d1214aba2d004") + @@map("user_login_log") +} + +model UserProfile { + id Int @id(map: "PK_f44d0cd18cfd80b0fed7806c3b7") @default(autoincrement()) + userId Int @unique(map: "IDX_51cb79b5555effaf7d69ba1cff") @map("user_id") + nickname String @db.VarChar + avatarId Int @map("avatar_id") + intro String @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + user User @relation(fields: [userId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "fk_user_profile_user_id") + avatar Avatar @relation(fields: [avatarId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "fk_user_profile_avatar_id") + + @@map("user_profile") +} + +model UserProfileQueryLog { + id Int @id(map: "PK_9aeff7c959703fad866e9ad581a") @default(autoincrement()) + viewerId Int? @map("viewer_id") + vieweeId Int @map("viewee_id") + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + userUserProfileQueryLogViewerIdTouser User? @relation("user_profile_query_log_viewerIdTouser", fields: [viewerId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_1261db28434fde159acda6094bc") + userUserProfileQueryLogVieweeIdTouser User @relation("user_profile_query_log_vieweeIdTouser", fields: [vieweeId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_ff592e4403b328be0de4f2b3973") + + @@index([viewerId], map: "IDX_1261db28434fde159acda6094b") + @@index([vieweeId], map: "IDX_ff592e4403b328be0de4f2b397") + @@map("user_profile_query_log") +} + +enum UserRegisterLogType { + RequestSuccess + RequestFailDueToAlreadyRegistered + RequestFailDueToInvalidOrNotSupportedEmail + RequestFailDurToSecurity + RequestFailDueToSendEmailFailure + Success + FailDueToUserExistence + FailDueToWrongCodeOrExpired +} + +model UserRegisterLog { + id Int @id(map: "PK_3596a6f74bd2a80be930f6d1e39") @default(autoincrement()) + email String @db.VarChar + type UserRegisterLogType + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([email], map: "IDX_3af79f07534d9f1c945cd4c702") + @@map("user_register_log") +} + +model UserRegisterRequest { + id Int @id(map: "PK_cdf2d880551e43d9362ddd37ae0") @default(autoincrement()) + email String @db.VarChar + code String @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@index([email], map: "IDX_c1d0ecc369d7a6a3d7e876c589") + @@map("user_register_request") +} + +enum UserResetPasswordLogType { + RequestSuccess + RequestFailDueToNoneExistentEmail + RequestFailDueToSecurity + Success + FailDueToInvalidToken + FailDueToExpiredRequest + FailDueToNoUser +} + +model UserResetPasswordLog { + id Int @id(map: "PK_3ee4f25e7f4f1d5a9bd9817b62b") @default(autoincrement()) + userId Int? @map("user_id") + type UserResetPasswordLogType + ip String @db.VarChar + userAgent String? @map("user_agent") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@map("user_reset_password_log") +} + +model UserBackupCode { + id Int @id @default(autoincrement()) + userId Int @map("user_id") + codeHash String @map("code_hash") @db.VarChar(128) // 加盐哈希存储 + used Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + user User @relation(fields: [userId], references: [id]) + + @@index([userId], map: "IDX_user_backup_code_user_id") + @@map("user_backup_code") +} + +model Passkey { + id Int @id(map: "PK_passkey") @default(autoincrement()) + credentialId String // 凭证ID + publicKey Bytes // 存储公钥(二进制数据) + counter Int // 验证计数器 + deviceType String // 'singleDevice' 或 'multiDevice' + backedUp Boolean // 是否已备份 + transports String? // 可选,存储传输方式(JSON 数组字符串) + userId Int // 关联用户ID + user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + + @@map("passkey") + @@index([userId], map: "IDX_passkey_user_id") +} diff --git a/src/users/users.service.ts b/src/users/users.service.ts new file mode 100644 index 00000000..436fb8ee --- /dev/null +++ b/src/users/users.service.ts @@ -0,0 +1,1573 @@ +/* + * Description: This file implements the UsersService class. + * It is responsible for the business logic of users. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Inject, Injectable, Logger, forwardRef } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + Passkey, + User, + UserFollowingRelationship, + UserProfile, + UserRegisterLogType, + UserResetPasswordLogType, +} from '@prisma/client'; +import { + AuthenticationResponseJSON, + CredentialDeviceType, + RegistrationResponseJSON, + WebAuthnCredential, + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from '@simplewebauthn/server'; +import bcrypt from 'bcryptjs'; +import { isEmail } from 'class-validator'; +import { Request } from 'express'; +import assert from 'node:assert'; +import { AnswerService } from '../answer/answer.service'; +import { + InvalidCredentialsError, + PermissionDeniedError, + TokenExpiredError, +} from '../auth/auth.error'; +import { AuthService } from '../auth/auth.service'; +import { Authorization } from '../auth/definitions'; +import { SessionService } from '../auth/session.service'; +import { AvatarNotFoundError } from '../avatars/avatars.error'; +import { AvatarsService } from '../avatars/avatars.service'; +import { PageDto } from '../common/DTO/page-response.dto'; +import { PageHelper } from '../common/helper/page.helper'; +import { PrismaService } from '../common/prisma/prisma.service'; +import { EmailRuleService } from '../email/email-rule.service'; +import { EmailService } from '../email/email.service'; +import { QuestionsService } from '../questions/questions.service'; +import { UserDto } from './DTO/user.dto'; +import { SrpService } from './srp.service'; +import { TOTPService } from './totp.service'; +import { UserChallengeRepository } from './user-challenge.repository'; +import { UsersPermissionService } from './users-permission.service'; +import { UsersRegisterRequestService } from './users-register-request.service'; +import { + ChallengeNotFoundError, + CodeNotMatchError, + EmailAlreadyRegisteredError, + EmailNotFoundError, + EmailSendFailedError, + FollowYourselfError, + InvalidEmailAddressError, + InvalidEmailSuffixError, + InvalidNicknameError, + InvalidPasswordError, + InvalidUsernameError, + PasskeyNotFoundError, + PasskeyVerificationFailedError, + PasswordNotMatchError, + SrpNotUpgradedError, + SrpVerificationError, + TOTPInvalidError, + TOTPRequiredError, + TOTPTempTokenInvalidError, + UserAlreadyFollowedError, + UserIdNotFoundError, + UserNotFollowedYetError, + UsernameAlreadyRegisteredError, + UsernameNotFoundError, +} from './users.error'; + +declare module 'express-session' { + interface SessionData { + passkeyChallenge?: string; + srpSession?: { + serverSecretEphemeral: string; + clientPublicEphemeral: string; + }; + } +} + +@Injectable() +export class UsersService { + constructor( + private readonly emailService: EmailService, + private readonly emailRuleService: EmailRuleService, + private readonly authService: AuthService, + private readonly configService: ConfigService, + private readonly sessionService: SessionService, + private readonly userChallengeRepository: UserChallengeRepository, + private readonly usersPermissionService: UsersPermissionService, + private readonly usersRegisterRequestService: UsersRegisterRequestService, + private readonly avatarsService: AvatarsService, + @Inject(forwardRef(() => AnswerService)) + private readonly answerService: AnswerService, + @Inject(forwardRef(() => QuestionsService)) + private readonly questionsService: QuestionsService, + private readonly prismaService: PrismaService, + private readonly totpService: TOTPService, + private readonly srpService: SrpService, + ) {} + + private readonly passwordResetEmailValidSeconds = 10 * 60; // 10 minutes + + private get rpName(): string { + return this.configService.get('webauthn.rpName') ?? 'Cheese Community'; + } + + private get rpID(): string { + return this.configService.get('webauthn.rpID') ?? 'localhost'; + } + + private get origin(): string { + return this.configService.get('webauthn.origin') ?? 'http://localhost:7777'; + } + + private generateVerifyCode(): string { + let code: string = ''; + for (let i = 0; i < 6; i++) { + code += Math.floor(Math.random() * 10).toString()[0]; + } + return code; + } + + get emailSuffixRule(): string { + return this.emailRuleService.emailSuffixRule; + } + + async generatePasskeyRegistrationOptions( + userId: number, + ): Promise { + const [user, _] = await this.findUserRecordAndProfileRecordOrThrow(userId); + + const existingPasskeys = await this.prismaService.passkey.findMany({ + where: { + userId, + }, + }); + + const options = await generateRegistrationOptions({ + rpName: this.rpName, + rpID: this.rpID, + userName: user.username, + userID: Buffer.from(user.id.toString()), + attestationType: 'none', + authenticatorSelection: { + residentKey: 'required', + userVerification: 'preferred', + }, + excludeCredentials: existingPasskeys.map((passkey) => ({ + id: passkey.credentialId, + transports: passkey.transports + ? JSON.parse(passkey.transports) + : undefined, + })), + timeout: 60000, + }); + + await this.userChallengeRepository.setChallenge( + userId, + options.challenge, + 600, + ); + + return options; + } + + async verifyPasskeyRegistration( + userId: number, + response: RegistrationResponseJSON, + ): Promise { + const challenge = await this.userChallengeRepository.getChallenge(userId); + + if (challenge == null) { + throw new ChallengeNotFoundError(); + } + + const { verified, registrationInfo } = await verifyRegistrationResponse({ + response, + expectedChallenge: challenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + requireUserVerification: false, + }); + + if (!verified || registrationInfo == null) { + throw new PasskeyVerificationFailedError(); + } + + const { credential, credentialBackedUp, credentialDeviceType } = + registrationInfo; + + await this.savePasskeyCredential( + userId, + credential, + credentialDeviceType, + credentialBackedUp, + ); + + await this.userChallengeRepository.deleteChallenge(userId); + } + + async savePasskeyCredential( + userId: number, + credential: WebAuthnCredential, + deviceType: CredentialDeviceType, + backedUp: boolean, + ): Promise { + await this.prismaService.passkey.create({ + data: { + userId, + credentialId: credential.id, + publicKey: Buffer.from(credential.publicKey), + counter: credential.counter, + deviceType, + backedUp, + transports: credential.transports + ? JSON.stringify(credential.transports) + : null, + }, + }); + } + + async generatePasskeyAuthenticationOptions( + req: Request, + userId?: number, + ): Promise { + if (!userId) { + const options = await generateAuthenticationOptions({ + rpID: this.rpID, + allowCredentials: [], + userVerification: 'preferred', + }); + + req.session.passkeyChallenge = options.challenge; + + return options; + } + const passkeys = await this.prismaService.passkey.findMany({ + where: { + userId, + }, + }); + const allowCredentials = passkeys.map((passkey) => ({ + id: passkey.credentialId, + transports: passkey.transports + ? JSON.parse(passkey.transports) + : undefined, + })); + + const options = await generateAuthenticationOptions({ + rpID: this.rpID, + allowCredentials, + userVerification: 'preferred', + }); + + req.session.passkeyChallenge = options.challenge; + + return options; + } + + async verifyPasskeyAuthentication( + req: Request, + response: AuthenticationResponseJSON, + ): Promise { + const challenge = req.session.passkeyChallenge; + + if (challenge == null) { + throw new ChallengeNotFoundError(); + } + + const authenticator = await this.prismaService.passkey.findFirst({ + where: { + credentialId: response.id, + }, + }); + + if (authenticator == null) { + throw new PasskeyNotFoundError(response.id); + } + + const { verified, authenticationInfo } = await verifyAuthenticationResponse( + { + response, + expectedChallenge: challenge, + expectedOrigin: this.origin, + expectedRPID: this.rpID, + credential: { + id: authenticator.credentialId, + publicKey: authenticator.publicKey, + counter: authenticator.counter, + transports: authenticator.transports + ? JSON.parse(authenticator.transports) + : undefined, + }, + requireUserVerification: false, + }, + ); + + if (!verified || authenticationInfo == null) { + return false; + } + + await this.prismaService.passkey.update({ + where: { + id: authenticator.id, + }, + data: { + counter: authenticationInfo.newCounter, + }, + }); + + return true; + } + + async handlePasskeyLogin( + userId: number, + ip: string, + userAgent: string | undefined, + ) { + await this.prismaService.userLoginLog.create({ + data: { + userId: userId, + ip, + userAgent, + }, + }); + return [ + await this.getUserDtoById(userId, userId, ip, userAgent), + await this.createSession(userId), + ]; + } + + async getUserPasskeys(userId: number): Promise { + return await this.prismaService.passkey.findMany({ + where: { + userId, + }, + }); + } + + async deletePasskey(userId: number, credentialId: string): Promise { + await this.prismaService.passkey.deleteMany({ + where: { + userId, + credentialId, + }, + }); + } + + async isEmailRegistered(email: string): Promise { + return ( + (await this.prismaService.user.count({ + where: { + email, + }, + })) > 0 + ); + } + + async findUserRecordOrThrow(userId: number): Promise { + const user = await this.prismaService.user.findUnique({ + where: { + id: userId, + }, + }); + if (user != undefined) { + return user; + } else { + throw new UserIdNotFoundError(userId); + } + } + + async findUserRecordByUsernameOrThrow(username: string): Promise { + const user = await this.prismaService.user.findUnique({ + where: { + username, + }, + }); + if (user != undefined) { + return user; + } else { + throw new UsernameNotFoundError(username); + } + } + + async findUserRecordAndProfileRecordOrThrow( + userId: number, + ): Promise<[User, UserProfile]> { + const userPromise = this.findUserRecordOrThrow(userId); + const profilePromise = this.prismaService.userProfile.findUnique({ + where: { + userId: userId, + }, + }); + const [user, profile] = await Promise.all([userPromise, profilePromise]); + /* istanbul ignore if */ + // Above is a hint for istanbul to ignore the following line. + if (profile == undefined) { + throw new Error(`User '${user.username}' DO NOT has a profile!`); + } + return [user, profile]; + } + + async isUsernameRegistered(username: string): Promise { + return ( + (await this.prismaService.user.count({ + where: { + username, + }, + })) > 0 + ); + } + + private async createUserRegisterLog( + type: UserRegisterLogType, + email: string, + ip: string, + userAgent: string | undefined, + ): Promise { + await this.prismaService.userRegisterLog.create({ + data: { + type, + email, + ip, + userAgent, + }, + }); + } + + private async createPasswordResetLog( + type: UserResetPasswordLogType, + userId: number | undefined, + ip: string, + userAgent: string | undefined, + ): Promise { + await this.prismaService.userResetPasswordLog.create({ + data: { + type, + userId, + ip, + userAgent, + }, + }); + } + + async sendRegisterEmailCode( + email: string, + ip: string, + userAgent: string | undefined, + ): Promise { + if (isEmail(email) == false) { + await this.createUserRegisterLog( + UserRegisterLogType.RequestFailDueToInvalidOrNotSupportedEmail, + email, + ip, + userAgent, + ); + throw new InvalidEmailAddressError(email); + } + if ((await this.emailRuleService.isEmailSuffixSupported(email)) == false) { + await this.createUserRegisterLog( + UserRegisterLogType.RequestFailDueToInvalidOrNotSupportedEmail, + email, + ip, + userAgent, + ); + throw new InvalidEmailSuffixError(email, this.emailSuffixRule); + } + + // TODO: Add logic to determain whether code is sent too frequently. + + // Determine whether the email is registered. + if (await this.isEmailRegistered(email)) { + await this.createUserRegisterLog( + UserRegisterLogType.RequestFailDueToAlreadyRegistered, + email, + ip, + userAgent, + ); + throw new EmailAlreadyRegisteredError(email); + } + + // Now, email is valid, supported and not registered. + // We can send the verify code. + const code = this.generateVerifyCode(); + try { + await this.emailService.sendRegisterCode(email, code); + } catch (e) { + await this.createUserRegisterLog( + UserRegisterLogType.RequestFailDueToSendEmailFailure, + email, + ip, + userAgent, + ); + throw new EmailSendFailedError(email); + } + await this.usersRegisterRequestService.createRequest(email, code); + await this.createUserRegisterLog( + UserRegisterLogType.RequestSuccess, + email, + ip, + userAgent, + ); + } + + private isValidUsername(username: string): boolean { + return /^[a-zA-Z0-9_-]{4,32}$/.test(username); + } + + get usernameRule(): string { + return 'Username must be 4-32 characters long and can only contain letters, numbers, underscores and hyphens.'; + } + + private isValidNickname(nickname: string): boolean { + return /^[a-zA-Z0-9_\u4e00-\u9fa5]{1,16}$/.test(nickname); + } + + get nicknameRule(): string { + return 'Nickname must be 1-16 characters long and can only contain letters, numbers, underscores, hyphens and Chinese characters.'; + } + + private isValidPassword(password: string): boolean { + // Password should contains at least one letter, one special character and one number. + // It should contain at least 8 chars. + // ? should \x00 be used in password? + // todo: we should only use visible special characters + // eslint-disable-next-line no-control-regex + return /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]).{8,}$/.test( + password, + ); + } + + get passwordRule(): string { + return 'Password must be at least 8 characters long and must contain at least one letter, one special character and one number.'; + } + + get defaultIntro(): string { + return 'This user has not set an introduction yet.'; + } + + async register( + username: string, + nickname: string, + srpSalt: string | undefined, + srpVerifier: string | undefined, + email: string, + emailCode: string, + ip: string, + userAgent: string | undefined, + password?: string, + isLegacyAuth?: boolean, + ): Promise { + // 验证基本参数 + if (this.isValidUsername(username) == false) { + throw new InvalidUsernameError(username, this.usernameRule); + } + if (this.isValidNickname(nickname) == false) { + throw new InvalidNicknameError(nickname, this.nicknameRule); + } + if (isEmail(email) == false) { + throw new InvalidEmailAddressError(email); + } + if ((await this.emailRuleService.isEmailSuffixSupported(email)) == false) { + throw new InvalidEmailSuffixError(email, this.emailSuffixRule); + } + + // 验证是否允许使用传统认证方式 + if (isLegacyAuth) { + if ( + process.env.NODE_ENV !== 'test' && + process.env.NODE_ENV !== 'development' + ) { + throw new Error( + 'Legacy authentication is only allowed in test/development environment', + ); + } + if (!password) { + throw new Error('Password is required for legacy authentication'); + } + if (!this.isValidPassword(password)) { + throw new InvalidPasswordError(this.passwordRule); + } + } else { + if (!srpSalt || !srpVerifier) { + throw new Error('SRP credentials are required for registration'); + } + } + + if ( + await this.usersRegisterRequestService.verifyRequest(email, emailCode) + ) { + if (await this.isEmailRegistered(email)) { + throw new Error( + `In a register attempt, the email is verified, but the email is already registered!` + + `There are 4 possible reasons:\n` + + `1. The user send two register email and verified them after that.\n` + + `2. There is a bug in the code.\n` + + `3. The database is corrupted.\n` + + `4. We are under attack!`, + ); + } + if (await this.isUsernameRegistered(username)) { + await this.createUserRegisterLog( + UserRegisterLogType.FailDueToUserExistence, + email, + ip, + userAgent, + ); + throw new UsernameAlreadyRegisteredError(username); + } + + const avatarId = await this.avatarsService.getDefaultAvatarId(); + const profile = { + nickname, + intro: this.defaultIntro, + avatarId, + }; + + let hashedPassword = ''; + let finalSrpSalt = srpSalt; + let finalSrpVerifier = srpVerifier; + let isSrpUpgraded = true; + + // 如果是传统认证方式,生成密码哈希 + if (isLegacyAuth && password) { + const salt = bcrypt.genSaltSync(10); + hashedPassword = bcrypt.hashSync(password, salt); + finalSrpSalt = ''; + finalSrpVerifier = ''; + isSrpUpgraded = false; + } + + const result = await this.prismaService.user.create({ + data: { + username, + email, + hashedPassword, + srpSalt: finalSrpSalt, + srpVerifier: finalSrpVerifier, + srpUpgraded: isSrpUpgraded, + userProfile: { + create: profile, + }, + }, + }); + + await this.createUserRegisterLog( + UserRegisterLogType.Success, + email, + ip, + userAgent, + ); + + return { + id: result.id, + username: result.username, + nickname: profile.nickname, + avatarId: profile.avatarId, + intro: profile.intro, + follow_count: 0, + fans_count: 0, + question_count: 0, + answer_count: 0, + is_follow: false, + }; + } else { + await this.createUserRegisterLog( + UserRegisterLogType.FailDueToWrongCodeOrExpired, + email, + ip, + userAgent, + ); + throw new CodeNotMatchError(email, emailCode); + } + } + + async getUserDtoById( + userId: number, + viewerId: number | undefined, // optional + ip: string, + userAgent: string | undefined, // optional + ): Promise { + const [user, profile] = + await this.findUserRecordAndProfileRecordOrThrow(userId); + const vieweeId = user.id; + await this.prismaService.userProfileQueryLog.create({ + data: { + viewerId, + vieweeId, + ip, + userAgent, + }, + }); + const followCountPromise = this.getFollowingCount(userId); + const fansCountPromise = this.getFollowedCount(userId); + const ifFollowPromise = this.isUserFollowUser(viewerId, userId); + const answerCountPromise = this.answerService.getAnswerCount(userId); + const questionCountPromise = this.questionsService.getQuestionCount(userId); + const [followCount, fansCount, isFollow, answerCount, questionCount] = + await Promise.all([ + followCountPromise, + fansCountPromise, + ifFollowPromise, + answerCountPromise, + questionCountPromise, + ]); + return { + id: user.id, + username: user.username, + nickname: profile.nickname, + avatarId: profile.avatarId, + intro: profile.intro, + follow_count: followCount, + fans_count: fansCount, + is_follow: isFollow, + question_count: questionCount, + answer_count: answerCount, + }; + } + + // Returns: + // [userDto, refreshToken] + async login( + username: string, + password: string, + ip: string, + userAgent: string | undefined, + isLegacyAuth: boolean = false, + ): Promise<[UserDto, string]> { + const user = await this.findUserRecordByUsernameOrThrow(username); + + // 验证密码 + if (!bcrypt.compareSync(password, user.hashedPassword!)) { + throw new PasswordNotMatchError(username); + } + + // 如果用户还没升级到 SRP,则自动升级 + if (!user.srpUpgraded && !isLegacyAuth) { + await this.srpService.upgradeUserToSrp(user.id, username, password); + } + + // 如果用户启用了 2FA,需要进行风险评估 + if (user.totpEnabled) { + const requireTOTP = await this.shouldRequire2FA(user.id, ip, userAgent); + + if (requireTOTP) { + const tempToken = this.totpService.generateTempToken(user.id); + throw new TOTPRequiredError(username, tempToken); + } + } + + // Login successfully. + await this.prismaService.userLoginLog.create({ + data: { + userId: user.id, + ip, + userAgent, + }, + }); + return [ + await this.getUserDtoById(user.id, user.id, ip, userAgent), + await this.createSession(user.id), + ]; + } + + // 新增风险评估方法 + private async shouldRequire2FA( + userId: number, + ip: string, + userAgent: string | undefined, + ): Promise { + // 首先检查用户是否开启了"始终要求2FA" + const user = await this.prismaService.user.findUnique({ + where: { id: userId }, + select: { totpAlwaysRequired: true }, + }); + + if (user?.totpAlwaysRequired) { + return true; + } + + // 其他风险评估逻辑保持不变 + const isKnownIP = await this.prismaService.userLoginLog.findFirst({ + where: { + userId, + ip, + createdAt: { + gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + }, + }, + }); + + const isKnownDevice = + userAgent && + (await this.prismaService.userLoginLog.findFirst({ + where: { + userId, + userAgent, + createdAt: { + gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + }, + }, + })); + + if (!isKnownIP || !isKnownDevice) { + return true; + } + + const hasSensitiveOperation = + await this.prismaService.userResetPasswordLog.findFirst({ + where: { + userId, + createdAt: { + gte: new Date(Date.now() - 24 * 60 * 60 * 1000), + }, + }, + }); + + return !!hasSensitiveOperation; + } + + // 新增:验证 TOTP 并完成登录 + async verifyTOTPAndLogin( + tempToken: string, + code: string, + ip: string, + userAgent: string | undefined, + ): Promise<[UserDto, string, boolean]> { + try { + // 验证临时 token + const auth = this.authService.verify(tempToken); + const userId = auth.userId; + + // 验证 token 的权限 + await this.authService.audit( + tempToken, + 'verify', + userId, + 'users/totp:verify', + ); + + // 验证 TOTP 代码 + const { isValid, usedBackupCode } = await this.totpService.verify2FA( + userId, + code, + ); + if (!isValid) { + throw new TOTPInvalidError(); + } + + // 记录登录日志 + await this.prismaService.userLoginLog.create({ + data: { + userId, + ip, + userAgent, + }, + }); + + // 返回用户信息和新的 session + return [ + await this.getUserDtoById(userId, userId, ip, userAgent), + await this.createSession(userId), + usedBackupCode, + ]; + } catch (error) { + if (error instanceof TOTPInvalidError) { + throw error; + } + throw new TOTPTempTokenInvalidError(); + } + } + + private async createSession(userId: number): Promise { + const authorization: Authorization = + await this.usersPermissionService.getAuthorizationForUser(userId); + return this.sessionService.createSession(userId, authorization); + } + + async sendResetPasswordEmail( + email: string, + ip: string, + userAgent: string | undefined, + ): Promise { + // Check email. + if (isEmail(email) == false) { + throw new InvalidEmailAddressError(email); + } + if ((await this.emailRuleService.isEmailSuffixSupported(email)) == false) { + throw new InvalidEmailSuffixError(email, this.emailSuffixRule); + } + const user = await this.prismaService.user.findUnique({ + where: { + email, + }, + }); + if (user == undefined) { + await this.createPasswordResetLog( + UserResetPasswordLogType.RequestFailDueToNoneExistentEmail, + undefined, + ip, + userAgent, + ); + throw new EmailNotFoundError(email); + } + const token = this.authService.sign( + { + userId: user.id, + username: user.username, + permissions: [ + { + authorizedActions: ['modify'], + authorizedResource: { + ownedByUser: user.id, + types: ['users/password:reset'], + resourceIds: undefined, + data: Date.now(), + }, + }, + ], + }, + this.passwordResetEmailValidSeconds, + ); + try { + await this.emailService.sendPasswordResetEmail( + email, + user.username, + token, + ); + } catch { + throw new EmailSendFailedError(email); + } + await this.createPasswordResetLog( + UserResetPasswordLogType.RequestSuccess, + user.id, + ip, + userAgent, + ); + } + + async verifyAndResetPassword( + token: string, + srpSalt: string, + srpVerifier: string, + ip: string, + userAgent: string | undefined, + ): Promise { + // Here, we do not need to check whether the token is valid. + // If we check, then, if the token is invalid, it won't be logged. + const userId = this.authService.decode(token).authorization.userId; + try { + await this.authService.audit( + token, + 'modify', + userId, + 'users/password:reset', + undefined, + ); + } catch (e) { + if (e instanceof PermissionDeniedError) { + await this.createPasswordResetLog( + UserResetPasswordLogType.FailDueToInvalidToken, + userId, + ip, + userAgent, + ); + Logger.warn( + `Permission denied when reset password: token = "${token}", ip = "${ip}", userAgent = "${userAgent}"`, + ); + } + if (e instanceof TokenExpiredError) { + await this.createPasswordResetLog( + UserResetPasswordLogType.FailDueToExpiredRequest, + userId, + ip, + userAgent, + ); + } + throw e; + } + + // Operation permitted. + const user = await this.prismaService.user.findUnique({ + where: { + id: userId, + }, + }); + /* istanbul ignore if */ + if (user == undefined) { + await this.createPasswordResetLog( + UserResetPasswordLogType.FailDueToNoUser, + userId, + ip, + userAgent, + ); + throw new Error( + `In an password reset attempt, the operation ` + + `is permitted, but the user is not found! There are 4 possible reasons:\n` + + `1. The user is deleted right after a password reset request.\n` + + `2. There is a bug in the code.\n` + + `3. The database is corrupted.\n` + + `4. We are under attack!`, + ); + } + + // 更新用户的 SRP 凭证和最后修改密码时间 + await this.prismaService.user.update({ + where: { + id: userId, + }, + data: { + hashedPassword: '', // 清除旧的密码哈希 + srpSalt, + srpVerifier, + srpUpgraded: true, + lastPasswordChangedAt: new Date(), + }, + }); + + await this.createPasswordResetLog( + UserResetPasswordLogType.Success, + userId, + ip, + userAgent, + ); + } + + async updateUserProfile( + userId: number, + nickname: string, + intro: string, + avatarId: number, + ): Promise { + const [, profile] = + await this.findUserRecordAndProfileRecordOrThrow(userId); + if ((await this.avatarsService.isAvatarExists(avatarId)) == false) { + throw new AvatarNotFoundError(avatarId); + } + await this.avatarsService.plusUsageCount(avatarId); + await this.avatarsService.minusUsageCount(profile.avatarId); + await this.prismaService.userProfile.update({ + where: { + userId, + }, + data: { + nickname, + intro, + avatarId, + }, + }); + } + + async getUniqueFollowRelationship( + followerId: number, + followeeId: number, + ): Promise { + let relationships = + await this.prismaService.userFollowingRelationship.findMany({ + where: { + followerId, + followeeId, + }, + }); + /* istanbul ignore if */ + if (relationships.length > 1) { + Logger.warn( + `There are more than one follow relationship between user ${followerId} and user ${followeeId}. Automaticly clean them up...`, + ); + await this.prismaService.userFollowingRelationship.updateMany({ + where: { + followerId, + followeeId, + }, + data: { + deletedAt: new Date(), + }, + }); + const result = await this.prismaService.userFollowingRelationship.create({ + data: { + followerId, + followeeId, + }, + }); + relationships = [result]; + } + return relationships.length == 0 ? undefined : relationships[0]; + } + + async addFollowRelationship( + followerId: number, + followeeId: number, + ): Promise { + if (followerId == followeeId) { + throw new FollowYourselfError(); + } + if ((await this.isUserExists(followerId)) == false) { + throw new UserIdNotFoundError(followerId); + } + if ((await this.isUserExists(followeeId)) == false) { + throw new UserIdNotFoundError(followeeId); + } + const oldRelationship = await this.getUniqueFollowRelationship( + followerId, + followeeId, + ); + if (oldRelationship != null) { + throw new UserAlreadyFollowedError(followeeId); + } + await this.prismaService.userFollowingRelationship.create({ + data: { + followerId, + followeeId, + }, + }); + } + + async deleteFollowRelationship( + followerId: number, + followeeId: number, + ): Promise { + const relationship = await this.getUniqueFollowRelationship( + followerId, + followeeId, + ); + if (relationship == undefined) { + throw new UserNotFollowedYetError(followeeId); + } + await this.prismaService.userFollowingRelationship.updateMany({ + where: { + followerId, + followeeId, + }, + data: { + deletedAt: new Date(), + }, + }); + } + + async getFollowers( + followeeId: number, + firstFollowerId: number | undefined, // undefined if from start + pageSize: number, + viewerId: number | undefined, // optional + ip: string, + userAgent: string | undefined, // optional + ): Promise<[UserDto[], PageDto]> { + if (firstFollowerId == undefined) { + const relations = + await this.prismaService.userFollowingRelationship.findMany({ + where: { + followeeId, + }, + take: pageSize + 1, + orderBy: { followerId: 'asc' }, + }); + const DTOs = await Promise.all( + relations.map((r) => { + return this.getUserDtoById(r.followerId, viewerId, ip, userAgent); + }), + ); + return PageHelper.PageStart(DTOs, pageSize, (item) => item.id); + } else { + const prevRelationshipsPromise = + this.prismaService.userFollowingRelationship.findMany({ + where: { + followeeId, + followerId: { lt: firstFollowerId }, + }, + take: pageSize, + orderBy: { followerId: 'desc' }, + }); + const queriedRelationsPromise = + this.prismaService.userFollowingRelationship.findMany({ + where: { + followeeId, + followerId: { gte: firstFollowerId }, + }, + take: pageSize + 1, + orderBy: { followerId: 'asc' }, + }); + const DTOs = await Promise.all( + (await queriedRelationsPromise).map((r) => { + return this.getUserDtoById(r.followerId, viewerId, ip, userAgent); + }), + ); + const prev = await prevRelationshipsPromise; + return PageHelper.PageMiddle( + prev, + DTOs, + pageSize, + (i) => i.followerId, + (i) => i.id, + ); + } + } + + async getFollowees( + followerId: number, + firstFolloweeId: number | undefined, // undefined if from start + pageSize: number, + viewerId: number | undefined, // optional + ip: string, // optional + userAgent: string | undefined, // optional + ): Promise<[UserDto[], PageDto]> { + if (firstFolloweeId == undefined) { + const relations = + await this.prismaService.userFollowingRelationship.findMany({ + where: { + followerId, + }, + take: pageSize + 1, + orderBy: { followeeId: 'asc' }, + }); + const DTOs = await Promise.all( + relations.map((r) => { + return this.getUserDtoById(r.followeeId, viewerId, ip, userAgent); + }), + ); + return PageHelper.PageStart(DTOs, pageSize, (item) => item.id); + } else { + const prevRelationshipsPromise = + this.prismaService.userFollowingRelationship.findMany({ + where: { + followerId, + followeeId: { lt: firstFolloweeId }, + }, + take: pageSize, + orderBy: { followeeId: 'desc' }, + }); + const queriedRelationsPromise = + this.prismaService.userFollowingRelationship.findMany({ + where: { + followerId, + followeeId: { gte: firstFolloweeId }, + }, + take: pageSize + 1, + orderBy: { followeeId: 'asc' }, + }); + const DTOs = await Promise.all( + (await queriedRelationsPromise).map((r) => { + return this.getUserDtoById(r.followeeId, viewerId, ip, userAgent); + }), + ); + const prev = await prevRelationshipsPromise; + return PageHelper.PageMiddle( + prev, + DTOs, + pageSize, + (i) => i.followeeId, + (i) => i.id, + ); + } + } + + async isUserExists(userId: number): Promise { + return (await this.prismaService.user.count({ where: { id: userId } })) > 0; + } + + async getFollowingCount(followerId: number): Promise { + return await this.prismaService.userFollowingRelationship.count({ + where: { + followerId, + }, + }); + } + + async getFollowedCount(followeeId: number): Promise { + return await this.prismaService.userFollowingRelationship.count({ + where: { + followeeId, + }, + }); + } + + async isUserFollowUser( + followerId: number | undefined, + followeeId: number | undefined, + ): Promise { + if (followerId == undefined || followeeId == undefined) return false; + const result = await this.prismaService.userFollowingRelationship.count({ + where: { + followerId, + followeeId, + }, + }); + assert(result == 0 || result == 1); + return result > 0; + } + + async verifySudo( + req: Request, + token: string, + method: 'password' | 'srp' | 'passkey' | 'totp', + credentials: { + password?: string; + clientPublicEphemeral?: string; + clientProof?: string; + passkeyResponse?: AuthenticationResponseJSON; + code?: string; + }, + ): Promise<{ + accessToken: string; + salt?: string; + serverPublicEphemeral?: string; + serverProof?: string; + srpUpgraded?: boolean; + }> { + const userId = this.authService.decode(token).authorization.userId; + let verified = false; + + if (method === 'password') { + const user = await this.findUserRecordOrThrow(userId); + + // 验证密码 + if (!credentials.password) { + throw new Error('Password is required for password verification'); + } + verified = await bcrypt.compare( + credentials.password, + user.hashedPassword!, + ); + + if (verified) { + // 如果用户还没升级到 SRP,自动升级 + let wasUpgraded = false; + if (!user.srpUpgraded) { + await this.srpService.upgradeUserToSrp( + user.id, + user.username, + credentials.password, + ); + wasUpgraded = true; + } + + const sudoToken = await this.authService.issueSudoToken(token); + return { + accessToken: sudoToken, + srpUpgraded: wasUpgraded, + }; + } + } else if (method === 'srp') { + const user = await this.findUserRecordOrThrow(userId); + + if (!user.srpUpgraded || !user.srpSalt || !user.srpVerifier) { + throw new SrpNotUpgradedError(user.username); + } + + // 如果是第一步(初始化),返回 salt 和服务器公钥 + if (credentials.clientPublicEphemeral && !credentials.clientProof) { + const { serverEphemeral } = await this.srpService.createServerSession( + user.username, + user.srpSalt, + user.srpVerifier, + credentials.clientPublicEphemeral, + ); + + // 将服务器的私密临时值存储在 session 中 + req.session.srpSession = { + serverSecretEphemeral: serverEphemeral.secret, + clientPublicEphemeral: credentials.clientPublicEphemeral, + }; + + return { + accessToken: token, // 返回原 token,因为验证还未完成 + salt: user.srpSalt, + serverPublicEphemeral: serverEphemeral.public, + }; + } + + // 如果是第二步(验证),验证客户端证明 + if (credentials.clientProof) { + const sessionState = req.session.srpSession; + if (!sessionState) { + throw new Error('SRP session not found. Please initialize first.'); + } + + const { success, serverProof } = await this.srpService.verifyClient( + sessionState.serverSecretEphemeral, + sessionState.clientPublicEphemeral, + user.srpSalt, + user.username, + user.srpVerifier, + credentials.clientProof, + ); + + // 清除 session 中的 SRP 状态 + delete req.session.srpSession; + + if (!success) { + throw new SrpVerificationError(); + } + + verified = true; + const sudoToken = await this.authService.issueSudoToken(token); + return { + accessToken: sudoToken, + serverProof, + }; + } + + throw new Error('Invalid SRP credentials'); + } else if (method === 'passkey') { + verified = await this.verifyPasskeyAuthentication( + req, + credentials.passkeyResponse!, + ); + } else if (method === 'totp') { + const { isValid } = await this.totpService.verify2FA( + userId, + credentials.code!, + ); + verified = isValid; + } + + if (!verified) { + throw new InvalidCredentialsError(); + } + + // 签发带有 sudo 权限的新 token + const sudoToken = await this.authService.issueSudoToken(token); + return { accessToken: sudoToken }; + } + + /** + * 处理 SRP 登录的第一步:初始化 + * 客户端发送用户名和公钥 A,服务器返回该用户的 salt 和服务器生成的公钥 B + */ + async handleSrpInit( + username: string, + clientPublicEphemeral: string, + ): Promise<{ + salt: string; + serverPublicEphemeral: string; + serverSecretEphemeral: string; + }> { + const user = await this.findUserRecordByUsernameOrThrow(username); + + if (!user.srpUpgraded || !user.srpSalt || !user.srpVerifier) { + throw new SrpNotUpgradedError(username); + } + + // 创建 SRP 服务器会话 + const { serverEphemeral } = await this.srpService.createServerSession( + username, + user.srpSalt, + user.srpVerifier, + clientPublicEphemeral, + ); + + return { + salt: user.srpSalt, + serverPublicEphemeral: serverEphemeral.public, + serverSecretEphemeral: serverEphemeral.secret, + }; + } + + /** + * 处理 SRP 登录的第二步:验证 + * 客户端发送其证明 M1,服务器验证并返回其证明 M2 + */ + async handleSrpVerify( + username: string, + clientPublicEphemeral: string, + clientProof: string, + serverSecretEphemeral: string, + ip: string, + userAgent: string | undefined, + ): Promise<{ + serverProof: string; + accessToken: string; + requires2FA: boolean; + tempToken?: string; + user?: UserDto; + }> { + const user = await this.findUserRecordByUsernameOrThrow(username); + + if (!user.srpUpgraded || !user.srpSalt || !user.srpVerifier) { + throw new SrpNotUpgradedError(username); + } + + const { success, serverProof } = await this.srpService.verifyClient( + serverSecretEphemeral, + clientPublicEphemeral, + user.srpSalt, + username, + user.srpVerifier, + clientProof, + ); + + if (!success) { + throw new SrpVerificationError(); + } + + // 记录登录日志 + await this.prismaService.userLoginLog.create({ + data: { + userId: user.id, + ip, + userAgent, + }, + }); + + // 获取用户信息 + const userDto = await this.getUserDtoById(user.id, user.id, ip, userAgent); + + // 检查是否需要 2FA + if (user.totpEnabled) { + const requireTOTP = await this.shouldRequire2FA(user.id, ip, userAgent); + if (requireTOTP) { + const tempToken = this.totpService.generateTempToken(user.id); + return { + serverProof, + accessToken: '', // 2FA 时不返回 access token + requires2FA: true, + tempToken, + user: userDto, + }; + } + } + + // 生成访问令牌 + const accessToken = await this.createSession(user.id); + + return { + serverProof, + accessToken, + requires2FA: false, + user: userDto, + }; + } + + /** + * 为新注册用户创建会话 + */ + async createSessionForNewUser(userId: number): Promise { + const authorization = + await this.usersPermissionService.getAuthorizationForUser(userId); + return this.sessionService.createSession(userId, authorization); + } + + async changePassword( + userId: number, + srpSalt: string, + srpVerifier: string, + ): Promise { + const user = await this.findUserRecordOrThrow(userId); + + // 更新 SRP 凭证和最后修改密码时间 + await this.prismaService.user.update({ + where: { id: userId }, + data: { + hashedPassword: '', // 清除旧的密码哈希 + srpSalt, + srpVerifier, + srpUpgraded: true, + lastPasswordChangedAt: new Date(), + }, + }); + } +} diff --git a/src/users/users.spec.ts b/src/users/users.spec.ts new file mode 100644 index 00000000..d698025b --- /dev/null +++ b/src/users/users.spec.ts @@ -0,0 +1,716 @@ +/* + * Description: This file provide additional tests to users module. + * + * Author(s): + * Nictheboy Li + * + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import bcrypt from 'bcryptjs'; +import { Request } from 'express'; +import { AppModule } from '../app.module'; +import { InvalidCredentialsError } from '../auth/auth.error'; +import { UsersService } from '../users/users.service'; +import { + SrpNotUpgradedError, + SrpVerificationError, + UserIdNotFoundError, +} from './users.error'; + +// Mock @simplewebauthn/server module +jest.mock('@simplewebauthn/server', () => ({ + generateRegistrationOptions: jest.fn(() => + Promise.resolve({ + challenge: 'fake-challenge', + rp: { name: 'Test RP', id: 'localhost' }, + user: { name: 'testuser', id: Buffer.from('1') }, + pubKeyCredParams: [], + timeout: 60000, + attestation: 'none', + excludeCredentials: [], + }), + ), + verifyRegistrationResponse: jest.fn(() => + Promise.resolve({ + verified: true, + registrationInfo: { + credential: { id: 'cred-id', publicKey: 'fake-public-key', counter: 1 }, + credentialBackedUp: false, + credentialDeviceType: 'singleDevice', + }, + }), + ), + generateAuthenticationOptions: jest.fn(() => + Promise.resolve({ + challenge: 'fake-auth-challenge', + timeout: 60000, + rpId: 'localhost', + allowCredentials: [], + userVerification: 'preferred', + }), + ), + verifyAuthenticationResponse: jest.fn(() => + Promise.resolve({ + verified: true, + authenticationInfo: { newCounter: 2 }, + }), + ), +})); + +describe('Users Module', () => { + let app: TestingModule; + let usersService: UsersService; + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + usersService = app.get(UsersService); + }); + afterAll(async () => { + await app.close(); + }); + + it('should wait until user with id 1 exists', async () => { + /* eslint-disable no-constant-condition */ + while (true) { + try { + await usersService.getUserDtoById(1, 1, '127.0.0.1', 'some user agent'); + } catch (e) { + // wait one second + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue; + } + break; + } + }, 120000); // 增加超时时间到 120 秒 + + it('should return UserIdNotFoundError', async () => { + // Mock findUnique 返回 null + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValue(null); + + await expect(usersService.addFollowRelationship(-1, 1)).rejects.toThrow( + new UserIdNotFoundError(-1), + ); + await expect(usersService.addFollowRelationship(1, -1)).rejects.toThrow( + new UserIdNotFoundError(-1), + ); + }); + + it('should return UserIdNotFoundError for updateUserProfile', async () => { + // Mock findUnique 返回 null + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValue(null); + + await expect( + usersService.updateUserProfile(-1, 'nick', 'int', 1), + ).rejects.toThrow(new UserIdNotFoundError(-1)); + }); + + it('should return zero', async () => { + expect(await usersService.isUserFollowUser(undefined, 1)).toBe(false); + expect(await usersService.isUserFollowUser(1, undefined)).toBe(false); + }); + + describe('Password Reset and SRP', () => { + beforeEach(() => { + // Mock emailRuleService + jest + .spyOn(usersService['emailRuleService'], 'isEmailSuffixSupported') + .mockResolvedValue(true); + }); + + it('should send reset password email successfully', async () => { + // Mock user查询 + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce({ + id: 1, + username: 'testuser', + email: 'test@example.com', + } as any); + + // Mock email发送 + jest + .spyOn(usersService['emailService'], 'sendPasswordResetEmail') + .mockResolvedValueOnce(); + + // Mock 日志记录 + jest + .spyOn(usersService['prismaService'].userResetPasswordLog, 'create') + .mockResolvedValueOnce({} as any); + + await expect( + usersService.sendResetPasswordEmail( + 'test@example.com', + '127.0.0.1', + 'test-agent', + ), + ).resolves.not.toThrow(); + }); + + it('should verify and reset password with SRP credentials', async () => { + // Mock token验证 + jest + .spyOn(usersService['authService'], 'decode') + .mockReturnValue({ authorization: { userId: 1 } } as any); + jest + .spyOn(usersService['authService'], 'audit') + .mockResolvedValueOnce(undefined); + + // Mock user查询和更新 + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce({ + id: 1, + username: 'testuser', + } as any); + + const updateSpy = jest + .spyOn(usersService['prismaService'].user, 'update') + .mockResolvedValueOnce({} as any); + + // Mock 日志记录 + jest + .spyOn(usersService['prismaService'].userResetPasswordLog, 'create') + .mockResolvedValueOnce({} as any); + + await usersService.verifyAndResetPassword( + 'test-token', + 'new-srp-salt', + 'new-srp-verifier', + '127.0.0.1', + 'test-agent', + ); + + expect(updateSpy).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { + hashedPassword: '', // 清除旧的密码哈希 + srpSalt: 'new-srp-salt', + srpVerifier: 'new-srp-verifier', + srpUpgraded: true, + lastPasswordChangedAt: expect.any(Date), + }, + }); + }); + + it('should handle expired reset token', async () => { + // Mock token验证抛出 TokenExpiredError + jest + .spyOn(usersService['authService'], 'decode') + .mockReturnValue({ authorization: { userId: 1 } } as any); + jest + .spyOn(usersService['authService'], 'audit') + .mockRejectedValueOnce(new Error('Token expired')); + + // Mock 日志记录 + jest + .spyOn(usersService['prismaService'].userResetPasswordLog, 'create') + .mockResolvedValueOnce({} as any); + + await expect( + usersService.verifyAndResetPassword( + 'expired-token', + 'new-srp-salt', + 'new-srp-verifier', + '127.0.0.1', + 'test-agent', + ), + ).rejects.toThrow('Token expired'); + }); + + it('should handle invalid reset token', async () => { + // Mock token验证抛出 PermissionDeniedError + jest + .spyOn(usersService['authService'], 'decode') + .mockReturnValue({ authorization: { userId: 1 } } as any); + jest + .spyOn(usersService['authService'], 'audit') + .mockRejectedValueOnce(new Error('Permission denied')); + + // Mock 日志记录 + jest + .spyOn(usersService['prismaService'].userResetPasswordLog, 'create') + .mockResolvedValueOnce({} as any); + + await expect( + usersService.verifyAndResetPassword( + 'invalid-token', + 'new-srp-salt', + 'new-srp-verifier', + '127.0.0.1', + 'test-agent', + ), + ).rejects.toThrow('Permission denied'); + }); + + it('should handle password change with SRP credentials', async () => { + // Mock user查询和更新 + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce({ + id: 1, + username: 'testuser', + } as any); + + const updateSpy = jest + .spyOn(usersService['prismaService'].user, 'update') + .mockResolvedValueOnce({} as any); + + await usersService.changePassword(1, 'new-srp-salt', 'new-srp-verifier'); + + expect(updateSpy).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { + hashedPassword: '', // 清除旧的密码哈希 + srpSalt: 'new-srp-salt', + srpVerifier: 'new-srp-verifier', + srpUpgraded: true, + lastPasswordChangedAt: expect.any(Date), + }, + }); + }); + }); + + describe('Sudo Mode Authentication', () => { + it('should check sudo mode status correctly', async () => { + const authorization = { + userId: 1, + permissions: [], + sudoUntil: Date.now() + 1000, // 设置1秒后过期 + }; + expect(usersService['authService'].checkSudoMode(authorization)).toBe( + true, + ); + + const expiredAuth = { + userId: 1, + permissions: [], + sudoUntil: Date.now() - 1000, // 已过期 + }; + expect(usersService['authService'].checkSudoMode(expiredAuth)).toBe( + false, + ); + + const noSudoAuth = { + userId: 1, + permissions: [], + }; + expect(usersService['authService'].checkSudoMode(noSudoAuth)).toBe(false); + }); + + it('should verify sudo with password successfully', async () => { + const hashedPassword = await bcrypt.hash('correct-password', 10); + + // Mock user查询 + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce({ + id: 1, + hashedPassword, + } as any); + + // Mock token验证和签发 + jest + .spyOn(usersService['authService'], 'verify') + .mockReturnValue({ userId: 1 } as any); + jest.spyOn(usersService['authService'], 'decode').mockReturnValue({ + authorization: { userId: 1 }, + validUntil: Date.now() + 3600000, + } as any); + jest + .spyOn(usersService['authService'], 'issueSudoToken') + .mockResolvedValueOnce('new-sudo-token'); + + const result = await usersService.verifySudo( + {} as Request, + 'old-token', + 'password', + { password: 'correct-password' }, + ); + expect(result.accessToken).toBe('new-sudo-token'); + }); + + it('should verify sudo with passkey successfully', async () => { + // Mock passkey验证 + jest + .spyOn(usersService, 'verifyPasskeyAuthentication') + .mockResolvedValueOnce(true); + + // Mock token相关操作 + jest + .spyOn(usersService['authService'], 'verify') + .mockReturnValue({ userId: 1 } as any); + jest.spyOn(usersService['authService'], 'decode').mockReturnValue({ + authorization: { userId: 1 }, + validUntil: Date.now() + 3600000, + } as any); + jest + .spyOn(usersService['authService'], 'issueSudoToken') + .mockResolvedValueOnce('new-sudo-token'); + + const result = await usersService.verifySudo( + {} as Request, + 'old-token', + 'passkey', + { passkeyResponse: {} as any }, + ); + expect(result.accessToken).toBe('new-sudo-token'); + }); + + it('should throw InvalidCredentialsError for wrong password', async () => { + const hashedPassword = await bcrypt.hash('correct-password', 10); + + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce({ + id: 1, + hashedPassword, + } as any); + + await expect( + usersService.verifySudo({} as Request, 'old-token', 'password', { + password: 'wrong-password', + }), + ).rejects.toThrow(InvalidCredentialsError); + }); + }); + + describe('Passkey Authentication', () => { + beforeEach(() => { + // Mock user查询和 profile + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValue({ + id: 1, + username: 'testuser', + } as any); + + jest + .spyOn(usersService['prismaService'].userProfile, 'findUnique') + .mockResolvedValue({ + userId: 1, + nickname: 'Test User', + intro: 'Test intro', + avatarId: 1, + } as any); + }); + + it('should generate passkey registration options and store challenge', async () => { + const options = await usersService.generatePasskeyRegistrationOptions(1); + expect(options.challenge).toBe('fake-challenge'); + }); + + it('should verify passkey registration successfully', async () => { + // 先为用户 1 设置 challenge + await usersService['userChallengeRepository'].setChallenge( + 1, + 'fake-challenge', + 600, + ); + const fakeRegistrationResponse = { + id: 'cred-id', + rawId: 'raw-cred-id', + response: {}, + type: 'public-key', + clientExtensionResults: {}, + }; + await expect( + usersService.verifyPasskeyRegistration( + 1, + fakeRegistrationResponse as any, + ), + ).resolves.not.toThrow(); + }); + + it('should throw ChallengeNotFoundError if challenge is missing', async () => { + await expect( + usersService.verifyPasskeyRegistration(1, {} as any), + ).rejects.toThrow(); + }); + + it('should generate passkey authentication options and set session challenge', async () => { + // 创建一个伪造的请求对象,包含 session 属性 + const req: any = { session: {} }; + const authOptions = + await usersService.generatePasskeyAuthenticationOptions(req); + expect(authOptions.challenge).toBe('fake-auth-challenge'); + expect(req.session.passkeyChallenge).toBe('fake-auth-challenge'); + }); + + it('should verify passkey authentication and update counter', async () => { + const req: any = { session: { passkeyChallenge: 'fake-auth-challenge' } }; + // 模拟 prismaService.passkey.findFirst 返回存在的认证器记录 + const findFirstSpy = jest + .spyOn(usersService['prismaService'].passkey, 'findFirst') + .mockResolvedValueOnce({ + id: 123, + credentialId: 'cred-id', + publicKey: Buffer.from('fake-public-key'), + counter: 1, + transports: JSON.stringify([]), + } as any); + // 监控 update 调用 + const updateSpy = jest + .spyOn(usersService['prismaService'].passkey, 'update') + .mockResolvedValueOnce({} as any); + const fakeAuthResponse = { + id: 'cred-id', + rawId: 'raw-cred-id', + response: {}, + type: 'public-key', + clientExtensionResults: {}, + }; + const result = await usersService.verifyPasskeyAuthentication( + req, + fakeAuthResponse as any, + ); + expect(result).toBe(true); + expect(findFirstSpy).toHaveBeenCalled(); + expect(updateSpy).toHaveBeenCalledWith({ + where: { + id: 123, + }, + data: { + counter: 2, + }, + }); + }); + + it('should throw PasskeyNotFoundError if authenticator is not found', async () => { + const req: any = { session: { passkeyChallenge: 'fake-auth-challenge' } }; + jest + .spyOn(usersService['prismaService'].passkey, 'findFirst') + .mockResolvedValueOnce(null); + const fakeAuthResponse = { + id: 'non-existent', + rawId: 'raw-id', + response: {}, + type: 'public-key', + clientExtensionResults: {}, + }; + await expect( + usersService.verifyPasskeyAuthentication(req, fakeAuthResponse as any), + ).rejects.toThrow(); + }); + + it('should get user passkeys', async () => { + jest + .spyOn(usersService['prismaService'].passkey, 'findMany') + .mockResolvedValueOnce([ + { + id: 1, + credentialId: 'test-id', + publicKey: Buffer.from('key'), + counter: 0, + transports: JSON.stringify([]), + deviceType: 'singleDevice', + backedUp: false, + userId: 1, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + const passkeys = await usersService.getUserPasskeys(1); + expect(passkeys).toHaveLength(1); + expect(passkeys[0].credentialId).toBe('test-id'); + }); + + it('should delete passkey for user', async () => { + const deleteSpy = jest + .spyOn(usersService['prismaService'].passkey, 'deleteMany') + .mockResolvedValueOnce({ count: 1 } as any); + await usersService.deletePasskey(1, 'test-id'); + expect(deleteSpy).toHaveBeenCalledWith({ + where: { + userId: 1, + credentialId: 'test-id', + }, + }); + }); + }); + + describe('SRP Authentication', () => { + it('should handle SRP initialization successfully', async () => { + // Mock user查询 + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce({ + id: 1, + username: 'testuser', + srpUpgraded: true, + srpSalt: 'test-salt', + srpVerifier: 'test-verifier', + } as any); + + // Mock SRP服务 + jest + .spyOn(usersService['srpService'], 'createServerSession') + .mockResolvedValueOnce({ + serverEphemeral: { + public: 'server-public', + secret: 'server-secret', + }, + }); + + const result = await usersService.handleSrpInit( + 'testuser', + 'client-public', + ); + + expect(result.salt).toBe('test-salt'); + expect(result.serverPublicEphemeral).toBe('server-public'); + expect(result.serverSecretEphemeral).toBe('server-secret'); + }); + + it('should handle SRP verification successfully', async () => { + // Mock user查询 + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce({ + id: 1, + username: 'testuser', + srpUpgraded: true, + srpSalt: 'test-salt', + srpVerifier: 'test-verifier', + } as any); + + // Mock SRP验证 + jest + .spyOn(usersService['srpService'], 'verifyClient') + .mockResolvedValueOnce({ + success: true, + serverProof: 'server-proof', + }); + + // Mock 登录日志创建 + jest + .spyOn(usersService['prismaService'].userLoginLog, 'create') + .mockResolvedValueOnce({} as any); + + // Mock getUserDtoById + jest.spyOn(usersService, 'getUserDtoById').mockResolvedValueOnce({ + id: 1, + username: 'testuser', + } as any); + + // Mock createSession + jest + .spyOn(usersService as any, 'createSession') + .mockResolvedValueOnce('access-token'); + + const result = await usersService.handleSrpVerify( + 'testuser', + 'client-public', + 'client-proof', + 'server-secret', + '127.0.0.1', + 'test-agent', + ); + + expect(result.serverProof).toBe('server-proof'); + expect(result.accessToken).toBe('access-token'); + expect(result.requires2FA).toBe(false); + }); + + it('should handle SRP verification with 2FA requirement', async () => { + // Mock user查询 + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce({ + id: 1, + username: 'testuser', + srpUpgraded: true, + srpSalt: 'test-salt', + srpVerifier: 'test-verifier', + totpEnabled: true, + } as any); + + // Mock SRP验证 + jest + .spyOn(usersService['srpService'], 'verifyClient') + .mockResolvedValueOnce({ + success: true, + serverProof: 'server-proof', + }); + + // Mock shouldRequire2FA + jest + .spyOn(usersService as any, 'shouldRequire2FA') + .mockResolvedValueOnce(true); + + // Mock generateTempToken + jest + .spyOn(usersService['totpService'], 'generateTempToken') + .mockReturnValueOnce('temp-token'); + + // Mock getUserDtoById + jest.spyOn(usersService, 'getUserDtoById').mockResolvedValueOnce({ + id: 1, + username: 'testuser', + } as any); + + const result = await usersService.handleSrpVerify( + 'testuser', + 'client-public', + 'client-proof', + 'server-secret', + '127.0.0.1', + 'test-agent', + ); + + expect(result.serverProof).toBe('server-proof'); + expect(result.requires2FA).toBe(true); + expect(result.tempToken).toBe('temp-token'); + expect(result.accessToken).toBe(''); + }); + + it('should throw SrpNotUpgradedError for non-upgraded users', async () => { + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce({ + id: 1, + username: 'testuser', + srpUpgraded: false, + } as any); + + await expect( + usersService.handleSrpInit('testuser', 'client-public'), + ).rejects.toThrow(SrpNotUpgradedError); + }); + + it('should throw SrpVerificationError for failed verification', async () => { + jest + .spyOn(usersService['prismaService'].user, 'findUnique') + .mockResolvedValueOnce({ + id: 1, + username: 'testuser', + srpUpgraded: true, + srpSalt: 'test-salt', + srpVerifier: 'test-verifier', + } as any); + + jest + .spyOn(usersService['srpService'], 'verifyClient') + .mockResolvedValueOnce({ + success: false, + serverProof: '', + }); + + await expect( + usersService.handleSrpVerify( + 'testuser', + 'client-public', + 'client-proof', + 'server-secret', + '127.0.0.1', + 'test-agent', + ), + ).rejects.toThrow(SrpVerificationError); + }); + }); +}); diff --git a/test/answer.e2e-spec.ts b/test/answer.e2e-spec.ts new file mode 100644 index 00000000..015fbbe2 --- /dev/null +++ b/test/answer.e2e-spec.ts @@ -0,0 +1,988 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; +jest.mock('../src/email/email.service'); + +describe('Answers Module', () => { + let app: INestApplication; + const MockedEmailService = >EmailService; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + const TestTopicCode = Math.floor(Math.random() * 10000000000).toString(); + const TestTopicPrefix = `[Test(${TestTopicCode}) Question]`; + const TestQuestionCode = Math.floor(Math.random() * 10000000000).toString(); + const TestQuestionPrefix = `[Test(${TestQuestionCode}) Question]`; + let TestToken: string; + let TestUserId: number; + const TopicIds: number[] = []; + const questionIds: number[] = []; + const answerIds: number[] = []; + const AnswerQuestionMap: { [key: number]: number } = {}; + const userIdTokenPairList: [number, string][] = []; + let auxUserId: number; + let auxAccessToken: string; + let specialQuestionId: number; + const specialAnswerIds: number[] = []; + const auxUserAskedAnswerIds: number[] = []; + + async function createAuxiliaryUser(): Promise<[number, string]> { + const email = `test-${Math.floor(Math.random() * 10000000000)}@ruc.edu.cn`; + const respond = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ email }); + expect(respond.status).toBe(201); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.at(-1)[1]; + const respond2 = await request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: `TestUser-${Math.floor(Math.random() * 10000000000)}`, + nickname: 'auxiliary_user', + password: 'abc123456!!!', + email, + emailCode: verificationCode, + isLegacyAuth: true, + }); + expect(respond2.status).toBe(201); + return [respond2.body.data.user.id, respond2.body.data.accessToken]; + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }, 20000); + + beforeEach(() => { + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.results.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.results.length = 0; + }); + + describe('preparation', () => { + it(`should send an email and register a user ${TestUsername}`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith(TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: TestUsername, + nickname: 'test_user', + password: 'abc123456!!!', + email: TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual('Register successfully.'); + expect(respond.body.code).toEqual(201); + req.expect(201); + expect(respond.body.data.accessToken).toBeDefined(); + TestToken = respond.body.data.accessToken; + expect(respond.body.data.user.id).toBeDefined(); + TestUserId = respond.body.data.user.id; + }); + it('should create some topics', async () => { + async function createTopic(name: string) { + const respond = await request(app.getHttpServer()) + .post('/topics') + .set('authorization', 'Bearer ' + TestToken) + .send({ + name: `${TestTopicPrefix} ${name}`, + }); + expect(respond.body.message).toBe('OK'); + expect(respond.status).toBe(201); + expect(respond.body.code).toBe(201); + TopicIds.push(respond.body.data.id); + } + await createTopic('数学'); + await createTopic('哥德巴赫猜想'); + await createTopic('钓鱼'); + }, 60000); + it('should create some questions', async () => { + async function createQuestion(title: string, content: string) { + const respond = await request(app.getHttpServer()) + .post('/questions') + .set('Authorization', `Bearer ${TestToken}`) + .send({ + title: `${TestQuestionPrefix} ${title}`, + content, + type: 0, + topics: [TopicIds[0], TopicIds[1]], + }); + expect(respond.body.message).toBe('Created'); + expect(respond.body.code).toBe(201); + expect(respond.status).toBe(201); + expect(respond.body.data.id).toBeDefined(); + questionIds.push(respond.body.data.id); + } + + await createQuestion( + '我这个哥德巴赫猜想的证明对吗?', + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', + ); + await createQuestion('求助', '给指导老师分配了任务,老师不干活怎么办?'); + await createQuestion('提问', '应该给指导老师分配什么任务啊'); + await createQuestion('不懂就问', '忘记给指导老师分配任务了怎么办'); + await createQuestion('小创求捞', '副教授职称,靠谱不鸽,求本科生带飞'); + await createQuestion('大创', '极限捞人'); + }); + it('should create some auxiliary users', async () => { + [auxUserId, auxAccessToken] = await createAuxiliaryUser(); + userIdTokenPairList.push([auxUserId, auxAccessToken]); + for (let i = 0; i < 5; i++) { + userIdTokenPairList.push(await createAuxiliaryUser()); + } + + expect(userIdTokenPairList.length).toBe(6); + }); + }); + + describe('Answer question', () => { + it('should create some answers', async () => { + async function createAnswer( + questionId: number, + content: string, + userId: number, + auxToken: string, + ): Promise { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionId}/answers`) + .set('Authorization', `Bearer ${auxToken}`) + .send({ content }); + expect(respond.body.message).toBe('Answer created successfully.'); + expect(respond.body.code).toBe(201); + expect(respond.status).toBe(201); + expect(typeof respond.body.data.id).toBe('number'); + answerIds.push(respond.body.data.id); + AnswerQuestionMap[respond.body.data.id] = questionId; + if (userId == auxUserId) + auxUserAskedAnswerIds.push(respond.body.data.id); + return respond.body.data.id; + } + + const answerContents1 = [ + '你说得对,但是原神是一款由米哈游自主研发的开放世界游戏,后面忘了', + '难道你真的是天才?', + '1+1明明等于3', + 'Answer content with emoji: 😂😂', + '烫烫烫'.repeat(1000), + ]; + for (let i = 0; i < 5; i++) { + await createAnswer( + questionIds[i], + answerContents1[i], + auxUserId, + auxAccessToken, + ); + } + + const answerContents2 = [ + 'answer1', + 'answer2', + 'answer3', + 'answer4', + 'answer5', + 'answer6', + ]; + specialQuestionId = questionIds[5]; + for (let i = 0; i < 6; i++) { + const id = await createAnswer( + questionIds[5], + answerContents2[i], + userIdTokenPairList[i][0], + userIdTokenPairList[i][1], + ); + specialAnswerIds.push(id); + } + }, 60000); + it('should return QuestionAlreadyAnsweredError when user answer the same question', async () => { + const TestQuestionId = questionIds[0]; + const content = 'content'; + await request(app.getHttpServer()) + .post(`/questions/${TestQuestionId}/answers`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ content }); + const respond = await request(app.getHttpServer()) + .post(`/questions/${TestQuestionId}/answers`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ content }); + expect(respond.body.message).toMatch(/QuestionAlreadyAnsweredError: /); + expect(respond.body.code).toBe(400); + }); + it('should return updated statistic info when getting user who not log in', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${auxUserId}`) + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.data.user.answer_count).toBe(6); + }); + it('should return updated statistic info when getting user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${auxUserId}`) + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.data.user.answer_count).toBe(6); + }); + it('should return AuthenticationRequiredError', async () => { + const TestQuestionId = questionIds[0]; + const content = 'content'; + const respond = await request(app.getHttpServer()) + .post(`/questions/${TestQuestionId}/answers`) + .send({ content }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + + describe('Get answer', () => { + it('should get a answer', async () => { + const TestAnswerId = answerIds[0]; + const TestQuestionId = AnswerQuestionMap[TestAnswerId]; + const response = await request(app.getHttpServer()) + .get(`/questions/${TestQuestionId}/answers/${TestAnswerId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .set('User-Agent', 'PostmanRuntime/7.26.8') + .send(); + expect(response.body.message).toBe('Answer fetched successfully.'); + expect(response.status).toBe(200); + expect(response.body.code).toBe(200); + expect(response.body.data.question.id).toBe(TestQuestionId); + expect(response.body.data.question.title).toBeDefined(); + expect(response.body.data.question.content).toBeDefined(); + expect(response.body.data.question.author).toBeDefined(); + expect(response.body.data.answer.id).toBe(TestAnswerId); + expect(response.body.data.answer.question_id).toBe(TestQuestionId); + expect(response.body.data.answer.content).toContain( + '你说得对,但是原神是一款由米哈游自主研发的开放世界游戏,', + ); + expect(response.body.data.answer.author.id).toBe(auxUserId); + expect(response.body.data.answer.created_at).toBeDefined(); + expect(response.body.data.answer.updated_at).toBeDefined(); + expect(response.body.data.answer.attitudes).toBeDefined(); + expect(response.body.data.answer.attitudes.positive_count).toBe(0); + expect(response.body.data.answer.attitudes.negative_count).toBe(0); + expect(response.body.data.answer.attitudes.difference).toBe(0); + expect(response.body.data.answer.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + expect(response.body.data.answer.is_favorite).toBe(false); + expect(response.body.data.answer.comment_count).toBe(0); + expect(response.body.data.answer.favorite_count).toBe(0); + expect(response.body.data.answer.view_count).toBeDefined(); + expect(response.body.data.answer.is_group).toBe(false); + }); + // it('should get a answer even without token', async () => { + // // const TestQuestionId = questionId[0]; + // const TestAnswerId = answerIds[0]; + // const TestQuestionId = AnswerQuestionMap[TestAnswerId]; + // const response = await request(app.getHttpServer()) + // .get(`/questions/${TestQuestionId}/answers/${TestAnswerId}`) + // .send(); + // expect(response.body.message).toBe('Answer fetched successfully.'); + // expect(response.status).toBe(200); + // expect(response.body.code).toBe(200); + // expect(response.body.data.question.id).toBe(TestQuestionId); + // expect(response.body.data.question.title).toBeDefined(); + // expect(response.body.data.question.content).toBeDefined(); + // expect(response.body.data.question.author.id).toBe(TestUserId); + // expect(response.body.data.answer.id).toBe(TestAnswerId); + // expect(response.body.data.answer.question_id).toBe(TestQuestionId); + // expect(response.body.data.answer.content).toContain( + // '你说得对,但是原神是一款由米哈游自主研发的开放世界游戏,', + // ); + // expect(response.body.data.answer.author.id).toBe(auxUserId); + // expect(response.body.data.answer.created_at).toBeDefined(); + // expect(response.body.data.answer.updated_at).toBeDefined(); + // //expect(response.body.data.answer.agree_type).toBe(0); + // expect(response.body.data.answer.is_favorite).toBe(false); + // //expect(response.body.data.answer.agree_count).toBe(0); + // expect(response.body.data.answer.favorite_count).toBe(0); + // expect(response.body.data.answer.view_count).toBeDefined(); + // }); + + it('should return AnswerNotFoundError', async () => { + const TestAnswerId = answerIds[0]; + const TestQuestionId = AnswerQuestionMap[TestAnswerId] + 1; + const response = await request(app.getHttpServer()) + .get(`/questions/${TestQuestionId}/answers/${TestAnswerId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.body.message).toMatch(/AnswerNotFoundError: /); + expect(response.body.message).toMatch(/AnswerNotFoundError: /); + expect(response.status).toBe(404); + expect(response.body.code).toBe(404); + }); + + it('should return AuthenticationRequiredError', async () => { + const TestAnswerId = answerIds[0]; + const TestQuestionId = AnswerQuestionMap[TestAnswerId]; + const response = await request(app.getHttpServer()) + .get(`/questions/${TestQuestionId}/answers/${TestAnswerId}`) + // .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(response.body.code).toBe(401); + }); + }); + + describe('Get Answers By Question ID', () => { + it('should successfully get all answers by question ID', async () => { + const response = await request(app.getHttpServer()) + .get(`/questions/${specialQuestionId}/answers`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.body.message).toBe('Answers fetched successfully.'); + + expect(response.status).toBe(200); + expect(response.body.code).toBe(200); + expect(response.body.data.page.page_start).toBe(specialAnswerIds[0]); + expect(response.body.data.page.page_size).toBe(specialAnswerIds.length); + expect(response.body.data.page.has_prev).toBe(false); + expect(response.body.data.page.prev_start).toBe(0); + expect(response.body.data.page.has_more).toBe(false); + expect(response.body.data.page.next_start).toBe(0); + expect(response.body.data.answers.length).toBe(specialAnswerIds.length); + for (let i = 0; i < specialAnswerIds.length; i++) { + expect(response.body.data.answers[i].question_id).toBe( + specialQuestionId, + ); + } + expect( + response.body.data.answers + .map((x: any) => x.id) + .sort((n1: number, n2: number) => n1 - n2), + ).toStrictEqual(specialAnswerIds); + }); + + it('should successfully get all answers by question ID', async () => { + const response = await request(app.getHttpServer()) + .get(`/questions/${specialQuestionId}/answers`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.body.message).toBe('Answers fetched successfully.'); + + expect(response.status).toBe(200); + expect(response.body.code).toBe(200); + expect(response.body.data.page.page_start).toBe(specialAnswerIds[0]); + expect(response.body.data.page.page_size).toBe(specialAnswerIds.length); + expect(response.body.data.page.has_prev).toBe(false); + expect(response.body.data.page.prev_start).toBe(0); + expect(response.body.data.page.has_more).toBe(false); + expect(response.body.data.page.next_start).toBe(0); + expect(response.body.data.answers.length).toBe(specialAnswerIds.length); + for (let i = 0; i < specialAnswerIds.length; i++) { + expect(response.body.data.answers[i].question_id).toBe( + specialQuestionId, + ); + } + expect( + response.body.data.answers + .map((x: any) => x.id) + .sort((n1: number, n2: number) => n1 - n2), + ).toStrictEqual(specialAnswerIds); + }); + + it('should successfully get all answers by question ID', async () => { + const auxAccessToken = userIdTokenPairList[0][1]; + const pageStart = specialAnswerIds[0]; + const pageSize = 20; + const response = await request(app.getHttpServer()) + .get(`/questions/${specialQuestionId}/answers`) + .query({ + page_start: pageStart, + page_size: pageSize, + }) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.body.message).toBe('Answers fetched successfully.'); + + expect(response.status).toBe(200); + expect(response.body.code).toBe(200); + // expect(response.body.data.page.page_start).toBe(pageStart); + expect(response.body.data.page.page_size).toBe(specialAnswerIds.length); + expect(response.body.data.page.has_prev).toBe(false); + expect(response.body.data.page.prev_start).toBe(0); + expect(response.body.data.page.has_more).toBe(false); + expect(response.body.data.page.next_start).toBe(0); + expect(response.body.data.answers.length).toBe(specialAnswerIds.length); + for (let i = 0; i < specialAnswerIds.length; i++) { + expect(response.body.data.answers[i].question_id).toBe( + specialQuestionId, + ); + } + expect( + response.body.data.answers + .map((x: any) => x.id) + .sort((n1: number, n2: number) => n1 - n2), + ).toStrictEqual(specialAnswerIds); + }); + + it('should successfully get answers by question ID and paging', async () => { + const pageSize = 2; + const response = await request(app.getHttpServer()) + .get(`/questions/${specialQuestionId}/answers`) + .query({ + page_start: specialAnswerIds[2], + page_size: pageSize, + }) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + + expect(response.body.message).toBe('Answers fetched successfully.'); + + expect(response.status).toBe(200); + expect(response.body.code).toBe(200); + expect(response.body.data.page.page_start).toBe(specialAnswerIds[2]); + expect(response.body.data.page.page_size).toBe(2); + expect(response.body.data.page.has_prev).toBe(true); + expect(response.body.data.page.prev_start).toBe(specialAnswerIds[0]); + expect(response.body.data.page.has_more).toBe(true); + expect(response.body.data.page.next_start).toBe(specialAnswerIds[4]); + expect(response.body.data.answers.length).toBe(2); + expect(response.body.data.answers[0].question_id).toBe(specialQuestionId); + expect(response.body.data.answers[1].question_id).toBe(specialQuestionId); + expect(response.body.data.answers[0].id).toBe(specialAnswerIds[2]); + expect(response.body.data.answers[1].id).toBe(specialAnswerIds[3]); + }); + + // it('should successfully get answers by question ID without token', async () => { + // const pageSize = 2; + // const response = await request(app.getHttpServer()) + // .get(`/questions/${specialQuestionId}/answers`) + // .query({ + // page_start: specialAnswerIds[2], + // page_size: pageSize, + // }) + // .send(); + + // expect(response.body.message).toBe('Answers fetched successfully.'); + + // expect(response.status).toBe(200); + // expect(response.body.code).toBe(200); + // expect(response.body.data.page.page_start).toBe(specialAnswerIds[2]); + // expect(response.body.data.page.page_size).toBe(2); + // expect(response.body.data.page.has_prev).toBe(true); + // expect(response.body.data.page.prev_start).toBe(specialAnswerIds[0]); + // expect(response.body.data.page.has_more).toBe(true); + // expect(response.body.data.page.next_start).toBe(specialAnswerIds[4]); + // expect(response.body.data.answers.length).toBe(2); + // expect(response.body.data.answers[0].question_id).toBe(specialQuestionId); + // expect(response.body.data.answers[1].question_id).toBe(specialQuestionId); + // expect(response.body.data.answers[0].id).toBe(specialAnswerIds[2]); + // expect(response.body.data.answers[1].id).toBe(specialAnswerIds[3]); + // }); + + it('should return QuestionNotFoundError for a non-existent question ID', async () => { + const nonExistentQuestionId = 99999; + const response = await request(app.getHttpServer()) + .get(`/questions/${nonExistentQuestionId}/answers`) + .set('Authorization', `Bearer ${TestToken}`); + expect(response.body.message).toMatch(/QuestionNotFoundError: /); + expect(response.status).toBe(404); + expect(response.body.code).toBe(404); + }); + + it('should return AuthenticationRequiredError', async () => { + const response = await request(app.getHttpServer()) + .get(`/questions/${specialQuestionId}/answers`) + // .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(response.body.code).toBe(401); + }); + }); + + describe('Get Answers By Asker ID', () => { + it('should return UserIdNotFoundError', async () => { + const noneExistUserId = -1; + const respond = await request(app.getHttpServer()) + .get(`/users/${noneExistUserId}/answers`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/UserIdNotFoundError: /); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + it('should get answers asked by auxUser with default page settings', async () => { + const response = await request(app.getHttpServer()) + .get(`/users/${auxUserId}/answers`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.body.message).toBe('Query asked questions successfully.'); + expect(response.status).toBe(200); + expect(response.body.code).toBe(200); + expect(response.body.data.page.page_start).toBe(auxUserAskedAnswerIds[0]); + expect(response.body.data.page.page_size).toBe( + auxUserAskedAnswerIds.length, + ); + expect(response.body.data.page.has_prev).toBe(false); + expect(response.body.data.page.prev_start).toBe(0); + expect(response.body.data.page.has_more).toBe(false); + expect(response.body.data.page.next_start).toBe(0); + expect(response.body.data.answers.length).toBe( + auxUserAskedAnswerIds.length, + ); + for (let i = 0; i < auxUserAskedAnswerIds.length; i++) { + expect(response.body.data.answers[i].id).toBe(auxUserAskedAnswerIds[i]); + } + }); + it('should get answers asked by auxUser with a page setting', async () => { + const response = await request(app.getHttpServer()) + .get(`/users/${auxUserId}/answers`) + .query({ + page_start: auxUserAskedAnswerIds[0], + page_size: 2, + }) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(response.body.message).toBe('Query asked questions successfully.'); + expect(response.status).toBe(200); + expect(response.body.code).toBe(200); + expect(response.body.data.page.page_start).toBe(auxUserAskedAnswerIds[0]); + expect(response.body.data.page.page_size).toBe(2); + expect(response.body.data.page.has_prev).toBe(false); + expect(response.body.data.page.prev_start).toBe(0); + expect(response.body.data.page.has_more).toBe(true); + expect(response.body.data.page.next_start).toBe(auxUserAskedAnswerIds[2]); + expect(response.body.data.answers.length).toBe(2); + expect(response.body.data.answers[0].id).toBe(auxUserAskedAnswerIds[0]); + expect(response.body.data.answers[1].id).toBe(auxUserAskedAnswerIds[1]); + }); + it('should get answers asked by auxUser with another page setting', async () => { + const response = await request(app.getHttpServer()) + .get(`/users/${auxUserId}/answers`) + .query({ + page_start: auxUserAskedAnswerIds[2], + page_size: 2, + }) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(response.body.message).toBe('Query asked questions successfully.'); + expect(response.status).toBe(200); + expect(response.body.code).toBe(200); + expect(response.body.data.page.page_start).toBe(auxUserAskedAnswerIds[2]); + expect(response.body.data.page.page_size).toBe(2); + expect(response.body.data.page.has_prev).toBe(true); + expect(response.body.data.page.prev_start).toBe(auxUserAskedAnswerIds[0]); + expect(response.body.data.page.has_more).toBe(true); + expect(response.body.data.page.next_start).toBe(auxUserAskedAnswerIds[4]); + expect(response.body.data.answers.length).toBe(2); + expect(response.body.data.answers[0].id).toBe(auxUserAskedAnswerIds[2]); + expect(response.body.data.answers[1].id).toBe(auxUserAskedAnswerIds[3]); + }); + it('should return AuthenticationRequiredError', async () => { + const response = await request(app.getHttpServer()) + .get(`/users/${auxUserId}/answers`) + .send(); + expect(response.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(response.body.code).toBe(401); + }); + }); + + describe('Update Answer', () => { + it('should return PermissionDeniedError', async () => { + const TestAnswerId = answerIds[0]; + const TestQuestionId = AnswerQuestionMap[TestAnswerId]; + const response = await request(app.getHttpServer()) + .put(`/questions/${TestQuestionId}/answers/${TestAnswerId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ content: 'Some content' }); + expect(response.body.message).toMatch(/PermissionDeniedError: /); + expect(response.body.code).toBe(403); + }); + it('should successfully update an answer', async () => { + const testAnswerId = answerIds[1]; + const testQuestionId = AnswerQuestionMap[testAnswerId]; + const updatedContent = '--------更新----------'; + const response = await request(app.getHttpServer()) + .put(`/questions/${testQuestionId}/answers/${testAnswerId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ content: updatedContent }); + expect(response.body.message).toBe('Answer updated successfully.'); + expect(response.status).toBe(200); + expect(response.body.code).toBe(200); + }); + + it('should throw AnswerNotFoundError when trying to update a non-existent answer', async () => { + const nonExistentAnswerId = 999999; + const testQuestionId = questionIds[0]; + const response = await request(app.getHttpServer()) + .put(`/questions/${testQuestionId}/answers/${nonExistentAnswerId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ content: 'Some content' }); + expect(response.body.message).toMatch(/AnswerNotFoundError: /); + expect(response.status).toBe(404); + expect(response.body.code).toBe(404); + }); + + it('should return AuthenticationRequiredError', async () => { + const testQuestionId = questionIds[0]; + const testAnswerId = answerIds[1]; + const updatedContent = '--------更新----------'; + const response = await request(app.getHttpServer()) + .put(`/questions/${testQuestionId}/answers/${testAnswerId}`) + .send({ content: updatedContent }); + expect(response.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(response.body.code).toBe(401); + }); + it('should throw AnswerNotFoundError', async () => { + const auxAccessToken = userIdTokenPairList[0][1]; + const testAnswerId = answerIds[0]; + const testQuestionId = AnswerQuestionMap[testAnswerId] + 1; + const response = await request(app.getHttpServer()) + .put(`/questions/${testQuestionId}/answers/${testAnswerId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ content: 'Some content' }); + + expect(response.body.message).toMatch(/AnswerNotFoundError: /); + expect(response.body.message).toMatch(/AnswerNotFoundError: /); + expect(response.status).toBe(404); + expect(response.body.code).toBe(404); + }); + }); + + describe('Delete Answer', () => { + it('should return PermissionDeniedError', async () => { + const TestAnswerId = answerIds[0]; + const TestQuestionId = AnswerQuestionMap[TestAnswerId]; + const response = await request(app.getHttpServer()) + .delete(`/questions/${TestQuestionId}/answers/${TestAnswerId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(response.body.message).toMatch(/PermissionDeniedError: /); + expect(response.body.code).toBe(403); + }); + it('should successfully delete an answer', async () => { + const TestAnswerId = answerIds[2]; + const testQuestionId = AnswerQuestionMap[TestAnswerId]; + const response = await request(app.getHttpServer()) + .delete(`/questions/${testQuestionId}/answers/${TestAnswerId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.status).toBe(200); + }); + + it('should return a not found error when trying to delete a non-existent answer', async () => { + const auxAccessToken = userIdTokenPairList[0][1]; + const testQuestionId = questionIds[0]; + const nonExistentAnswerId = 0; + const response = await request(app.getHttpServer()) + .delete(`/questions/${testQuestionId}/answers/${nonExistentAnswerId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + + expect(response.body.message).toMatch(/AnswerNotFoundError: /); + expect(response.status).toBe(404); + expect(response.body.code).toBe(404); + }); + + it('should return AuthenticationRequiredError', async () => { + const testQuestionId = questionIds[0]; + const TestAnswerId = answerIds[2]; + const response = await request(app.getHttpServer()).delete( + `/questions/${testQuestionId}/answers/${TestAnswerId}`, + ); + + expect(response.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(response.body.code).toBe(401); + }); + }); + + describe('Favorite Answer', () => { + it('should successfully favorite an answer', async () => { + const auxAccessToken = userIdTokenPairList[0][1]; + const TestAnswerId = answerIds[1]; + const TestQuestionId = AnswerQuestionMap[TestAnswerId]; + const response = await request(app.getHttpServer()) + .put(`/questions/${TestQuestionId}/answers/${TestAnswerId}/favorite`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.body.message).toBe('Answer favorited successfully.'); + expect(response.status).toBe(200); + }); + + it('should successfully unfavorite an answer', async () => { + const auxAccessToken = userIdTokenPairList[0][1]; + const TestAnswerId = answerIds[1]; + const TestQuestionId = AnswerQuestionMap[TestAnswerId]; + await request(app.getHttpServer()) + .put(`/questions/${TestQuestionId}/answers/${TestAnswerId}/favorite`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + const response = await request(app.getHttpServer()) + .delete(`/questions/${TestQuestionId}/answers/${TestAnswerId}/favorite`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.status).toBe(200); + }); + + it('should throw AnswerNotFavoriteError when trying to unfavorite an answer that has not been favorited yet', async () => { + const auxAccessToken = userIdTokenPairList[0][1]; + const TestAnswerId = answerIds[4]; + const TestQuestionId = AnswerQuestionMap[TestAnswerId]; + const response = await request(app.getHttpServer()) + .delete(`/questions/${TestQuestionId}/answers/${TestAnswerId}/favorite`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(response.body.message).toMatch(/AnswerNotFavoriteError: /); + expect(response.status).toBe(400); + expect(response.body.code).toBe(400); + }); + it('should throw AnswerNotFoundError when trying to favorite a non-existent answer', async () => { + const TestQuestionId = questionIds[0]; + const nonExistentAnswerId = 99999; + const response = await request(app.getHttpServer()) + .put( + `/questions/${TestQuestionId}/answers/${nonExistentAnswerId}/favorite`, + ) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + + expect(response.body.message).toMatch(/AnswerNotFoundError: /); + expect(response.status).toBe(404); + + expect(response.body.code).toBe(404); + }); + it('should throw AnswerNotFoundError when trying to unfavorite a non-existent answer', async () => { + const TestQuestionId = questionIds[0]; + const auxAccessToken = userIdTokenPairList[0][1]; + const nonExistentAnswerId = 99998; + const response = await request(app.getHttpServer()) + .delete( + `/questions/${TestQuestionId}/answers/${nonExistentAnswerId}/favorite`, + ) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + + expect(response.body.message).toMatch(/AnswerNotFoundError: /); + expect(response.status).toBe(404); + expect(response.body.code).toBe(404); + }); + + it('should return AuthenticationRequiredError', async () => { + const TestAnswerId = answerIds[1]; + const TestQuestionId = questionIds[0]; + const response = await request(app.getHttpServer()) + .put(`/questions/${TestQuestionId}/answers/${TestAnswerId}/favorite`) + .send(); + + expect(response.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(response.body.code).toBe(401); + }); + }); + + describe('Set Attitude to Answer', () => { + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .post( + `/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}/attitudes`, + ) + .send({ attitude_type: 'POSITIVE' }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + expect(respond.statusCode).toBe(401); + }); + it('should set attitude successfully', async () => { + const respond = await request(app.getHttpServer()) + .post( + `/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}/attitudes`, + ) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ attitude_type: 'POSITIVE' }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the answer', + ); + expect(respond.body.code).toBe(201); + expect(respond.statusCode).toBe(201); + expect(respond.body.data.attitudes.positive_count).toBe(1); + expect(respond.body.data.attitudes.negative_count).toBe(0); + expect(respond.body.data.attitudes.difference).toBe(1); + expect(respond.body.data.attitudes.user_attitude).toBe('POSITIVE'); + }); + it('should set attitude successfully', async () => { + const respond = await request(app.getHttpServer()) + .post( + `/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}/attitudes`, + ) + .set('Authorization', `Bearer ${TestToken}`) + .send({ attitude_type: 'NEGATIVE' }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the answer', + ); + expect(respond.body.code).toBe(201); + expect(respond.statusCode).toBe(201); + expect(respond.body.data.attitudes.positive_count).toBe(1); + expect(respond.body.data.attitudes.negative_count).toBe(1); + expect(respond.body.data.attitudes.difference).toBe(0); + expect(respond.body.data.attitudes.user_attitude).toBe('NEGATIVE'); + }); + // it('should get answer dto with attitude statics', async () => { + // const respond = await request(app.getHttpServer()) + // .get(`/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}`) + // .send(); + // expect(respond.body.message).toBe('Answer fetched successfully.'); + // expect(respond.body.code).toBe(200); + // expect(respond.statusCode).toBe(200); + // expect(respond.body.data.answer.attitudes.positive_count).toBe(1); + // expect(respond.body.data.answer.attitudes.negative_count).toBe(1); + // expect(respond.body.data.answer.attitudes.difference).toBe(0); + // expect(respond.body.data.answer.attitudes.user_attitude).toBe( + // 'UNDEFINED', + // ); + // }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + expect(respond.statusCode).toBe(401); + }); + it('should get answer dto with attitude statics', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Answer fetched successfully.'); + expect(respond.body.code).toBe(200); + expect(respond.statusCode).toBe(200); + expect(respond.body.data.answer.attitudes.positive_count).toBe(1); + expect(respond.body.data.answer.attitudes.negative_count).toBe(1); + expect(respond.body.data.answer.attitudes.difference).toBe(0); + expect(respond.body.data.answer.attitudes.user_attitude).toBe('POSITIVE'); + }); + it('should get answer dto with attitude statics', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('Answer fetched successfully.'); + expect(respond.body.code).toBe(200); + expect(respond.statusCode).toBe(200); + expect(respond.body.data.answer.attitudes.positive_count).toBe(1); + expect(respond.body.data.answer.attitudes.negative_count).toBe(1); + expect(respond.body.data.answer.attitudes.difference).toBe(0); + expect(respond.body.data.answer.attitudes.user_attitude).toBe('NEGATIVE'); + }); + it('should set attitude to positive successfully', async () => { + const respond = await request(app.getHttpServer()) + .post( + `/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}/attitudes`, + ) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ attitude_type: 'UNDEFINED' }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the answer', + ); + expect(respond.body.code).toBe(201); + expect(respond.statusCode).toBe(201); + expect(respond.body.data.attitudes.positive_count).toBe(0); + expect(respond.body.data.attitudes.negative_count).toBe(1); + expect(respond.body.data.attitudes.difference).toBe(-1); + expect(respond.body.data.attitudes.user_attitude).toBe('UNDEFINED'); + }); + it('should set attitude to positive successfully', async () => { + const respond = await request(app.getHttpServer()) + .post( + `/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}/attitudes`, + ) + .set('Authorization', `Bearer ${TestToken}`) + .send({ attitude_type: 'UNDEFINED' }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the answer', + ); + expect(respond.body.code).toBe(201); + expect(respond.statusCode).toBe(201); + expect(respond.body.data.attitudes.positive_count).toBe(0); + expect(respond.body.data.attitudes.negative_count).toBe(0); + expect(respond.body.data.attitudes.difference).toBe(0); + expect(respond.body.data.attitudes.user_attitude).toBe('UNDEFINED'); + }); + it('should get answer dto with attitude statics', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('Answer fetched successfully.'); + expect(respond.body.code).toBe(200); + expect(respond.statusCode).toBe(200); + expect(respond.body.data.answer.attitudes.positive_count).toBe(0); + expect(respond.body.data.answer.attitudes.negative_count).toBe(0); + expect(respond.body.data.answer.attitudes.difference).toBe(0); + expect(respond.body.data.answer.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + }); + it('should get answer dto with attitude statics', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Answer fetched successfully.'); + expect(respond.body.code).toBe(200); + expect(respond.statusCode).toBe(200); + expect(respond.body.data.answer.attitudes.positive_count).toBe(0); + expect(respond.body.data.answer.attitudes.negative_count).toBe(0); + expect(respond.body.data.answer.attitudes.difference).toBe(0); + expect(respond.body.data.answer.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + }); + it('should get answer dto with attitude statics', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${specialQuestionId}/answers/${specialAnswerIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('Answer fetched successfully.'); + expect(respond.body.code).toBe(200); + expect(respond.statusCode).toBe(200); + expect(respond.body.data.answer.attitudes.positive_count).toBe(0); + expect(respond.body.data.answer.attitudes.negative_count).toBe(0); + expect(respond.body.data.answer.attitudes.difference).toBe(0); + expect(respond.body.data.answer.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + }); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/test/attachments.e2e-spec.ts b/test/attachments.e2e-spec.ts new file mode 100644 index 00000000..9b2939ad --- /dev/null +++ b/test/attachments.e2e-spec.ts @@ -0,0 +1,264 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; +jest.mock('../src/email/email.service'); + +describe('Attachment Module', () => { + let app: INestApplication; + const MockedEmailService = >EmailService; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + + let TestToken: string; + let ImageId: number; + let ImageAsFileId: number; + let VideoId: number; + let VideoAsFileId: number; + let AudioId: number; + let AudioAsFileId: number; + let FileId: number; + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }, 20000); + beforeEach(() => { + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.results.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.results.length = 0; + }); + describe('preparation', () => { + it(`should send an email and register a user ${TestUsername}`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith(TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: TestUsername, + nickname: 'test_user', + password: 'abc123456!!!', + email: TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual('Register successfully.'); + expect(respond.body.code).toEqual(201); + req.expect(201); + expect(respond.body.data.accessToken).toBeDefined(); + TestToken = respond.body.data.accessToken; + expect(respond.body.data.user.id).toBeDefined(); + }); + }); + describe('upload attachments', () => { + it('should upload an image', async () => { + const respond = await request(app.getHttpServer()) + .post('/attachments') + .field('type', 'image') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.jpg'); + expect(respond.body.message).toBe('Attachment uploaded successfully'); + expect(respond.body.code).toBe(201); + expect(respond.body.data).toHaveProperty('id'); + ImageId = respond.body.data.id; + }); + it('should upload an image as file', async () => { + const respond = await request(app.getHttpServer()) + .post('/attachments') + .field('type', 'file') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.jpg'); + expect(respond.body.message).toBe('Attachment uploaded successfully'); + expect(respond.body.code).toBe(201); + expect(respond.body.data).toHaveProperty('id'); + ImageAsFileId = respond.body.data.id; + }); + it('should upload a video ', async () => { + const respond = await request(app.getHttpServer()) + .post('/attachments') + .field('type', 'video') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.mp4'); + expect(respond.body.code).toBe(201); + expect(respond.body.message).toBe('Attachment uploaded successfully'); + expect(respond.body.data).toHaveProperty('id'); + VideoId = respond.body.data.id; + }); + it('should upload a video as file ', async () => { + const respond = await request(app.getHttpServer()) + .post('/attachments') + .field('type', 'file') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.mp4'); + expect(respond.body.code).toBe(201); + expect(respond.body.message).toBe('Attachment uploaded successfully'); + expect(respond.body.data).toHaveProperty('id'); + VideoAsFileId = respond.body.data.id; + }); + it('should upload an audio ', async () => { + const respond = await request(app.getHttpServer()) + .post('/attachments') + .field('type', 'audio') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.mp3'); + expect(respond.body.code).toBe(201); + expect(respond.body.message).toBe('Attachment uploaded successfully'); + expect(respond.body.data).toHaveProperty('id'); + AudioId = respond.body.data.id; + }); + it('should upload an audio as file', async () => { + const respond = await request(app.getHttpServer()) + .post('/attachments') + .field('type', 'file') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.mp3'); + expect(respond.body.code).toBe(201); + expect(respond.body.message).toBe('Attachment uploaded successfully'); + expect(respond.body.data).toHaveProperty('id'); + AudioAsFileId = respond.body.data.id; + }); + it('should upload a file ', async () => { + const respond = await request(app.getHttpServer()) + .post('/attachments') + .field('type', 'file') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.pdf'); + expect(respond.body.code).toBe(201); + expect(respond.body.message).toBe('Attachment uploaded successfully'); + expect(respond.body.data).toHaveProperty('id'); + FileId = respond.body.data.id; + }); + it('should return InvalidAttachmentTypeError', async () => { + const respond = await request(app.getHttpServer()) + .post('/attachments') + .field('type', 'yuiii') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.pdf'); + expect(respond.body.code).toBe(400); + expect(respond.body.message).toMatch(/InvalidAttachmentTypeError: /); + }); + it('should return MimeTypeNotMatchError', async () => { + const respond = await request(app.getHttpServer()) + .post('/attachments') + .field('type', 'image') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.pdf'); + expect(respond.status).toBe(422); + expect(respond.body.code).toBe(422); + expect(respond.body.message).toMatch(/MimeTypeNotMatchError: /); + }); + }); + describe('get attachment', () => { + it('should get the uploaded image detail', async () => { + const respond = await request(app.getHttpServer()) + .get(`/attachments/${ImageId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + expect(respond.body.data.attachment.meta.height).toEqual(200); + expect(respond.body.data.attachment.meta.width).toEqual(200); + expect(respond.body.data.attachment.meta.size).toEqual(53102); + }); + it('should get the uploaded video detail', async () => { + const respond = await request(app.getHttpServer()) + .get(`/attachments/${VideoId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + expect(respond.body.data.attachment.meta.height).toEqual(1080); + expect(respond.body.data.attachment.meta.width).toEqual(2160); + expect(respond.body.data.attachment.meta.size).toEqual(240563); + expect(respond.body.data.attachment.meta.duration).toBeCloseTo(3.1, 0.15); + expect(respond.body.data.attachment.meta.thumbnail).toMatch( + /static\/images\/.*\.jpg/, + ); + }); + it('should get the uploaded audio detail', async () => { + const respond = await request(app.getHttpServer()) + .get(`/attachments/${AudioId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + expect(respond.body.data.attachment.meta.size).toEqual(70699); + expect(respond.body.data.attachment.meta.duration).toEqual(3); + }); + + it('should get the uploaded file detail', async () => { + const respond = await request(app.getHttpServer()) + .get(`/attachments/${ImageAsFileId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + }); + it('should get the uploaded file detail', async () => { + const respond = await request(app.getHttpServer()) + .get(`/attachments/${VideoAsFileId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + }); + it('should get the uploaded file detail', async () => { + const respond = await request(app.getHttpServer()) + .get(`/attachments/${AudioAsFileId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + }); + it('should get the uploaded file detail', async () => { + const respond = await request(app.getHttpServer()) + .get(`/attachments/${FileId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + expect(respond.body.data.attachment.meta.mime).toBe('application/pdf'); + expect(respond.body.data.attachment.meta.size).toEqual(50146); + }); + it('should return AttachmentNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/attachments/${FileId + 20}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + expect(respond.body.message).toMatch(/AttachmentNotFoundError: /); + }); + }); + afterAll(async () => { + await app.close(); + }); +}); diff --git a/test/avatars.e2e-spec.ts b/test/avatars.e2e-spec.ts new file mode 100644 index 00000000..d6a98c4e --- /dev/null +++ b/test/avatars.e2e-spec.ts @@ -0,0 +1,186 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AvatarType } from '@prisma/client'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; +jest.mock('../src/email/email.service'); + +describe('Avatar Module', () => { + let app: INestApplication; + const MockedEmailService = >EmailService; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + let TestToken: string; + let TestUserId: number; + let AvatarId: number; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }, 20000); + describe('preparation', () => { + it(`should send an email and register a user ${TestUsername}`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith(TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: TestUsername, + nickname: 'test_user', + password: 'abc123456!!!', + email: TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual('Register successfully.'); + expect(respond.body.code).toEqual(201); + req.expect(201); + expect(respond.body.data.accessToken).toBeDefined(); + TestToken = respond.body.data.accessToken; + expect(respond.body.data.user.id).toBeDefined(); + TestUserId = respond.body.data.user.id; + }); + }); + + describe('upload avatar', () => { + it('should upload an avatar', async () => { + const respond = await request(app.getHttpServer()) + .post('/avatars') + .set('Authorization', `Bearer ${TestToken}`) + .attach('avatar', 'src/resources/avatars/default.jpg'); + expect(respond.status).toBe(201); + expect(respond.body.message).toBe('Upload avatar successfully'); + expect(respond.body.data).toHaveProperty('avatarId'); + AvatarId = respond.body.data.avatarId; + }); + it('should upload a large avatar', async () => { + const respond = await request(app.getHttpServer()) + .post('/avatars') + .set('Authorization', `Bearer ${TestToken}`) + .attach('avatar', 'test/resources/large-image.jpg'); + expect(respond.status).toBe(201); + expect(respond.body.message).toBe('Upload avatar successfully'); + expect(respond.body.data).toHaveProperty('avatarId'); + }); + it('should return AuthenticationRequiredError when no token is provided', async () => { + const respond = await request(app.getHttpServer()) + .post('/avatars') + .attach('avatar', 'src/resources/avatars/default.jpg'); + expect(respond.status).toBe(401); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + }); + }); + describe('get avatar', () => { + it('should get the uploaded avatar', async () => { + const avatarId = AvatarId; + const respond = await request(app.getHttpServer()) + .get(`/avatars/${avatarId}`) + .send() + .responseType('blob'); + expect(respond.status).toBe(200); + expect(respond.headers['cache-control']).toContain('max-age'); + expect(respond.headers['content-type']).toMatch(/image\/.*/); + expect(respond.headers['content-disposition']).toContain('inline'); + expect(respond.headers['content-length']).toBeDefined(); + expect(respond.headers['etag']).toBeDefined(); + expect(respond.headers['last-modified']).toBeDefined(); + }); + it('should return AvatarNotFoundError when an avatar is not found', async () => { + const respond = await request(app.getHttpServer()) + .get('/avatars/1000') + .send(); + expect(respond.body.message).toMatch(/^AvatarNotFoundError: /); + expect(respond.status).toBe(404); + }); + it('should get avatar without authentication', async () => { + const avatarId = AvatarId; + const respond = await request(app.getHttpServer()) + .get(`/avatars/${avatarId}`) + .send() + .responseType('blob'); + expect(respond.status).toBe(200); + expect(respond.headers['cache-control']).toContain('max-age'); + expect(respond.headers['content-type']).toMatch(/image\/.*/); + expect(respond.headers['content-disposition']).toContain('inline'); + expect(respond.headers['content-length']).toBeDefined(); + expect(respond.headers['etag']).toBeDefined(); + expect(respond.headers['last-modified']).toBeDefined(); + }); + }); + describe('get default avatar', () => { + it('should get default avatar', async () => { + const respond = await request(app.getHttpServer()) + .get('/avatars/default') + .send(); + expect(respond.status).toBe(200); + expect(respond.headers['cache-control']).toContain('max-age'); + expect(respond.headers['content-type']).toMatch(/image\/.*/); + expect(respond.headers['content-disposition']).toContain('inline'); + expect(respond.headers['content-length']).toBeDefined(); + expect(respond.headers['etag']).toBeDefined(); + expect(respond.headers['last-modified']).toBeDefined(); + }); + }); + describe('get pre available avatarIds', () => { + it('should get available avatarIds', async () => { + const respond = await request(app.getHttpServer()) + .get('/avatars/') + .set('Authorization', `Bearer ${TestToken}`) + .query({ type: AvatarType.predefined }) + .send(); + expect(respond.status).toBe(200); + expect(respond.body.message).toContain( + 'Get available avatarIds successfully', + ); + expect(respond.body.data.avatarIds.length).toEqual(3); + }); + it('should return InvalidAvatarTypeError', async () => { + const respond = await request(app.getHttpServer()) + .get('/avatars/') + .set('Authorization', `Bearer ${TestToken}`) + .query({ type: 'yuiiiiiii' }) + .send(); + expect(respond.status).toBe(400); + expect(respond.body.message).toContain('Invalid Avatar type'); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get('/avatars/') + .query({ type: AvatarType.predefined }) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + }); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/test/comment.e2e-spec.ts b/test/comment.e2e-spec.ts new file mode 100644 index 00000000..5b5e3da8 --- /dev/null +++ b/test/comment.e2e-spec.ts @@ -0,0 +1,624 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; +jest.mock('../src/email/email.service'); + +describe('comments Module', () => { + let app: INestApplication; + const MockedEmailService = >EmailService; + const TestTopicCode = Math.floor(Math.random() * 10000000000).toString(); + const TestTopicPrefix = `[Test(${TestTopicCode}) Question]`; + const TestQuestionCode = Math.floor(Math.random() * 10000000000).toString(); + const TestQuestionPrefix = `[Test(${TestQuestionCode}) Question]`; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + + let TestToken: string; + let auxAccessToken: string; + let TestUserId: number; + let TestUserDto: any; + + // for future use + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let auxAdminAccessToken: string; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let auxAdminUserDto: any; + const CommentIds: number[] = []; + const TopicIds: number[] = []; + const questionIds: number[] = []; + const TestCommentPrefix = `G${Math.floor(Math.random() * 1000000)}`; + + async function createAuxiliaryUser(): Promise<[number, string]> { + // returns [userId, accessToken] + const email = `test-${Math.floor(Math.random() * 10000000000)}@ruc.edu.cn`; + const respond = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ email }); + expect(respond.status).toBe(201); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.at(-1)[1]; + const respond2 = await request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: `TestUser-${Math.floor(Math.random() * 10000000000)}`, + nickname: 'auxiliary_user', + password: 'abc123456!!!', + email, + emailCode: verificationCode, + isLegacyAuth: true, + }); + expect(respond2.status).toBe(201); + return [respond2.body.data.user, respond2.body.data.accessToken]; + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }, 20000); + + beforeEach(() => { + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.results.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.results.length = 0; + }); + + describe('preparation', () => { + it(`should send an email and register a user ${TestUsername}`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith(TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: TestUsername, + nickname: 'test_user', + password: 'abc123456!!!', + email: TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual('Register successfully.'); + expect(respond.body.code).toEqual(201); + req.expect(201); + expect(respond.body.data.accessToken).toBeDefined(); + TestToken = respond.body.data.accessToken; + expect(respond.body.data.user.id).toBeDefined(); + TestUserId = respond.body.data.user.id; + }); + + it('should create some topics', async () => { + async function createTopic(name: string) { + const respond = await request(app.getHttpServer()) + .post('/topics') + .set('authorization', 'Bearer ' + TestToken) + .send({ + name: `${TestTopicPrefix} ${name}`, + }); + expect(respond.body.message).toBe('OK'); + expect(respond.status).toBe(201); + expect(respond.body.code).toBe(201); + TopicIds.push(respond.body.data.id); + } + await createTopic('数学'); + await createTopic('哥德巴赫猜想'); + await createTopic('钓鱼'); + }, 60000); + it('should create an auxiliary user', async () => { + [, auxAccessToken] = await createAuxiliaryUser(); + }); + it('should create some questions', async () => { + async function createQuestion(title: string, content: string) { + const respond = await request(app.getHttpServer()) + .post('/questions') + .set('Authorization', `Bearer ${TestToken}`) + .send({ + title: `${TestQuestionPrefix} ${title}`, + content, + type: 0, + topics: [TopicIds[0], TopicIds[1]], + }); + expect(respond.body.message).toBe('Created'); + expect(respond.body.code).toBe(201); + expect(respond.status).toBe(201); + expect(respond.body.data.id).toBeDefined(); + questionIds.push(respond.body.data.id); + } + await createQuestion( + '我这个哥德巴赫猜想的证明对吗?', + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', + ); + await Promise.all([ + createQuestion('这学期几号放假啊?', '如题'), + createQuestion( + '好难受啊', + '我这学期选了五十学分,每天都要早八,而且还有好多作业要写,好难受啊。安慰安慰我吧。', + ), + createQuestion('Question title with emoji: 😂😂', 'content'), + createQuestion('title', 'Question content with emoji: 😂😂'), + createQuestion('long question', '啊'.repeat(30000)), + ]); + }, 60000); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .post('/questions') + .send({ + title: `${TestQuestionPrefix} 我这个哥德巴赫猜想的证明对吗?`, + content: + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', + type: 0, + topics: [TopicIds[0], TopicIds[1]], + }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + it('should return TopicNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .post('/questions') + .set('Authorization', `Bearer ${TestToken}`) + .send({ + title: `${TestQuestionPrefix} 我这个哥德巴赫猜想的证明对吗?`, + content: + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', + type: 0, + topics: [-1], + }); + expect(respond.body.message).toMatch(/^TopicNotFoundError: /); + expect(respond.body.code).toBe(404); + }); + it('should create some comments', async () => { + async function createComment( + commentableId: number, + commentableType: 'comment' | 'question' | 'answer', + content: string, + ) { + const respond = await request(app.getHttpServer()) + .post(`/comments/${commentableType}/${commentableId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ + content: `${TestCommentPrefix} ${content}`, + }); + + expect(respond.body.message).toBe('Comment created successfully'); + expect(respond.body.code).toBe(201); + expect(respond.status).toBe(201); + expect(respond.body.data.id).toBeTruthy(); + CommentIds.push(respond.body.data.id); + } + await createComment(questionIds[0], 'question', 'zfgg好帅'); + await createComment(questionIds[1], 'question', 'zfggnb'); + await createComment(questionIds[2], 'question', 'zfgg???????'); + await createComment(questionIds[3], 'question', '宵宫!'); + await createComment(CommentIds[0], 'comment', '啦啦啦德玛西亚'); + await createComment(CommentIds[1], 'comment', '你猜我是谁'); + await createComment(CommentIds[5], 'comment', '滚啊,我怎么知道你是谁'); + }, 80000); + it('should not create comment due to invalid commentableId', async () => { + const content = 'what you gonna to know?'; + const respond = await request(app.getHttpServer()) + .post(`/comments/question/114514`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ + content: `${TestCommentPrefix} ${content}`, + }); + + expect(respond.body.message).toMatch(/^CommentableNotFoundError: /); + expect(respond.body.code).toBe(404); + expect(respond.status).toBe(404); + }); + it('should return AuthenticationRequiredError', async () => { + const content = 'what you gonna to know?'; + const respond = await request(app.getHttpServer()) + .post(`/comments/question/${questionIds[0]}/`) + .send({ + content: `${TestCommentPrefix} ${content}`, + }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + it('should create some auxiliary users', async () => { + [, auxAccessToken] = await createAuxiliaryUser(); + [auxAdminUserDto, auxAdminAccessToken] = await createAuxiliaryUser(); + }); + it('should get user dto', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + TestUserDto = respond.body.data.user; + }); + }); + + // describe('get Comments', () => { + // it('should get all comments', async () => { + // console.log(questionIds[0]); + // const respond = await request(app.getHttpServer()) + // .get(`/comments/question/${questionIds[0]}`) + // .set('Authorization', `Bearer ${TestToken}`) + // .send(); + + // expect(respond.body.message).toBe('Get comments successfully'); + // expect(respond.status).toBe(200); + // expect(respond.body.code).toBe(200); + // console.log(respond.body.comment) + // expect(respond.body.data.comments[0].commentableId).toBe(questionIds[0]); + // expect(respond.body.data.comments[0].commentableType).toBe('QUESTION'); + // expect(respond.body.data.comments[0].content).toBe('zfgg好帅!'); + // expect(respond.body.data.comments[0].id).toBeDefined(); + // expect(respond.body.data.comments[0].created_at).toBeDefined(); + // expect(respond.body.data.comments[0].agree_count).toBe(0); + // expect(respond.body.data.comments[0].disagree_count).toBe(0); + // expect(respond.body.data.comments[0].agree_type).toBe('3'); + // expect(respond.body.data.comments[0].user).toStrictEqual(TestUserDto); + // // expect(respond.body.data.page.page_start).toBe(CommentIds[3]); + // // expect(respond.body.data.page.page_size).toBe(2); + // // expect(respond.body.data.page.has_prev).toBe(false); + // // expect(respond.body.data.page.prev_start).toBeFalsy(); + // // expect(respond.body.data.page.has_more).toBe(true); + // // expect(respond.body.data.page.next_start).toBe(CommentIds[1]); + // }); + // }); + + describe('get comment list by id', () => { + it('should get comment by id', async () => { + const respond = await request(app.getHttpServer()) + .get(`/comments/${CommentIds[0]}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Details are as follows'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.comment.id).toBeDefined(); + expect(respond.body.data.comment.commentable_id).toBe(questionIds[0]); + expect(respond.body.data.comment.commentable_type).toBe('QUESTION'); + expect(respond.body.data.comment.content).toContain('zfgg好帅'); + expect(respond.body.data.comment.user).toStrictEqual(TestUserDto); + expect(respond.body.data.comment.created_at).toBeDefined(); + expect(respond.body.data.comment.attitudes.positive_count).toBe(0); + expect(respond.body.data.comment.attitudes.negative_count).toBe(0); + expect(respond.body.data.comment.attitudes.difference).toBe(0); + expect(respond.body.data.comment.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + }); + it('should get comment by id', async () => { + const respond = await request(app.getHttpServer()) + .get(`/comments/${CommentIds[4]}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Details are as follows'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.comment.id).toBeDefined(); + expect(respond.body.data.comment.commentable_id).toBe(CommentIds[0]); + expect(respond.body.data.comment.commentable_type).toBe('COMMENT'); + expect(respond.body.data.comment.content).toContain('啦啦啦德玛西亚'); + expect(respond.body.data.comment.user).toStrictEqual(TestUserDto); + expect(respond.body.data.comment.created_at).toBeDefined(); + expect(respond.body.data.comment.attitudes.positive_count).toBe(0); + expect(respond.body.data.comment.attitudes.negative_count).toBe(0); + expect(respond.body.data.comment.attitudes.difference).toBe(0); + expect(respond.body.data.comment.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + }); + it('should return CommentNotFoundError due to the invalid id', async () => { + const respond = await request(app.getHttpServer()) + .get(`/comments/114514`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toContain('CommentNotFoundError'); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/comments/${CommentIds[0]}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + + describe('AttitudeToComment', () => { + it('should agree to a comment', async () => { + const commentId = CommentIds[0]; + const respond = await request(app.getHttpServer()) + .post(`/comments/${commentId}/attitudes`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ attitude_type: 'POSITIVE' }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the comment', + ); + expect(respond.status).toBe(201); + expect(respond.body.code).toBe(200); + }); + it('should agree to another comment', async () => { + const commentId = CommentIds[4]; + const respond = await request(app.getHttpServer()) + .post(`/comments/${commentId}/attitudes`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ attitude_type: 'POSITIVE' }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the comment', + ); + expect(respond.status).toBe(201); + expect(respond.body.code).toBe(200); + }); + it('should get some difference from others', async () => { + const respond = await request(app.getHttpServer()) + .get(`/comments/${CommentIds[0]}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Details are as follows'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.comment.id).toBeDefined(); + expect(respond.body.data.comment.commentable_id).toBe(questionIds[0]); + expect(respond.body.data.comment.commentable_type).toBe('QUESTION'); + expect(respond.body.data.comment.content).toContain('zfgg好帅'); + expect(respond.body.data.comment.user).toStrictEqual(TestUserDto); + expect(respond.body.data.comment.created_at).toBeDefined(); + expect(respond.body.data.comment.attitudes.positive_count).toBe(1); + expect(respond.body.data.comment.attitudes.negative_count).toBe(0); + expect(respond.body.data.comment.attitudes.difference).toBe(1); + expect(respond.body.data.comment.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + }); + it('should get some difference from self', async () => { + const respond = await request(app.getHttpServer()) + .get(`/comments/${CommentIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('Details are as follows'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.comment.id).toBeDefined(); + expect(respond.body.data.comment.commentable_id).toBe(questionIds[0]); + expect(respond.body.data.comment.commentable_type).toBe('QUESTION'); + expect(respond.body.data.comment.content).toContain('zfgg好帅'); + expect(respond.body.data.comment.user).toStrictEqual(TestUserDto); + expect(respond.body.data.comment.created_at).toBeDefined(); + expect(respond.body.data.comment.attitudes.positive_count).toBe(1); + expect(respond.body.data.comment.attitudes.negative_count).toBe(0); + expect(respond.body.data.comment.attitudes.difference).toBe(1); + expect(respond.body.data.comment.attitudes.user_attitude).toBe( + 'POSITIVE', + ); + }); + it('should disagree to a comment', async () => { + const commentId = CommentIds[0]; + const respond = await request(app.getHttpServer()) + .post(`/comments/${commentId}/attitudes`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ attitude_type: 'NEGATIVE' }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the comment', + ); + expect(respond.status).toBe(201); + expect(respond.body.code).toBe(200); + }); + it('should get some difference from others', async () => { + const respond = await request(app.getHttpServer()) + .get(`/comments/${CommentIds[0]}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Details are as follows'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.comment.id).toBeDefined(); + expect(respond.body.data.comment.commentable_id).toBe(questionIds[0]); + expect(respond.body.data.comment.commentable_type).toBe('QUESTION'); + expect(respond.body.data.comment.content).toContain('zfgg好帅'); + expect(respond.body.data.comment.user).toStrictEqual(TestUserDto); + expect(respond.body.data.comment.created_at).toBeDefined(); + expect(respond.body.data.comment.attitudes.positive_count).toBe(0); + expect(respond.body.data.comment.attitudes.negative_count).toBe(1); + expect(respond.body.data.comment.attitudes.difference).toBe(-1); + expect(respond.body.data.comment.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + }); + it('should get some difference from self', async () => { + const respond = await request(app.getHttpServer()) + .get(`/comments/${CommentIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('Details are as follows'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.comment.id).toBeDefined(); + expect(respond.body.data.comment.commentable_id).toBe(questionIds[0]); + expect(respond.body.data.comment.commentable_type).toBe('QUESTION'); + expect(respond.body.data.comment.content).toContain('zfgg好帅'); + expect(respond.body.data.comment.user).toStrictEqual(TestUserDto); + expect(respond.body.data.comment.created_at).toBeDefined(); + expect(respond.body.data.comment.attitudes.positive_count).toBe(0); + expect(respond.body.data.comment.attitudes.negative_count).toBe(1); + expect(respond.body.data.comment.attitudes.difference).toBe(-1); + expect(respond.body.data.comment.attitudes.user_attitude).toBe( + 'NEGATIVE', + ); + }); + it('should return CommentNotFoundError', async () => { + const commentId = 114514; + const respond = await request(app.getHttpServer()) + .post(`/comments/${commentId}/attitudes`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ attitude_type: 'POSITIVE' }); + expect(respond.body.message).toContain('CommentNotFoundError:'); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + it('should return AuthenticationRequiredError', async () => { + const commentId = CommentIds[1]; + const respond = await request(app.getHttpServer()) + .post(`/comments/${commentId}/attitudes`) + .send({ attitude_type: 'NEGATIVE' }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + + describe('deleteComment', () => { + it('should delete a comment', async () => { + const commentId = CommentIds[1]; + const respond = await request(app.getHttpServer()) + .delete(`/comments/${commentId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + }); + it('should not delete a comment when the user does not match', async () => { + const commentId = CommentIds[0]; + const respond = await request(app.getHttpServer()) + .delete(`/comments/${commentId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toMatch(/^PermissionDeniedError: /); + expect(respond.status).toBe(403); + expect(respond.body.code).toBe(403); + }); + it('should not delete a comment due to the invalid commentId', async () => { + const commentId = 114514; + const respond = await request(app.getHttpServer()) + .delete(`/comments/${commentId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toContain('CommentNotFoundError:'); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + it('should return AuthenticationRequiredError', async () => { + const commentId = CommentIds[0]; + const respond = await request(app.getHttpServer()) + .delete(`/comments/${commentId}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + it('should delete a comment', async () => { + const commentId = CommentIds[4]; + const respond = await request(app.getHttpServer()) + .delete(`/comments/${commentId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + }); + }); + describe('update comment', () => { + it('should return CommentNotFoundError', async () => { + const commentId = CommentIds[1]; + const content = '主播,你怎么不说话了'; + const respond = await request(app.getHttpServer()) + .patch(`/comments/${commentId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ content: `${TestCommentPrefix} ${content}` }); + expect(respond.body.message).toContain('CommentNotFoundError:'); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + it('should update a comment', async () => { + const commentId = CommentIds[3]; + const respond = await request(app.getHttpServer()) + .patch(`/comments/${commentId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ content: `${TestCommentPrefix} 我超,宵宫!` }); + expect(respond.body.message).toContain('Comment updated successfully'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + + const respond2 = await request(app.getHttpServer()) + .get(`/comments/${commentId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond2.body.message).toBe('Details are as follows'); + expect(respond2.status).toBe(200); + expect(respond2.body.code).toBe(200); + expect(respond2.body.data.comment.id).toBeDefined(); + expect(respond2.body.data.comment.commentable_id).toBe(questionIds[3]); + expect(respond2.body.data.comment.commentable_type).toBe('QUESTION'); + expect(respond2.body.data.comment.content).toContain('我超,宵宫!'); + expect(respond2.body.data.comment.user).toStrictEqual(TestUserDto); + expect(respond2.body.data.comment.created_at).toBeDefined(); + expect(respond2.body.data.comment.attitudes.positive_count).toBe(0); + expect(respond2.body.data.comment.attitudes.negative_count).toBe(0); + expect(respond2.body.data.comment.attitudes.difference).toBe(0); + expect(respond2.body.data.comment.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + }); + it('should return AuthenticationRequiredError', async () => { + const commentId = CommentIds[3]; + const respond = await request(app.getHttpServer()) + .patch(`/comments/${commentId}`) + .send({ content: `${TestCommentPrefix} 我超,宵宫!` }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + it('should get comment by id', async () => { + const respond = await request(app.getHttpServer()) + .get(`/comments/${CommentIds[3]}`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ tag: true }) + .send(); + expect(respond.body.message).toBe('Details are as follows'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.comment.id).toBeDefined(); + expect(respond.body.data.comment.commentable_id).toBe(questionIds[3]); + expect(respond.body.data.comment.commentable_type).toBe('QUESTION'); + expect(respond.body.data.comment.content).toContain('宵宫!'); + expect(respond.body.data.comment.user).toStrictEqual(TestUserDto); + expect(respond.body.data.comment.created_at).toBeDefined(); + expect(respond.body.data.comment.attitudes.positive_count).toBe(0); + expect(respond.body.data.comment.attitudes.negative_count).toBe(0); + expect(respond.body.data.comment.attitudes.difference).toBe(0); + expect(respond.body.data.comment.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + }); + }); + afterAll(async () => { + await app.close(); + }); +}); diff --git a/test/groups.e2e-spec.ts b/test/groups.e2e-spec.ts new file mode 100644 index 00000000..2faa3c9d --- /dev/null +++ b/test/groups.e2e-spec.ts @@ -0,0 +1,782 @@ +/* + * Description: This file tests the groups module. + * + * Author(s): + * Andy Lee + * + */ + +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; +jest.mock('../src/email/email.service'); + +describe('Groups Module', () => { + let app: INestApplication; + const MockedEmailService = >EmailService; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + + let TestToken: string; + let TestUserDto: any; + let auxAccessToken: string; + let auxUserDto: any; + // for future use + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let auxAdminAccessToken: string; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let auxAdminUserDto: any; + let PreAvatarId: number; + let UpdateAvatarId: number; + const GroupIds: number[] = []; + const TestGroupPrefix = `G${Math.floor(Math.random() * 1000000)}`; + + async function createAuxiliaryUser(): Promise<[number, string]> { + // returns [userId, accessToken] + const email = `test-${Math.floor(Math.random() * 10000000000)}@ruc.edu.cn`; + const respond = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ email }); + expect(respond.status).toBe(201); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.at(-1)[1]; + const respond2 = await request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: `TestUser-${Math.floor(Math.random() * 10000000000)}`, + nickname: 'auxiliary_user', + password: 'abc123456!!!', + email, + emailCode: verificationCode, + isLegacyAuth: true, + }); + expect(respond2.status).toBe(201); + return [respond2.body.data.user, respond2.body.data.accessToken]; + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }, 20000); + + beforeEach(() => { + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.results.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.results.length = 0; + }); + + describe('preparation', () => { + it(`should send an email and register a user ${TestUsername}`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith(TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: TestUsername, + nickname: 'test_user', + password: 'abc123456!!!', + email: TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual('Register successfully.'); + expect(respond.body.code).toEqual(201); + req.expect(201); + expect(respond.body.data.accessToken).toBeDefined(); + TestToken = respond.body.data.accessToken; + expect(respond.body.data.user.id).toBeDefined(); + TestUserDto = respond.body.data.user; + }); + + it('should upload two avatars for creating and updating', async () => { + async function uploadAvatar() { + const respond = await request(app.getHttpServer()) + .post('/avatars') + .set('Authorization', `Bearer ${TestToken}`) + .attach('avatar', 'src/resources/avatars/default.jpg'); + expect(respond.status).toBe(201); + expect(respond.body.message).toBe('Upload avatar successfully'); + expect(respond.body.data).toHaveProperty('avatarId'); + return respond.body.data.avatarId; + } + PreAvatarId = await uploadAvatar(); + UpdateAvatarId = await uploadAvatar(); + }); + + it('should create some groups', async () => { + async function createGroup(name: string, intro: string) { + const respond = await request(app.getHttpServer()) + .post('/groups') + .set('Authorization', `Bearer ${TestToken}`) + .send({ name: TestGroupPrefix + name, intro, avatarId: PreAvatarId }); + expect(respond.body.message).toBe('Group created successfully'); + expect(respond.body.code).toBe(201); + expect(respond.status).toBe(201); + const groupDto = respond.body.data.group; + expect(groupDto.id).toBeTruthy(); + expect(groupDto.name).toContain(name); + expect(groupDto.avatarId).toBe(PreAvatarId); + expect(groupDto.owner).toStrictEqual(TestUserDto); + expect(groupDto.created_at).toBeDefined(); + expect(groupDto.updated_at).toBeDefined(); + expect(groupDto.member_count).toBe(1); + expect(groupDto.question_count).toBe(0); + expect(groupDto.answer_count).toBe(0); + expect(groupDto.is_member).toBe(true); + expect(groupDto.is_owner).toBe(true); + expect(groupDto.is_public).toBe(true); + expect(groupDto.intro).toBe(intro); + GroupIds.push(groupDto.id); + } + await createGroup('数学之神膜膜喵', '不如原神'); + await createGroup('ICS膜膜膜', 'pwb txdy!'); + await createGroup('嘉然今天学什么', '学, 学个屁!'); + await createGroup('XCPC启动', '启不动了'); + }, 80000); + it('should create some auxiliary users', async () => { + [auxUserDto, auxAccessToken] = await createAuxiliaryUser(); + [auxAdminUserDto, auxAdminAccessToken] = await createAuxiliaryUser(); + }); + }); + + // The following test is disabled because we have decided to migrate searching + // to elastic search. However, it is not implemented yet. + /* + describe('get groups', () => { + it('should get all groups', async () => { + const respond = await request(app.getHttpServer()) + .get('/groups') + .query({ q: '', page_size: 2, type: 'new' }) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('Groups fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.groups.length).toBe(2); + expect(respond.body.data.groups[0].id).toBeDefined(); + expect(respond.body.data.groups[0].name).toContain('XCPC启动'); + expect(respond.body.data.groups[0].intro).toBe('启不动了'); + expect(respond.body.data.groups[0].avatar).toBe('🐱'); + expect(respond.body.data.groups[0].owner).toStrictEqual(TestUserDto); + expect(respond.body.data.groups[0].created_at).toBeDefined(); + expect(respond.body.data.groups[0].updated_at).toBeDefined(); + expect(respond.body.data.groups[0].member_count).toBe(1); + expect(respond.body.data.groups[0].question_count).toBe(0); + expect(respond.body.data.groups[0].answer_count).toBe(0); + expect(respond.body.data.groups[0].is_member).toBe(true); + expect(respond.body.data.groups[0].is_owner).toBe(true); + expect(respond.body.data.groups[1].id).toBeDefined(); + expect(respond.body.data.groups[1].name).toContain('嘉然今天学什么'); + expect(respond.body.data.groups[1].intro).toBe('学, 学个屁!'); + expect(respond.body.data.groups[1].avatar).toBe('🤡'); + expect(respond.body.data.groups[1].owner).toStrictEqual(TestUserDto); + expect(respond.body.data.groups[1].created_at).toBeDefined(); + expect(respond.body.data.groups[1].updated_at).toBeDefined(); + expect(respond.body.data.groups[1].member_count).toBe(1); + expect(respond.body.data.groups[1].question_count).toBe(0); + expect(respond.body.data.groups[1].answer_count).toBe(0); + expect(respond.body.data.groups[1].is_member).toBe(true); + expect(respond.body.data.groups[1].is_owner).toBe(true); + expect(respond.body.data.page.page_start).toBe(GroupIds[3]); + expect(respond.body.data.page.page_size).toBe(2); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBeFalsy(); + expect(respond.body.data.page.has_more).toBe(true); + expect(respond.body.data.page.next_start).toBe(GroupIds[1]); + }); + it('should get groups by name for another user', async () => { + const respond = await request(app.getHttpServer()) + .get('/groups') + .query({ q: '数学', page_size: 2, type: 'new' }) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Groups fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.groups[0].id).toBeDefined(); + expect(respond.body.data.groups[0].name).toContain('数学之神膜膜喵'); + expect(respond.body.data.groups[0].intro).toBe('不如原神'); + expect(respond.body.data.groups[0].avatar).toBe('🥸'); + expect(respond.body.data.groups[0].owner).toStrictEqual(TestUserDto); + expect(respond.body.data.groups[0].created_at).toBeDefined(); + expect(respond.body.data.groups[0].updated_at).toBeDefined(); + expect(respond.body.data.groups[0].member_count).toBe(1); + expect(respond.body.data.groups[0].question_count).toBe(0); + expect(respond.body.data.groups[0].answer_count).toBe(0); + expect(respond.body.data.groups[0].is_member).toBe(false); + expect(respond.body.data.groups[0].is_owner).toBe(false); + expect(respond.body.data.page.page_start).toBe(GroupIds[0]); + expect(respond.body.data.page.page_size).toBeLessThanOrEqual(2); // ! since tests are run multiple times + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBeFalsy(); + expect(respond.body.data.page.has_more).toBeDefined(); + expect(respond.body.data.page.next_start).toBeDefined(); + }); + it('should get groups by name without login', async () => { + const respond = await request(app.getHttpServer()) + .get('/groups') + .query({ q: '数学', page_size: 2, type: 'new' }) + //.set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Groups fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.groups[0].id).toBeDefined(); + expect(respond.body.data.groups[0].name).toContain('数学之神膜膜喵'); + expect(respond.body.data.groups[0].intro).toBe('不如原神'); + expect(respond.body.data.groups[0].avatar).toBe('🥸'); + expect(respond.body.data.groups[0].owner).toStrictEqual(TestUserDto); + expect(respond.body.data.groups[0].created_at).toBeDefined(); + expect(respond.body.data.groups[0].updated_at).toBeDefined(); + expect(respond.body.data.groups[0].member_count).toBe(1); + expect(respond.body.data.groups[0].question_count).toBe(0); + expect(respond.body.data.groups[0].answer_count).toBe(0); + expect(respond.body.data.groups[0].is_member).toBe(false); + expect(respond.body.data.groups[0].is_owner).toBe(false); + expect(respond.body.data.page.page_start).toBe(GroupIds[0]); + expect(respond.body.data.page.page_size).toBeLessThanOrEqual(2); // ! since tests are run multiple times + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBeFalsy(); + expect(respond.body.data.page.has_more).toBeDefined(); + expect(respond.body.data.page.next_start).toBeDefined(); + }); + it('should get groups from half of the groups', async () => { + const respond = await request(app.getHttpServer()) + .get('/groups') + .query({ + q: '膜', + page_start: GroupIds[0], + page_size: 2, + type: 'new', + }) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('Groups fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.groups[0].id).toBeDefined(); + expect(respond.body.data.groups[0].name).toContain('数学之神膜膜喵'); + expect(respond.body.data.groups[0].intro).toBe('不如原神'); + expect(respond.body.data.groups[0].avatar).toBe('🥸'); + expect(respond.body.data.groups[0].owner).toStrictEqual(TestUserDto); + expect(respond.body.data.groups[0].created_at).toBeDefined(); + expect(respond.body.data.groups[0].updated_at).toBeDefined(); + expect(respond.body.data.groups[0].member_count).toBe(1); + expect(respond.body.data.groups[0].question_count).toBe(0); + expect(respond.body.data.groups[0].answer_count).toBe(0); + expect(respond.body.data.groups[0].is_member).toBe(true); + expect(respond.body.data.groups[0].is_owner).toBe(true); + expect(respond.body.data.groups[0].is_public).toBe(true); + expect(respond.body.data.page.page_start).toBe(GroupIds[0]); + expect(respond.body.data.page.page_size).toBeLessThanOrEqual(2); // ! since tests are run multiple times + expect(respond.body.data.page.has_prev).toBe(true); + expect(respond.body.data.page.prev_start).toBe(GroupIds[1]); + expect(respond.body.data.page.has_more).toBeDefined(); + expect(respond.body.data.page.next_start).toBeDefined(); + }); + it('should return groups even page_start group not contains keyword', async () => { + const respond = await request(app.getHttpServer()) + .get('/groups') + .query({ + q: '嘉然', + page_start: GroupIds[3], + page_size: 1, + type: 'new', + }) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('Groups fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.groups.length).toBe(1); + expect(respond.body.data.groups[0].id).toBeDefined(); + expect(respond.body.data.groups[0].name).toContain('嘉然今天学什么'); + expect(respond.body.data.groups[0].intro).toBe('学, 学个屁!'); + expect(respond.body.data.groups[0].avatar).toBe('🤡'); + expect(respond.body.data.groups[0].owner).toStrictEqual(TestUserDto); + expect(respond.body.data.groups[0].created_at).toBeDefined(); + expect(respond.body.data.groups[0].updated_at).toBeDefined(); + expect(respond.body.data.groups[0].member_count).toBe(1); + expect(respond.body.data.groups[0].question_count).toBe(0); + expect(respond.body.data.groups[0].answer_count).toBe(0); + expect(respond.body.data.groups[0].is_member).toBe(true); + expect(respond.body.data.groups[0].is_owner).toBe(true); + expect(respond.body.data.groups[0].is_public).toBe(true); + expect(respond.body.data.page.page_start).toBe(GroupIds[2]); + expect(respond.body.data.page.page_size).toBe(1); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBeFalsy(); + expect(respond.body.data.page.has_more).toBeDefined(); + expect(respond.body.data.page.next_start).toBeDefined(); + }); + it('should return empty array when no group is found', async () => { + const respond = await request(app.getHttpServer()) + .get('/groups') + .query({ + q: '嘉然', + page_start: 2, // ! since tests are run multiple times + // ! be sure id = 2 exists + page_size: 2, + type: 'new', + }) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('Groups fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.groups.length).toBe(0); + expect(respond.body.data.page.page_start).toBeFalsy(); + expect(respond.body.data.page.page_size).toBe(0); + expect(respond.body.data.page.has_prev).toBeDefined(); // ! since tests are run multiple times + expect(respond.body.data.page.prev_start).toBeDefined(); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBeFalsy(); + }); + }); + */ + + describe('get group', () => { + it('should get a group', async () => { + const TestGroupId = GroupIds[0]; + const respond = await request(app.getHttpServer()) + .get(`/groups/${TestGroupId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('Group fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + const groupDto = respond.body.data.group; + expect(groupDto.id).toBe(TestGroupId); + expect(groupDto.name).toContain('数学之神膜膜喵'); + expect(groupDto.intro).toBe('不如原神'); + expect(groupDto.avatarId).toBe(PreAvatarId); + expect(groupDto.owner).toStrictEqual(TestUserDto); + expect(groupDto.created_at).toBeDefined(); + expect(groupDto.updated_at).toBeDefined(); + expect(groupDto.member_count).toBe(1); + expect(groupDto.question_count).toBe(0); + expect(groupDto.answer_count).toBe(0); + expect(groupDto.is_member).toBe(true); + expect(groupDto.is_owner).toBe(true); + }); + + it('should get a group for another user', async () => { + const TestGroupId = GroupIds[0]; + const respond = await request(app.getHttpServer()) + .get(`/groups/${TestGroupId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Group fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + const groupDto = respond.body.data.group; + expect(groupDto.id).toBe(TestGroupId); + expect(groupDto.name).toContain('数学之神膜膜喵'); + expect(groupDto.intro).toBe('不如原神'); + expect(groupDto.avatarId).toBe(PreAvatarId); + expect(groupDto.owner).toStrictEqual(TestUserDto); + expect(groupDto.created_at).toBeDefined(); + expect(groupDto.updated_at).toBeDefined(); + expect(groupDto.member_count).toBe(1); + expect(groupDto.question_count).toBe(0); + expect(groupDto.answer_count).toBe(0); + expect(groupDto.is_member).toBe(false); + expect(groupDto.is_owner).toBe(false); + }); + + it('should return GroupNotFoundError when group is not found', async () => { + const respond = await request(app.getHttpServer()) + .get(`/groups/0`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/^GroupNotFoundError: /); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + }); + + describe('join group', () => { + it('should join a group', async () => { + async function joinGroup(groupId: number) { + const respond = await request(app.getHttpServer()) + .post(`/groups/${groupId}/members`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ intro: '我是初音未来' }); + expect(respond.body.message).toBe('Joined group successfully.'); + expect(respond.status).toBe(201); + expect(respond.body.code).toBe(201); + } + await joinGroup(GroupIds[0]); + await joinGroup(GroupIds[1]); + }); + it('should return a group with is_member true', async () => { + const TestGroupId = GroupIds[0]; + const respond = await request(app.getHttpServer()) + .get(`/groups/${TestGroupId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Group fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + const groupDto = respond.body.data.group; + expect(groupDto.id).toBe(TestGroupId); + expect(groupDto.name).toContain('数学之神膜膜喵'); + expect(groupDto.intro).toBe('不如原神'); + expect(groupDto.avatarId).toBe(PreAvatarId); + expect(groupDto.owner).toStrictEqual(TestUserDto); + expect(groupDto.created_at).toBeDefined(); + expect(groupDto.updated_at).toBeDefined(); + expect(groupDto.member_count).toBe(2); + expect(groupDto.question_count).toBe(0); + expect(groupDto.answer_count).toBe(0); + expect(groupDto.is_member).toBe(true); + expect(groupDto.is_owner).toBe(false); + }); + it('should return GroupNotFoundError when group is not found', async () => { + const respond = await request(app.getHttpServer()) + .post(`/groups/0/members`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ intro: '我是初音未来' }); + expect(respond.body.message).toMatch(/^GroupNotFoundError: /); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + it('should return GroupAlreadyJoinedError when user is already in the group', async () => { + const TestGroupId = GroupIds[0]; + const respond = await request(app.getHttpServer()) + .post(`/groups/${TestGroupId}/members`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ intro: '我是初音未来' }); + expect(respond.body.message).toMatch(/^GroupAlreadyJoinedError: /); + expect(respond.status).toBe(409); + expect(respond.body.code).toBe(409); + }); + }); + + describe('update group', () => { + it('should update a group', async () => { + const TestGroupId = GroupIds[0]; + const respond = await request(app.getHttpServer()) + .put(`/groups/${TestGroupId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ + name: TestGroupPrefix + '关注幻城谢谢喵', + intro: '湾原审万德', + avatarId: UpdateAvatarId, + }); + expect(respond.body.message).toBe('Group updated successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + }); + it('should return a group with updated info from another user', async () => { + const TestGroupId = GroupIds[0]; + const respond = await request(app.getHttpServer()) + .get(`/groups/${TestGroupId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Group fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + const groupDto = respond.body.data.group; + expect(groupDto.id).toBe(TestGroupId); + expect(groupDto.name).toContain('关注幻城谢谢喵'); + expect(groupDto.intro).toBe('湾原审万德'); + expect(groupDto.avatarId).toBe(UpdateAvatarId); + expect(groupDto.owner).toStrictEqual(TestUserDto); + expect(groupDto.created_at).toBeDefined(); + expect(groupDto.updated_at).toBeDefined(); + expect(groupDto.member_count).toBe(2); + expect(groupDto.question_count).toBe(0); + expect(groupDto.answer_count).toBe(0); + expect(groupDto.is_member).toBe(true); + expect(groupDto.is_owner).toBe(false); + }); + it('should return GroupNotFoundError when group is not found', async () => { + const respond = await request(app.getHttpServer()) + .put('/groups/0') + .set('Authorization', `Bearer ${TestToken}`) + .send({ + name: TestGroupPrefix + '关注幻城谢谢喵', + intro: '湾原审万德', + avatarId: UpdateAvatarId, + }); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + expect(respond.body.message).toMatch(/^GroupNotFoundError: /); + }); + it('should return GroupNameAlreadyUsedError when group name is already used', async () => { + const TestGroupId = GroupIds[0]; + const respond = await request(app.getHttpServer()) + .put(`/groups/${TestGroupId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ + name: TestGroupPrefix + 'ICS膜膜膜', + intro: '湾原审万德', + avatarId: UpdateAvatarId, + }); + expect(respond.body.message).toMatch(/^GroupNameAlreadyUsedError: /); + expect(respond.status).toBe(409); + expect(respond.body.code).toBe(409); + }); + // TODO: add permission control + it('should return CannotDeleteGroupError when user is not the owner', async () => { + const TestGroupId = GroupIds[0]; + const respond = await request(app.getHttpServer()) + .put(`/groups/${TestGroupId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ + name: TestGroupPrefix + '关注幻城谢谢喵', + intro: '湾原审万德', + avatarId: UpdateAvatarId, + }); + expect(respond.body.message).toMatch(/^CannotDeleteGroupError: /); + expect(respond.status).toBe(403); + expect(respond.body.code).toBe(403); + }); + }); + + describe('leave group', () => { + it('should leave a group', async () => { + const TestGroupId = GroupIds[0]; + const respond = await request(app.getHttpServer()) + .delete(`/groups/${TestGroupId}/members`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Quit group successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + }); + it('should return a group with is_member false', async () => { + const TestGroupId = GroupIds[0]; + const respond = await request(app.getHttpServer()) + .get(`/groups/${TestGroupId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Group fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + const groupDto = respond.body.data.group; + expect(groupDto.id).toBe(TestGroupId); + expect(groupDto.name).toContain('关注幻城谢谢喵'); + expect(groupDto.intro).toBe('湾原审万德'); + expect(groupDto.avatarId).toBe(UpdateAvatarId); + expect(groupDto.owner).toStrictEqual(TestUserDto); + expect(groupDto.created_at).toBeDefined(); + expect(groupDto.updated_at).toBeDefined(); + expect(groupDto.member_count).toBe(1); + expect(groupDto.question_count).toBe(0); + expect(groupDto.answer_count).toBe(0); + expect(groupDto.is_member).toBe(false); + expect(groupDto.is_owner).toBe(false); + }); + it('should return GroupNotFoundError when group is not found', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/groups/0/members`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toMatch(/^GroupNotFoundError: /); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }, 10000); + it('should return GroupNotJoinedError when user is not in the group', async () => { + const TestGroupId = GroupIds[0]; + const respond = await request(app.getHttpServer()) + .delete(`/groups/${TestGroupId}/members`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toMatch(/^GroupNotJoinedError: /); + expect(respond.status).toBe(409); + expect(respond.body.code).toBe(409); + }); + // todo: GroupOwnerCannotLeaveError + }); + + describe('delete group', () => { + it('should delete a group', async () => { + const TestGroupId = GroupIds[3]; + const respond = await request(app.getHttpServer()) + .delete(`/groups/${TestGroupId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + }); + it('should return GroupNotFoundError after deletion', async () => { + const TestGroupId = GroupIds[3]; + const respond = await request(app.getHttpServer()) + .get(`/groups/${TestGroupId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/^GroupNotFoundError: /); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + it('should return GroupNotFoundError when group is not found', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/groups/0`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/^GroupNotFoundError: /); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + it('should return CannotDeleteGroupError when user is not the owner', async () => { + const TestGroupId = GroupIds[1]; + const respond = await request(app.getHttpServer()) + .delete(`/groups/${TestGroupId}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toMatch(/^CannotDeleteGroupError: /); + expect(respond.status).toBe(403); + expect(respond.body.code).toBe(403); + }); + }); + + describe('get group members', () => { + it('should get group members', async () => { + const TestGroupId = GroupIds[1]; + const respond = await request(app.getHttpServer()) + .get(`/groups/${TestGroupId}/members`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ page_size: 1 }) + .send(); + expect(respond.body.message).toBe('Group members fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.members.length).toBe(1); + expect(respond.body.data.members[0]).toStrictEqual(TestUserDto); + expect(respond.body.data.page.page_start).toBe(TestUserDto.id); + expect(respond.body.data.page.page_size).toBe(1); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBeFalsy(); + expect(respond.body.data.page.has_more).toBe(true); + expect(respond.body.data.page.next_start).toBe(auxUserDto.id); + }); + it('should get group members from a specific user', async () => { + const TestGroupId = GroupIds[1]; + const respond = await request(app.getHttpServer()) + .get(`/groups/${TestGroupId}/members`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ page_size: 1, page_start: auxUserDto.id }) + .send(); + expect(respond.body.message).toBe('Group members fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.members.length).toBe(1); + expect(respond.body.data.members[0]).toStrictEqual(auxUserDto); + expect(respond.body.data.page.page_start).toBe(auxUserDto.id); + expect(respond.body.data.page.page_size).toBe(1); + expect(respond.body.data.page.has_prev).toBe(true); + expect(respond.body.data.page.prev_start).toBe(TestUserDto.id); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBeFalsy(); + }); + it('should get group members from a specific user even quited', async () => { + const TestGroupId = GroupIds[0]; + const respond = await request(app.getHttpServer()) + .get(`/groups/${TestGroupId}/members`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ page_size: 1, page_start: auxUserDto.id }) + .send(); + expect(respond.body.message).toBe('Group members fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.members.length).toBe(0); + expect(respond.body.data.page.page_start).toBeFalsy(); + expect(respond.body.data.page.page_size).toBe(0); + expect(respond.body.data.page.has_prev).toBe(true); + expect(respond.body.data.page.prev_start).toBe(TestUserDto.id); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBeFalsy(); + }); + it('should get group members for another user', async () => { + const TestGroupId = GroupIds[1]; + const respond = await request(app.getHttpServer()) + .get(`/groups/${TestGroupId}/members`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('Group members fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.members.length).toBe(2); + expect(respond.body.data.members[0]).toStrictEqual(TestUserDto); + expect(respond.body.data.members[1]).toStrictEqual(auxUserDto); + expect(respond.body.data.page.page_start).toBe(TestUserDto.id); + expect(respond.body.data.page.page_size).toBe(2); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBeFalsy(); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBeFalsy(); + }); + it('should return GroupNotFoundError when group is not found', async () => { + const respond = await request(app.getHttpServer()) + .get(`/groups/0/members`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/^GroupNotFoundError: /); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + it('should return empty list when page_size is not positive', async () => { + const TestGroupId = GroupIds[1]; + const respond = await request(app.getHttpServer()) + .get(`/groups/${TestGroupId}/members`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ page_size: -1 }) + .send(); + expect(respond.body.message).toBe('Group members fetched successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.members.length).toBe(0); + expect(respond.body.data.page.page_start).toBeFalsy(); + expect(respond.body.data.page.page_size).toBe(0); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBeFalsy(); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBeFalsy(); + }); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/test/materialbundle.e2e-spec.ts b/test/materialbundle.e2e-spec.ts new file mode 100644 index 00000000..0a50710a --- /dev/null +++ b/test/materialbundle.e2e-spec.ts @@ -0,0 +1,492 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; +jest.mock('../src/email/email.service'); + +describe('MaterialBundle Module', () => { + let app: INestApplication; + const MockedEmailService = >EmailService; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + const unique = Math.floor(Math.random() * 10000000000); + const bundleIds: number[] = []; + let TestToken: string; + let TestUserId: number; + let ImageId: number; + let VideoId: number; + let AudioId: number; + let FileId: number; + let bundleId1: number; + let bundleId2: number; + let auxAccessToken: string; + async function createAuxiliaryUser(): Promise { + // returns [userId, accessToken] + const email = `test-${Math.floor(Math.random() * 10000000000)}@ruc.edu.cn`; + const respond = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ email }); + expect(respond.status).toBe(201); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.at(-1)[1]; + const respond2 = await request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: `TestUser-${Math.floor(Math.random() * 10000000000)}`, + nickname: 'auxiliary_user', + password: 'abc123456!!!', + email, + emailCode: verificationCode, + isLegacyAuth: true, + }); + expect(respond2.status).toBe(201); + return respond2.body.data.accessToken; + } + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }, 20000); + beforeEach(() => { + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.results.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.results.length = 0; + }); + describe('preparation', () => { + it(`should send an email and register a user ${TestUsername}`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith(TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: TestUsername, + nickname: 'test_user', + password: 'abc123456!!!', + email: TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual('Register successfully.'); + expect(respond.body.code).toEqual(201); + req.expect(201); + expect(respond.body.data.accessToken).toBeDefined(); + TestToken = respond.body.data.accessToken; + expect(respond.body.data.user.id).toBeDefined(); + TestUserId = respond.body.data.user.id; + }); + it('upload some materials', async () => { + async function uploadMaterial(type: string, filePath: string) { + const response = await request(app.getHttpServer()) + .post('/materials') + .field('type', type) + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', filePath); + + expect(response.body.code).toBe(200); + expect(response.body.message).toBe('Material upload successfully'); + expect(response.body.data).toHaveProperty('id'); + return response.body.data.id; + } + const promises = [ + uploadMaterial('image', 'src/materials/resources/test.jpg'), + uploadMaterial('video', 'src/materials/resources/test.mp4'), + uploadMaterial('audio', 'src/materials/resources/test.mp3'), + uploadMaterial('file', 'src/materials/resources/test.pdf'), + ]; + [ImageId, VideoId, AudioId, FileId] = await Promise.all(promises); + }); + it('should create an auxiliary user', async () => { + auxAccessToken = await createAuxiliaryUser(); + }); + }); + describe('create some materialbundles', () => { + it('should create some materialbundles', async () => { + async function createMaterialBundle( + materialIds: number[], + title: string, + content: string, + ) { + const respond = await request(app.getHttpServer()) + .post('/material-bundles') + .set('Authorization', `Bearer ${TestToken}`) + .send({ + title, + content, + materials: materialIds, + }); + expect(respond.body.message).toBe( + 'MaterialBundle created successfully', + ); + expect(respond.body.code).toBe(201); + expect(respond.body.data).toHaveProperty('id'); + return respond.body.data.id; + } + bundleId1 = await createMaterialBundle( + [ImageId, VideoId, AudioId], + 'a materialbundle', + 'content about materialbundle', + ); + bundleId2 = await createMaterialBundle( + [ImageId, VideoId, FileId], + 'a materialbundle', + 'content about materialbundle', + ); + for (let i = 0; i < 20; i++) { + const bundleId = await createMaterialBundle( + [], + 'test_for_pagination-' + unique.toString(), + `just_for_test`, + ); + bundleIds.push(bundleId); + } + }); + it('should return MaterialNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .post('/material-bundles') + .set('Authorization', `Bearer ${TestToken}`) + .send({ + title: 'a materialbundle', + content: 'content about materialbundle', + materials: [ImageId, VideoId, FileId, FileId + 30], + }); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + expect(respond.body.message).toMatch(/^MaterialNotFoundError: /); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .post('/material-bundles') + .send({ + title: 'a materialbundle', + content: 'content about materialbundle', + materials: [ImageId, VideoId, FileId], + }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + describe('get materialbundles', () => { + it('should get all of the materialbundles', async () => { + const respond = await request(app.getHttpServer()) + .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + q: '', + sort: '', + }); + expect(respond.body.message).toBe('get material bundles successfully'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.materials.length).toEqual(20); + expect(respond.body.data.materials[0].id).toEqual(1); + expect(respond.body.data.page.page_size).toBe(20); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(true); + //expect(respond.body.data.page.next_start).toBe(); + }); + it('should get the materialbundles with keyword without size and start', async () => { + const respond = await request(app.getHttpServer()) + .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + q: unique.toString(), + sort: '', + }); + expect(respond.body.message).toBe('get material bundles successfully'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.materials.length).toEqual(20); + respond.body.data.materials + .slice(0, 20) + .map((material: { id: number }, index: number) => { + expect(material.id).toEqual(bundleIds[index]); + }); + expect(respond.body.data.page.page_size).toBe(20); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + }); + it('should get the materialbundles with keyword and size without start', async () => { + const respond = await request(app.getHttpServer()) + .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + q: unique.toString(), + page_size: 10, + sort: '', + }); + expect(respond.body.message).toBe('get material bundles successfully'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.materials.length).toEqual(10); + respond.body.data.materials + .slice(0, 10) + .map((material: { id: number }, index: number) => { + expect(material.id).toEqual(bundleIds[index]); + }); + expect(respond.body.data.page.page_size).toBe(10); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(true); + expect(respond.body.data.page.next_start).toBe(bundleIds[10]); + }); + it('should get the materialbundles with keyword,size and start', async () => { + const respond = await request(app.getHttpServer()) + .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + q: unique.toString(), + page_size: 10, + page_start: bundleIds[4], + sort: '', + }); + expect(respond.body.message).toBe('get material bundles successfully'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.materials.length).toEqual(10); + respond.body.data.materials + .slice(0, 10) + .map((material: { id: number }, index: number) => { + expect(material.id).toEqual(bundleIds[index + 4]); + }); + expect(respond.body.data.page.page_size).toBe(10); + expect(respond.body.data.page.has_prev).toBe(true); + expect(respond.body.data.page.prev_start).toBe(bundleIds[3]); + expect(respond.body.data.page.has_more).toBe(true); + expect(respond.body.data.page.next_start).toBe(bundleIds[14]); + }); + it('should get the materialbundles with keyword,size and search syntax string as start', async () => { + const respond = await request(app.getHttpServer()) + .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + q: `title:${unique.toString()} id:>=${bundleIds[4]}`, + page_size: 10, + sort: '', + }); + expect(respond.body.message).toBe('get material bundles successfully'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.materials.length).toEqual(10); + respond.body.data.materials + .slice(0, 10) + .map((material: { id: number }, index: number) => { + expect(material.id).toEqual(bundleIds[index + 4]); + }); + expect(respond.body.data.page.page_size).toBe(10); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(true); + expect(respond.body.data.page.next_start).toBe(bundleIds[14]); + }); + it('should get the materialbundles with keyword,size,start and sort', async () => { + const respond = await request(app.getHttpServer()) + .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + q: unique.toString(), + page_size: 10, + page_start: bundleIds[14], + sort: 'newest', + }); + expect(respond.body.message).toBe('get material bundles successfully'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.materials.length).toEqual(10); + respond.body.data.materials + .slice(0, 10) + .map((material: { id: number }, index: number) => { + expect(material.id).toEqual(bundleIds[14 - index]); + }); + expect(respond.body.data.page.page_size).toBe(10); + expect(respond.body.data.page.has_prev).toBe(true); + expect(respond.body.data.page.prev_start).toBe(bundleIds[15]); + expect(respond.body.data.page.has_more).toBe(true); + expect(respond.body.data.page.next_start).toBe(bundleIds[4]); + }); + it('should return KeywordTooLongError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/material-bundles`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + q: 'yui'.repeat(100), + page_size: 10, + page_start: bundleIds[14], + sort: 'newest', + }); + expect(respond.status).toBe(400); + expect(respond.body.code).toBe(400); + expect(respond.body.message).toMatch(/^KeywordTooLongError: /); + }); + it('should get the materialbundle detail', async () => { + const respond = await request(app.getHttpServer()) + .get(`/material-bundles/${bundleId1}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + expect(respond.body.message).toBe( + 'get material bundle detail successfully', + ); + expect(respond.body.data.materialBundle.title).toBe('a materialbundle'); + expect(respond.body.data.materialBundle.content).toBe( + 'content about materialbundle', + ); + expect(respond.body.data.materialBundle.creator.id).toEqual(TestUserId); + expect(respond.body.data.materialBundle.materials.length).toEqual(3); + for (const material of respond.body.data.materialBundle.materials) { + expect([ImageId, VideoId, AudioId]).toContain(material.id); + } + + // to do + }); + it('should return BundleNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/material-bundles/${bundleId1 + 30}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + expect(respond.body.message).toMatch(/^BundleNotFoundError: /); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/material-bundles/${bundleId1}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + describe('update materialbundle', () => { + it('should update the materialbundle', async () => { + const respond1 = await request(app.getHttpServer()) + .patch(`/material-bundles/${bundleId2}`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ + title: 'new title', + content: 'new content', + materials: [ImageId, VideoId, AudioId], // add new & remove old + }); + expect(respond1.status).toBe(200); + expect(respond1.body.message).toBe('Materialbundle updated successfully'); + const respond2 = await request(app.getHttpServer()) + .get(`/material-bundles/${bundleId2}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond2.body.data.materialBundle.title).toBe('new title'); + expect(respond2.body.data.materialBundle.content).toBe('new content'); + expect(respond2.body.data.materialBundle.creator.id).toEqual(TestUserId); + expect(respond2.body.data.materialBundle.materials.length).toEqual(3); + for (const material of respond2.body.data.materialBundle.materials) { + expect([ImageId, VideoId, AudioId]).toContain(material.id); + } + }); + it('should return BundleNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .patch(`/material-bundles/${bundleId1 + 30}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + expect(respond.body.message).toMatch(/^BundleNotFoundError: /); + }); + it('should return UpdateBundleDeniedErro', async () => { + const respond = await request(app.getHttpServer()) + .patch(`/material-bundles/${bundleId1}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.status).toBe(403); + expect(respond.body.code).toBe(403); + expect(respond.body.message).toMatch(/^UpdateBundleDeniedError: /); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .patch(`/material-bundles/${bundleId1}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + describe('delete materialbundle', () => { + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/material-bundles/${bundleId2}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + it('should return BundleNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/material-bundles/${bundleId1 + 30}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + expect(respond.body.message).toMatch(/^BundleNotFoundError: /); + }); + it('should return DeleteBundleDeniedError', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/material-bundles/${bundleId1}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toMatch(/^DeleteBundleDeniedError: /); + expect(respond.body.code).toBe(403); + }); + it('should delete a materialbundle', async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const respond1 = await request(app.getHttpServer()) + .delete(`/material-bundles/${bundleId2}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + //expect(respond.status).toBe(200); + const respond2 = await request(app.getHttpServer()) + .get(`/material-bundles/${bundleId2}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond2.body.message).toMatch(/^BundleNotFoundError: /); + expect(respond2.body.code).toBe(404); + }); + }); + afterAll(async () => { + await app.close(); + }); +}); diff --git a/test/materials.e2e-spec.ts b/test/materials.e2e-spec.ts new file mode 100644 index 00000000..450ec396 --- /dev/null +++ b/test/materials.e2e-spec.ts @@ -0,0 +1,236 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; +jest.mock('../src/email/email.service'); + +describe('Material Module', () => { + let app: INestApplication; + const MockedEmailService = >EmailService; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + + let TestToken: string; + let ImageId: number; + let VideoId: number; + let AudioId: number; + let FileId: number; + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }, 20000); + beforeEach(() => { + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.results.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.results.length = 0; + }); + describe('preparation', () => { + it(`should send an email and register a user ${TestUsername}`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith(TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: TestUsername, + nickname: 'test_user', + password: 'abc123456!!!', + email: TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual('Register successfully.'); + expect(respond.body.code).toEqual(201); + req.expect(201); + expect(respond.body.data.accessToken).toBeDefined(); + TestToken = respond.body.data.accessToken; + expect(respond.body.data.user.id).toBeDefined(); + }); + }); + describe('upload materials', () => { + it('should upload an image', async () => { + const respond = await request(app.getHttpServer()) + .post('/materials') + .field('type', 'image') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.jpg'); + expect(respond.body.message).toBe('Material upload successfully'); + expect(respond.body.code).toBe(200); + expect(respond.body.data).toHaveProperty('id'); + ImageId = respond.body.data.id; + }); + it('should upload a video ', async () => { + const respond = await request(app.getHttpServer()) + .post('/materials') + .field('type', 'video') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.mp4'); + expect(respond.body.code).toBe(200); + expect(respond.body.message).toBe('Material upload successfully'); + expect(respond.body.data).toHaveProperty('id'); + VideoId = respond.body.data.id; + }); + it('should upload an audio ', async () => { + const respond = await request(app.getHttpServer()) + .post('/materials') + .field('type', 'audio') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.mp3'); + expect(respond.body.code).toBe(200); + expect(respond.body.message).toBe('Material upload successfully'); + expect(respond.body.data).toHaveProperty('id'); + AudioId = respond.body.data.id; + }); + it('should upload a file ', async () => { + const respond = await request(app.getHttpServer()) + .post('/materials') + .field('type', 'file') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.pdf'); + expect(respond.body.code).toBe(200); + expect(respond.body.message).toBe('Material upload successfully'); + expect(respond.body.data).toHaveProperty('id'); + FileId = respond.body.data.id; + }); + it('should return InvalidMaterialTypeError', async () => { + const respond = await request(app.getHttpServer()) + .post('/materials') + .field('type', 'yuiii') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.pdf'); + expect(respond.body.code).toBe(400); + expect(respond.body.message).toMatch(/InvalidMaterialTypeError: /); + }); + it('should return MimeTypeNotMatchError', async () => { + const respond = await request(app.getHttpServer()) + .post('/materials') + .field('type', 'image') + .set('Authorization', `Bearer ${TestToken}`) + .attach('file', 'src/materials/resources/test.pdf'); + expect(respond.status).toBe(422); + expect(respond.body.code).toBe(422); + expect(respond.body.message).toMatch(/MimeTypeNotMatchError: /); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .post('/materials') + .field('type', 'image') + .attach('file', 'src/materials/resources/test.jpg'); + expect(respond.status).toBe(401); + expect(respond.body.code).toBe(401); + expect(respond.body.message).toMatch(/AuthenticationRequiredError: /); + }); + }); + describe('get material', () => { + it('should get the uploaded image detail', async () => { + const respond = await request(app.getHttpServer()) + .get(`/materials/${ImageId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + expect(respond.body.data.material.meta.height).toEqual(200); + expect(respond.body.data.material.meta.width).toEqual(200); + expect(respond.body.data.material.meta.hash).toBe( + 'f50ed1d47f88ddd0934f088fb63262fd', + ); + expect(respond.body.data.material.meta.size).toEqual(53102); + }); + it('should get the uploaded video detail', async () => { + const respond = await request(app.getHttpServer()) + .get(`/materials/${VideoId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + expect(respond.body.data.material.meta.height).toEqual(1080); + expect(respond.body.data.material.meta.width).toEqual(2160); + expect(respond.body.data.material.meta.size).toEqual(240563); + expect(respond.body.data.material.meta.hash).toBe( + '7333193845d631941208e2e546ff57af', + ); + expect(respond.body.data.material.meta.duration).toBeCloseTo(3.1, 0.15); + expect(respond.body.data.material.meta.thumbnail).toMatch( + /static\/images\/.*\.jpg/, + ); + }); + it('should get the uploaded audio detail', async () => { + const respond = await request(app.getHttpServer()) + .get(`/materials/${AudioId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + expect(respond.body.data.material.meta.size).toEqual(70699); + expect(respond.body.data.material.meta.duration).toEqual(3); + expect(respond.body.data.material.meta.hash).toBe( + 'f785204fc974ae48fe818ac9052ccf0b', + ); + }); + + it('should get the uploaded file detail', async () => { + const respond = await request(app.getHttpServer()) + .get(`/materials/${FileId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + expect(respond.body.data.material.meta.mime).toBe('application/pdf'); + expect(respond.body.data.material.meta.hash).toBe( + '748cafd9b83123300f712375bba68ec3', + ); + expect(respond.body.data.material.meta.size).toEqual(50146); + }); + it('should return MaterialNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/materials/${FileId + 20}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + expect(respond.body.message).toMatch(/MaterialNotFoundError: /); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/materials/${FileId}`) + .send(); + expect(respond.status).toBe(401); + expect(respond.body.code).toBe(401); + expect(respond.body.message).toMatch(/AuthenticationRequiredError: /); + }); + }); + afterAll(async () => { + await app.close(); + }); +}); diff --git a/test/question.e2e-spec.ts b/test/question.e2e-spec.ts new file mode 100644 index 00000000..a833f71d --- /dev/null +++ b/test/question.e2e-spec.ts @@ -0,0 +1,1569 @@ +/* + * Description: This file tests the questions module. + * + * Author(s): + * Nictheboy Li + * Andy Lee + * HuanCheng65 + * + */ + +import { INestApplication, Logger } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; +jest.mock('../src/email/email.service'); + +describe('Questions Module', () => { + let app: INestApplication; + const MockedEmailService = >EmailService; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + const TestTopicCode = Math.floor(Math.random() * 10000000000).toString(); + const TestTopicPrefix = `[Test(${TestTopicCode}) Question]`; + const TestQuestionCode = Math.floor(Math.random() * 10000000000).toString(); + const TestQuestionPrefix = `[Test(${TestQuestionCode}) Question]`; + let TestToken: string; + let TestUserId: number; + const TopicIds: number[] = []; + const questionIds: number[] = []; + const answerIds: number[] = []; + const invitationIds: number[] = []; + let auxUserId: number; + let auxAccessToken: string; + + async function createAuxiliaryUser(): Promise<[number, string]> { + // returns [userId, accessToken] + const email = `test-${Math.floor(Math.random() * 10000000000)}@ruc.edu.cn`; + const respond = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ email }); + expect(respond.status).toBe(201); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.at(-1)[1]; + const respond2 = await request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: `TestUser-${Math.floor(Math.random() * 10000000000)}`, + nickname: 'auxiliary_user', + password: 'abc123456!!!', + email, + emailCode: verificationCode, + isLegacyAuth: true, + }); + expect(respond2.status).toBe(201); + return [respond2.body.data.user.id, respond2.body.data.accessToken]; + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }, 20000); + + beforeEach(() => { + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.results.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.results.length = 0; + }); + + describe('preparation', () => { + it(`should send an email and register a user ${TestUsername}`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith(TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: TestUsername, + nickname: 'test_user', + password: 'abc123456!!!', + email: TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual('Register successfully.'); + expect(respond.body.code).toEqual(201); + req.expect(201); + expect(respond.body.data.accessToken).toBeDefined(); + TestToken = respond.body.data.accessToken; + expect(respond.body.data.user.id).toBeDefined(); + TestUserId = respond.body.data.user.id; + }); + it('should create some topics', async () => { + async function createTopic(name: string) { + const respond = await request(app.getHttpServer()) + .post('/topics') + .set('authorization', 'Bearer ' + TestToken) + .send({ + name: `${TestTopicPrefix} ${name}`, + }); + expect(respond.body.message).toBe('OK'); + expect(respond.status).toBe(201); + expect(respond.body.code).toBe(201); + TopicIds.push(respond.body.data.id); + } + await createTopic('数学'); + await createTopic('哥德巴赫猜想'); + await createTopic('钓鱼'); + }, 60000); + it('should create an auxiliary user', async () => { + [auxUserId, auxAccessToken] = await createAuxiliaryUser(); + }); + }); + + describe('create question', () => { + it('should create some questions', async () => { + async function createQuestion(title: string, content: string) { + const respond = await request(app.getHttpServer()) + .post('/questions') + .set('Authorization', `Bearer ${TestToken}`) + .send({ + title: `${TestQuestionPrefix} ${title}`, + content, + type: 0, + topics: [TopicIds[0], TopicIds[1]], + }); + expect(respond.body.message).toBe('Created'); + expect(respond.body.code).toBe(201); + expect(respond.status).toBe(201); + expect(respond.body.data.id).toBeDefined(); + questionIds.push(respond.body.data.id); + } + await createQuestion( + '我这个哥德巴赫猜想的证明对吗?', + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', + ); + await createQuestion('这学期几号放假啊?', '如题'); + await createQuestion( + '好难受啊', + '我这学期选了五十学分,每天都要早八,而且还有好多作业要写,好难受啊。安慰安慰我吧。', + ); + await createQuestion('Question title with emoji: 😂😂', 'content'); + await createQuestion('title', 'Question content with emoji: 😂😂'); + await createQuestion('long question', '啊'.repeat(30000)); + }, 60000); + it('should return updated statistic info when getting user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}`) + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.data.user.question_count).toBe(6); + }); + it('should return updated statistic info when getting user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}`) + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.data.user.question_count).toBe(6); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .post('/questions') + .send({ + title: `${TestQuestionPrefix} 我这个哥德巴赫猜想的证明对吗?`, + content: + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', + type: 0, + topics: [TopicIds[0], TopicIds[1]], + }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + it('should return TopicNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .post('/questions') + .set('Authorization', `Bearer ${TestToken}`) + .send({ + title: `${TestQuestionPrefix} 我这个哥德巴赫猜想的证明对吗?`, + content: + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', + type: 0, + topics: [-1], + }); + expect(respond.body.message).toMatch(/^TopicNotFoundError: /); + expect(respond.body.code).toBe(404); + }); + }); + + describe('get question', () => { + it('should get a question', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.question.id).toBe(questionIds[0]); + expect(respond.body.data.question.title).toContain(TestQuestionPrefix); + expect(respond.body.data.question.content).toBe( + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', + ); + expect(respond.body.data.question.author.id).toBe(TestUserId); + expect(respond.body.data.question.author.username).toBe(TestUsername); + expect(respond.body.data.question.author.nickname).toBe('test_user'); + expect(respond.body.data.question.type).toBe(0); + expect(respond.body.data.question.topics.length).toBe(2); + expect(respond.body.data.question.topics[0].name).toContain( + TestTopicPrefix, + ); + expect(respond.body.data.question.topics[1].name).toContain( + TestTopicPrefix, + ); + expect(respond.body.data.question.created_at).toBeDefined(); + expect(respond.body.data.question.updated_at).toBeDefined(); + expect(respond.body.data.question.attitudes.positive_count).toBe(0); + expect(respond.body.data.question.attitudes.negative_count).toBe(0); + expect(respond.body.data.question.attitudes.difference).toBe(0); + expect(respond.body.data.question.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + expect(respond.body.data.question.is_follow).toBe(false); + expect(respond.body.data.question.answer_count).toBe(0); + expect(respond.body.data.question.comment_count).toBe(0); + expect(respond.body.data.question.follow_count).toBe(0); + expect(respond.body.data.question.view_count).toBe(0); + expect(respond.body.data.question.group).toBe(null); + }, 20000); + // it('should get a question without token', async () => { + // const respond = await request(app.getHttpServer()) + // .get(`/questions/${questionIds[0]}`) + // .send(); + // expect(respond.body.message).toBe('OK'); + // expect(respond.body.code).toBe(200); + // expect(respond.status).toBe(200); + // expect(respond.body.data.question.id).toBe(questionIds[0]); + // expect(respond.body.data.question.title).toContain(TestQuestionPrefix); + // expect(respond.body.data.question.content).toBe( + // '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', + // ); + // expect(respond.body.data.question.author.id).toBe(TestUserId); + // expect(respond.body.data.question.author.username).toBe(TestUsername); + // expect(respond.body.data.question.author.nickname).toBe('test_user'); + // expect(respond.body.data.question.type).toBe(0); + // expect(respond.body.data.question.topics.length).toBe(2); + // expect(respond.body.data.question.topics[0].name).toContain( + // TestTopicPrefix, + // ); + // expect(respond.body.data.question.topics[1].name).toContain( + // TestTopicPrefix, + // ); + // expect(respond.body.data.question.created_at).toBeDefined(); + // expect(respond.body.data.question.updated_at).toBeDefined(); + // expect(respond.body.data.question.attitudes.positive_count).toBe(0); + // expect(respond.body.data.question.attitudes.negative_count).toBe(0); + // expect(respond.body.data.question.attitudes.difference).toBe(0); + // expect(respond.body.data.question.attitudes.user_attitude).toBe( + // 'UNDEFINED', + // ); + // expect(respond.body.data.question.is_follow).toBe(false); + // expect(respond.body.data.question.answer_count).toBe(0); + // expect(respond.body.data.question.view_count).toBe(1); + // expect(respond.body.data.question.follow_count).toBe(0); + // expect(respond.body.data.question.comment_count).toBe(0); + // expect(respond.body.data.question.group).toBe(null); + // }, 20000); + it('should return QuestionNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .get('/questions/-1') + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/^QuestionNotFoundError: /); + expect(respond.body.code).toBe(404); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[0]}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + + describe('get questions asked by user', () => { + it('should return UserIdNotFoundError', async () => { + const noneExistUserId = -1; + const respond = await request(app.getHttpServer()) + .get(`/users/${noneExistUserId}/questions`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/^UserIdNotFoundError: /); + expect(respond.body.code).toBe(404); + expect(respond.statusCode).toBe(404); + }); + it('should get all the questions asked by the user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}/questions`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('Query asked questions successfully.'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(questionIds.length); + expect(respond.body.data.page.page_start).toBe(questionIds[0]); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + expect(respond.body.data.questions.length).toBe(questionIds.length); + expect(respond.body.data.questions[0].id).toBe(questionIds[0]); + expect(respond.body.data.questions[0].title).toBe( + `${TestQuestionPrefix} 我这个哥德巴赫猜想的证明对吗?`, + ); + expect(respond.body.data.questions[0].content).toBe( + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', + ); + expect(respond.body.data.questions[0].author.id).toBe(TestUserId); + for (let i = 0; i < questionIds.length; i++) { + expect(respond.body.data.questions[i].id).toBe(questionIds[i]); + } + }); + it('should get all the questions asked by the user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}/questions`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + page_start: questionIds[1], + page_size: 1000, + }) + .send(); + expect(respond.body.message).toBe('Query asked questions successfully.'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(questionIds.length - 1); + expect(respond.body.data.page.page_start).toBe(questionIds[1]); + expect(respond.body.data.page.has_prev).toBe(true); + expect(respond.body.data.page.prev_start).toBe(questionIds[0]); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + expect(respond.body.data.questions.length).toBe(questionIds.length - 1); + for (let i = 1; i < questionIds.length; i++) { + expect(respond.body.data.questions[i - 1].id).toBe(questionIds[i]); + } + }); + it('should get paged questions asked by the user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}/questions`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + page_start: questionIds[0], + page_size: 2, + }) + .send(); + expect(respond.body.message).toBe('Query asked questions successfully.'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(2); + expect(respond.body.data.page.page_start).toBe(questionIds[0]); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(true); + expect(respond.body.data.page.next_start).toBe(questionIds[2]); + expect(respond.body.data.questions.length).toBe(2); + expect(respond.body.data.questions[0].id).toBe(questionIds[0]); + expect(respond.body.data.questions[0].title).toBe( + `${TestQuestionPrefix} 我这个哥德巴赫猜想的证明对吗?`, + ); + expect(respond.body.data.questions[0].content).toBe( + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。', + ); + expect(respond.body.data.questions[0].author.id).toBe(TestUserId); + expect(respond.body.data.questions[1].id).toBe(questionIds[1]); + }); + it('should get paged questions asked by the user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}/questions`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + page_start: questionIds[2], + page_size: 2, + }) + .send(); + expect(respond.body.message).toBe('Query asked questions successfully.'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(2); + expect(respond.body.data.page.page_start).toBe(questionIds[2]); + expect(respond.body.data.page.has_prev).toBe(true); + expect(respond.body.data.page.prev_start).toBe(questionIds[0]); + expect(respond.body.data.page.has_more).toBe(true); + expect(respond.body.data.page.next_start).toBe(questionIds[4]); + expect(respond.body.data.questions.length).toBe(2); + expect(respond.body.data.questions[0].id).toBe(questionIds[2]); + expect(respond.body.data.questions[1].id).toBe(questionIds[3]); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}/questions`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + + describe('search question', () => { + it('should wait some time for elasticsearch to refresh', async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + }); + it('should return empty page without parameters', async () => { + const respond = await request(app.getHttpServer()) + .get('/questions') + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.questions.length).toBe(0); + expect(respond.body.data.page.page_size).toBe(0); + expect(respond.body.data.page.page_start).toBe(0); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + }); + it('should return empty page without page_size and page_start', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions?q=${TestQuestionCode}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.questions.length).toBe( + respond.body.data.page.page_size, + ); + expect(questionIds).toContain(respond.body.data.page.page_start); + expect(respond.body.data.page.page_size).toBeGreaterThanOrEqual(6); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + }); + it('should search successfully with page_size, with or without page_start', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions?q=${TestQuestionCode}&page_size=1`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.questions.length).toBe(1); + expect(questionIds).toContain(respond.body.data.page.page_start); + expect(respond.body.data.page.page_size).toBe(1); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(true); + const next = respond.body.data.page.next_start; + const respond2 = await request(app.getHttpServer()) + .get(`/questions?q=${TestQuestionCode}&page_size=1&page_start=${next}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond2.body.message).toBe('OK'); + expect(respond2.body.code).toBe(200); + expect(respond2.status).toBe(200); + expect(respond2.body.data.questions.length).toBe(1); + expect(respond2.body.data.page.page_start).toBe(next); + expect(respond2.body.data.page.page_size).toBe(1); + expect(respond2.body.data.page.has_prev).toBe(true); + expect(respond2.body.data.page.prev_start).toBe( + respond.body.data.page.page_start, + ); + expect(respond2.body.data.page.has_more).toBe(true); + }); + it('should return QuestionNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions?q=${TestQuestionPrefix}&page_size=5&page_start=-1`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/^QuestionNotFoundError: /); + expect(respond.body.code).toBe(404); + expect(respond.status).toBe(404); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions?q=${TestQuestionCode}&page_size=1`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + + describe('update question', () => { + it('should update a question', async () => { + const respond = await request(app.getHttpServer()) + .put(`/questions/${questionIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ + title: `${TestQuestionPrefix} 我这个哥德巴赫猜想的证明对吗?(flag)`, + content: + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。(flag)', + type: 1, + topics: [TopicIds[2]], + }); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + const respond2 = await request(app.getHttpServer()) + .get(`/questions/${questionIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond2.body.message).toBe('OK'); + expect(respond2.body.code).toBe(200); + expect(respond2.status).toBe(200); + expect(respond2.body.data.question.id).toBe(questionIds[0]); + expect(respond2.body.data.question.title).toContain('flag'); + expect(respond2.body.data.question.content).toContain('flag'); + expect(respond2.body.data.question.type).toBe(1); + expect(respond2.body.data.question.topics.length).toBe(1); + expect(respond2.body.data.question.topics[0].id).toBe(TopicIds[2]); + expect(respond2.body.data.question.topics[0].name).toBe( + `${TestTopicPrefix} 钓鱼`, + ); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .put(`/questions/${questionIds[0]}`) + .send({ + title: `${TestQuestionPrefix} 我这个哥德巴赫猜想的证明对吗?(flag)`, + content: + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。(flag)', + type: 1, + topics: [TopicIds[2]], + }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + it('should return QuestionNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .put('/questions/-1') + .set('Authorization', `Bearer ${TestToken}`) + .send({ + title: `${TestQuestionPrefix} 我这个哥德巴赫猜想的证明对吗?(flag)`, + content: + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。(flag)', + type: 1, + topics: [TopicIds[2]], + }); + expect(respond.body.message).toMatch(/^QuestionNotFoundError: /); + expect(respond.body.code).toBe(404); + }); + it('should return PermissionDeniedError', async () => { + Logger.log(`accessToken: ${auxAccessToken}`); + const respond = await request(app.getHttpServer()) + .put(`/questions/${questionIds[0]}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ + title: `${TestQuestionPrefix} 我这个哥德巴赫猜想的证明对吗?(flag)`, + content: + '哥德巴赫猜想又名1+1=2,而显然1+1=2是成立的,所以哥德巴赫猜想是成立的。(flag)', + type: 1, + topics: [TopicIds[2]], + }); + expect(respond.body.message).toMatch(/^PermissionDeniedError: /); + expect(respond.body.code).toBe(403); + }); + }); + + describe('delete question', () => { + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionIds[0]}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + it('should return QuestionNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .delete('/questions/-1') + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/^QuestionNotFoundError: /); + expect(respond.body.code).toBe(404); + }); + it('should return PermissionDeniedError', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionIds[0]}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toMatch(/^PermissionDeniedError: /); + expect(respond.body.code).toBe(403); + }); + it('should delete a question', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.status).toBe(200); + const respond2 = await request(app.getHttpServer()) + .get(`/questions/${questionIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond2.body.message).toMatch(/^QuestionNotFoundError: /); + expect(respond2.body.code).toBe(404); + }); + }); + + describe('follow logic', () => { + it('should return QuestionNotFollowedYetError', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionIds[1]}/followers`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/^QuestionNotFollowedYetError: /); + expect(respond.body.code).toBe(400); + }); + it('should follow questions', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/followers`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(201); + expect(respond.status).toBe(201); + expect(respond.body.data.follow_count).toBe(1); + const respond2 = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/followers`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond2.body.message).toBe('OK'); + expect(respond2.body.code).toBe(201); + expect(respond2.status).toBe(201); + expect(respond2.body.data.follow_count).toBe(2); + const respond3 = await request(app.getHttpServer()) + .post(`/questions/${questionIds[2]}/followers`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond3.body.message).toBe('OK'); + expect(respond3.body.code).toBe(201); + expect(respond3.status).toBe(201); + expect(respond3.body.data.follow_count).toBe(1); + const respond4 = await request(app.getHttpServer()) + .post(`/questions/${questionIds[3]}/followers`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond4.body.message).toBe('OK'); + expect(respond4.body.code).toBe(201); + expect(respond4.status).toBe(201); + expect(respond4.body.data.follow_count).toBe(1); + const respond5 = await request(app.getHttpServer()) + .post(`/questions/${questionIds[4]}/followers`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond5.body.message).toBe('OK'); + expect(respond5.body.code).toBe(201); + expect(respond5.status).toBe(201); + expect(respond5.body.data.follow_count).toBe(1); + }); + it('should get followed questions', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}/follow/questions`) + .set('authorization', 'Bearer ' + TestToken) + .send(); + expect(respond.body.message).toBe( + 'Query followed questions successfully.', + ); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(1); + expect(respond.body.data.page.page_start).toBe(questionIds[1]); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + expect(respond.body.data.questions.length).toBe(1); + expect(respond.body.data.questions[0].id).toBe(questionIds[1]); + }); + it('should get followed questions', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${auxUserId}/follow/questions`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe( + 'Query followed questions successfully.', + ); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(4); + expect(respond.body.data.page.page_start).toBe(questionIds[1]); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + expect(respond.body.data.questions.length).toBe(4); + expect(respond.body.data.questions[0].id).toBe(questionIds[1]); + expect(respond.body.data.questions[1].id).toBe(questionIds[2]); + expect(respond.body.data.questions[2].id).toBe(questionIds[3]); + expect(respond.body.data.questions[3].id).toBe(questionIds[4]); + }); + it('should get followed questions', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${auxUserId}/follow/questions`) + .query({ + page_start: questionIds[2], + page_size: 1000, + }) + .set('authorization', 'Bearer ' + TestToken) + .send(); + expect(respond.body.message).toBe( + 'Query followed questions successfully.', + ); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(3); + expect(respond.body.data.page.page_start).toBe(questionIds[2]); + expect(respond.body.data.page.has_prev).toBe(true); + expect(respond.body.data.page.prev_start).toBe(questionIds[1]); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + expect(respond.body.data.questions.length).toBe(3); + expect(respond.body.data.questions[0].id).toBe(questionIds[2]); + expect(respond.body.data.questions[1].id).toBe(questionIds[3]); + expect(respond.body.data.questions[2].id).toBe(questionIds[4]); + }); + it('should get followed questions', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${auxUserId}/follow/questions`) + .query({ + page_start: questionIds[2], + page_size: 1, + }) + .set('authorization', 'Bearer ' + TestToken) + .send(); + expect(respond.body.message).toBe( + 'Query followed questions successfully.', + ); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(1); + expect(respond.body.data.page.page_start).toBe(questionIds[2]); + expect(respond.body.data.page.has_prev).toBe(true); + expect(respond.body.data.page.prev_start).toBe(questionIds[1]); + expect(respond.body.data.page.has_more).toBe(true); + expect(respond.body.data.page.next_start).toBe(questionIds[3]); + expect(respond.body.data.questions.length).toBe(1); + expect(respond.body.data.questions[0].id).toBe(questionIds[2]); + }); + it('should return QuestionNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[0]}/followers`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/^QuestionNotFoundError: /); + expect(respond.body.code).toBe(404); + expect(respond.status).toBe(404); + }); + it('should return QuestionAlreadyFollowedError', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/followers`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/^QuestionAlreadyFollowedError: /); + expect(respond.body.code).toBe(400); + }); + it('should get follower list', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/followers`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.users.length).toBe(2); + expect(respond.body.data.page.page_size).toBe(2); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + + const respond2 = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/followers?page_size=1`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond2.body.message).toBe('OK'); + expect(respond2.body.code).toBe(200); + expect(respond2.status).toBe(200); + expect(respond2.body.data.users.length).toBe(1); + expect(respond2.body.data.users[0].id).toBe(TestUserId); + expect(respond2.body.data.users[0].username).toBe(TestUsername); + expect(respond2.body.data.users[0].nickname).toBe('test_user'); + expect(respond2.body.data.page.page_size).toBe(1); + expect(respond2.body.data.page.has_prev).toBe(false); + expect(respond2.body.data.page.prev_start).toBe(0); + expect(respond2.body.data.page.has_more).toBe(true); + expect(respond2.body.data.page.next_start).toBe(auxUserId); + + const respond3 = await request(app.getHttpServer()) + .get( + `/questions/${questionIds[1]}/followers?page_size=1&page_start=${TestUserId}`, + ) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond3.body).toStrictEqual(respond2.body); + + const respond4 = await request(app.getHttpServer()) + .get( + `/questions/${questionIds[1]}/followers?page_size=1&page_start=${auxUserId}`, + ) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond4.body.message).toBe('OK'); + expect(respond4.body.code).toBe(200); + expect(respond4.status).toBe(200); + expect(respond4.body.data.users.length).toBe(1); + expect(respond4.body.data.users[0].id).toBe(auxUserId); + expect(respond4.body.data.page.page_size).toBe(1); + expect(respond4.body.data.page.has_prev).toBe(true); + expect(respond4.body.data.page.prev_start).toBe(TestUserId); + expect(respond4.body.data.page.has_more).toBe(false); + expect(respond4.body.data.page.next_start).toBe(0); + }); + it('should unfollow questions', async () => { + async function unfollow(questionId: number) { + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionId}/followers`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.follow_count).toBe(1); + } + await unfollow(questionIds[1]); + }); + it('should return QuestionNotFollowedYetError', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionIds[1]}/followers`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch(/^QuestionNotFollowedYetError: /); + expect(respond.body.code).toBe(400); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionIds[1]}/followers`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + + describe('attitude', () => { + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/attitudes`) + .send({ + attitude_type: 'POSITIVE', + }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + it('should pose positive attitude successfully', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/attitudes`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ + attitude_type: 'POSITIVE', + }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the question', + ); + expect(respond.statusCode).toBe(201); + expect(respond.body.code).toBe(201); + expect(respond.body.data.attitudes.positive_count).toBe(1); + expect(respond.body.data.attitudes.negative_count).toBe(0); + expect(respond.body.data.attitudes.difference).toBe(1); + expect(respond.body.data.attitudes.user_attitude).toBe('POSITIVE'); + }); + it('should pose negative attitude successfully', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/attitudes`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ + attitude_type: 'NEGATIVE', + }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the question', + ); + expect(respond.statusCode).toBe(201); + expect(respond.body.code).toBe(201); + expect(respond.body.data.attitudes.positive_count).toBe(1); + expect(respond.body.data.attitudes.negative_count).toBe(1); + expect(respond.body.data.attitudes.difference).toBe(0); + expect(respond.body.data.attitudes.user_attitude).toBe('NEGATIVE'); + }); + it('should get modified question attitude statistic', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.question.attitudes.positive_count).toBe(1); + expect(respond.body.data.question.attitudes.negative_count).toBe(1); + expect(respond.body.data.question.attitudes.difference).toBe(0); + expect(respond.body.data.question.attitudes.user_attitude).toBe( + 'POSITIVE', + ); + }); + it('should get modified question attitude statistic', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.question.attitudes.positive_count).toBe(1); + expect(respond.body.data.question.attitudes.negative_count).toBe(1); + expect(respond.body.data.question.attitudes.difference).toBe(0); + expect(respond.body.data.question.attitudes.user_attitude).toBe( + 'POSITIVE', + ); + }); + it('should get modified question attitude statistic', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.question.attitudes.positive_count).toBe(1); + expect(respond.body.data.question.attitudes.negative_count).toBe(1); + expect(respond.body.data.question.attitudes.difference).toBe(0); + expect(respond.body.data.question.attitudes.user_attitude).toBe( + 'NEGATIVE', + ); + }); + it('should pose undefined attitude successfully', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/attitudes`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ + attitude_type: 'UNDEFINED', + }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the question', + ); + expect(respond.statusCode).toBe(201); + expect(respond.body.code).toBe(201); + expect(respond.body.data.attitudes.positive_count).toBe(0); + expect(respond.body.data.attitudes.negative_count).toBe(1); + expect(respond.body.data.attitudes.difference).toBe(-1); + expect(respond.body.data.attitudes.user_attitude).toBe('UNDEFINED'); + }); + it('should pose undefined attitude successfully', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/attitudes`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ + attitude_type: 'UNDEFINED', + }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the question', + ); + expect(respond.statusCode).toBe(201); + expect(respond.body.code).toBe(201); + expect(respond.body.data.attitudes.positive_count).toBe(0); + expect(respond.body.data.attitudes.negative_count).toBe(0); + expect(respond.body.data.attitudes.difference).toBe(0); + expect(respond.body.data.attitudes.user_attitude).toBe('UNDEFINED'); + }); + it('should get modified question attitude statistic', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.question.attitudes.positive_count).toBe(0); + expect(respond.body.data.question.attitudes.negative_count).toBe(0); + expect(respond.body.data.question.attitudes.difference).toBe(0); + expect(respond.body.data.question.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + }); + it('should get modified question attitude statistic', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.question.attitudes.positive_count).toBe(0); + expect(respond.body.data.question.attitudes.negative_count).toBe(0); + expect(respond.body.data.question.attitudes.difference).toBe(0); + expect(respond.body.data.question.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + }); + it('should get modified question attitude statistic', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.question.attitudes.positive_count).toBe(0); + expect(respond.body.data.question.attitudes.negative_count).toBe(0); + expect(respond.body.data.question.attitudes.difference).toBe(0); + expect(respond.body.data.question.attitudes.user_attitude).toBe( + 'UNDEFINED', + ); + }); + + // repeat to detect if the database operation has caused some problem + it('should pose positive attitude successfully', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/attitudes`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ + attitude_type: 'POSITIVE', + }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the question', + ); + expect(respond.statusCode).toBe(201); + expect(respond.body.code).toBe(201); + expect(respond.body.data.attitudes.positive_count).toBe(1); + expect(respond.body.data.attitudes.negative_count).toBe(0); + expect(respond.body.data.attitudes.difference).toBe(1); + expect(respond.body.data.attitudes.user_attitude).toBe('POSITIVE'); + }); + it('should pose negative attitude successfully', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/attitudes`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ + attitude_type: 'NEGATIVE', + }); + expect(respond.body.message).toBe( + 'You have expressed your attitude towards the question', + ); + expect(respond.statusCode).toBe(201); + expect(respond.body.code).toBe(201); + expect(respond.body.data.attitudes.positive_count).toBe(1); + expect(respond.body.data.attitudes.negative_count).toBe(1); + expect(respond.body.data.attitudes.difference).toBe(0); + expect(respond.body.data.attitudes.user_attitude).toBe('NEGATIVE'); + }); + }); + + describe('invite somebody to answer', () => { + it('should invite some users to answer question', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ user_id: TestUserId }); + expect(respond.body.message).toBe('Invited'); + expect(respond.body.code).toBe(201); + expect(respond.status).toBe(201); + expect(respond.body.data.invitationId).toBeDefined(); + invitationIds.push(respond.body.data.invitationId); + }); + it('should invite some users to answer question', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ user_id: auxUserId }); + expect(respond.body.message).toBe('Invited'); + expect(respond.body.code).toBe(201); + expect(respond.status).toBe(201); + expect(respond.body.data.invitationId).toBeDefined(); + invitationIds.push(respond.body.data.invitationId); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/invitations`) + .send({ user_id: TestUserId }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + it('should return UserIdNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ user_id: 114514 }); + expect(respond.body.message).toContain('UserIdNotFoundError'); + expect(respond.body.code).toBe(404); + }); + it('should return QuestionNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/114514/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ user_id: TestUserId }); + expect(respond.body.message).toContain('QuestionNotFoundError'); + expect(respond.body.code).toBe(404); + }); + + it('should get UserAlreadyInvitedError', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ user_id: TestUserId }); + expect(respond.body.message).toContain('UserAlreadyInvitedError'); + expect(respond.body.code).toBe(400); + }); + }); + it('should get invitations', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(2); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + expect(respond.body.data.invitations.length).toBe(2); + expect(respond.body.data.invitations[0].question_id).toBe(questionIds[1]); + expect(typeof respond.body.data.invitations[0].id).toBe('number'); + expect(respond.body.data.invitations[0].user).toBeDefined(); + expect(typeof respond.body.data.invitations[0].created_at).toBe('number'); + expect(typeof respond.body.data.invitations[0].updated_at).toBe('number'); + expect(typeof respond.body.data.invitations[0].is_answered).toBe('boolean'); + expect(respond.body.data.invitations[1].question_id).toBe(questionIds[1]); + }); + it('should get invitations', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + sort: '+created_at', + }) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(2); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + expect(respond.body.data.invitations.length).toBe(2); + expect(respond.body.data.invitations[0].question_id).toBe(questionIds[1]); + expect(respond.body.data.invitations[0].user.id).toBe(TestUserId); + expect(respond.body.data.invitations[1].question_id).toBe(questionIds[1]); + expect(respond.body.data.invitations[1].user.id).toBe(auxUserId); + }); + it('should get invitations', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + sort: '+created_at', + page_size: 1, + }) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(1); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(true); + expect(respond.body.data.invitations.length).toBe(1); + expect(respond.body.data.invitations[0].question_id).toBe(questionIds[1]); + expect(respond.body.data.invitations[0].user.id).toBe(TestUserId); + const next = respond.body.data.page.next_start; + const respond2 = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + sort: '+created_at', + page_start: next, + page_size: 1, + }) + .send(); + expect(respond2.body.message).toBe('OK'); + expect(respond2.body.code).toBe(200); + expect(respond2.status).toBe(200); + expect(respond2.body.data.page.page_size).toBe(1); + expect(respond2.body.data.page.has_prev).toBe(true); + expect(respond2.body.data.page.prev_start).toBe( + respond.body.data.invitations[0].id, + ); + expect(respond2.body.data.page.has_more).toBe(false); + expect(respond2.body.data.invitations.length).toBe(1); + expect(respond2.body.data.invitations[0].question_id).toBe(questionIds[1]); + expect(respond2.body.data.invitations[0].user.id).toBe(auxUserId); + }); + it('should get invitations', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + sort: '-created_at', + }) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(2); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + expect(respond.body.data.invitations.length).toBe(2); + expect(respond.body.data.invitations[0].question_id).toBe(questionIds[1]); + expect(respond.body.data.invitations[0].user.id).toBe(auxUserId); + expect(respond.body.data.invitations[1].question_id).toBe(questionIds[1]); + expect(respond.body.data.invitations[1].user.id).toBe(TestUserId); + }); + it('should get invitations', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + sort: '-created_at', + page_size: 1, + }) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.page.page_size).toBe(1); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(true); + expect(respond.body.data.invitations.length).toBe(1); + expect(respond.body.data.invitations[0].question_id).toBe(questionIds[1]); + expect(respond.body.data.invitations[0].user.id).toBe(auxUserId); + const next = respond.body.data.page.next_start; + const respond2 = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ + sort: '-created_at', + page_start: next, + page_size: 1, + }) + .send(); + expect(respond2.body.message).toBe('OK'); + expect(respond2.body.code).toBe(200); + expect(respond2.status).toBe(200); + expect(respond2.body.data.page.page_size).toBe(1); + expect(respond2.body.data.page.has_prev).toBe(true); + expect(respond2.body.data.page.prev_start).toBe( + respond.body.data.invitations[0].id, + ); + expect(respond2.body.data.page.has_more).toBe(false); + expect(respond2.body.data.invitations.length).toBe(1); + expect(respond2.body.data.invitations[0].question_id).toBe(questionIds[1]); + expect(respond2.body.data.invitations[0].user.id).toBe(TestUserId); + }); + describe('should deal with the Q&A function', () => { + it('should answer the question', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/answers`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ content: 'woc' }); + expect(respond.body.code).toBe(201); + answerIds.push(respond.body.data.id); + }); + + it('should return alreadyAnsweredError', async () => { + const respond = await request(app.getHttpServer()) + .post(`/questions/${questionIds[1]}/invitations`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ user_id: TestUserId }); + expect(respond.body.message).toContain('AlreadyAnswered'); + expect(respond.body.code).toBe(400); + }); + }); + describe('it will cancel the invitations', () => { + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionIds[1]}/invitations/${invitationIds[0]}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + it('should cancel the invitations', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionIds[1]}/invitations/${invitationIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toBe('successfully cancelled'); + expect(respond.body.code).toBe(204); + expect(respond.status).toBe(200); + }); + it('should successfully cancel the invitation', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionIds[1]}/invitations/${invitationIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch( + /^QuestionInvitationNotFoundError: /, + ); + expect(respond.body.code).toBe(400); + expect(respond.status).toBe(400); + }); + it('should return QuestionInvitationNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionIds[1]}/invitations/1919818`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toMatch( + /^QuestionInvitationNotFoundError: /, + ); + expect(respond.body.code).toBe(400); + expect(respond.status).toBe(400); + }); + it('should return QuestionNotFoundError', async () => { + const questionId = 1234567; + const respond = await request(app.getHttpServer()) + .delete(`/questions/${questionId}/invitations/${invitationIds[1]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toContain('QuestionNotFoundError'); + expect(respond.body.code).toBe(404); + expect(respond.status).toBe(404); + }); + }); + describe('it may get some details', () => { + it('should get some details', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations/${invitationIds[1]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.data.invitation.question_id).toBe(questionIds[1]); + expect(respond.body.data.invitation.id).toBe(invitationIds[1]); + expect(respond.body.code).toBe(200); + }); + it('should return QuestionNotFoundError', async () => { + const questionId = 1234567; + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionId}/invitations/${invitationIds[1]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.message).toContain('QuestionNotFoundError'); + expect(respond.body.code).toBe(404); + expect(respond.status).toBe(404); + }); + it('should return QuestionInvitationNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations/${invitationIds[0]}`) + .set('Authorization', `Bearer ${TestToken}`); + expect(respond.body.message).toContain('QuestionInvitationNotFoundError'); + expect(respond.body.code).toBe(400); + expect(respond.status).toBe(400); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations/${invitationIds[1]}`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + + describe('get recommendation function test', () => { + it('should get recommendation', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations/recommendations`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ page_size: 5 }); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.users.length).toBe(5); + }); + it('should return QuestionNotFoundEroor', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/1919810/invitations/recommendations`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ page_size: 5 }); + expect(respond.body.message).toContain('QuestionNotFoundError'); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}/invitations/recommendations`) + .query({ page_size: 5 }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + + describe('Bounty test', () => { + it('should create a question with bounty', async () => { + async function createQuestion( + title: string, + content: string, + bounty: number, + ) { + const respond = await request(app.getHttpServer()) + .post('/questions') + .set('Authorization', `Bearer ${TestToken}`) + .send({ + title: `${TestQuestionPrefix} ${title}`, + content, + type: 0, + topics: [TopicIds[0], TopicIds[1]], + bounty, + }); + expect(respond.body.message).toBe('Created'); + expect(respond.body.code).toBe(201); + expect(respond.status).toBe(201); + expect(respond.body.data.id).toBeDefined(); + questionIds.push(respond.body.data.id); + return respond.body.data.id; + } + + const bountyQuestionId = await createQuestion( + 'Bounty Test 1', + 'test', + 10, + ); + const respond = await request(app.getHttpServer()) + .get(`/questions/${bountyQuestionId}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.data.question.bounty).toBe(10); + expect(typeof respond.body.data.question.bounty_start_at).toBe('number'); + }); + it('should set bounty to a question', async () => { + const respond = await request(app.getHttpServer()) + .put(`/questions/${questionIds[1]}/bounty`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ bounty: 15 }); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + }); + it('should get the change', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.data.question.bounty).toBe(15); + expect(typeof respond.body.data.question.bounty_start_at).toBe('number'); + }); + it('should not set bounty by a non-owner', async () => { + const respond = await request(app.getHttpServer()) + .put(`/questions/${questionIds[1]}/bounty`) + .set('Authorization', `Bearer ${auxAccessToken}`) + .send({ bounty: 15 }); + expect(respond.body.message).toMatch(/^PermissionDeniedError: /); + expect(respond.body.code).toBe(403); + }); + it('should return BountyNotBiggerError', async () => { + const respond = await request(app.getHttpServer()) + .put(`/questions/${questionIds[1]}/bounty`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ bounty: 10 }); + expect(respond.body.message).toMatch(/^BountyNotBiggerError: /); + expect(respond.body.code).toBe(400); + }); + it('should return BountyOutOfLimitError', async () => { + const respond = await request(app.getHttpServer()) + .put(`/questions/${questionIds[1]}/bounty`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ bounty: 1000 }); + // expect(respond.body.message).toMatch(/^BountyOutOfLimitError: /); + // ! Now it throws a BadRequestError, since the limit is checked parsing the DTO + expect(respond.body.code).toBe(400); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .put(`/questions/${questionIds[1]}/bounty`) + .send({ bounty: 15 }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + it('should return QuestionNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .put(`/questions/1919810/bounty`) + .set('Authorization', `Bearer ${TestToken}`) + .send({ bounty: 15 }); + expect(respond.body.message).toMatch(/^QuestionNotFoundError: /); + expect(respond.body.code).toBe(404); + }); + }); + describe('Accpet answer test', () => { + it('should accept an answer', async () => { + const respond = await request(app.getHttpServer()) + .put(`/questions/${questionIds[1]}/acceptance`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ answer_id: answerIds[0] }); + expect(respond.body.code).toBe(200); + expect(respond.body.message).toBe('OK'); + }); + it('should get the change', async () => { + const respond = await request(app.getHttpServer()) + .get(`/questions/${questionIds[1]}`) + .set('Authorization', `Bearer ${TestToken}`) + .send(); + expect(respond.body.data.question.accepted_answer.id).toBe(answerIds[0]); + }); + it('should return questionNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .put(`/questions/1919810/acceptance`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ answer_id: answerIds[0] }); + expect(respond.body.code).toBe(404); + expect(respond.body.message).toMatch(/^QuestionNotFoundError: /); + }); + it('should return answerIdNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .put(`/questions/${questionIds[1]}/acceptance`) + .set('Authorization', `Bearer ${TestToken}`) + .query({ answer_id: 123456798 }); + expect(respond.body.code).toBe(404); + expect(respond.body.message).toMatch(/^AnswerNotFoundError: /); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .put(`/questions/${questionIds[1]}/acceptance`) + .query({ answer_id: answerIds[0] }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.body.code).toBe(401); + }); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/test/resources/large-image.jpg b/test/resources/large-image.jpg new file mode 100644 index 00000000..3785c616 Binary files /dev/null and b/test/resources/large-image.jpg differ diff --git a/test/topic.e2e-spec.ts b/test/topic.e2e-spec.ts new file mode 100644 index 00000000..81254623 --- /dev/null +++ b/test/topic.e2e-spec.ts @@ -0,0 +1,360 @@ +/* + * Description: This file tests the topic module. + * + * Author(s): + * Nictheboy Li + * + */ + +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; +jest.mock('../src/email/email.service'); + +describe('Topic Module', () => { + let app: INestApplication; + const MockedEmailService = >EmailService; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + const TestTopicCode = Math.floor(Math.random() * 10000000000).toString(); + const TestTopicPrefix = `[Test(${TestTopicCode}) Topic]`; + let TestToken: string; + const TopicIds: number[] = []; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }, 20000); + + beforeEach(() => { + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.results.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.results.length = 0; + }); + + describe('preparation', () => { + it(`should send an email and register a user ${TestUsername}`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith(TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: TestUsername, + nickname: 'test_user', + password: 'abc123456!!!', + email: TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual('Register successfully.'); + expect(respond.body.code).toEqual(201); + req.expect(201); + expect(respond.body.data.accessToken).toBeDefined(); + TestToken = respond.body.data.accessToken; + }); + }); + + describe('create topic', () => { + it('should create some topics', async () => { + async function createTopic(name: string) { + const respond = await request(app.getHttpServer()) + .post('/topics') + .set('authorization', 'Bearer ' + TestToken) + .send({ + name: `${TestTopicPrefix} ${name}`, + }); + expect(respond.body.message).toBe('OK'); + expect(respond.status).toBe(201); + expect(respond.body.code).toBe(201); + expect(respond.body.data.id).toBeDefined(); + // expect(respond.body.data.name).toBe(`${TestTopicPrefix} ${name}`); + TopicIds.push(respond.body.data.id); + } + await createTopic('高等数学'); + await createTopic('高等代数'); + await createTopic('高等数学习题'); + await createTopic('高等代数习题'); + await createTopic('大学英语'); + await createTopic('军事理论(思政)'); + await createTopic('思想道德与法治(思政)'); + await createTopic('大学物理'); + await createTopic('普通物理'); + await createTopic('An English Topic'); + await createTopic('An English topic with some 中文 in it'); + await createTopic('Emojis in the topic name 😂😂😂'); + await createTopic('Emojis in the topic name 🧑‍🦲'); + await createTopic('Emojis in the topic name 😂😂😂 with some 中文 in it'); + await createTopic('Emojis in the topic name 🧑‍🦲 with some 中文 in it'); + }, 60000); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .post('/topics') + .send({ + name: `${TestTopicPrefix} 高等数学`, + }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + }); + it('should return InvalidTokenError', async () => { + const respond = await request(app.getHttpServer()) + .post('/topics') + .set('authorization', 'Bearer ' + TestToken + '1') + .send({ + name: `${TestTopicPrefix} 高等数学`, + }); + expect(respond.body.message).toMatch(/^InvalidTokenError: /); + expect(respond.status).toBe(401); + }); + it('should return TopicAlreadyExistsError', async () => { + const respond = await request(app.getHttpServer()) + .post('/topics') + .set('authorization', 'Bearer ' + TestToken) + .send({ + name: `${TestTopicPrefix} 高等数学`, + }); + expect(respond.body.message).toMatch(/^TopicAlreadyExistsError: /); + expect(respond.status).toBe(409); + }); + }); + + describe('search topic', () => { + it('should wait some time for elasticsearch to refresh', async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + }); + it('should return empty page without parameters', async () => { + const respond = await request(app.getHttpServer()) + .get('/topics') + .set('authorization', 'Bearer ' + TestToken) + .query({ + q: '', + }) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.body.code).toBe(200); + expect(respond.status).toBe(200); + expect(respond.body.data.topics.length).toBe(0); + expect(respond.body.data.page.page_size).toBe(0); + expect(respond.body.data.page.page_start).toBe(0); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + }); + it('should search topics and do paging', async () => { + // Try search: `${TestTopicCode} 高等` + const respond = await request(app.getHttpServer()) + .get(`/topics`) + .set('authorization', 'Bearer ' + TestToken) + .query({ + q: `${TestTopicCode} 高等`, + }) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.topics.length).toBeGreaterThanOrEqual(15); + for (let i = 0; i < 4; i++) { + expect(respond.body.data.topics[i].name).toContain(TestTopicCode); + } + + const respond2 = await request(app.getHttpServer()) + .get(`/topics`) + .query({ + q: `${TestTopicCode} 高等`, + page_size: 3, + }) + .set('Authorization', 'Bearer ' + TestToken) + .send(); + expect(respond2.body.message).toBe('OK'); + expect(respond2.status).toBe(200); + expect(respond2.body.code).toBe(200); + expect(respond2.body.data.topics.length).toBe(3); + expect(respond2.body.data.topics[0].name).toContain(TestTopicCode); + expect(respond2.body.data.topics[0].name).toContain('高等'); + expect(respond2.body.data.topics[1].name).toContain(TestTopicCode); + expect(respond2.body.data.topics[1].name).toContain('高等'); + expect(respond2.body.data.topics[2].name).toContain(TestTopicCode); + expect(respond2.body.data.topics[2].name).toContain('高等'); + expect(respond2.body.data.page.page_size).toBe(3); + expect(respond2.body.data.page.has_prev).toBe(false); + expect(respond2.body.data.page.prev_start).toBe(0); + expect(respond2.body.data.page.has_more).toBe(true); + + const respond3 = await request(app.getHttpServer()) + .get(`/topics`) + .set('authorization', 'Bearer ' + TestToken) + .query({ + q: `${TestTopicCode} 高等`, + page_size: 3, + page_start: respond2.body.data.page.next_start, + }) + .send(); + expect(respond3.body.message).toBe('OK'); + expect(respond3.status).toBe(200); + expect(respond3.body.code).toBe(200); + expect(respond3.body.data.topics.length).toBe(3); + expect(respond3.body.data.topics[0].id).toBe( + respond2.body.data.page.next_start, + ); + expect(respond3.body.data.topics[0].name).toContain(TestTopicCode); + expect(respond3.body.data.topics[0].name).toContain('高等'); + expect(respond3.body.data.page.page_start).toBe( + respond3.body.data.topics[0].id, + ); + expect(respond3.body.data.page.page_size).toBe(3); + expect(respond3.body.data.page.has_prev).toBe(true); + expect(respond3.body.data.page.prev_start).toBe( + respond2.body.data.topics[0].id, + ); + expect(respond3.body.data.page.has_more).toBe(true); + + const respond4 = await request(app.getHttpServer()) + .get(`/topics`) + .query({ + q: `${TestTopicCode} 高等`, + page_size: 3, + page_start: respond2.body.data.page.page_start, + }) + .set('Authorization', 'Bearer ' + TestToken) + .send(); + expect(respond4.body).toStrictEqual(respond2.body); + + // Try emoji search to see if unicode storage works. + const respond5 = await request(app.getHttpServer()) + .get(`/topics`) + .set('authorization', 'Bearer ' + TestToken) + .query({ + q: `${TestTopicCode} 🧑‍🦲`, + page_size: 3, + }) + .send(); + expect(respond5.body.message).toBe('OK'); + expect(respond5.status).toBe(200); + expect(respond5.body.data.topics.length).toBe(3); + // respond5.body.data.topics.map((topic) => topic.name).sort(); + expect(respond5.body.data.topics[0].name).toBe( + `${TestTopicPrefix} Emojis in the topic name 🧑‍🦲`, + ); + expect(respond5.body.data.topics[1].name).toBe( + `${TestTopicPrefix} Emojis in the topic name 🧑‍🦲 with some 中文 in it`, + ); + }, 60000); + it('should return an empty page', async () => { + const respond = await request(app.getHttpServer()) + .get('/topics?q=%E6%AF%B3%E6%AF%B3%E6%AF%B3%E6%AF%B3') + .set('authorization', 'Bearer ' + TestToken) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.topics.length).toBe(0); + expect(respond.body.data.page.page_start).toBe(0); + expect(respond.body.data.page.page_size).toBe(0); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + }); + it('should return TopicNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .get('/topics?q=something&page_start=-1') + .set('authorization', 'Bearer ' + TestToken) + .send(); + expect(respond.body.message).toMatch(/^TopicNotFoundError: /); + expect(respond.status).toBe(404); + }); + it('should return BadRequestException', async () => { + const respond = await request(app.getHttpServer()) + .get('/topics?q=something&page_start=abc') + .set('authorization', 'Bearer ' + TestToken) + .send(); + expect(respond.body.message).toMatch(/^BadRequestException: /); + expect(respond.status).toBe(400); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get('/topics?q=something') + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + }); + }); + + describe('get topic', () => { + it('should get a topic', async () => { + const respond = await request(app.getHttpServer()) + .get(`/topics/${TopicIds[0]}`) + .set('authorization', 'Bearer ' + TestToken) + .send(); + expect(respond.body.message).toBe('OK'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.topic.id).toBe(TopicIds[0]); + expect(respond.body.data.topic.name).toBe(`${TestTopicPrefix} 高等数学`); + }); + it('should return TopicNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .get('/topics/-1') + .set('authorization', 'Bearer ' + TestToken) + .send(); + expect(respond.body.message).toMatch(/^TopicNotFoundError: /); + expect(respond.status).toBe(404); + }); + it('should return BadRequestException', async () => { + const respond = await request(app.getHttpServer()) + .get('/topics/abc') + .set('authorization', 'Bearer ' + TestToken) + .send(); + expect(respond.body.message).toMatch(/^BadRequestException: /); + expect(respond.status).toBe(400); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get('/topics/1') + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + }); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/test/user.e2e-spec.ts b/test/user.e2e-spec.ts new file mode 100644 index 00000000..f0e222cf --- /dev/null +++ b/test/user.e2e-spec.ts @@ -0,0 +1,1217 @@ +/* + * Description: This file tests the core function of user module. + * + * Author(s): + * Nictheboy Li + * + */ + +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import session from 'express-session'; +import { authenticator } from 'otplib'; +import * as srpClient from 'secure-remote-password/client'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; + +jest.mock('../src/email/email.service'); + +// // Mock SRP client +// jest.mock('secure-remote-password/client', () => ({ +// generateSalt: jest.fn(() => 'test-salt'), +// derivePrivateKey: jest.fn((salt, username, password) => 'test-private-key'), +// deriveVerifier: jest.fn((privateKey) => 'test-verifier'), +// generateEphemeral: jest.fn(() => ({ public: 'client-public', secret: 'client-secret' })), +// deriveSession: jest.fn((clientSecretEphemeral, serverPublicEphemeral, salt, username, privateKey) => ({ +// key: 'session-key', +// proof: 'client-proof' +// })), +// verifySession: jest.fn((serverProof, clientSession) => true) +// })); + +jest.mock('@simplewebauthn/server', () => ({ + generateRegistrationOptions: jest.fn(() => + Promise.resolve({ challenge: 'fake-challenge' }), + ), + verifyRegistrationResponse: jest.fn(() => + Promise.resolve({ + verified: true, + registrationInfo: { + credential: { id: 'cred-id', publicKey: 'fake-public-key', counter: 1 }, + credentialBackedUp: false, + credentialDeviceType: 'singleDevice', + }, + }), + ), + generateAuthenticationOptions: jest.fn(() => + Promise.resolve({ challenge: 'fake-auth-challenge', allowCredentials: [] }), + ), + verifyAuthenticationResponse: jest.fn(() => + Promise.resolve({ + verified: true, + authenticationInfo: { newCounter: 2 }, + }), + ), +})); + +const MockedEmailService = >EmailService; + +type HttpServer = ReturnType; + +/** + * 使用 SRP 进行登录 + */ +async function loginWithSRP( + httpServer: HttpServer, + username: string, + password: string, +): Promise<{ + accessToken: string; + refreshToken: string; + userId: number; +}> { + const clientEphemeral = srpClient.generateEphemeral(); + const agent = request.agent(httpServer); + + // 1. 初始化 SRP 登录 + const initResponse = await agent + .post('/users/auth/srp/init') + .send({ + username, + clientPublicEphemeral: clientEphemeral.public, + }) + .expect(201); + + // 2. 完成 SRP 验证 + const privateKey = srpClient.derivePrivateKey( + initResponse.body.data.salt, + username, + password, + ); + const clientSession = srpClient.deriveSession( + clientEphemeral.secret, + initResponse.body.data.serverPublicEphemeral, + initResponse.body.data.salt, + username, + privateKey, + ); + + const verifyResponse = await agent + .post('/users/auth/srp/verify') + .send({ + username, + clientProof: clientSession.proof, + }) + .expect(201); + + const refreshToken = verifyResponse.header['set-cookie'][0] + .split(';')[0] + .split('=')[1]; + + return { + accessToken: verifyResponse.body.data.accessToken, + refreshToken, + userId: verifyResponse.body.data.user.id, + }; +} + +/** + * 使用 SRP 进行 sudo 验证 + */ +async function verifySudoWithSRP( + httpServer: HttpServer, + token: string, + username: string, + password: string, +): Promise { + const clientEphemeral = srpClient.generateEphemeral(); + const agent = request.agent(httpServer); + + const sudoInitRes = await agent + .post('/users/auth/sudo') + .set('Authorization', `Bearer ${token}`) + .send({ + method: 'srp', + credentials: { + clientPublicEphemeral: clientEphemeral.public, + }, + }) + .expect(201); + + const privateKey = srpClient.derivePrivateKey( + sudoInitRes.body.data.salt, + username, + password, + ); + const clientSession = srpClient.deriveSession( + clientEphemeral.secret, + sudoInitRes.body.data.serverPublicEphemeral, + sudoInitRes.body.data.salt, + username, + privateKey, + ); + + const sudoVerifyRes = await agent + .post('/users/auth/sudo') + .set('Authorization', `Bearer ${token}`) + .send({ + method: 'srp', + credentials: { + clientProof: clientSession.proof, + }, + }) + .expect(201); + + return sudoVerifyRes.body.data.accessToken; +} + +/** + * 创建一个使用传统认证的用户 + */ +async function createLegacyUser(httpServer: HttpServer): Promise<{ + username: string; + password: string; + accessToken: string; + refreshToken: string; + userId: number; +}> { + const username = `TestLegacyUser-${Math.floor(Math.random() * 10000000000)}`; + const password = 'Legacy@123456'; + const email = `legacy-${Math.floor(Math.random() * 10000000000)}@ruc.edu.cn`; + + // 发送验证邮件 + const emailRes = await request(httpServer) + .post('/users/verify/email') + .send({ email }) + .expect(201); + + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + + // 注册用户 + const registerRes = await request(httpServer) + .post('/users') + .send({ + username, + nickname: 'legacy_user', + password, + email, + emailCode: verificationCode, + isLegacyAuth: true, + }) + .expect(201); + + const refreshToken = registerRes.header['set-cookie'][0] + .split(';')[0] + .split('=')[1]; + + return { + username, + password, + accessToken: registerRes.body.data.accessToken, + refreshToken, + userId: registerRes.body.data.user.id, + }; +} + +describe('User Module', () => { + let app: INestApplication; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestPassword = 'abc123456!!!'; + const TestNewPassword = 'ABC^^^666'; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + let TestRefreshTokenOld: string; + let TestRefreshToken: string; + let TestToken: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.use( + session({ + secret: 'testSecret', + resave: false, + saveUninitialized: false, + }), + ); + await app.init(); + }, 20000); + + beforeEach(() => { + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.results.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.results.length = 0; + }); + + describe('register logic', () => { + it('should return InvalidEmailAddressError', () => { + return request(app.getHttpServer()) + .post('/users/verify/email') + .send({ + email: 'test', + }) + .expect({ + code: 422, + message: + 'InvalidEmailAddressError: Invalid email address: test. Email should look like someone@example.com', + }) + .expect(422); + }); + + it('should return InvalidEmailSuffixError', () => { + return request(app.getHttpServer()) + .post('/users/verify/email') + .send({ + email: 'test@126.com', + }) + .expect({ + code: 422, + message: + 'InvalidEmailSuffixError: Invalid email suffix: test@126.com. Only @ruc.edu.cn is supported currently.', + }) + .expect(422); + }); + + it('should return EmailSendFailedError', async () => { + MockedEmailService.prototype.sendRegisterCode.mockImplementation(() => { + throw new Error('Email service error'); + }); + const respond = await request(app.getHttpServer()) + .post('/users/verify/email') + .send({ + email: TestEmail, + }); + expect(respond.body).toStrictEqual({ + code: 500, + message: `EmailSendFailedError: Failed to send email to ${TestEmail}`, + }); + expect(respond.status).toBe(500); + MockedEmailService.prototype.sendRegisterCode.mockImplementation(() => { + return; + }); + }); + + it(`should send an email and register a user ${TestUsername} with SRP`, async () => { + jest.useFakeTimers({ advanceTimers: true }); + + // 1. 发送验证邮件 + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + + jest.advanceTimersByTime(9 * 60 * 1000); + + // 2. 使用 SRP 注册 + const salt = srpClient.generateSalt(); + const privateKey = srpClient.derivePrivateKey( + salt, + TestUsername, + TestPassword, + ); + const verifier = srpClient.deriveVerifier(privateKey); + + const registerResponse = await request(app.getHttpServer()) + .post('/users') + .send({ + username: TestUsername, + nickname: 'test_user', + srpSalt: salt, + srpVerifier: verifier, + email: TestEmail, + emailCode: verificationCode, + }); + + expect(registerResponse.status).toBe(201); + expect(registerResponse.body.message).toBe('Register successfully.'); + expect(registerResponse.body.data.user.username).toBe(TestUsername); + expect(registerResponse.body.data.user.nickname).toBe('test_user'); + expect(registerResponse.body.data.accessToken).toBeDefined(); + + // 保存 token 用于后续测试 + TestToken = registerResponse.body.data.accessToken; + TestRefreshToken = registerResponse.header['set-cookie'][0] + .split(';')[0] + .split('=')[1]; + + jest.useRealTimers(); + }); + + it('should return EmailAlreadyRegisteredError', () => { + return request(app.getHttpServer()) + .post('/users/verify/email') + .send({ + email: TestEmail, + }) + .expect({ + code: 409, + message: `EmailAlreadyRegisteredError: Email already registered: ${TestEmail}`, + }) + .expect(409); + }); + it(`should return UsernameAlreadyRegisteredError`, async () => { + const respond = await request(app.getHttpServer()) + .post('/users/verify/email') + .send({ + email: 'another-' + TestEmail, + }); + expect(respond.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith('another-' + TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + return request(app.getHttpServer()) + .post('/users') + .send({ + username: TestUsername, + nickname: 'test_user', + password: TestPassword, + email: 'another-' + TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }) + .expect({ + code: 409, + message: `UsernameAlreadyRegisteredError: Username already registered: ${TestUsername}`, + }) + .expect(409); + }); + it(`should return InvalidEmailAddressError`, async () => { + const respond = await request(app.getHttpServer()).post('/users').send({ + username: TestUsername, + nickname: 'test_user', + password: TestPassword, + email: 'abc123', + emailCode: '000000', + isLegacyAuth: true, + }); + expect(respond.body.message).toMatch(/^InvalidEmailAddressError: /); + expect(respond.body.code).toEqual(422); + expect(respond.status).toBe(422); + }); + it(`should return InvalidEmailSuffixError`, async () => { + const respond = await request(app.getHttpServer()).post('/users').send({ + username: TestUsername, + nickname: 'test_user', + password: TestPassword, + email: 'abc123@123.com', + emailCode: '000000', + isLegacyAuth: true, + }); + expect(respond.body.message).toMatch(/^InvalidEmailSuffixError: /); + expect(respond.body.code).toEqual(422); + expect(respond.status).toBe(422); + }); + it(`should return InvalidUsernameError`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + .send({ + email: 'another-' + TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith('another-' + TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + .send({ + username: TestUsername + ' Invalid', + nickname: 'test_user', + password: TestPassword, + email: 'another-' + TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual( + `InvalidUsernameError: Invalid username: ${ + TestUsername + ' Invalid' + }. Username must be 4-32 characters long and can only contain letters, numbers, underscores and hyphens.`, + ); + expect(respond.body.code).toEqual(422); + req.expect(422); + }); + it(`should return InvalidNicknameError`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + .send({ + email: 'another-' + TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith('another-' + TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + .send({ + username: TestUsername, + nickname: 'test user', + password: TestPassword, + email: 'another-' + TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual( + `InvalidNicknameError: Invalid nickname: test user. Nickname must be 1-16 characters long and can only contain letters, numbers, underscores, hyphens and Chinese characters.`, + ); + expect(respond.body.code).toEqual(422); + req.expect(422); + }); + it(`should return InvalidPasswordError`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + .send({ + email: 'another-' + TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith('another-' + TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + .send({ + username: TestUsername, + nickname: 'test_user', + password: '123456', + email: 'another-' + TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual( + `InvalidPasswordError: Invalid password. Password must be at least 8 characters long and must contain at least one letter, one special character and one number.`, + ); + expect(respond.body.code).toEqual(422); + req.expect(422); + }); + it(`should return CodeNotMatchError`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + .send({ + email: 'another-' + TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith('another-' + TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + .send({ + username: TestUsername, + nickname: 'test_user', + password: TestPassword, + email: 'another-' + TestEmail, + emailCode: verificationCode + '1', + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual( + `CodeNotMatchError: Code not match: ${'another-' + TestEmail}, ${ + verificationCode + '1' + }`, + ); + expect(respond.body.code).toEqual(422); + req.expect(422); + }); + }); + + describe('login logic', () => { + it('should login successfully with SRP', async () => { + const auth = await loginWithSRP( + app.getHttpServer(), + TestUsername, + TestPassword, + ); + TestToken = auth.accessToken; + TestRefreshToken = auth.refreshToken; + }); + + it('should return SrpVerificationError', async () => { + const clientEphemeral = srpClient.generateEphemeral(); + + const agent = request.agent(app.getHttpServer()); + await agent.post('/users/auth/srp/init').send({ + username: TestUsername, + clientPublicEphemeral: clientEphemeral.public, + }); + + const verifyResponse = await agent.post('/users/auth/srp/verify').send({ + username: TestUsername, + clientProof: 'wrong-proof', + }); + + expect(verifyResponse.status).toBe(401); + expect(verifyResponse.body.message).toMatch(/^SrpVerificationError: /); + }); + + it('should return UsernameNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .post('/users/auth/srp/init') + .send({ + username: TestUsername + 'KKK', + clientPublicEphemeral: 'any-public-key', + }); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + expect(respond.body.message).toMatch(/^UsernameNotFoundError: /); + }); + + it('should refresh access token successfully', async () => { + const respond2 = await request(app.getHttpServer()) + .post('/users/auth/refresh-token') + .set('Cookie', `REFRESH_TOKEN=${TestRefreshToken}`) + .send(); + expect(respond2.body.message).toBe('Refresh token successfully.'); + expect(respond2.status).toBe(201); + expect(respond2.body.code).toBe(201); + expect(respond2.body.data.accessToken).toBeDefined(); + TestRefreshTokenOld = TestRefreshToken; + TestRefreshToken = respond2.header['set-cookie'][0] + .split(';')[0] + .split('=')[1]; + TestToken = respond2.body.data.accessToken; + expect(respond2.body.data.user.username).toBe(TestUsername); + expect(respond2.body.data.user.nickname).toBe('test_user'); + }); + + it('should verify sudo mode with SRP', async () => { + TestToken = await verifySudoWithSRP( + app.getHttpServer(), + TestToken, + TestUsername, + TestPassword, + ); + }); + }); + + describe('password reset logic', () => { + it('should return InvalidEmailAddressError', async () => { + const respond = await request(app.getHttpServer()) + .post('/users/recover/password/request') + .send({ + email: 'test', + }); + expect(respond.body.message).toMatch(/^InvalidEmailAddressError: /); + expect(respond.body.code).toBe(422); + expect(respond.status).toBe(422); + }); + + it('should return InvalidEmailSuffixError', async () => { + const respond = await request(app.getHttpServer()) + .post('/users/recover/password/request') + .send({ + email: 'test@test.com', + }); + expect(respond.body.message).toMatch(/^InvalidEmailSuffixError: /); + expect(respond.body.code).toBe(422); + expect(respond.status).toBe(422); + }); + + it('should return EmailSendFailedError', async () => { + MockedEmailService.prototype.sendPasswordResetEmail.mockImplementation( + () => { + throw new Error('Email service error'); + }, + ); + const respond = await request(app.getHttpServer()) + .post('/users/recover/password/request') + .send({ + email: TestEmail, + }); + expect(respond.body.message).toMatch(/^EmailSendFailedError: /); + expect(respond.body.code).toBe(500); + expect(respond.status).toBe(500); + MockedEmailService.prototype.sendPasswordResetEmail.mockImplementation( + () => { + return; + }, + ); + }); + + it('should return EmailNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .post('/users/recover/password/request') + .send({ + email: 'KKK-' + TestEmail, + }); + expect(respond.body.message).toMatch(/^EmailNotFoundError: /); + expect(respond.body.code).toBe(404); + expect(respond.status).toBe(404); + }); + + it('should send a password reset email and reset the password', async () => { + jest.useFakeTimers({ advanceTimers: true }); + + // 1. 发送重置密码邮件 + const respond = await request(app.getHttpServer()) + .post('/users/recover/password/request') + .send({ + email: TestEmail, + }); + expect(respond.body.message).toBe('Send email successfully.'); + expect(respond.body.code).toBe(201); + expect(respond.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendPasswordResetEmail, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendPasswordResetEmail, + ).toHaveBeenCalledWith(TestEmail, TestUsername, expect.any(String)); + const token = ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls[0][2]; + + jest.advanceTimersByTime(9 * 60 * 1000); + + // 2. 使用 token 重置密码,使用 SRP 凭证 + const salt = srpClient.generateSalt(); + const privateKey = srpClient.derivePrivateKey( + salt, + TestUsername, + TestNewPassword, + ); + const verifier = srpClient.deriveVerifier(privateKey); + + const respond2 = await request(app.getHttpServer()) + .post('/users/recover/password/verify') + .send({ + token: token, + srpSalt: salt, + srpVerifier: verifier, + }); + expect(respond2.body.message).toBe('Reset password successfully.'); + expect(respond2.body.code).toBe(201); + expect(respond2.status).toBe(201); + + // 3. 使用新密码通过 SRP 登录 + const clientEphemeral = srpClient.generateEphemeral(); + const agent = request.agent(app.getHttpServer()); + + const initResponse = await agent.post('/users/auth/srp/init').send({ + username: TestUsername, + clientPublicEphemeral: clientEphemeral.public, + }); + + expect(initResponse.status).toBe(201); + expect(initResponse.body.data.salt).toBe(salt); // 应该与重置时使用的 salt 相同 + + const clientSession = srpClient.deriveSession( + clientEphemeral.secret, + initResponse.body.data.serverPublicEphemeral, + salt, + TestUsername, + privateKey, + ); + + const verifyResponse = await agent.post('/users/auth/srp/verify').send({ + username: TestUsername, + clientProof: clientSession.proof, + }); + + expect(verifyResponse.status).toBe(201); + expect(verifyResponse.body.data.accessToken).toBeDefined(); + expect(verifyResponse.body.data.user.username).toBe(TestUsername); + + jest.useRealTimers(); + }); + + it('should return PermissionDeniedError', async () => { + const salt = srpClient.generateSalt(); + const privateKey = srpClient.derivePrivateKey( + salt, + TestUsername, + TestNewPassword, + ); + const verifier = srpClient.deriveVerifier(privateKey); + + const respond = await request(app.getHttpServer()) + .post('/users/recover/password/verify') + .send({ + token: TestToken, + srpSalt: salt, + srpVerifier: verifier, + }); + expect(respond.body.message).toMatch(/^PermissionDeniedError: /); + expect(respond.body.code).toBe(403); + expect(respond.status).toBe(403); + }); + + it('should return TokenExpiredError', async () => { + jest.useFakeTimers({ advanceTimers: true }); + + const respond = await request(app.getHttpServer()) + .post('/users/recover/password/request') + .send({ + email: TestEmail, + }); + expect(respond.body.message).toBe('Send email successfully.'); + expect(respond.body.code).toBe(201); + expect(respond.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendPasswordResetEmail, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendPasswordResetEmail, + ).toHaveBeenCalledWith(TestEmail, TestUsername, expect.any(String)); + const token = ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls[0][2]; + + jest.advanceTimersByTime(11 * 60 * 1000); + + const salt = srpClient.generateSalt(); + const privateKey = srpClient.derivePrivateKey( + salt, + TestUsername, + TestNewPassword, + ); + const verifier = srpClient.deriveVerifier(privateKey); + + const respond2 = await request(app.getHttpServer()) + .post('/users/recover/password/verify') + .send({ + token: token, + srpSalt: salt, + srpVerifier: verifier, + }); + expect(respond2.body.message).toMatch(/^TokenExpiredError: /); + expect(respond2.body.code).toBe(401); + expect(respond2.status).toBe(401); + + jest.useRealTimers(); + }); + }); + + describe('Sudo Mode Authentication', () => { + let testUserId: number; + let validToken: string; + + beforeAll(async () => { + const auth = await loginWithSRP( + app.getHttpServer(), + TestUsername, + TestNewPassword, + ); + validToken = auth.accessToken; + testUserId = auth.userId; + }); + + it('should verify sudo mode with password and trigger SRP upgrade', async () => { + // 创建传统认证用户 + const legacyUser = await createLegacyUser(app.getHttpServer()); + + // 使用密码验证 sudo,这应该会触发 SRP 升级 + const sudoRes = await request(app.getHttpServer()) + .post('/users/auth/sudo') + .set('Authorization', `Bearer ${legacyUser.accessToken}`) + .send({ + method: 'password', + credentials: { + password: legacyUser.password, + }, + }); + + expect(sudoRes.status).toBe(201); + expect(sudoRes.body.data.accessToken).toBeDefined(); + expect(sudoRes.body.data.srpUpgraded).toBe(true); + expect(sudoRes.body.message).toBe( + 'Password verification successful and account upgraded to SRP', + ); + + // 验证用户现在可以使用 SRP 登录 + const clientEphemeral = srpClient.generateEphemeral(); + + const agent = request.agent(app.getHttpServer()); + + // 初始化 SRP 登录 + const initRes = await agent.post('/users/auth/srp/init').send({ + username: legacyUser.username, + clientPublicEphemeral: clientEphemeral.public, + }); + + expect(initRes.status).toBe(201); + expect(initRes.body.data.salt).toBeDefined(); + expect(initRes.body.data.serverPublicEphemeral).toBeDefined(); + + // 完成 SRP 验证 + const privateKey = srpClient.derivePrivateKey( + initRes.body.data.salt, + legacyUser.username, + legacyUser.password, + ); + const clientSession = srpClient.deriveSession( + clientEphemeral.secret, + initRes.body.data.serverPublicEphemeral, + initRes.body.data.salt, + legacyUser.username, + privateKey, + ); + + const verifyRes = await agent.post('/users/auth/srp/verify').send({ + username: legacyUser.username, + clientProof: clientSession.proof, + }); + + expect(verifyRes.status).toBe(201); + expect(verifyRes.body.data.accessToken).toBeDefined(); + expect(verifyRes.body.data.user.username).toBe(legacyUser.username); + }); + + it('should verify sudo mode with TOTP', async () => { + // 进入 sudo 模式 + validToken = await verifySudoWithSRP( + app.getHttpServer(), + validToken, + TestUsername, + TestNewPassword, + ); + + // 首先启用 TOTP - 获取 secret + const enable2FARes = await request(app.getHttpServer()) + .post(`/users/${testUserId}/2fa/enable`) + .set('Authorization', `Bearer ${validToken}`) + .send({}) + .expect(201); + + const secret = enable2FARes.body.data.secret; + + // 使用 otplib 生成真实的 TOTP 验证码 + const totpCode = authenticator.generate(secret); + + // 完成 TOTP 设置 + const setupRes = await request(app.getHttpServer()) + .post(`/users/${testUserId}/2fa/enable`) + .set('Authorization', `Bearer ${validToken}`) + .send({ + secret, + code: totpCode, + }) + .expect(201); + + expect(setupRes.body.data.backup_codes).toBeDefined(); + expect(Array.isArray(setupRes.body.data.backup_codes)).toBe(true); + expect(setupRes.body.data.backup_codes.length).toBeGreaterThan(0); + + // 生成新的 TOTP 码用于 sudo 验证 + const sudoTotpCode = authenticator.generate(secret); + + // 使用 TOTP 验证 sudo + const sudoRes = await request(app.getHttpServer()) + .post('/users/auth/sudo') + .set('Authorization', `Bearer ${validToken}`) + .send({ + method: 'totp', + credentials: { + code: sudoTotpCode, + }, + }) + .expect(201); + + expect(sudoRes.body.data.accessToken).toBeDefined(); + expect(sudoRes.body.message).toBe('Sudo mode activated successfully'); + + // 测试完成后,禁用 2FA + const disableRes = await request(app.getHttpServer()) + .post(`/users/${testUserId}/2fa/disable`) + .set('Authorization', `Bearer ${validToken}`) + .send({}) + .expect(200); + + // 验证 2FA 已被禁用 + const statusRes = await request(app.getHttpServer()) + .get(`/users/${testUserId}/2fa/status`) + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + expect(statusRes.body.data.enabled).toBe(false); + }); + + it('should reject sudo verification with wrong password', async () => { + const res = await request(app.getHttpServer()) + .post('/users/auth/sudo') + .set('Authorization', `Bearer ${validToken}`) + .send({ + method: 'password', + credentials: { + password: 'wrong-password', + }, + }) + .expect(401); + + expect(res.body.message).toMatch(/InvalidCredentialsError/); + }); + + it('should reject sudo verification with wrong TOTP code', async () => { + const res = await request(app.getHttpServer()) + .post('/users/auth/sudo') + .set('Authorization', `Bearer ${validToken}`) + .send({ + method: 'totp', + credentials: { + code: '000000', + }, + }) + .expect(401); + + expect(res.body.message).toMatch(/InvalidCredentialsError/); + }); + + it('should reject sudo verification with invalid SRP proof', async () => { + const clientEphemeral = srpClient.generateEphemeral(); + + const agent = request.agent(app.getHttpServer()); + + await agent + .post('/users/auth/sudo') + .set('Authorization', `Bearer ${validToken}`) + .send({ + method: 'srp', + credentials: { + clientPublicEphemeral: clientEphemeral.public, + }, + }); + + const res = await agent + .post('/users/auth/sudo') + .set('Authorization', `Bearer ${validToken}`) + .send({ + method: 'srp', + credentials: { + clientProof: 'invalid-proof', + }, + }) + .expect(401); + + expect(res.body.message).toMatch(/SrpVerificationError/); + }); + }); + + describe('Passkey Authentication Endpoints', () => { + let testUserId: number; + let validToken: string; + const passkeyTestUsername = `PasskeyUser-${Math.floor(Math.random() * 10000000000)}`; + const passkeyTestPassword = 'Passkey@123'; + const passkeyTestEmail = `passkey-${Math.floor(Math.random() * 10000000000)}@ruc.edu.cn`; + + beforeAll(async () => { + jest.useFakeTimers({ advanceTimers: true }); + + // 1. 发送验证邮件 + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + .send({ + email: passkeyTestEmail, + }); + expect(respond1.status).toBe(201); + + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + + jest.advanceTimersByTime(9 * 60 * 1000); + + // 2. 使用 SRP 注册新用户 + const salt = srpClient.generateSalt(); + const privateKey = srpClient.derivePrivateKey( + salt, + passkeyTestUsername, + passkeyTestPassword, + ); + const verifier = srpClient.deriveVerifier(privateKey); + + const registerResponse = await request(app.getHttpServer()) + .post('/users') + .send({ + username: passkeyTestUsername, + nickname: 'passkey_test', + srpSalt: salt, + srpVerifier: verifier, + email: passkeyTestEmail, + emailCode: verificationCode, + }); + + expect(registerResponse.status).toBe(201); + + // 3. 使用 SRP 登录 + const auth = await loginWithSRP( + app.getHttpServer(), + passkeyTestUsername, + passkeyTestPassword, + ); + validToken = auth.accessToken; + testUserId = auth.userId; + + // 4. 进入 sudo 模式 + validToken = await verifySudoWithSRP( + app.getHttpServer(), + validToken, + passkeyTestUsername, + passkeyTestPassword, + ); + + jest.useRealTimers(); + }); + + it('POST /users/{userId}/passkeys/options should return registration options', async () => { + const res = await request(app.getHttpServer()) + .post(`/users/${testUserId}/passkeys/options`) + .set('Authorization', `Bearer ${validToken}`) + .send() + .expect(201); + expect(res.body.data.options).toBeDefined(); + }); + + it('POST /users/{userId}/passkeys should register a new passkey', async () => { + // 先获取注册选项 + const resOptions = await request(app.getHttpServer()) + .post(`/users/${testUserId}/passkeys/options`) + .set('Authorization', `Bearer ${validToken}`) + .send() + .expect(201); + + const fakeRegistrationResponse = { + id: 'cred-id', + rawId: 'raw-cred-id', + response: {}, + type: 'public-key', + clientExtensionResults: {}, + }; + + const res = await request(app.getHttpServer()) + .post(`/users/${testUserId}/passkeys`) + .set('Authorization', `Bearer ${validToken}`) + .send({ response: fakeRegistrationResponse }) + .expect(201); + expect(res.body.message).toBe('Passkey registered successfully.'); + }); + + it('POST /users/auth/passkey/options should return authentication options', async () => { + const res = await request(app.getHttpServer()) + .post('/users/auth/passkey/options') + .send() + .expect(201); + expect(res.body.data.options).toBeDefined(); + }); + + it('POST /users/auth/passkey/verify should verify passkey login and return tokens', async () => { + // 先获取认证选项 + const resOptions = await request(app.getHttpServer()) + .post('/users/auth/passkey/options') + .send() + .expect(201); + const cookies = resOptions.headers['set-cookie']; + + const fakeAuthResponse = { + id: 'cred-id', + rawId: 'raw-cred-id', + response: {}, + type: 'public-key', + clientExtensionResults: {}, + }; + + const res = await request(app.getHttpServer()) + .post('/users/auth/passkey/verify') + .set('Cookie', cookies) + .send({ response: fakeAuthResponse }) + .expect(201); + expect(res.body.data.user).toBeDefined(); + expect(res.body.data.accessToken).toBeDefined(); + }); + + it('GET /users/{userId}/passkeys should return user passkeys list', async () => { + const res = await request(app.getHttpServer()) + .get(`/users/${testUserId}/passkeys`) + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + expect(Array.isArray(res.body.data.passkeys)).toBe(true); + }); + + it('DELETE /users/{userId}/passkeys/{credentialId} should delete passkey', async () => { + const res = await request(app.getHttpServer()) + .delete(`/users/${testUserId}/passkeys/cred-id`) + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + expect(res.body.message).toBe('Delete passkey successfully.'); + }); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/test/user.follow.e2e-spec.ts b/test/user.follow.e2e-spec.ts new file mode 100644 index 00000000..874663ec --- /dev/null +++ b/test/user.follow.e2e-spec.ts @@ -0,0 +1,435 @@ +/* + * Description: This file tests the following submodule of user module. + * + * Author(s): + * Nictheboy Li + * + */ + +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; +jest.mock('../src/email/email.service'); + +describe('Following Submodule of User Module', () => { + let app: INestApplication; + const MockedEmailService = >EmailService; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + let TestToken: string; + let TestUserId: number; + const tempUserIds: number[] = []; + const tempUserTokens: string[] = []; + + async function createAuxiliaryUser(): Promise<[number, string]> { + // returns [userId, accessToken] + const email = `test-${Math.floor(Math.random() * 10000000000)}@ruc.edu.cn`; + const respond = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ email }); + expect(respond.status).toBe(201); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.at(-1)[1]; + const respond2 = await request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: `TestUser-${Math.floor(Math.random() * 10000000000)}`, + nickname: 'auxiliary_user', + password: 'abc123456!!!', + email, + emailCode: verificationCode, + isLegacyAuth: true, + }); + expect(respond2.status).toBe(201); + return [respond2.body.data.user.id, respond2.body.data.accessToken]; + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }, 20000); + + beforeEach(() => { + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.results.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.results.length = 0; + }); + + describe('preparation', () => { + it(`should send an email and register a user ${TestUsername}`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith(TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: TestUsername, + nickname: 'test_user', + password: 'abc123456!!!', + email: TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual('Register successfully.'); + expect(respond.body.code).toEqual(201); + req.expect(201); + expect(respond.body.data.accessToken).toBeDefined(); + TestToken = respond.body.data.accessToken; + TestUserId = respond.body.data.user.id; + }); + }); + + describe('follow logic', () => { + it('should successfully create some auxiliary users first', async () => { + for (let i = 0; i < 10; i++) { + const [id, token] = await createAuxiliaryUser(); + tempUserIds.push(id); + tempUserTokens.push(token); + } + }); + + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()).post( + `/users/${tempUserIds[0]}/followers`, + ); + //.set('User-Agent', 'PostmanRuntime/7.26.8') + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + expect(respond.body.code).toBe(401); + }); + + it('should return UserIdNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .post(`/users/${tempUserIds[0] + 1000000000}/followers`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.message).toMatch(/^UserIdNotFoundError: /); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + + it('should return FollowYourselfError', async () => { + const respond = await request(app.getHttpServer()) + .post(`/users/${TestUserId}/followers`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.message).toMatch(/^FollowYourselfError: /); + expect(respond.status).toBe(422); + expect(respond.body.code).toBe(422); + }); + + it('should follow user successfully', async () => { + for (const id of tempUserIds) { + const respond = await request(app.getHttpServer()) + .post(`/users/${id}/followers`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.message).toBe('Follow user successfully.'); + expect(respond.status).toBe(201); + expect(respond.body.code).toBe(201); + } + }); + + it('should follow user successfully', async () => { + for (const token of tempUserTokens) { + const respond = await request(app.getHttpServer()) + .post(`/users/${TestUserId}/followers`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + token) + .send(); + expect(respond.body.message).toBe('Follow user successfully.'); + expect(respond.status).toBe(201); + expect(respond.body.code).toBe(201); + } + }); + + it('should return updated statistic info when getting user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}`) + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.data.user.follow_count).toBe(tempUserIds.length); + expect(respond.body.data.user.fans_count).toBe(tempUserIds.length); + expect(respond.body.data.user.is_follow).toBe(false); + }); + + it('should return updated statistic info when getting user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}`) + .set('authorization', 'Bearer ' + tempUserTokens[0]); + expect(respond.body.data.user.follow_count).toBe(tempUserIds.length); + expect(respond.body.data.user.fans_count).toBe(tempUserIds.length); + expect(respond.body.data.user.is_follow).toBe(true); + }); + + it('should return updated statistic info when getting user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${tempUserIds[0]}`) + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.data.user.follow_count).toBe(1); + expect(respond.body.data.user.fans_count).toBe(1); + expect(respond.body.data.user.is_follow).toBe(true); + }); + + it('should return UserAlreadyFollowedError', async () => { + const respond = await request(app.getHttpServer()) + .post(`/users/${tempUserIds[0]}/followers`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.message).toMatch(/^UserAlreadyFollowedError: /); + expect(respond.status).toBe(422); + expect(respond.body.code).toBe(422); + }); + + let unfollowedUserId: number; + it('should unfollow user successfully', async () => { + unfollowedUserId = tempUserIds.at(-1)!; + const respond = await request(app.getHttpServer()) + .delete(`/users/${unfollowedUserId}/followers`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.message).toBe('Unfollow user successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + tempUserIds.pop(); + }); + + it('should return UserNotFollowedYetError', async () => { + const respond = await request(app.getHttpServer()) + .delete(`/users/${unfollowedUserId}/followers`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.message).toMatch(/^UserNotFollowedYetError: /); + expect(respond.status).toBe(422); + expect(respond.body.code).toBe(422); + }); + + it('should get follower list successfully', async () => { + for (const id of tempUserIds) { + const respond = await request(app.getHttpServer()) + .get(`/users/${id}/followers`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.message).toBe('Query followers successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.users.length).toBe(1); + expect(respond.body.data.users[0].id).toBe(TestUserId); + //expect(respond.body.data.users[0].avatar).toBe('default.jpg'); + expect(respond.body.data.page.page_start).toBe(TestUserId); + expect(respond.body.data.page.page_size).toBe(1); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + + const respond2 = await request(app.getHttpServer()) + .get(`/users/${id}/followers?page_start=${TestUserId}&page_size=1`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken); + expect(respond2.body.message).toBe('Query followers successfully.'); + expect(respond2.status).toBe(200); + expect(respond2.body.code).toBe(200); + expect(respond2.body.data.users.length).toBe(1); + expect(respond2.body.data.users[0].id).toBe(TestUserId); + //expect(respond2.body.data.users[0].avatar).toBe('default.jpg'); + expect(respond2.body.data.page.page_start).toBe(TestUserId); + expect(respond2.body.data.page.page_size).toBe(1); + expect(respond2.body.data.page.has_prev).toBe(false); + expect(respond2.body.data.page.prev_start).toBe(0); + expect(respond2.body.data.page.has_more).toBe(false); + expect(respond2.body.data.page.next_start).toBe(0); + } + const respond3 = await request(app.getHttpServer()) + .get(`/users/${TestUserId}/followers`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken) + .send(); + expect(respond3.body.message).toBe('Query followers successfully.'); + expect(respond3.status).toBe(200); + expect(respond3.body.code).toBe(200); + expect(respond3.body.data.users.length).toBe(tempUserTokens.length); + expect(respond3.body.data.users[0].id).toBe(tempUserIds[0]); + expect(respond3.body.data.users[0].avatarId).toBeDefined(); + expect(respond3.body.data.page.page_start).toBe(tempUserIds[0]); + expect(respond3.body.data.page.page_size).toBe(tempUserTokens.length); + expect(respond3.body.data.page.has_prev).toBe(false); + expect(respond3.body.data.page.prev_start).toBe(0); + expect(respond3.body.data.page.has_more).toBe(false); + expect(respond3.body.data.page.next_start).toBe(0); + + const respond4 = await request(app.getHttpServer()) + .get( + `/users/${TestUserId}/followers?page_start=${tempUserIds[3]}&page_size=3`, + ) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken) + .send(); + expect(respond4.body.message).toBe('Query followers successfully.'); + expect(respond4.status).toBe(200); + expect(respond4.body.code).toBe(200); + expect(respond4.body.data.users.length).toBe(3); + expect(respond4.body.data.users[0].id).toBe(tempUserIds[3]); + expect(respond4.body.data.users[1].id).toBe(tempUserIds[4]); + expect(respond4.body.data.users[2].id).toBe(tempUserIds[5]); + expect(respond4.body.data.page.page_start).toBe(tempUserIds[3]); + expect(respond4.body.data.page.page_size).toBe(3); + expect(respond4.body.data.page.has_prev).toBe(true); + expect(respond4.body.data.page.prev_start).toBe(tempUserIds[0]); + expect(respond4.body.data.page.has_more).toBe(true); + expect(respond4.body.data.page.next_start).toBe(tempUserIds[6]); + }); + + it('should get following list and split the page successfully', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}/follow/users`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.message).toBe('Query followees successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.users.length).toBe(9); + expect(respond.body.data.users[0].id).toBe(tempUserIds[0]); + expect(respond.body.data.page.page_start).toBe(tempUserIds[0]); + expect(respond.body.data.page.page_size).toBe(9); + expect(respond.body.data.page.has_prev).toBe(false); + expect(respond.body.data.page.prev_start).toBe(0); + expect(respond.body.data.page.has_more).toBe(false); + expect(respond.body.data.page.next_start).toBe(0); + + const respond2 = await request(app.getHttpServer()) + .get( + `/users/${TestUserId}/follow/users?page_start=${tempUserIds[0]}&page_size=1`, + ) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken); + expect(respond2.body.message).toBe('Query followees successfully.'); + expect(respond2.status).toBe(200); + expect(respond2.body.code).toBe(200); + expect(respond2.body.data.users.length).toBe(1); + expect(respond2.body.data.users[0].id).toBe(tempUserIds[0]); + expect(respond2.body.data.page.page_start).toBe(tempUserIds[0]); + expect(respond2.body.data.page.page_size).toBe(1); + expect(respond2.body.data.page.has_prev).toBe(false); + expect(respond2.body.data.page.prev_start).toBe(0); + expect(respond2.body.data.page.has_more).toBe(true); + expect(respond2.body.data.page.next_start).toBe(tempUserIds[1]); + + const respond3 = await request(app.getHttpServer()) + .get( + `/users/${TestUserId}/follow/users?page_start=${tempUserIds[2]}&page_size=2`, + ) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken); + expect(respond3.body.message).toBe('Query followees successfully.'); + expect(respond3.status).toBe(200); + expect(respond3.body.code).toBe(200); + expect(respond3.body.data.users.length).toBe(2); + expect(respond3.body.data.users[0].id).toBe(tempUserIds[2]); + expect(respond3.body.data.page.page_start).toBe(tempUserIds[2]); + expect(respond3.body.data.page.page_size).toBe(2); + expect(respond3.body.data.page.has_prev).toBe(true); + expect(respond3.body.data.page.prev_start).toBe(tempUserIds[0]); + expect(respond3.body.data.page.has_more).toBe(true); + expect(respond3.body.data.page.next_start).toBe(tempUserIds[4]); + }, 20000); + }); + + describe('statistics', () => { + // it('should return updated statistic info when getting user', async () => { + // const respond = await request(app.getHttpServer()).get( + // `/users/${TestUserId}`, + // ); + // expect(respond.body.data.user.follow_count).toBe(tempUserIds.length); + // expect(respond.body.data.user.fans_count).toBe(tempUserIds.length + 1); + // expect(respond.body.data.user.is_follow).toBe(false); + // }); + + it('should return updated statistic info when getting user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}`) + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.data.user.follow_count).toBe(tempUserIds.length); + expect(respond.body.data.user.fans_count).toBe(tempUserIds.length + 1); + expect(respond.body.data.user.is_follow).toBe(false); + }); + + it('should return updated statistic info when getting user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${tempUserIds[0]}`) + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.data.user.follow_count).toBe(1); + expect(respond.body.data.user.fans_count).toBe(1); + expect(respond.body.data.user.is_follow).toBe(true); + }); + + // it('should return updated statistic info when getting user', async () => { + // const respond = await request(app.getHttpServer()).get( + // `/users/${tempUserIds[0]}`, + // ); + // expect(respond.body.data.user.follow_count).toBe(1); + // expect(respond.body.data.user.fans_count).toBe(1); + // expect(respond.body.data.user.is_follow).toBe(false); + // }); + + it('should return updated statistic info when getting user', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${tempUserIds[0]}`) + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.data.user.follow_count).toBe(1); + expect(respond.body.data.user.fans_count).toBe(1); + expect(respond.body.data.user.is_follow).toBe(true); + }); + + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${tempUserIds[0]}/followers`) + .send(); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + expect(respond.body.code).toBe(401); + }); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/test/user.profile.e2e-spec.ts b/test/user.profile.e2e-spec.ts new file mode 100644 index 00000000..8a917c78 --- /dev/null +++ b/test/user.profile.e2e-spec.ts @@ -0,0 +1,210 @@ +/* + * Description: This file tests the profile submodule of user module. + * + * Author(s): + * Nictheboy Li + * + */ + +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { EmailService } from '../src/email/email.service'; +jest.mock('../src/email/email.service'); + +describe('Profile Submodule of User Module', () => { + let app: INestApplication; + const MockedEmailService = >EmailService; + const TestUsername = `TestUser-${Math.floor(Math.random() * 10000000000)}`; + const TestEmail = `test-${Math.floor( + Math.random() * 10000000000, + )}@ruc.edu.cn`; + let TestToken: string; + let TestUserId: number; + let UpdateAvatarId: number; + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }, 20000); + + beforeEach(() => { + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.results.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.calls.length = 0; + ( + MockedEmailService.mock.instances[0].sendPasswordResetEmail as jest.Mock + ).mock.results.length = 0; + }); + + describe('preparation', () => { + it(`should send an email and register a user ${TestUsername}`, async () => { + const respond1 = await request(app.getHttpServer()) + .post('/users/verify/email') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + email: TestEmail, + }); + expect(respond1.body).toStrictEqual({ + code: 201, + message: 'Send email successfully.', + }); + expect(respond1.status).toBe(201); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveReturnedTimes(1); + expect( + MockedEmailService.mock.instances[0].sendRegisterCode, + ).toHaveBeenCalledWith(TestEmail, expect.any(String)); + const verificationCode = ( + MockedEmailService.mock.instances[0].sendRegisterCode as jest.Mock + ).mock.calls[0][1]; + const req = request(app.getHttpServer()) + .post('/users') + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + username: TestUsername, + nickname: 'test_user', + password: 'abc123456!!!', + email: TestEmail, + emailCode: verificationCode, + isLegacyAuth: true, + }); + const respond = await req; + expect(respond.body.message).toStrictEqual('Register successfully.'); + expect(respond.body.code).toEqual(201); + req.expect(201); + expect(respond.body.data.accessToken).toBeDefined(); + TestToken = respond.body.data.accessToken; + TestUserId = respond.body.data.user.id; + }); + }); + + it('should upload an avatar for updating', async () => { + async function uploadAvatar() { + const respond = await request(app.getHttpServer()) + .post('/avatars') + .set('Authorization', `Bearer ${TestToken}`) + .attach('avatar', 'src/resources/avatars/default.jpg'); + expect(respond.status).toBe(201); + expect(respond.body.message).toBe('Upload avatar successfully'); + expect(respond.body.data).toHaveProperty('avatarId'); + return respond.body.data.avatarId; + } + UpdateAvatarId = await uploadAvatar(); + }); + + describe('update user profile', () => { + it('should update user profile', async () => { + const respond = await request(app.getHttpServer()) + .put(`/users/${TestUserId}`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken) + .send({ + nickname: 'test_user_updated', + intro: 'test user updated', + avatarId: UpdateAvatarId, + }); + expect(respond.body.message).toBe('Update user successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + }); + it('should return InvalidTokenError', async () => { + const respond = await request(app.getHttpServer()) + .put(`/users/${TestUserId}`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken + '1') + .send({ + nickname: 'test_user_updated', + intro: 'test user updated', + avatarId: UpdateAvatarId, + }); + expect(respond.body.message).toMatch(/^InvalidTokenError: /); + expect(respond.status).toBe(401); + expect(respond.body.code).toBe(401); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()) + .put(`/users/${TestUserId}`) + //.set('User-Agent', 'PostmanRuntime/7.26.8') + .send({ + nickname: 'test_user_updated', + intro: 'test user updated', + avatarId: UpdateAvatarId, + }); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + expect(respond.body.code).toBe(401); + }); + }); + + describe('get user profile', () => { + it('should get modified user profile', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/${TestUserId}`) + .set('User-Agent', 'PostmanRuntime/7.26.8') + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.message).toBe('Query user successfully.'); + expect(respond.status).toBe(200); + expect(respond.body.code).toBe(200); + expect(respond.body.data.user.username).toBe(TestUsername); + expect(respond.body.data.user.nickname).toBe('test_user_updated'); + expect(respond.body.data.user.avatarId).toBe(UpdateAvatarId); + expect(respond.body.data.user.intro).toBe('test user updated'); + expect(respond.body.data.user.follow_count).toBe(0); + expect(respond.body.data.user.fans_count).toBe(0); + expect(respond.body.data.user.question_count).toBe(0); + expect(respond.body.data.user.answer_count).toBe(0); + expect(respond.body.data.user.is_follow).toBe(false); + }); + // it('should get modified user profile even without a token', async () => { + // const respond = await request(app.getHttpServer()).get( + // `/users/${TestUserId}`, + // ); + // //.set('User-Agent', 'PostmanRuntime/7.26.8') + // //.set('authorization', 'Bearer ' + TestToken); + // expect(respond.body.message).toBe('Query user successfully.'); + // expect(respond.status).toBe(200); + // expect(respond.body.code).toBe(200); + // expect(respond.body.data.user.username).toBe(TestUsername); + // expect(respond.body.data.user.nickname).toBe('test_user_updated'); + // expect(respond.body.data.user.avatarId).toBe(UpdateAvatarId); + // expect(respond.body.data.user.intro).toBe('test user updated'); + // expect(respond.body.data.user.follow_count).toBe(0); + // expect(respond.body.data.user.fans_count).toBe(0); + // expect(respond.body.data.user.question_count).toBe(0); + // expect(respond.body.data.user.answer_count).toBe(0); + // expect(respond.body.data.user.is_follow).toBe(false); + // }); + it('should return UserIdNotFoundError', async () => { + const respond = await request(app.getHttpServer()) + .get(`/users/-1`) + .set('authorization', 'Bearer ' + TestToken); + expect(respond.body.message).toMatch(/^UserIdNotFoundError: /); + expect(respond.status).toBe(404); + expect(respond.body.code).toBe(404); + }); + it('should return AuthenticationRequiredError', async () => { + const respond = await request(app.getHttpServer()).get( + `/users/${TestUserId}`, + ); + expect(respond.body.message).toMatch(/^AuthenticationRequiredError: /); + expect(respond.status).toBe(401); + expect(respond.body.code).toBe(401); + }); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 00000000..64f86c6b --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..dbcd6790 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "esModuleInterop": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "strictPropertyInitialization": false, + "noImplicitAny": true, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": false, + "strict": true, + "typeRoots": ["./node_modules/@types"], + "types": ["node", "jest"] + } +}