diff --git a/.commitlintrc.mjs b/.commitlintrc.mjs new file mode 100644 index 00000000..803dc4f2 --- /dev/null +++ b/.commitlintrc.mjs @@ -0,0 +1,3 @@ +export default { + extends: ['@commitlint/config-conventional'], +}; diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..6918d361 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules +npm-debug.log +yarn-error.log +pnpm-debug.log +infra/k6/node_modules + +# Build output +dist +artifacts +out + +# Environment variables +.env +.env.production +.env.local +!.env.example + +# Docker / Infrastructure +docker-compose.yml +docker-compose.*.yml +Dockerfile +Dockerfile.* +.dockerignore + +# Git +.git +.gitignore +.gitattributes + +# Editor / OS +.vscode +.idea +.DS_Store +*.swp +*.log + +# Tests and Coverage +coverage +test-results +*.spec.ts +*.e2e-spec.ts diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..5d7ec36a --- /dev/null +++ b/.env.example @@ -0,0 +1,64 @@ +# --- APP --- +PORT=3000 +NODE_ENV=development +COOKIE_SECRET=same-serious-secret +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +THROTTLE_LIMIT=100 +THROTTLE_TTL=60000 + +# --- POSTGRES --- +DB_SCHEMA=base + +# ВАЖНО: +# Для работы ВНУТРИ Docker: используй хост 'database' и порт 5432 +# DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@database:5432/${DB_DATABASE} + +# Для работы ЛОКАЛЬНО (без докера): используй 'localhost' и порт 6000 +DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_DATABASE} + +# --- REDIS --- +# in the docker network will be, not show port redis, at prod env +# REDIS_HOST=redis +# at development mode +REDIS_HOST=127.0.0.1 +REDIS_PORT=7000 +REDIS_PASSWORD=same-password + +JWT_AUDIENCE="task-tracker-client" + +JWT_ACCESS_SECRET=same-same-same-same-same +JWT_ACCESS_EXPIRES_IN=15m + +JWT_REFRESH_SECRET=same-same-same-same-same +JWT_REFRESH_EXPIRES_IN=15m + +# --- MAIL SETTINGS --- +MAIL_HOST=smtp.gmail.com +MAIL_PORT=465 +MAIL_USER=example@gmail.com + +# 16x password +MAIL_PASSWORD=xxxxxxxxyyyyyyyy +MAIL_FROM_NAME="Task Tracker" +MAIL_FROM_EMAIL=example@gmail.com + +S3_BUCKET_NAME='' +S3_ENDPOINT='' +S3_REGION='' +S3_ACCESS_KEY='' +S3_SECRET_KEY='' + +IMAGOR_SECRET='' +IMAGOR_URL='' + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +YANDEX_CLIENT_ID= +YANDEX_CLIENT_SECRET= + +VKONTAKTE_CLIENT_ID= +VKONTAKTE_CLIENT_SECRET= \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 259de13c..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - tsconfigRootDir: __dirname, - sourceType: 'module', - }, - plugins: ['@typescript-eslint/eslint-plugin'], - extends: [ - 'plugin:@typescript-eslint/recommended', - 'plugin:prettier/recommended', - ], - 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/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..26f3a9a1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,41 @@ +name: 'Bug Report' +description: 'Сообщить об ошибке в работе приложения' +labels: ['bug', 'triage'] +body: + - type: markdown + attributes: + value: | + Спасибо, что решили помочь сделать проект лучше! + - type: input + id: version + attributes: + label: 'Версия приложения' + description: 'Какую версию вы используете? (например, 0.0.1)' + placeholder: '0.0.x' + validations: + required: true + - type: textarea + id: steps + attributes: + label: 'Шаги воспроизведения' + description: 'Как нам увидеть эту ошибку?' + placeholder: | + 1. Запустить docker-compose + 2. Отправить POST запрос на /api/v1/auth... + validations: + required: true + - type: dropdown + id: environment + attributes: + label: 'Окружение' + options: + - Docker + - Local (pnpm) + - Production + validations: + required: true + - type: textarea + id: expected + attributes: + label: 'Ожидаемое поведение' + placeholder: 'Что должно было произойти?' diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..c6c92927 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,10 @@ +blank_issues_enabled: false + +contact_links: + - name: '❓ Вопросы по использованию' + url: 'https://github.com/Task-Tracker-Lab/task-tracker-backend/discussions/new?category=q-a' + about: 'Если вы не уверены, баг это или нет, или вам нужна помощь в настройке — спросите здесь.' + + - name: '💡 Идеи и предложения' + url: 'https://github.com/Task-Tracker-Lab/task-tracker-backend/discussions/new?category=ideas' + about: 'Хотите обсудить новую крутую фичу перед тем, как заводить задачу? Вам сюда.' diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..f356707d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,18 @@ +name: 'Feature Request' +description: 'Предложить новую идею или улучшение' +labels: ['enhancement'] +body: + - type: textarea + id: problem + attributes: + label: 'Какую проблему мы решаем?' + description: 'Опишите, почему текущего функционала недостаточно.' + validations: + required: true + - type: textarea + id: solution + attributes: + label: 'Ваше предложение' + description: 'Как именно вы видите реализацию этой фичи?' + validations: + required: true diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..35ee000d --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,56 @@ +core: + - changed-files: + - any-glob-to-any-file: 'src/**/*' + - all-globs-to-all-files: + - '!src/shared/entities/**/*' + - '!src/**/*.spec.{ts,js}' + +database: + - changed-files: + - any-glob-to-any-file: + - 'src/shared/entities/**/*' + - 'libs/database/**/*' + - 'migrations/**/*' + - 'drizzle.config.ts' + +dependencies: + - changed-files: + - any-glob-to-any-file: + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + +devops: + - changed-files: + - any-glob-to-any-file: + - 'infra/**/*' + - '.github/workflows/**/*' + - 'Dockerfile*' + - '.dockerignore' + +testing: + - changed-files: + - any-glob-to-any-file: + - 'test/**/*' + - 'src/**/*.spec.{ts,js}' + - 'k6/**/*' + - 'vitest.config*' + +libs: + - changed-files: + - any-glob-to-any-file: 'libs/**/*' + - all-globs-to-all-files: + - '!libs/database/**/*' + +dx: + - changed-files: + - any-glob-to-any-file: + - 'pnpm-workspace.yaml' + - '.*' + - '!package.json' + +documentation: + - changed-files: + - any-glob-to-any-file: + - '**/*.md' + - 'LICENSE' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..fdc2afc0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,73 @@ +name: Build and Push + +on: + push: + branches: [main, dev, 'feat/**', 'fix/**', 'refactor/**', 'chore/**'] + pull_request: + branches: [main, dev] + workflow_dispatch: + inputs: + force_push: + description: 'Force push image to registry?' + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-push: + runs-on: ubuntu-latest + env: + IS_BASE_BRANCH: ${{ github.ref_name == 'main' || github.ref_name == 'dev' }} + IS_PUSH: ${{ github.event_name == 'push' }} + FORCE_PUSH: ${{ github.event.inputs.force_push == 'true' }} + + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + if: ${{ (env.IS_PUSH == 'true' && env.IS_BASE_BRANCH == 'true') || + env.FORCE_PUSH == 'true' }} + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=sha,format=short + # latest вешаем только когда мерджим в main + type=raw,value=latest,enable=${{ github.ref_name == 'main' }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.prod + push: ${{ (env.IS_PUSH == 'true' && env.IS_BASE_BRANCH == 'true') || + env.FORCE_PUSH == 'true' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..763a6e58 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: + - main + - dev + - 'feat/**' + - 'fix/**' + - 'refactor/**' + - 'chore/**' + - 'perf/**' + - 'build/**' + - 'ci/**' + paths-ignore: + - '**.md' + - 'infra/**' + - '.gitignore' + - 'docker-compose.yml' + + pull_request: + branches: + - main + - dev + paths-ignore: + - '**.md' + - 'infra/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + quality-check: + name: Lint & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Run Lint + run: pnpm run lint + + - name: Type Check + run: pnpm exec tsc --noEmit + + - name: Run Tests + run: pnpm run test diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml new file mode 100644 index 00000000..407b8a90 --- /dev/null +++ b/.github/workflows/cleanup.yml @@ -0,0 +1,45 @@ +name: 'Cleanup' + +on: + schedule: + - cron: '0 0 * * 0' + workflow_dispatch: + +permissions: + actions: write + contents: read + +jobs: + garbage-collector: + name: 'Purge Storage' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: 'Clean Actions Cache' + shell: bash + run: | + echo "::group::Deleting Caches" + gh cache delete --all --succeed-on-no-caches || echo "Caches already empty or cleared" + echo "::endgroup::" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: 'Clean Old Artifacts' + shell: bash + run: | + echo "::group::Deleting Artifacts" + artifacts=$(gh api repos/${{ github.repository }}/actions/artifacts --paginate -q '.artifacts[].id' || echo "") + + if [ -n "$artifacts" ]; then + for id in $artifacts; do + gh api -X DELETE repos/${{ github.repository }}/actions/artifacts/$id || true + done + echo "Artifacts cleared." + else + echo "No artifacts found." + fi + echo "::endgroup::" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..cc77c3fa --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,46 @@ +name: 'CodeQL' + +on: + push: + branches: [main, dev] + paths-ignore: + - '**.md' + - 'infra/**' + - 'migrations/**' + pull_request: + branches: [main, dev] + paths-ignore: + - '**.md' + schedule: + - cron: '15 13 * * 5' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + timeout-minutes: 360 + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + queries: security-extended + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: '/language:javascript-typescript' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 00000000..7b94cf66 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,38 @@ +name: 'Pull Request Labeler' + +on: + # Важно: target позволяет работать в PR из форков + pull_request_target: + types: [opened, synchronize] + +jobs: + label: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - name: Ensure Labels Exist + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + labels=( + "database:5319e7:Database schema and migrations" + "core:e99695:Main application logic" + "testing:ff69b4:Unit and E2E tests" + "devops:006b75:Infrastructure and CI/CD" + "shared-libs:bfdadc:Shared libraries in libs/" + "dx:eeeeee:Developer experience and configs" + "documentation:0075ca:Documentation and markdown files" + ) + + for label in "${labels[@]}"; do + IFS=":" read -r name color desc <<< "$label" + gh label create "$name" --color "$color" --description "$desc" --repo ${{ github.repository }} || true + done + + - name: Run Labeler + uses: actions/labeler@v5 + with: + configuration-path: .github/labeler.yml + sync-labels: true diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml new file mode 100644 index 00000000..4847ac52 --- /dev/null +++ b/.github/workflows/migrations.yml @@ -0,0 +1,85 @@ +name: 'Database Consistency Check' + +on: + pull_request: + branches: [main, dev] + paths: + - 'src/shared/entities/**' + - 'migrations/**' + - 'drizzle.config.ts' + - 'package.json' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + DB_USER: tracker + DB_PASSWORD: super-tracker-password + DB_NAME: test + DB_URL: postgres://tracker:super-tracker-password@localhost:5432/test + +jobs: + check: + name: 'Check & Test' + runs-on: ubuntu-latest + timeout-minutes: 10 + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_USER: ${{ env.DB_USER }} + POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }} + POSTGRES_DB: ${{ env.DB_NAME }} + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + + - name: Install + run: | + echo "::group::pnpm install" + pnpm install --frozen-lockfile + echo "::endgroup::" + + - name: Drift Check + env: + DATABASE_URL: ${{ env.DB_URL }} + run: | + echo "::group::Drizzle Check" + pnpm drizzle-kit check + echo "::endgroup::" + + - name: Seed + run: | + echo "::group::Seed Data" + # pnpm ts-node ./scripts/seed-ci.ts + echo "Done" + echo "::endgroup::" + + - name: Migrate + env: + DATABASE_URL: ${{ env.DB_URL }} + run: | + echo "::group::Apply Schema" + pnpm drizzle-kit push --force + echo "::endgroup::" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 00000000..c7cfee18 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,72 @@ +name: release-please + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@v4 + id: release + with: + release-type: node + + publish-release: + needs: release-please + if: ${{ needs.release-please.outputs.release_created }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + # Версия из тега (например, 1.2.3) + type=semver,pattern={{version}},value=${{ needs.release-please.outputs.tag_name }} + # Мажорная версия (например, 1) + type=semver,pattern={{major}},value=${{ needs.release-please.outputs.tag_name }} + # Всегда обновляем latest для продакшена + type=raw,value=latest + + - name: Build and push Production Image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.prod + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..fac0b763 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,37 @@ +name: 'Close stale issues and PRs' + +on: + schedule: + - cron: '30 1 * * *' + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v9 + with: + stale-issue-message: >- + Эта задача давно не обновлялась. Она будет закрыта через 5 + дней, если не появится новой активности. 🤖 + stale-pr-message: >- + Этот PR замер. Мы закроем его через 5 дней, чтобы не + копить очередь, но вы всегда можете переоткрыть его + позже. 🤖 + + days-before-stale: 30 + days-before-close: 5 + + exempt-issue-labels: 'bug,priority:high,pinned,security' + exempt-pr-labels: 'dependencies,security' + exempt-all-milestones: true + + stale-issue-label: 'stale' + stale-pr-label: 'stale' + + operations-per-run: 50 + ascending: true diff --git a/.gitignore b/.gitignore index 5866f4e4..b0b24123 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,6 @@ pids *.pid *.seed *.pid.lock -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json \ No newline at end of file +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +infra/k6/data/*.json \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 00000000..0a4b97de --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no -- commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..e02c24e2 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged \ No newline at end of file diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs new file mode 100644 index 00000000..0ce6883d --- /dev/null +++ b/.lintstagedrc.mjs @@ -0,0 +1,4 @@ +export default { + '*.{ts,js}': ['eslint --fix', 'prettier --write'], + '*.{json,css,md,yaml,yml}': ['prettier --write'], +}; diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..c8d0ce74 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +dist +node_modules +pnpm-lock.yaml + +migrations + +*.sql +Dockerfile* +.dockerignore \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index dcb72794..a6643be4 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,7 @@ { - "singleQuote": true, - "trailingComma": "all" -} \ No newline at end of file + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 4, + "semi": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..ba92e259 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,31 @@ +{ + "eslint.enable": true, + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], + "eslint.options": { + "overrideConfigFile": "eslint.config.mjs" + }, + "eslint.format.enable": true, + "eslint.lintTask.enable": true, + "eslint.run": "onSave", + "eslint.problems.shortenToSingleLine": false, + + "eslint.trace.server": "off", + "eslint.debug": false, + + "editor.formatOnSave": false, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + + "js/ts.preferences.importModuleSpecifier": "relative", + "js/ts.preferences.preferTypeOnlyAutoImports": false, + + "prettier.enable": true, + "prettier.requireConfig": true, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..6aa8cca3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contributing Guidelines 🤝 + +Добро пожаловать в проект! Чтобы поддерживать высокое качество кода и чистоту истории коммитов, мы следуем строгим стандартам разработки. + +## 1. Workflow (Работа с ветками) + +Мы используем **Trunk-based** подход. Это означает, что `main` (trunk) — единственный источник истины. + +- **Запрет на прямые пуши:** Пуш напрямую в `main` (или `master/dev`) строго запрещен. Любые изменения вносятся **только через Pull Request (PR)**. +- **Code Review:** Каждый PR должен пройти обязательное код-ревью перед мерджем. +- **Short-lived branches:** Ветки должны быть короткими (1-2 дня). + +**Правила именования веток:** + +- `feat/` — для новых функциональных возможностей. +- `fix/` — для исправления багов. +- `refactor/` — для переписывания кода без изменения логики. +- `chore/` — для технических задач, настройки окружения и зависимостей +- `docs/` — для обновления документации. + +## 2. Commit Message Convention + +Мы используем стандарт **Conventional Commits**. Это позволяет автоматически генерировать логи изменений и поддерживать историю в чистоте. + +**Формат:** `(): ` + +- **Пример:** `feat(database): add user entity and drizzle migrations` +- **Пример:** `fix(auth): resolve jwt expiration issue` + +## 3. Разработка и стандарты кода + +- **Validation (Zod):** Использование схем **Zod** для эндпоинтов (DTO) обязательно. Без них не будет работать валидация данных и автоматическая типизация в Swagger. +- **Linting & Formatting:** Перед каждым коммитом обязательно запускайте `pnpm lint` и `pnpm format`. Код, не прошедший проверку линтером, не будет принят. +- **Drizzle Migrations:** **Никаких ручных изменений в базе данных.** Все изменения схем должны сопровождаться миграциями. + - Команда: `pnpm db:generate` +- **No God-commits:** Разделяйте свои изменения на небольшие логические коммиты. + +## 4. Pull Request (PR) Process + +Прежде чем отправить PR, убедитесь, что: + +1. **Self-Review:** Вы сами перечитали свой код и удалили отладочные логи (`console.log`). +2. **Checks:** Все автоматические тесты (`pnpm test`) и линтер проходят успешно. +3. **Description:** В описании PR четко указано, что именно было сделано и зачем. + +_PR не принимается, если тесты или линтер упали на этапе CI._ + +## 5. Local Setup + +Для быстрой настройки локального окружения обратитесь к разделу **Quick Start** в [README.md](./README.md). + +Краткий список команд для старта: + +```bash +pnpm install +cp .env.example .env +pnpm db:generate +pnpm db:migrate +pnpm start:dev +``` diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..55b2adf2 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,14 @@ +FROM node:23-alpine + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /app + +COPY package.json pnpm-lock.yaml ./ +COPY tsconfig* ./ + +RUN pnpm install --no-frozen-lockfile + +COPY . . + +CMD ["pnpm", "run", "start:dev"] \ No newline at end of file diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 00000000..e6a6c37a --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,54 @@ +FROM node:22-alpine AS base + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" + +RUN corepack enable + +WORKDIR /app + +FROM base AS fetch + +COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./ + +# Загружаем всё в виртуальное хранилище. +# Если lock-файл не менялся, этот слой будет взят из кэша +ENV CI=true +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm fetch + +FROM fetch AS build +COPY package.json ./ + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install -w --frozen-lockfile --offline + +COPY . . + +RUN pnpm run build + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm prune --prod --ignore-scripts + +FROM node:22-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=${PORT:-1010} + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nestjs + +COPY --from=build --chown=nestjs:nodejs /app/dist ./dist +COPY --from=build --chown=nestjs:nodejs /app/node_modules ./node_modules +COPY --from=build --chown=nestjs:nodejs /app/templates ./templates +COPY --from=build --chown=nestjs:nodejs /app/package.json ./ +COPY --from=build --chown=nestjs:nodejs /app/migrations ./migrations +COPY --from=build --chown=nestjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts + +USER nestjs + +EXPOSE $PORT + +CMD ["node", "dist/main"] diff --git a/README.md b/README.md index 6d83c084..cd789c08 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,55 @@ -# task-tracker-backend -Task-tracker-backend +# Task Tracker Backend 🚀 + +Современная лёгкая open-source система управления IT-проектами (альтернатива Jira/Yandex Tracker). Бэкенд построен на высокопроизводительном стеке с упором на типизацию и скорость разработки. + +**Статус:** `In Development` + +## Технологический стек + +- **Runtime:** Node.js 22+ (pnpm) +- **Framework:** NestJS 11 (**Fastify**) +- **Database:** PostgreSQL + **Drizzle ORM** +- **Validation:** Zod +- **API:** Swagger (OpenAPI) +- **Infrastructure:** Docker (Multi-stage builds) +- **Testing:** Vitest + +## Quick Start + +### 1. Окружение + +Скопируйте пример файла окружения и настройте переменные (БД, API ключи DeepSeek): + +```bash +cp .env.example .env +``` + +### 2. Запуск через Docker (Рекомендуется) + +Проект полностью контейнеризирован: + +```bash +docker-compose up --build +``` + +### 3. Локальный запуск + +Если вы хотите запустить проект без Docker: + +```bash +pnpm install +pnpm db:generate +pnpm db:migrate +pnpm start:dev +``` + +## API Documentation + +После запуска проекта документация доступна по адресу: + +**http://localhost:3000/api/v1/docs** + +## Infrastructure + +- CI/CD: Настроены GitHub Actions для автоматической проверки типов, линтинга и запуска тестов. +- Docker: Используются оптимизированные multi-stage образы для минимизации размера production-билда. diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..025c8ab8 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/shared/entities/index.ts', + out: './migrations', + dialect: 'postgresql', + breakpoints: false, + casing: 'snake_case', + strict: true, + verbose: true, + dbCredentials: { + url: process.env['DATABASE_URL']!, + }, + introspect: { casing: 'camel' }, + schemaFilter: ['*'], +}); diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..9752b6d1 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,205 @@ +// @ts-check +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import pluginJsdoc from 'eslint-plugin-jsdoc'; +import pluginPerfectionist from 'eslint-plugin-perfectionist'; +import functional from 'eslint-plugin-functional'; +import pluginUnicorn from 'eslint-plugin-unicorn'; +import pluginSonarjs from 'eslint-plugin-sonarjs'; + +export default tseslint.config( + { + ignores: ['node_modules', 'dist', '**/*.js', '**/*.d.ts', 'infra', 'migrations'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + plugins: { + functional, + perfectionist: pluginPerfectionist, + unicorn: pluginUnicorn, + sonarjs: pluginSonarjs, + jsdoc: pluginJsdoc, + }, + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + args: 'after-used', + ignoreRestSiblings: true, + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/prefer-readonly': 'off', + '@typescript-eslint/prefer-readonly-parameter-types': 'off', + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + disallowTypeAnnotations: false, + fixStyle: 'separate-type-imports', + }, + ], + 'functional/prefer-readonly-type': 'off', + 'functional/no-conditional-statements': 'off', + 'functional/no-return-void': 'off', + 'functional/immutable-data': 'warn', + 'functional/no-let': 'off', + 'functional/no-expression-statements': 'off', + + 'perfectionist/sort-imports': [ + 'error', + { + type: 'natural', + order: 'asc', + groups: [ + 'builtin', // node:fs + 'external', // @nestjs, rxjs + 'internal', // @core, @shared + 'parent', // ../ + 'sibling', // ./ + 'index', // index.ts + 'type', // type imports + ], + }, + ], + 'no-duplicate-imports': 'error', + + 'unicorn/filename-case': [ + 'error', + { + case: 'kebabCase', + ignore: ['index.ts', '\\.d\\.ts$'], + }, + ], + 'unicorn/prefer-node-protocol': 'error', + 'unicorn/no-array-method-this-argument': 'warn', + 'unicorn/prefer-structured-clone': 'error', + 'unicorn/no-useless-undefined': 'error', + 'unicorn/prefer-export-from': 'error', + 'unicorn/prefer-spread': 'warn', + 'unicorn/no-array-reduce': 'warn', + 'unicorn/no-array-push-push': 'warn', + + 'sonarjs/cognitive-complexity': ['error', 15], + 'sonarjs/no-duplicate-string': ['warn', { threshold: 5 }], + 'sonarjs/no-identical-functions': 'error', + 'sonarjs/no-collapsible-if': 'error', + 'sonarjs/no-unused-collection': 'error', + + 'jsdoc/require-description': ['warn', { descriptionStyle: 'body' }], + 'jsdoc/check-param-names': 'error', + 'jsdoc/check-types': 'error', + 'jsdoc/require-param-type': 'error', + 'jsdoc/require-returns-type': 'error', + + eqeqeq: ['error', 'always'], + curly: ['error', 'all'], + 'no-console': ['warn', { allow: ['warn', 'error'] }], + 'no-return-await': 'error', + 'require-await': 'error', + 'no-var': 'error', + 'prefer-const': 'error', + 'prefer-template': 'error', + 'object-shorthand': 'error', + 'arrow-body-style': ['error', 'as-needed'], + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + + 'no-restricted-syntax': [ + 'error', + { + selector: `ImportDeclaration[importKind=type] > ImportSpecifier[imported.name=/.*Dto/]`, + message: + 'DTO с декораторами должны использовать обычный импорт, а не import type', + }, + { + selector: `ImportDeclaration[importKind=type] > ImportSpecifier[imported.name=/.*Validation/]`, + message: + 'Классы валидации должны использовать обычный импорт, а не import type', + }, + { + selector: `ImportDeclaration[importKind=type] > ImportSpecifier[imported.name=/.*Entity/]`, + message: + 'Entity с декораторами должны использовать обычный импорт, а не import type', + }, + { + selector: 'WhileStatement', + message: + '⚠️ Цикл while может быть бесконечным. Используйте for или for...of с явным счётчиком', + }, + { + selector: 'DoWhileStatement', + message: '⚠️ Цикл do-while сложнее читать. Используйте for или for...of', + }, + { + selector: 'ForInStatement', + message: 'Цикл for-in включает прототип. Используйте Object.keys() + for...of', + }, + { + selector: 'LabeledStatement', + message: 'Метки делают код нечитаемым. Используйте функции с return', + }, + { + selector: 'SwitchStatement[cases.length>10]', + message: '⚠️ Switch с более чем 10 кейсами. Используйте Map или объект', + }, + { + selector: 'SwitchStatement:not([cases.length<=5])', + message: + '⚠️ Большой switch statement. Рассмотрите использование Map или реестра стратегий', + }, + ], + 'no-restricted-properties': [ + 'error', + { + object: 'Array', + property: 'pop', + message: 'Используйте slice/spread вместо pop', + }, + { + object: 'Array', + property: 'splice', + message: 'Используйте filter/slice вместо splice', + }, + { + object: 'Object', + property: 'assign', + message: 'Используйте spread оператор: {...obj, newProp} вместо Object.assign', + }, + ], + }, + }, + { + files: ['infra/**/*.ts', '**/migrations/**/*.ts', '**/*.config.ts', 'libs/**/*.ts'], + rules: { + 'functional/no-conditional-statements': 'off', + 'functional/immutable-data': 'off', + }, + }, + { + files: [ + '**/*.{facade,repository,service,controller,query,use-case,adapter}.ts', + '**/controller.ts', + '**/adapter.ts', + ], + rules: { + 'require-await': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'no-useless-constructor': 'off', + 'sonarjs/cognitive-complexity': 'off', + 'unicorn/no-useless-undefined': 'off', + 'unicorn/prefer-export-from': 'off', + 'functional/immutable-data': 'off', + }, + }, +); diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 00000000..faec6384 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,32 @@ +# Инфраструктура проекта + +Данный каталог содержит конфигурации для локальной разработки и инструменты для нагрузочного тестирования. + +## Модули инфраструктуры + +### dev + +Конфигурации Docker Compose для поднятия окружения разработки (базы данных, очереди, кеш). + +Команда для запуска из корня проекта: + +```sh +docker compose -f ./infra/dev/compose.dev.yaml --env-file .env --profile infra up --build -d -V +``` + +### k6 + +Сценарии нагрузочного и стресс-тестирования модулей API. Инструкции по установке и запуску находятся в infra/k6/README.md. + +Команды запуска из **корня** **(../cwd)** проекта: + +```sh + pnpm run k6:all + pnpm run k6:auth + pnpm run k6:team + pnpm run k6:projects + pnpm run k6:user + pnpm run k6:board + pnpm run k6:tasks + pnpm run k6:smoke +``` diff --git a/infra/dev/README.md b/infra/dev/README.md new file mode 100644 index 00000000..ac46b3a7 --- /dev/null +++ b/infra/dev/README.md @@ -0,0 +1,41 @@ +# Файл для фронт разрабов + +## Описание + +Данный конфиг разворачивает полный инстанс бэкенда (API + DB + Redis) +для локальной разработки фронтенда. + +## ТРЕБОВАНИЯ: + +1. Положить актуальный файл .env в директорию с этим файлом + (путь: ./infra/dev/.env). +2. Наличие Docker Desktop / Docker Engine. + +## ЗАПУСК: + +Выполните команду из корня проекта: + +```sh +docker compose -f ./infra/dev/compose.dev.yaml --profile infra up --pull always --build -d -V +``` + +## ЧТО ВНУТРИ: + +- API: http://localhost:3000 +- Postgres: localhost:6000 (пароли и база берутся из .env) +- Redis: localhost:7000 + +## ОСОБЕННОСТИ: + +- Авто-миграции: Приложение само накатит SQL-схему при старте. +- Healthchecks: Контейнер API не поднимется, пока DB и Redis + не станут доступны (status: healthy). +- Изоляция: Используется выделенная сеть 'task-tracker-gateway'. + +## RESET: + +Если нужно полностью очистить базу и начать с нуля: + +```sh +docker compose -f ./infra/dev/compose.dev.yaml --profile infra down -v +``` diff --git a/infra/dev/compose.dev.yaml b/infra/dev/compose.dev.yaml new file mode 100644 index 00000000..fc46b1b2 --- /dev/null +++ b/infra/dev/compose.dev.yaml @@ -0,0 +1,152 @@ +version: '3.9' + +name: task-tracker-api + +services: + api: + hostname: api + container_name: api + image: ghcr.io/task-tracker-lab/backend:dev + env_file: + - .env + ports: + - '3000:3000' + depends_on: + database: + condition: service_healthy + redis: + condition: service_healthy + networks: + - backend + deploy: + resources: + limits: + cpus: '2.0' + memory: 1024M + reservations: + cpus: '0.5' + memory: 256M + + database: + hostname: database + container_name: database + image: postgres:16-alpine + restart: always + env_file: + - .env + environment: + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_DATABASE} + ports: + - '6000:5432' + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - backend + healthcheck: + test: [ + 'CMD-SHELL', + 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -q + || exit 1', + ] + interval: 5s + timeout: 5s + retries: 5 + profiles: ['infra'] + + redis: + hostname: redis + container_name: redis + image: redis:7-alpine + restart: always + ports: + - '${REDIS_PORT:-6999}:6379' + command: redis-server --save 60 1 --loglevel notice + volumes: + - redis_data:/data + networks: + - backend + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 5 + profiles: ['infra'] + + minio: + hostname: minio + container_name: minio + image: minio/minio:latest + restart: always + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} + ports: + - '9000:9000' # API + - '9001:9001' # Console (UI) + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + networks: + - backend + profiles: ['infra'] + + minio-init: + image: minio/mc:latest + depends_on: + - minio + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} + networks: + - backend + profiles: ['infra'] + entrypoint: > + /bin/sh -c " sleep 5; mc alias set myminio http://minio:9000 + ${S3_ACCESS_KEY} ${S3_SECRET_KEY}; mc mb myminio/${S3_BUCKET_NAME} + --ignore-existing; mc anonymous set download + myminio/${S3_BUCKET_NAME}; exit 0; " + imagor: + image: shumc/imagor:latest + container_name: imagor + restart: always + environment: + - IMAGOR_SECRET=${IMAGOR_SECRET:-supersecret} + - HTTP_LOADER_DISABLE=1 + + - AWS_ACCESS_KEY_ID=${S3_ACCESS_KEY} + - AWS_SECRET_ACCESS_KEY=${S3_SECRET_KEY} + - AWS_REGION=${S3_REGION} + + - S3_ENDPOINT=${S3_ENDPOINT} + - S3_FORCE_PATH_STYLE=1 + + - S3_LOADER_BUCKET=${S3_BUCKET_NAME} + - S3_LOADER_BASE_DIR=sources + + - S3_STORAGE_BUCKET=${S3_BUCKET_NAME} + - S3_STORAGE_BASE_DIR= + - S3_STORAGE_ACL=private + + - S3_RESULT_STORAGE_BUCKET=${S3_BUCKET_NAME} + - S3_RESULT_STORAGE_ACL=private + + - VIPS_CONCURRENCY=1 + - DEBUG=1 + ports: + - '8000:8000' + depends_on: + - minio + networks: + - backend + profiles: ['infra'] + +volumes: + postgres_data: + redis_data: + minio_data: + +networks: + backend: + name: task-tracker-gateway diff --git a/infra/k6/README.md b/infra/k6/README.md new file mode 100644 index 00000000..c7ad0179 --- /dev/null +++ b/infra/k6/README.md @@ -0,0 +1,78 @@ +# Нагрузочное тестирование k6 + +В данном каталоге расположены сценарии для проведения нагрузочного и стресс-тестирования API task-backend. Инструментарий базируется на k6 и интегрирован в монорепозиторий как отдельный пакет воркспейса. + +## Предварительные требования + +Для выполнения сценариев необходимо наличие установленного бинарного файла k6 в операционной системе. Пакет не устанавливается через менеджеры пакетов Node.js автоматически. + +### Инструкции по установке + +- Windows: winget install k6 +- macOS: brew install k6 +- Linux: sudo apt-get install k6 + +После установки необходимо перезапустить терминал для обновления переменных окружения. + +## Структура каталогов + +- modules/ — Атомарные функции для взаимодействия с конкретными модулями системы (auth, team, projects, user, board, board-columns, tasks). Содержат логику запросов и проверки статусов. +- scenarios/ — Комплексные пользовательские сценарии, имитирующие реальное поведение (например, создание полной структуры от проекта до задачи). +- scripts/ — Вспомогательные скрипты и быстрые тесты (Smoke tests). +- common/ — Общая конфигурация, параметры нагрузки (stages), пороговые значения (thresholds) и функции генерации тестовых данных. + +## Команды запуска + +Все команды должны запускаться из корневой директории проекта. + +- pnpm k6:seed — Сидинг тестовых данных для k6 (PostgreSQL + Redis). +- pnpm k6:clear — Удаление k6-тестовых данных из PostgreSQL и Redis. +- pnpm k6:smoke — Запуск проверочного теста с минимальной нагрузкой. +- pnpm k6:all — Проведение полного стресс-теста всех модулей API. +- pnpm k6:auth — Тестирование производительности модуля авторизации. +- pnpm k6:team — Тестирование производительности модуля команд. +- pnpm k6:projects — Тестирование производительности модуля проектов. +- pnpm k6:user — Тестирование производительности модуля пользователей. +- pnpm k6:board — Тестирование производительности модуля досок. +- pnpm k6:tasks — Тестирование производительности модуля задач. + +## Использование переменных окружения + +Для смены целевого адреса сервера без изменения кода сценариев используется флаг -e: + +pnpm --filter @project/performance-tests exec k6 run -e BASE_URL=https://api.example.com scenarios/stress-full.js + +## Анализ результатов + +При анализе отчетов следует ориентироваться на следующие метрики: + +- http_req_duration: Общее время обработки запроса сервером. +- http_req_failed: Процент запросов, завершившихся ошибкой. +- vus: Количество виртуальных пользователей в активной фазе теста. +- thresholds: Статус выполнения установленных критериев качества (SLA). + +## Конфигурация и профили нагрузки + +Система поддерживает динамическое переключение интенсивности тестирования с помощью переменных окружения. + +### Доступные профили (PROFILE) + +В `common/config.js` определены следующие уровни нагрузки: + +| Профиль | Описание | Нагрузка | +| :------- | :--------------------------- | :------------------- | +| `smoke` | Быстрая проверка доступности | 1 VU, 10 секунд | +| `low` | Базовая стабильность | 10 VUs, разгон 30с | +| `medium` | Стандартная рабочая нагрузка | 50 VUs, плато 3 мин | +| `high` | Проверка предела прочности | 300 VUs, плато 5 мин | + +### Примеры запуска с профилями + +Для управления нагрузкой передайте переменную `PROFILE`: + +```sh +pnpm k6:tasks -e PROFILE=medium + +# Запуск стресс-теста на кастомный URL +pnpm k6:all -e PROFILE=high -e BASE_URL=[http://staging-api.local](http://staging-api.local) +``` diff --git a/infra/k6/common/api-client.js b/infra/k6/common/api-client.js new file mode 100644 index 00000000..ee17c216 --- /dev/null +++ b/infra/k6/common/api-client.js @@ -0,0 +1,177 @@ +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL } from './config.js'; + +/** + * Обертка над стандартным HTTP-клиентом k6. + */ +export class ApiClient { + /** + * @param {string} baseUrl - Базовый адрес API. + * @param {string|null} [token=null] - Bearer токен для авторизации. + */ + constructor({ baseUrl = BASE_URL, token = null } = {}) { + this.baseUrl = baseUrl; + this.token = token; + } + + /** + * Формирует заголовки запроса. + * @private + * @returns {Object.} + */ + _getHeaders(useJsonDefault = true, extraHeaders = {}) { + const headers = {}; + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + if (useJsonDefault) { + headers['Content-Type'] = 'application/json'; + } + return Object.assign(headers, extraHeaders); + } + + /** + * Формирует параметры запроса (headers/cookies/tags). + * @private + * @param {Object} [options] - Доп. параметры запроса. + * @param {Object.} [options.headers] - Доп. заголовки. + * @param {Object.} [options.cookies] - Cookies для запроса. + * @param {Object.} [options.tags] - Tags для метрик k6. + * @param {boolean} [useJsonDefault=true] - Добавлять ли JSON Content-Type по умолчанию. + * @returns {Object} + */ + _buildOptions(options = {}, useJsonDefault = true) { + const headers = this._getHeaders(useJsonDefault, options.headers || {}); + const reqOptions = { headers }; + + if (options.cookies) { + reqOptions.cookies = options.cookies; + } + if (options.tags) { + reqOptions.tags = options.tags; + } + + return reqOptions; + } + + /** + * Формирует строку query-параметров. + * @private + * @param {Object.} [params] - Query-параметры. + * @returns {string} + */ + _buildQuery(params = {}) { + return Object.keys(params).length + ? `?${Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .join('&')}` + : ''; + } + + /** + * Выполняет GET запрос. + * @param {string} path - Относительный путь (напр. '/tasks'). + * @param {Object.} [params] - Query-параметры. + * @returns {import('k6/http').RefinedResponse} + */ + get(path, params = {}, options = {}) { + const query = this._buildQuery(params); + const res = http.get(`${this.baseUrl}${path}${query}`, this._buildOptions(options)); + this._logError(res, 'GET', path, options); + return res; + } + + /** + * Выполняет POST запрос. + * @param {string} path - Относительный путь. + * @param {Object} body - Объект данных (будет преобразован в JSON). + * @returns {import('k6/http').RefinedResponse} + */ + post(path, body, options = {}) { + const useJsonDefault = !options.rawBody; + const payload = options.rawBody ? body : JSON.stringify(body); + const res = http.post( + `${this.baseUrl}${path}`, + payload, + this._buildOptions(options, useJsonDefault), + ); + this._logError(res, 'POST', path, options); + return res; + } + + /** + * Выполняет PATCH запрос. + * @param {string} path - Относительный путь. + * @param {Object} body - Данные для частичного обновления. + * @returns {import('k6/http').RefinedResponse} + */ + patch(path, body, options = {}) { + const useJsonDefault = !options.rawBody; + const payload = options.rawBody ? body : JSON.stringify(body); + const res = http.patch( + `${this.baseUrl}${path}`, + payload, + this._buildOptions(options, useJsonDefault), + ); + this._logError(res, 'PATCH', path, options); + return res; + } + + /** + * Выполняет PUT запрос. + * @param {string} path - Относительный путь. + * @param {Object} body - Данные для полного обновления. + * @returns {import('k6/http').RefinedResponse} + */ + put(path, body, options = {}) { + const useJsonDefault = !options.rawBody; + const payload = options.rawBody ? body : JSON.stringify(body); + const res = http.put( + `${this.baseUrl}${path}`, + payload, + this._buildOptions(options, useJsonDefault), + ); + this._logError(res, 'PUT', path, options); + return res; + } + + /** + * Выполняет DELETE запрос. + * @param {string} path - Относительный путь. + * @returns {import('k6/http').RefinedResponse} + */ + delete(path, options = {}) { + const res = http.del(`${this.baseUrl}${path}`, null, this._buildOptions(options, false)); + this._logError(res, 'DELETE', path, options); + return res; + } + + /** + * Внутренняя валидация ответа и логирование ошибок. + * @private + * @param {import('k6/http').RefinedResponse} res - Объект ответа k6. + * @param {string} method - Название HTTP метода для лога. + * @param {string} path - Путь запроса для лога. + * @param {Object} [options] - Доп. параметры запроса. + */ + _logError(res, method, path, options = {}) { + const expectedStatuses = Array.isArray(options.expectedStatuses) + ? options.expectedStatuses + : null; + const isOk = expectedStatuses + ? (r) => expectedStatuses.includes(r.status) + : (r) => r.status >= 200 && r.status < 300; + const statusLabel = expectedStatuses + ? `statuses [${expectedStatuses.join(',')}]` + : 'statuses 2xx'; + + check(res, { + [`${method} ${path} ${statusLabel} is expected`]: isOk, + }); + + if (!isOk(res)) { + console.error(`Error on ${method} ${path}: [${res.status}] ${res.body}`); + } + } +} diff --git a/infra/k6/common/config.js b/infra/k6/common/config.js new file mode 100644 index 00000000..9b86f400 --- /dev/null +++ b/infra/k6/common/config.js @@ -0,0 +1,73 @@ +export const BASE_URL = __ENV.BASE_URL || 'http://0.0.0.0:3000/v1'; +export const REDIS_URL = __ENV.REDIS_URL || 'http://localhost:7000'; + +/** + * Профили нагрузки (Workload Profiles). + * Описывают поведение виртуальных пользователей (VUs) во времени. + * * @typedef {Object} Stage + * @property {string} duration - Продолжительность этапа (напр. '2m') + * @property {number} target - Целевое количество активных пользователей + * * @typedef {Object} Profile + * @property {number} [vus] - Фиксированное количество пользователей + * @property {string} [duration] - Общая продолжительность теста + * @property {Stage[]} [stages] - Этапы изменения нагрузки + */ +/** @type {Object.} */ +export const PROFILES = { + /** Минимальная проверка доступности: 1 юзер, 10 секунд */ + smoke: { + vus: 1, + duration: '10s', + }, + /** Низкая нагрузка: проверка базовой стабильности (10 юзеров) */ + low: { + stages: [ + { duration: '30s', target: 10 }, + { duration: '1m', target: 10 }, + { duration: '30s', target: 0 }, + ], + }, + /** Средняя нагрузка: имитация нормальной рабочей нагрузки (50 юзеров) */ + medium: { + stages: [ + { duration: '1m', target: 50 }, + { duration: '3m', target: 50 }, + { duration: '1m', target: 0 }, + ], + }, + /** Высокая нагрузка: поиск предела производительности (300 юзеров) */ + high: { + stages: [ + { duration: '2m', target: 300 }, + { duration: '5m', target: 300 }, + { duration: '2m', target: 0 }, + ], + }, +}; + +/** * Критерии успеха (Thresholds). + * Если метрики выходят за эти пределы, k6 завершает тест с ошибкой. + * @type {Object.} + */ +export const THRESHOLDS = { + /** Допустимый процент ошибок: менее 1% */ + http_req_failed: ['rate<0.01'], + /** Допустимое время ответа: 95-й перцентиль должен быть быстрее 200мс */ + http_req_duration: ['p(95)<200'], +}; + +/** + * Автоматически выбирает профиль на основе переменной окружения. + * Использование в сценарии: export const options = GET_OPTIONS(); + */ +export const GET_OPTIONS = () => { + const profileName = __ENV.PROFILE || 'smoke'; + const profile = PROFILES[profileName] || PROFILES.smoke; + + return { + vus: profile.vus, + duration: profile.duration, + stages: profile.stages, + thresholds: THRESHOLDS, + }; +}; diff --git a/infra/k6/common/redis-client.js b/infra/k6/common/redis-client.js new file mode 100644 index 00000000..4644f885 --- /dev/null +++ b/infra/k6/common/redis-client.js @@ -0,0 +1,64 @@ +import redis from 'k6/x/redis'; +import { REDIS_URL } from './config.js'; + +/** + * Обертка для работы с Redis в k6. + */ +export class RedisClient { + /** + * @param {string} url - URL редиса (напр. 'redis://localhost:6379'). + */ + constructor(url = REDIS_URL) { + this.client = redis.connect(url); + } + + /** + * Формирует ключи по тем же правилам, что и бэкенд/сидер. + * @private + */ + _keys = { + invite: (code) => `inv:code:${code}`, + teamInvites: (teamId) => `team:invites:${teamId}`, + userInvites: (email) => `user:invites:${email.toLowerCase()}`, + otp: (email) => `otp:${email.toLowerCase()}`, + }; + + /** + * Получает OTP код для юзера. + * @param {string} email + * @returns {string|null} + */ + getOtp(email) { + return redis.get(this.client, this._keys.otp(email)); + } + + /** + * Получает данные инвайта по коду. + * @param {string} code + * @returns {Object|null} + */ + getInvite(code) { + const data = redis.get(this.client, this._keys.invite(code)); + return data ? JSON.parse(data) : null; + } + + /** + * Получает все коды инвайтов для конкретной команды (из Set). + * @param {string} teamId + * @returns {string[]} + */ + getTeamInvitesCodes(teamId) { + return redis.smembers(this.client, this._keys.teamInvites(teamId)); + } + + getUserInvitesCodes(email) { + return redis.smembers(this.client, this._keys.userInvites(email)); + } + + /** + * Удаляет ключ + */ + del(key) { + redis.del(this.client, key); + } +} diff --git a/infra/k6/data/user-avatar.png b/infra/k6/data/user-avatar.png new file mode 100644 index 00000000..2386e31d Binary files /dev/null and b/infra/k6/data/user-avatar.png differ diff --git a/infra/k6/modules/.gitkeep b/infra/k6/modules/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/infra/k6/package.json b/infra/k6/package.json new file mode 100644 index 00000000..cd1384a6 --- /dev/null +++ b/infra/k6/package.json @@ -0,0 +1,22 @@ +{ + "name": "@project/performance-tests", + "version": "0.0.1", + "description": "Нагрузочные и стресс-тесты API бэкенда с использованием k6", + "scripts": { + "test:all": "k6 run scenarios/stress-full.js", + "test:auth": "k6 run scenarios/auth.js", + "test:teams": "k6 run scenarios/teams.js", + "test:teams-members": "k6 run scenarios/teams-members.js", + "test:teams-invitations": "k6 run scenarios/teams-invitations.js", + "test:teams-me": "k6 run scenarios/teams-me.js", + "test:projects": "k6 run scenarios/projects.js", + "test:users": "k6 run scenarios/users.js", + "test:boards:all": "k6 run scenarios/boards.js && k6 run scenarios/boards-columns.js && k6 run scenarios/boards-views.js", + "test:boards": "k6 run scenarios/boards.js", + "test:boards:columns": "k6 run scenarios/boards-columns.js", + "test:boards:views": "k6 run scenarios/boards-views.js", + "test:tasks": "k6 run scenarios/tasks.js", + "smoke": "k6 run smoke.js" + }, + "packageManager": "pnpm@10.33.0" +} diff --git a/infra/k6/scenarios/.gitkeep b/infra/k6/scenarios/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/infra/k6/scenarios/auth.js b/infra/k6/scenarios/auth.js new file mode 100644 index 00000000..5a76e9ca --- /dev/null +++ b/infra/k6/scenarios/auth.js @@ -0,0 +1,55 @@ +import { SharedArray } from 'k6/data'; +import { sleep } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:auth-refresh}': ['p(95)<333'], + 'http_req_duration{name:auth-sign-out}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const { client, token, refreshCookie } = getAuthUser(user); + + sleep(1); + + // --- REFRESH --- + const refreshRes = client.post('/auth/refresh', null, { + cookies: { refresh: refreshCookie }, + tags: { name: 'auth-refresh' }, + }); + + const newAccessToken = refreshRes.json().token; + const newRefreshCookie = refreshRes.cookies.refresh + ? refreshRes.cookies.refresh[0].value + : 'NOT_ROTATED'; + + sleep(1); + + // --- SIGN OUT --- + const refreshToken = newAccessToken || token; + const signOutCookie = newRefreshCookie !== 'NOT_ROTATED' ? newRefreshCookie : refreshCookie; + + client.post( + '/auth/sign-out', + {}, + { + headers: { + Authorization: `Bearer ${refreshToken}`, + }, + cookies: { refresh: signOutCookie }, + tags: { name: 'auth-sign-out' }, + }, + ); + + sleep(1); +} diff --git a/infra/k6/scenarios/boards-columns.js b/infra/k6/scenarios/boards-columns.js new file mode 100644 index 00000000..71d6c543 --- /dev/null +++ b/infra/k6/scenarios/boards-columns.js @@ -0,0 +1,133 @@ +import { SharedArray } from 'k6/data'; +import { sleep, check } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const randomNum = (min = 1, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min; + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:post-teams-projects}': ['p(95)<333'], + 'http_req_duration{name:post-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:get-boards-columns}': ['p(95)<333'], + 'http_req_duration{name:post-boards-columns}': ['p(95)<333'], + 'http_req_duration{name:get-boards-columns-id}': ['p(95)<333'], + 'http_req_duration{name:patch-boards-columns}': ['p(95)<333'], + 'http_req_duration{name:delete-boards-columns}': ['p(95)<333'], + 'http_req_duration{name:delete-teams-projects}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const team = teams[(__VU - 1) % teams.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- create project --- + const project = { + name: `k6_columns_project_${randomStr(6)}`, + key: `K6${randomNum(1000, 9999)}`, + description: 'k6 columns scenario project', + visibility: 'public', + }; + const createProjectRes = client.post(`/teams/${team.id}/projects`, project, { + tags: { name: 'post-teams-projects' }, + }); + const projectId = createProjectRes.json().projectId; + + check(createProjectRes, { + 'POST /teams/:id/projects: has projectId': (r) => r.json().projectId !== undefined, + }); + + sleep(1); + + // --- create board --- + const boardPayload = { + name: `k6_columns_board_${randomStr(6)}`, + position: Date.now(), + }; + const createBoardRes = client.post(`/projects/${projectId}/boards`, boardPayload, { + tags: { name: 'post-projects-boards' }, + }); + const boardId = createBoardRes.json().boardId; + + check(createBoardRes, { + 'POST /projects/:id/boards: has board id': (r) => r.json().boardId !== undefined, + }); + + sleep(1); + + // --- get all columns --- + const listRes = client.get( + `/boards/${boardId}/columns`, + {}, + { tags: { name: 'get-boards-columns' } }, + ); + + sleep(1); + + // --- create column --- + const columnPayload = { + name: `k6_column_${randomStr(6)}`, + position: 4000, + color: '#22c55e', + }; + const createColumnRes = client.post(`/boards/${boardId}/columns`, columnPayload, { + tags: { name: 'post-boards-columns' }, + }); + const columnId = createColumnRes.json().columnId; + + check(createColumnRes, { + 'POST /boards/:id/columns: has column id': (r) => r.json().columnId !== undefined, + }); + + sleep(1); + + // --- get column --- + client.get( + `/boards/${boardId}/columns/${columnId}`, + {}, + { tags: { name: 'get-boards-columns-id' } }, + ); + + sleep(1); + + // --- update column --- + const updatedColumn = { + name: `k6_column_${randomStr(7)}`, + color: '#3b82f6', + }; + client.patch(`/boards/${boardId}/columns/${columnId}`, updatedColumn, { + tags: { name: 'patch-boards-columns' }, + }); + + sleep(1); + + // --- delete column --- + client.delete(`/boards/${boardId}/columns/${columnId}`, { + tags: { name: 'delete-boards-columns' }, + }); + + sleep(1); + + // --- delete project --- + client.delete(`/teams/${team.id}/projects/${projectId}`, { + tags: { name: 'delete-teams-projects' }, + }); + + sleep(1); +} diff --git a/infra/k6/scenarios/boards-views.js b/infra/k6/scenarios/boards-views.js new file mode 100644 index 00000000..a4eec3da --- /dev/null +++ b/infra/k6/scenarios/boards-views.js @@ -0,0 +1,129 @@ +import { SharedArray } from 'k6/data'; +import { sleep, check } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const randomNum = (min = 1, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min; + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:post-teams-projects}': ['p(95)<333'], + 'http_req_duration{name:post-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:get-boards-views}': ['p(95)<333'], + 'http_req_duration{name:post-boards-views}': ['p(95)<333'], + 'http_req_duration{name:get-boards-views-id}': ['p(95)<333'], + 'http_req_duration{name:patch-boards-views}': ['p(95)<333'], + 'http_req_duration{name:delete-boards-views}': ['p(95)<333'], + 'http_req_duration{name:delete-teams-projects}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const team = teams[(__VU - 1) % teams.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- create project --- + const project = { + name: `k6_views_project_${randomStr(6)}`, + key: `K6${randomNum(1000, 9999)}`, + description: 'k6 views scenario project', + visibility: 'public', + }; + const createProjectRes = client.post(`/teams/${team.id}/projects`, project, { + tags: { name: 'post-teams-projects' }, + }); + const projectId = createProjectRes.json().projectId; + + check(createProjectRes, { + 'POST /teams/:id/projects: has projectId': (r) => r.json().projectId !== undefined, + }); + + sleep(1); + + // --- create board --- + const boardPayload = { + name: `k6_views_board_${randomStr(6)}`, + position: Date.now(), + }; + const createBoardRes = client.post(`/projects/${projectId}/boards`, boardPayload, { + tags: { name: 'post-projects-boards' }, + }); + const boardId = createBoardRes.json().boardId; + + check(createBoardRes, { + 'POST /projects/:id/boards: has board id': (r) => r.json().boardId !== undefined, + }); + + sleep(1); + + // --- get all views --- + const listRes = client.get( + `/boards/${boardId}/views`, + {}, + { tags: { name: 'get-boards-views' } }, + ); + + sleep(1); + + // --- create view --- + const viewPayload = { + type: 'kanban', + name: `k6_view_${randomStr(6)}`, + position: 4000, + settings: { mock: 'k6' }, + }; + const createViewRes = client.post(`/boards/${boardId}/views`, viewPayload, { + tags: { name: 'post-boards-views' }, + }); + const viewId = createViewRes.json().viewId; + + check(createViewRes, { + 'POST /boards/:id/views: has view id': (r) => r.json().viewId !== undefined, + }); + + sleep(1); + + // --- get view --- + client.get(`/boards/${boardId}/views/${viewId}`, {}, { tags: { name: 'get-boards-views-id' } }); + + sleep(1); + + // --- update view --- + const updatedView = { + name: `k6_view_${randomStr(7)}`, + }; + client.patch(`/boards/${boardId}/views/${viewId}`, updatedView, { + tags: { name: 'patch-boards-views' }, + }); + + sleep(1); + + // --- delete view --- + client.delete(`/boards/${boardId}/views/${viewId}`, { + tags: { name: 'delete-boards-views' }, + }); + + sleep(1); + + // --- delete project --- + client.delete(`/teams/${team.id}/projects/${projectId}`, { + tags: { name: 'delete-teams-projects' }, + }); + + sleep(1); +} diff --git a/infra/k6/scenarios/boards.js b/infra/k6/scenarios/boards.js new file mode 100644 index 00000000..5d1da42e --- /dev/null +++ b/infra/k6/scenarios/boards.js @@ -0,0 +1,114 @@ +import { SharedArray } from 'k6/data'; +import { sleep, check } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const randomNum = (min = 1, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min; + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:post-teams-projects}': ['p(95)<333'], + 'http_req_duration{name:post-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:get-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:get-projects-boards-id}': ['p(95)<333'], + 'http_req_duration{name:patch-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:delete-projects-boards}': ['p(95)<333'], + 'http_req_duration{name:delete-teams-projects}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const team = teams[(__VU - 1) % teams.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- create project --- + const project = { + name: `k6_board_project_${randomStr(6)}`, + key: `K6${randomNum(1000, 9999)}`, + description: 'k6 boards scenario project', + visibility: 'public', + }; + const createProjectRes = client.post(`/teams/${team.id}/projects`, project, { + tags: { name: 'post-teams-projects' }, + }); + const projectId = createProjectRes.json().projectId; + + check(createProjectRes, { + 'POST /teams/:id/projects: has projectId': (r) => r.json().projectId !== undefined, + }); + + sleep(1); + + // --- create board --- + const boardPayload = { + name: `k6_board_${randomStr(6)}`, + position: Date.now(), + }; + const createBoardRes = client.post(`/projects/${projectId}/boards`, boardPayload, { + tags: { name: 'post-projects-boards' }, + }); + const boardId = createBoardRes.json().boardId; + + check(createBoardRes, { + 'POST /projects/:id/boards: has board id': (r) => r.json().boardId !== undefined, + }); + + sleep(1); + + // --- get all boards --- + const listRes = client.get( + `/projects/${projectId}/boards`, + {}, + { tags: { name: 'get-projects-boards' } }, + ); + + sleep(1); + + // --- get board --- + client.get( + `/projects/${projectId}/boards/${boardId}`, + {}, + { tags: { name: 'get-projects-boards-id' } }, + ); + + sleep(1); + + // --- update board --- + const updatedBoard = { + name: `k6_board_${randomStr(7)}`, + }; + client.patch(`/projects/${projectId}/boards/${boardId}`, updatedBoard, { + tags: { name: 'patch-projects-boards' }, + }); + + sleep(1); + + // --- delete board --- + client.delete(`/projects/${projectId}/boards/${boardId}`, { + tags: { name: 'delete-projects-boards' }, + }); + + sleep(1); + + // --- delete project --- + client.delete(`/teams/${team.id}/projects/${projectId}`, { + tags: { name: 'delete-teams-projects' }, + }); + + sleep(1); +} diff --git a/infra/k6/scenarios/projects.js b/infra/k6/scenarios/projects.js new file mode 100644 index 00000000..9734f17a --- /dev/null +++ b/infra/k6/scenarios/projects.js @@ -0,0 +1,117 @@ +import { SharedArray } from 'k6/data'; +import { sleep, check } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const randomNum = (min = 1, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min; +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:post-teams-projects}': ['p(95)<333'], + 'http_req_duration{name:teams-projects}': ['p(95)<333'], + 'http_req_duration{name:teams-projects-id}': ['p(95)<333'], + 'http_req_duration{name:teams-projects-generate-token}': ['p(95)<333'], + 'http_req_duration{name:teams-projects-archive}': ['p(95)<333'], + 'http_req_duration{name:delete-teams-projects}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const team = teams[(__VU - 1) % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- create project --- + const projectName = randomStr(); + const project = { + name: projectName, + key: `QWE${randomNum(1000, 9999)}`, + description: 'description for k6_test_project', + visibility: 'public', + }; + const createRes = client.post(`/teams/${team.id}/projects`, project, { + tags: { name: 'post-teams-projects' }, + }); + const projectId = createRes.json().projectId; + + sleep(1); + + // --- update project --- + const newProjectName = randomStr(); + const updatedProject = { + name: newProjectName, + }; + client.patch(`/teams/${team.id}/projects/${projectId}`, updatedProject, { + tags: { name: 'patch-teams-projects' }, + }); + + sleep(1); + + // --- get all teams projects --- + + const getAllRes = client.get( + `/teams/${team.id}/projects`, + {}, + { tags: { name: 'get-teams-projects' } }, + ); + + check(getAllRes, { 'projects list has meta': (r) => r.json().meta !== undefined }); + + sleep(1); + + // --- get one team project --- + client.get( + `/teams/${team.id}/projects/${projectId}`, + {}, + { tags: { name: 'teams-projects-id' } }, + ); + + sleep(1); + + // --- generate share token --- + const shareTokenRes = client.post( + `/teams/${team.id}/projects/${projectId}/share`, + {}, + { tags: { name: 'teams-projects-generate-token' } }, + ); + + check(shareTokenRes, { + 'POST /teams/:id/projects/:id/share: has token': (r) => + r.json().payload.token !== undefined, + }); + + sleep(1); + + // --- archive project --- + + client.post( + `/teams/${team.id}/projects/${projectId}/archive`, + {}, + { tags: { name: 'teams-projects-archive' } }, + ); + + sleep(1); + + // --- delete project --- + + client.delete( + `/teams/${team.id}/projects/${projectId}`, + {}, + { tags: { name: 'delete-teams-projects' } }, + ); + + sleep(1); +} diff --git a/infra/k6/scenarios/teams-invitations.js b/infra/k6/scenarios/teams-invitations.js new file mode 100644 index 00000000..5031120b --- /dev/null +++ b/infra/k6/scenarios/teams-invitations.js @@ -0,0 +1,119 @@ +import { SharedArray } from 'k6/data'; +import { sleep } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:teams-invitations-list}': ['p(95)<333'], + 'http_req_duration{name:teams-invitations-get}': ['p(95)<333'], + 'http_req_duration{name:teams-invitations-update}': ['p(95)<333'], + 'http_req_duration{name:teams-invitations-create}': ['p(95)<333'], + 'http_req_duration{name:teams-invitations-create-duplicate}': ['p(95)<333'], + 'http_req_duration{name:teams-invitations-accept}': ['p(95)<333'], +}); + +export const options = baseOptions; + +function buildUserIndex(list) { + const index = {}; + for (const u of list) index[u.email] = u; + return index; +} + +const userByEmail = buildUserIndex(users); + +export default function () { + const idx = (__VU - 1) % teams.length; + const team = teams[idx]; + const owner = users[idx % users.length]; + const { client } = getAuthUser(owner); + + sleep(1); + + // --- GET /teams/:id/invitations --- + const listRes = client.get( + `/teams/${team.id}/invitations`, + {}, + { + tags: { name: 'teams-invitations-list' }, + }, + ); + + sleep(1); + + const listBody = + listRes && listRes.status >= 200 && listRes.status < 300 ? listRes.json() : null; + const items = listBody && Array.isArray(listBody.items) ? listBody.items : []; + const invite = items.length ? items[0] : null; + + if (invite && invite.code) { + // --- GET /teams/:id/invitations/:code --- + client.get( + `/teams/${team.id}/invitations/${invite.code}`, + {}, + { + tags: { name: 'teams-invitations-get' }, + }, + ); + + sleep(1); + + // --- PATCH /teams/:id/invitations/:code --- + client.patch( + `/teams/${team.id}/invitations/${invite.code}`, + { role: 'member' }, + { tags: { name: 'teams-invitations-update' } }, + ); + + sleep(1); + + // --- POST /teams/:id/invitations/:code/accept --- + if (__ITER === 0 && invite.email && userByEmail[invite.email]) { + const invitedUser = userByEmail[invite.email]; + const { client: invitedClient } = getAuthUser(invitedUser, { + tags: { name: 'auth-sign-in' }, + }); + + invitedClient.post( + `/teams/${team.id}/invitations/${invite.code}/accept`, + {}, + { tags: { name: 'teams-invitations-accept' } }, + ); + } + } + + sleep(1); + + // --- POST /teams/:id/invitations --- + const randomEmail = `k6_invite_${__VU}_${__ITER}@tasktracker.local`; + client.post( + `/teams/${team.id}/invitations`, + { email: randomEmail, role: 'member' }, + { + tags: { name: 'teams-invitations-create' }, + }, + ); + + sleep(1); + + // --- POST /teams/:id/invitations (duplicate) --- + client.post( + `/teams/${team.id}/invitations`, + { email: randomEmail, role: 'member' }, + { + tags: { name: 'teams-invitations-create-duplicate' }, + expectedStatuses: [400], + }, + ); + + sleep(1); +} diff --git a/infra/k6/scenarios/teams-me.js b/infra/k6/scenarios/teams-me.js new file mode 100644 index 00000000..aa8851f4 --- /dev/null +++ b/infra/k6/scenarios/teams-me.js @@ -0,0 +1,34 @@ +import { SharedArray } from 'k6/data'; +import { sleep } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:users-me-teams}': ['p(95)<333'], + 'http_req_duration{name:users-me-invites}': ['p(95)<333'], +}); + +export const options = baseOptions; + +export default function () { + const user = users[(__VU - 1) % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- GET /users/me/teams --- + client.get('/users/me/teams', {}, { tags: { name: 'users-me-teams' } }); + + sleep(1); + + // --- GET /users/me/invites --- + client.get('/users/me/invites', {}, { tags: { name: 'users-me-invites' } }); + + sleep(1); +} diff --git a/infra/k6/scenarios/teams-members.js b/infra/k6/scenarios/teams-members.js new file mode 100644 index 00000000..7114f95b --- /dev/null +++ b/infra/k6/scenarios/teams-members.js @@ -0,0 +1,60 @@ +import { SharedArray } from 'k6/data'; +import { sleep } from 'k6'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const teams = new SharedArray('test teams', function () { + return JSON.parse(open('../data/teams.json')); +}); + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:teams-members-list}': ['p(95)<333'], + 'http_req_duration{name:teams-members-update}': ['p(95)<333'], +}); + +export const options = baseOptions; + +function pickTargetMember(items = []) { + if (!items.length) return null; + const notOwner = items.find((m) => m.role && m.role !== 'owner'); + return notOwner || (items.length > 1 ? items[1] : items[0]); +} + +export default function () { + const idx = (__VU - 1) % teams.length; + const team = teams[idx]; + const user = users[idx % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- GET /teams/:id/members --- + const membersRes = client.get( + `/teams/${team.id}/members`, + {}, + { + tags: { name: 'teams-members-list' }, + }, + ); + + const members = membersRes.json().items || []; + const target = pickTargetMember(members); + + sleep(1); + + // --- PATCH /teams/:slug/members/:userId --- + if (target && target.id) { + client.patch( + `/teams/${team.id}/members/${target.id}`, + { role: 'member' }, + { tags: { name: 'teams-members-update' } }, + ); + } + + sleep(1); +} diff --git a/infra/k6/scenarios/teams.js b/infra/k6/scenarios/teams.js new file mode 100644 index 00000000..c8ae202f --- /dev/null +++ b/infra/k6/scenarios/teams.js @@ -0,0 +1,98 @@ +import { SharedArray } from 'k6/data'; +import { sleep } from 'k6'; +import http from 'k6/http'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; +import { FormData } from 'https://jslib.k6.io/formdata/0.0.2/index.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:teams-create}': ['p(95)<333'], + 'http_req_duration{name:teams-check-slug}': ['p(95)<333'], + 'http_req_duration{name:teams-find-one}': ['p(95)<333'], + 'http_req_duration{name:teams-update}': ['p(95)<333'], + 'http_req_duration{name:teams-avatar}': ['p(95)<333'], + 'http_req_duration{name:teams-banner}': ['p(95)<333'], + 'http_req_duration{name:teams-delete}': ['p(95)<333'], +}); + +export const options = baseOptions; +const avatar = open('../data/user-avatar.png', 'b'); + +export default function () { + const user = users[(__VU - 1) % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- POST /teams --- + const team = { + name: 'k6_team_' + randomStr(10), + description: randomStr(15), + }; + const teamRes = client.post('/teams', team, { tags: { name: 'teams-create' } }); + console.log(teamRes); + const teamId = teamRes.json().teamId; + sleep(1); + + // --- GET /:teamId --- + client.get(`/teams/${teamId}`, {}, { tags: { name: 'teams-find-one' } }); + + sleep(1); + + // --- PATCH /:teamId --- + const updatedTeam = { + description: randomStr(25), + }; + client.patch(`/teams/${teamId}`, updatedTeam, { + tags: { name: 'teams-update' }, + }); + + sleep(1); + + // --- Update team avatar --- + const fdAvatar = new FormData(); + fdAvatar.append('file', http.file(avatar, 'avatar.png', 'image/png')); + fdAvatar.append('teamId', teamId); + fdAvatar.append('context', 'team.avatar'); + + client.post('/upload', fdAvatar.body(), { + rawBody: true, + headers: { + 'Content-Type': `multipart/form-data; boundary=${fdAvatar.boundary}`, + }, + tags: { name: 'teams-avatar' }, + }); + + sleep(1); + + // --- Update team banner --- + const fdBanner = new FormData(); + fdBanner.append('file', http.file(avatar, 'avatar.png', 'image/png')); + fdBanner.append('teamId', teamId); + fdBanner.append('context', 'team.banner'); + client.post('/upload', fdBanner.body(), { + rawBody: true, + headers: { + 'Content-Type': `multipart/form-data; boundary=${fdBanner.boundary}`, + }, + tags: { name: 'teams-banner' }, + }); + + sleep(1); + + // --- DELETE /:teamId --- + client.delete(`/teams/${teamId}`, { + tags: { name: 'teams-delete' }, + }); + + sleep(1); +} diff --git a/infra/k6/scenarios/users.js b/infra/k6/scenarios/users.js new file mode 100644 index 00000000..d1c2ab94 --- /dev/null +++ b/infra/k6/scenarios/users.js @@ -0,0 +1,98 @@ +import { SharedArray } from 'k6/data'; +import http from 'k6/http'; +import { sleep } from 'k6'; +import { FormData } from 'https://jslib.k6.io/formdata/0.0.2/index.js'; +import { GET_OPTIONS } from '../common/config.js'; +import getAuthUser from '../shared/get-auth-user.js'; + +const users = new SharedArray('test users', function () { + return JSON.parse(open('../data/users.json')); +}); + +const baseOptions = GET_OPTIONS(); +baseOptions.thresholds = Object.assign({}, baseOptions.thresholds, { + 'http_req_duration{name:auth-sign-in}': ['p(95)<333'], + 'http_req_duration{name:users-me}': ['p(95)<333'], + 'http_req_duration{name:users-activity}': ['p(95)<333'], + 'http_req_duration{name:users-patch}': ['p(95)<333'], + 'http_req_duration{name:users-avatar}': ['p(95)<333'], + 'http_req_duration{name:users-notifications}': ['p(95)<333'], +}); + +export const options = baseOptions; + +const avatar = open('../data/user-avatar.png', 'b'); +const randomBool = () => Math.random() < 0.5; +const randomStr = (len = 8) => + Math.random() + .toString(36) + .substring(2, 2 + len); + +export default function () { + const user = users[(__VU - 1) % users.length]; + const { client } = getAuthUser(user); + + sleep(1); + + // --- GET /me --- + client.get('/users/me', {}, { tags: { name: 'users-me' } }); + + sleep(1); + + // --- GET /me/activity --- + const randomPage = Math.floor(Math.random() * 5) + 1; + const randomLimit = Math.floor(Math.random() * 15) + 5; + client.get( + '/users/me/activity', + { page: randomPage, limit: randomLimit }, + { tags: { name: 'users-activity' } }, + ); + + sleep(1); + + // --- PATCH /me --- + const meBody = { + firstName: `Name_${randomStr(5)}`, + lastName: `Surname_${randomStr(5)}`, + bio: `Testing bio with random data: ${randomStr(30)}`, + language: Math.random() > 0.5 ? 'ru' : 'en', + }; + + client.patch('/users/me', meBody, { tags: { name: 'users-patch' } }); + + sleep(1); + + // --- Update user avatar --- + const fd = new FormData(); + fd.append('file', http.file(avatar, 'avatar.png', 'image/png')); + fd.append('context', 'user.avatar'); + + client.post('/upload', fd.body(), { + rawBody: true, + headers: { + 'Content-Type': `multipart/form-data; boundary=${fd.boundary}`, + }, + tags: { name: 'users-avatar' }, + }); + + sleep(1); + + // --- PATCH /me/notifications --- + const notificationsBody = { + email: { + task_assigned: randomBool(), + mentions: randomBool(), + daily_summary: randomBool(), + }, + push: { + task_assigned: randomBool(), + reminders: randomBool(), + }, + }; + + client.patch('/users/me/notifications', notificationsBody, { + tags: { name: 'users-notifications' }, + }); + + sleep(1); +} diff --git a/infra/k6/scripts/clear-k6-data.ts b/infra/k6/scripts/clear-k6-data.ts new file mode 100644 index 00000000..a1c8fa64 --- /dev/null +++ b/infra/k6/scripts/clear-k6-data.ts @@ -0,0 +1,49 @@ +import Redis from 'ioredis'; +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import * as sc from '../../../src/shared/entities'; +import { sql } from 'drizzle-orm'; +import postgres from 'postgres'; +import { assertEnv, DB_URL, REDIS_URL } from './k6-env'; +import { KEYS } from './k6-data-keys'; + +async function clearDB(db: PostgresJsDatabase) { + console.log('Cleaning up ONLY k6 test data from DB...'); + return await db.transaction(async (tx) => { + await tx.delete(sc.users).where(sql`${sc.users.email} LIKE 'k6_user_%'`); + await tx.delete(sc.teams).where(sql`${sc.teams.name} LIKE 'k6_team_%'`); + }); +} + +async function clearRedis(redis: Redis) { + console.log('Cleaning up ONLY k6 test data from Redis...'); + const SCAN_PATTERNS = Object.values(KEYS).map((fn) => fn('*')); + + for (const pattern of SCAN_PATTERNS) { + let cursor = '0'; + do { + const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + if (keys.length > 0) await redis.del(...keys); + } while (cursor !== '0'); + } +} + +async function main() { + assertEnv(); + const redis = new Redis(REDIS_URL); + const queryClient = postgres(DB_URL, { max: 1 }); + const db = drizzle(queryClient, { schema: sc }); + + try { + await clearDB(db); + await clearRedis(redis); + } catch (e) { + console.error('Error:', e); + process.exit(1); + } finally { + await queryClient.end(); + await redis.quit(); + } +} + +main(); diff --git a/infra/k6/scripts/k6-data-keys.ts b/infra/k6/scripts/k6-data-keys.ts new file mode 100644 index 00000000..025f0ea1 --- /dev/null +++ b/infra/k6/scripts/k6-data-keys.ts @@ -0,0 +1,5 @@ +export const KEYS = { + INVITE: (code: string) => `inv:code:${code}`, + TEAM_INVITES: (teamId: string) => `team:invites:${teamId}`, + USER_INVITES: (email: string) => `user:invites:${email.toLowerCase()}`, +}; diff --git a/infra/k6/scripts/k6-env.ts b/infra/k6/scripts/k6-env.ts new file mode 100644 index 00000000..d95a666c --- /dev/null +++ b/infra/k6/scripts/k6-env.ts @@ -0,0 +1,13 @@ +import 'dotenv/config'; + +export const DB_URL = process.env.DATABASE_URL; +export const REDIS_URL = + process.env.REDIS_HOST && process.env.REDIS_PORT + ? `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}` + : undefined; + +export function assertEnv() { + if (!DB_URL || !REDIS_URL) { + throw new Error('DATABASE_URL OR REDIS_HOST, REDIS_PORT is not defined in .env'); + } +} diff --git a/infra/k6/scripts/seed-k6-data.ts b/infra/k6/scripts/seed-k6-data.ts new file mode 100644 index 00000000..a550b06e --- /dev/null +++ b/infra/k6/scripts/seed-k6-data.ts @@ -0,0 +1,190 @@ +import { createId } from '@paralleldrive/cuid2'; +import * as argon from 'argon2'; +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { writeFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import * as sc from '../../../src/shared/entities/index'; +import Redis from 'ioredis'; +import { assertEnv, DB_URL, REDIS_URL } from './k6-env'; +import { KEYS } from './k6-data-keys'; + +async function seed_db(db: PostgresJsDatabase) { + const COUNT = 1000; + const OUT_USERS_FILE = resolve(process.cwd(), 'infra/k6/data/users.json'); + const OUT_TEAMS_FILE = resolve(process.cwd(), 'infra/k6/data/teams.json'); + + console.log(`Start seeding using pg driver...`); + + const password = 'TestPassword123!'; + const passwordHash = await argon.hash(password); + + const usersToInsert = []; + const securityToInsert = []; + const notificationsToInsert = []; + const activitiesToInsert = []; + const usersData = []; + const teamsData = []; + const teamsToInsert = []; + const teamMembersToInsert = []; + + for (let i = 0; i < COUNT; i++) { + const userId = createId(); + const teamId = createId(); + const email = `k6_user_${i}@tasktracker.com`; + + const user = { + id: userId, + email, + firstName: 'K6', + lastName: `User ${i}`, + timezone: 'UTC', + language: 'ru', + }; + const team = { + id: teamId, + ownerId: userId, + name: `k6_team_${i}`, + description: `description team - ${i}`, + }; + const teamMember = { + teamId: teamId, + userId: userId, + role: 'owner', + status: 'active', + joinedAt: new Date().toISOString(), + }; + + usersToInsert.push(user); + teamsToInsert.push(team); + teamMembersToInsert.push(teamMember); + securityToInsert.push({ userId, passwordHash }); + notificationsToInsert.push({ userId }); + + usersData.push({ email, password }); + teamsData.push(team); + + for (let j = 0; j < 10; j++) { + activitiesToInsert.push({ + id: createId(), + userId: userId, + eventType: 'SIGN_IN', + entityId: userId, + metadata: { + description: `K6 Load Test Iteration ${j}`, + ip: '127.0.0.1', + userAgent: 'k6-test-agent', + }, + createdAt: new Date(Date.now() - j * 1000 * 60 * 60).toISOString(), + }); + } + } + + await db.transaction(async (tx) => { + await tx.insert(sc.users).values(usersToInsert); + await tx.insert(sc.userSecurity).values(securityToInsert); + await tx.insert(sc.userNotifications).values(notificationsToInsert); + + const chunkSize = 1000; + for (let i = 0; i < activitiesToInsert.length; i += chunkSize) { + const chunk = activitiesToInsert.slice(i, i + chunkSize); + await tx.insert(sc.userActivity).values(chunk); + } + await tx.insert(sc.teams).values(teamsToInsert); + await tx.insert(sc.teamMembers).values(teamMembersToInsert); + }); + + const filesToSave = [ + { path: OUT_USERS_FILE, data: usersData }, + { path: OUT_TEAMS_FILE, data: teamsData }, + ]; + + for (const { path, data } of filesToSave) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2)); + } + + console.log(`Success! Created ${COUNT} entries for each entity`); + console.log(`User data saved to: ${OUT_USERS_FILE}`); + console.log(`Teams data saved to: ${OUT_TEAMS_FILE}`); +} + +async function seed_redis(redis: Redis) { + console.log('Seeding Redis with OTP codes...'); + const multi = redis.multi(); + + const dataDir = resolve(process.cwd(), 'infra/k6/data'); + const users = JSON.parse(readFileSync(`${dataDir}/users.json`, 'utf-8')) as { + email: string; + }[]; + const teams = JSON.parse(readFileSync(`${dataDir}/teams.json`, 'utf-8')) as { + id: string; + ownerId: string; + name: string; + description: string; + }[]; + + const INVITE_TTL = 86400; + const INVITES_PER_TEAM = 10; + + const invitesData = []; + + teams.forEach((team, teamIdx) => { + for (let j = 1; j <= INVITES_PER_TEAM; j++) { + const inviteeIdx = (teamIdx + j) % users.length; + const invitee = users[inviteeIdx]; + + const code = `INV_${teamIdx}_${inviteeIdx}`; + + const inviteData = { + teamId: team.id, + teamName: team.name, + teamAvatar: + 'https://cdn.pixabay.com/photo/2016/08/08/09/17/avatar-1577909_1280.png', + email: invitee.email, + role: 'member', + inviterId: team.ownerId, + inviterName: `Owner of ${team.name}`, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + INVITE_TTL * 1000).toISOString(), + }; + + multi.set(KEYS.INVITE(code), JSON.stringify(inviteData), 'EX', INVITE_TTL); + multi.sadd(KEYS.TEAM_INVITES(team.id), code); + multi.sadd(KEYS.USER_INVITES(invitee.email), code); + + invitesData.push({ + code, + email: invitee.email, + }); + } + }); + + await multi.exec(); + + const OUT_FILE = `${dataDir}/invites.json`; + writeFileSync(OUT_FILE, JSON.stringify(invitesData, null, 2)); + + console.log(`Success! Redis seeded. Created ${invitesData.length} unique invites.`); + console.log(`Invites data saved to: ${OUT_FILE}`); +} + +async function main() { + assertEnv(); + const redis = new Redis(REDIS_URL); + const queryClient = postgres(DB_URL, { max: 1 }); + const db = drizzle(queryClient, { schema: sc }); + + try { + await seed_db(db); + await seed_redis(redis); + } catch (e) { + console.error('Error:', e); + process.exit(1); + } finally { + await queryClient.end(); + await redis.quit(); + } +} + +main(); diff --git a/infra/k6/shared/get-auth-user.js b/infra/k6/shared/get-auth-user.js new file mode 100644 index 00000000..c6e813ba --- /dev/null +++ b/infra/k6/shared/get-auth-user.js @@ -0,0 +1,36 @@ +import { check } from 'k6'; +import { ApiClient } from '../common/api-client.js'; + +export default function getAuthUser(user, options = {}) { + const client = new ApiClient(); + const requestOptions = Object.assign({}, options); + + if (!requestOptions.tags) { + requestOptions.tags = { name: 'auth-sign-in' }; + } + + const signInRes = client.post( + '/auth/sign-in', + { + email: user.email, + password: user.password, + }, + requestOptions, + ); + + check(signInRes, { + 'POST /auth/sign-in has token': (r) => r.json().token !== undefined, + }); + + const token = signInRes.json().token; + const refreshCookie = signInRes.cookies.refresh + ? signInRes.cookies.refresh[0].value + : 'MISSING'; + + return { + client: new ApiClient({ token }), + token, + refreshCookie, + signInRes, + }; +} diff --git a/infra/k6/smoke.js b/infra/k6/smoke.js new file mode 100644 index 00000000..db6cd61f --- /dev/null +++ b/infra/k6/smoke.js @@ -0,0 +1,12 @@ +import { sleep } from 'k6'; +import { ApiClient } from './common/client.js'; +import { BASE_URL, GET_OPTIONS } from './common/config.js'; + +export const options = GET_OPTIONS(); + +const client = new ApiClient(BASE_URL); + +export default function () { + client.get('/health'); + sleep(1); +} diff --git a/libs/bootstrap/src/bootstrap.ts b/libs/bootstrap/src/bootstrap.ts new file mode 100644 index 00000000..0ef9f3c9 --- /dev/null +++ b/libs/bootstrap/src/bootstrap.ts @@ -0,0 +1,172 @@ +import fastifyCompress from '@fastify/compress'; +import fastifyCookie from '@fastify/cookie'; +import fastifyCsrf from '@fastify/csrf-protection'; +import fastifyMultipart from '@fastify/multipart'; +import { Logger, VersioningType } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { createId } from '@paralleldrive/cuid2'; + +import { DEFAULT_THROTTLER_OPTIONS } from './configs/throttler'; +import { setupCors, setupLogger, setupThrottler, setupSwagger } from './setups'; + +import type { BootstrapOptions } from './interfaces/options.interface'; +import type { IncomingMessage } from 'node:http'; + +export async function bootstrapApp(options: BootstrapOptions) { + const startTime = performance.now(); + const adapter = new FastifyAdapter({ + requestIdHeader: 'x-request-id', + requestIdLogLabel: 'request', + genReqId: (req: IncomingMessage) => (req.headers['x-request-id'] as string) || createId(), + }); + + const { + appModule, + apiPrefix, + version = 'v1', + serviceName = 'App', + portEnvKey = 'PORT', + defaultPort = 3000, + setupApp, + useCookieParser = true, + useCors = true, + throttlerOptions = DEFAULT_THROTTLER_OPTIONS, + swaggerOptions, + } = options; + + let rootModule = appModule; + + if (throttlerOptions) { + rootModule = setupThrottler(rootModule, throttlerOptions); + } + + const app = await NestFactory.create(rootModule, adapter, { + rawBody: true, + bufferLogs: false, + }); + + const logger = new Logger(serviceName?.[0]?.toUpperCase() + serviceName.slice(1)); + const configService = app.get(ConfigService); + const port = configService.getOrThrow(portEnvKey, defaultPort); + const origins = configService.getOrThrow('CORS_ALLOWED_ORIGINS'); + + app.enableShutdownHooks(); + + app.getHttpAdapter() + .getInstance() + /** + * НАЗНАЧЕНИЕ: Полифил совместимости Fastify с экосистемой Passport.js (Express-way). + * * ПОЧЕМУ ТУТ ТИП 'any': + * Объекты 'request' и 'reply' принадлежат типам 'FastifyRequest' и 'FastifyReply'. + * Библиотека 'passport' жестко ожидает архитектуру Express (в частности, наличие методов + * res.setHeader(), res.end() и прямой ссылки req.res). + * * Расширение интерфейсов Fastify через декларацию модулей (Module Augmentation) в данном + * контексте избыточно, так как мы мутируем объекты исключительно локально внутри инфраструктурного + * хука для Node.js HTTP-слоя (this.raw). Приведение к 'any' здесь является легитимным решением + * для динамического monkey-patching-а. + */ + .addHook('onRequest', (request: any, reply: any, done) => { + reply.setHeader = function (key: string, value: string) { + return this.raw.setHeader(key, value); + }; + + reply.end = function () { + this.raw.end(); + }; + + request.res = reply; + done(); + }) + // eslint-disable-next-line require-await + .addHook('onSend', async (request, reply, payload) => { + reply.header('x-request-id', request.id); + return payload; + }); + + setupLogger(app, options.serviceName); + + await app.register(fastifyCompress, { + global: true, + threshold: 1024, + }); + + await app.register(fastifyMultipart, { + limits: { + fileSize: 5 * 1024 * 1024, + fieldNameSize: 100, + files: 5, + }, + }); + + if (apiPrefix) { + app.setGlobalPrefix(apiPrefix); + } + if (version) { + const hasV = version.startsWith('v'); + + app.enableVersioning({ + type: VersioningType.URI, + prefix: hasV ? 'v' : '', + defaultVersion: hasV ? version.slice(1) : version, + }); + } + if (useCors) { + setupCors(app, origins); + } + if (swaggerOptions) { + const { path = 'docs', ...metadata } = swaggerOptions; + + const domain = configService.get('DOMAIN'); + const stage = configService.get('STAGE_DOMAIN'); + + const fullOptions = { + ...metadata, + path, + server: { + port, + domain, + stage, + }, + }; + + await setupSwagger(app, fullOptions); + } + if (useCookieParser) { + const secret = configService.getOrThrow('COOKIE_SECRET'); + await app.register(fastifyCookie, { secret }); + await app.register(fastifyCsrf, { + cookieOpts: { + signed: true, + httpOnly: true, + sameSite: 'strict', + secure: configService.getOrThrow('NODE_ENV') === 'production', + }, + }); + } + if (setupApp) { + await setupApp(app); + } + + await app.listen(port, '0.0.0.0', (_err, address) => { + const prefix = [apiPrefix, version].filter(Boolean).join('/'); + const baseUrl = `${address}${prefix ? `/${prefix}` : ''}`; + + const swaggerBase = `${address}${apiPrefix ? `/${apiPrefix}` : ''}`; + const swaggerPath = swaggerOptions?.path ?? 'docs'; + + if (_err) { + logger.error(_err); + process.exit(1); + } + + const startupTime = (performance.now() - startTime).toFixed(2); + logger.verbose(`Environment: ${process.env['NODE_ENV'] || 'development'}`); + logger.verbose(`API Endpoint: ${baseUrl}`); + logger.verbose(`Health Check: ${baseUrl}/health`); + logger.verbose(`Swagger UI: ${swaggerBase}/${swaggerPath}`); + logger.verbose(`OpenAPI (Specs): ${swaggerBase}/${swaggerPath}/s/{json,yaml}`); + logger.verbose(`Boot Time: ${startupTime}ms`); + }); +} diff --git a/libs/bootstrap/src/configs/swagger.ts b/libs/bootstrap/src/configs/swagger.ts new file mode 100644 index 00000000..a13eb68f --- /dev/null +++ b/libs/bootstrap/src/configs/swagger.ts @@ -0,0 +1,7 @@ +import type { SwaggerOptions } from '../interfaces/options.interface'; + +export const SWAGGER_DEFAULTS: SwaggerOptions = { + title: 'API', + description: 'API Documentation', + version: '1.0.0', +}; diff --git a/libs/bootstrap/src/configs/throttler.ts b/libs/bootstrap/src/configs/throttler.ts new file mode 100644 index 00000000..0fb96936 --- /dev/null +++ b/libs/bootstrap/src/configs/throttler.ts @@ -0,0 +1,11 @@ +import 'dotenv/config'; + +import type { ThrottlerModuleOptions } from '@nestjs/throttler'; + +export const DEFAULT_THROTTLER_OPTIONS: ThrottlerModuleOptions = [ + { + ttl: process.env['THROTTLE_TTL'] ? parseInt(process.env['THROTTLE_LIMIT'] ?? '') : 60000, + limit: process.env['THROTTLE_LIMIT'] ? parseInt(process.env['THROTTLE_LIMIT']) : 100, + skipIf: (context) => context.getType() !== 'http', + }, +]; diff --git a/libs/bootstrap/src/index.ts b/libs/bootstrap/src/index.ts new file mode 100644 index 00000000..cbc96b30 --- /dev/null +++ b/libs/bootstrap/src/index.ts @@ -0,0 +1 @@ +export { bootstrapApp } from './bootstrap'; diff --git a/libs/bootstrap/src/interfaces/index.ts b/libs/bootstrap/src/interfaces/index.ts new file mode 100644 index 00000000..4d45ac85 --- /dev/null +++ b/libs/bootstrap/src/interfaces/index.ts @@ -0,0 +1 @@ +export type { BootstrapOptions, SwaggerOptions } from './options.interface'; diff --git a/libs/bootstrap/src/interfaces/options.interface.ts b/libs/bootstrap/src/interfaces/options.interface.ts new file mode 100644 index 00000000..7f2492f3 --- /dev/null +++ b/libs/bootstrap/src/interfaces/options.interface.ts @@ -0,0 +1,36 @@ +import type { Config } from '@libs/config'; +import type { Type } from '@nestjs/common'; +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; +import type { ThrottlerModuleOptions } from '@nestjs/throttler'; + +export interface SwaggerMetadata { + readonly title?: string; + readonly description?: string; + readonly version?: string; + readonly path?: string; +} + +export interface SwaggerInfrastructure { + readonly server?: { + readonly port?: string | number; + readonly domain?: string; + readonly stage?: string; + }; + readonly services?: readonly { readonly name: string; readonly port: number }[]; +} + +export interface SwaggerOptions extends SwaggerMetadata, SwaggerInfrastructure {} + +export interface BootstrapOptions { + readonly apiPrefix?: string; + readonly version?: string; + readonly appModule: Type; + readonly defaultPort?: number; + readonly portEnvKey?: keyof Config; + readonly serviceName: string; + readonly setupApp?: (app: NestFastifyApplication) => Promise | void; + readonly swaggerOptions?: SwaggerMetadata; + readonly throttlerOptions?: ThrottlerModuleOptions; + readonly useCookieParser?: boolean; + readonly useCors?: boolean; +} diff --git a/libs/bootstrap/src/setups/cors.ts b/libs/bootstrap/src/setups/cors.ts new file mode 100644 index 00000000..7f3c6c20 --- /dev/null +++ b/libs/bootstrap/src/setups/cors.ts @@ -0,0 +1,37 @@ +import fastifyCors from '@fastify/cors'; + +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; + +export function setupCors(app: NestFastifyApplication, origins: readonly string[]) { + app.getHttpAdapter() + .getInstance() + .register(fastifyCors, { + origin: (origin, callback) => { + // server-to-server / curl / healthcheck + if (!origin) { + return callback(null, true); + } + + try { + const { hostname } = new URL(origin); + const allowedHostnames = origins.map((o) => new URL(o).hostname); + + if ( + allowedHostnames.some( + (allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`), + ) + ) { + return callback(null, origin); + } + + callback(new Error('Not allowed by CORS'), false); + } catch { + callback(new Error('Invalid origin format'), false); + } + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], + preflightContinue: false, + optionsSuccessStatus: 204, + }); +} diff --git a/libs/bootstrap/src/setups/index.ts b/libs/bootstrap/src/setups/index.ts new file mode 100644 index 00000000..e5d3f070 --- /dev/null +++ b/libs/bootstrap/src/setups/index.ts @@ -0,0 +1,4 @@ +export { setupCors } from './cors'; +export { setupThrottler } from './throttler'; +export { setupSwagger } from './swagger'; +export { setupLogger } from './logger'; diff --git a/libs/bootstrap/src/setups/logger.ts b/libs/bootstrap/src/setups/logger.ts new file mode 100644 index 00000000..f689330d --- /dev/null +++ b/libs/bootstrap/src/setups/logger.ts @@ -0,0 +1,228 @@ +import { + Injectable, + NestInterceptor, + type ExecutionContext, + type CallHandler, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { WinstonModule, utilities } from 'nest-winston'; +import { throwError } from 'rxjs'; +import { tap, catchError } from 'rxjs/operators'; +import { format, transports } from 'winston'; + +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; +import type { FastifyRequest } from 'fastify'; + +export function setupLogger(app: NestFastifyApplication, service: string) { + const cfg = app.get(ConfigService); + const isProduction = cfg.get('NODE_ENV') === 'production'; + + const logger = WinstonModule.createLogger({ + level: isProduction ? 'info' : 'debug', + format: format.combine( + format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }), + format.errors({ stack: true }), + isProduction + ? format.json() + : format.combine(format.ms(), utilities.format.nestLike(service, { colors: true })), + ), + transports: [new transports.Console()], + }); + + app.useLogger(logger); + app.useGlobalInterceptors(new LoggingInterceptor()); +} + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger('HTTP'); + private readonly sensitiveFields = ['password', 'token', 'access', 'refresh', 'code', 'secret']; + + intercept(context: ExecutionContext, next: CallHandler) { + const request = context.switchToHttp().getRequest(); + const startTime = Date.now(); + + const baseCtx = { + request_id: request.id || request.headers['x-request-id'] || 'unknown', + method: request.method, + url: request.url, + path: request.url.split('?')[0], + controller: context.getClass().name, + handler: context.getHandler().name, + ip: request.ip, + referer: request.headers['referer'] || 'direct', + user_agent: request.headers['user-agent'] || 'unknown', + triggered_by: 'interceptor', + }; + + this.logger.log(`Incoming ${baseCtx.method} ${baseCtx.url}`, { + ...baseCtx, + type: 'request', + body: this.sanitize(request.body), + query: request.query, + }); + + return next.handle().pipe( + tap(() => { + const delay_num = Date.now() - startTime; + + this.logger.log(`${baseCtx.method} ${baseCtx.path} | 200 | ${delay_num}ms`, { + ...baseCtx, + type: 'response', + status_code: 200, + delay_num, + }); + }), + catchError((err) => { + const delay_num = Date.now() - startTime; + const status_code = err.status || err.statusCode || 500; + + this.logger.error( + `${baseCtx.method} ${baseCtx.path} | ${status_code} | ${delay_num}ms`, + { + ...baseCtx, + type: 'error', + status_code, + delay_num, + stack: err.stack, + error_details: err.response || err.message, + }, + ); + + return throwError(() => err); + }), + ); + } + + private sanitize(data: T): T { + if (!data || typeof data !== 'object') { + return data; + } + if (Array.isArray(data)) { + return data.map((v) => this.sanitize(v)) as T; + } + + const cleanData = structuredClone(data) as Record; + + return Object.keys(cleanData).reduce>((acc, key) => { + const isSensitive = this.sensitiveFields.some((field) => + key.toLowerCase().includes(field), + ); + + if (isSensitive) { + acc[key] = '***'; + } else if (typeof cleanData[key] === 'object' && cleanData[key] !== null) { + acc[key] = this.sanitize(cleanData[key]); + } else { + acc[key] = cleanData[key]; + } + return acc; + }, {}) as T; + } +} + +/** + * Represents a structured application log payload for Grafana Loki. + * This object is flattened to ensure each property is indexed as a top-level label/column. + * + * @typedef {object} TLog + */ +export type TLog = { + /** + * The severity level of the log. + * Used by Grafana to color-code rows and for alerting. + * @type {'info' | 'error' | 'warn'} + */ + readonly level: 'info' | 'error' | 'warn'; + /** + * Human-readable summary of the event. + * @example 'Request completed POST /v1/auth/sign-in | 200 | 145ms' + * @type {string} + */ + readonly message: string; + /** + * Event occurrence time in ISO 8601 format. + * @example '2026-05-09T01:17:29.000Z' + * @type {string} + */ + readonly timestamp: string; + /** + * Unique identifier for the HTTP request (e.g., UUID, NanoID). + * Used to correlate all logs produced within a single request lifecycle. + * @type {string} + */ + readonly request_id: string; + /** + * The system component that triggered the log entry. + * @type {'interceptor' | 'filter_exception' | 'guard' | 'service'} + */ + readonly triggered_by: 'interceptor' | 'filter_exception' | 'guard' | 'service'; + /** + * The logical type of the event within the request/response flow. + * @type {'request' | 'response' | 'error' | 'system'} + */ + readonly type: 'request' | 'response' | 'error' | 'system'; + /** + * The HTTP method used for the request. + * @type {'POST' | 'GET' | 'DELETE' | 'PATCH' | 'PUT' | 'OPTIONS' | 'HEAD'} + */ + readonly method: 'POST' | 'GET' | 'DELETE' | 'PATCH' | 'PUT' | 'OPTIONS' | 'HEAD'; + /** + * The full URL of the request, including query parameters. + * @example '/v1/auth/sign-in?source=mobile' + * @type {string} + */ + readonly url: string; + /** + * The sanitized API path, including versioning but excluding query parameters. + * Ideal for aggregating statistics per endpoint. + * @example '/v1/auth/sign-in' + * @type {string} + */ + readonly path: string; + /** + * The HTTP status code returned to the client. + * @example 200 + * @type {number} + */ + readonly status_code: number; + /** + * Request processing time in milliseconds. + * Note: Typically undefined for entries with type 'request'. + * @type {number} + */ + readonly delay_num?: number; + /** + * The client's IP address. + * @type {string} + */ + readonly ip: string; + /** + * The client's application or browser identification string. + * @type {string} + */ + readonly user_agent: string; + /** + * The name of the NestJS controller handling the request. + * @example 'AuthController' + * @type {string} + */ + readonly controller: string; + /** + * The name of the specific controller method (handler). + * @example 'signIn' + * @type {string} + */ + readonly handler: string; + /** + * The error stack trace. Only populated when level is 'error'. + * @type {string} + */ + readonly stack?: string; + /** + * Additional contextual data for debugging (e.g., Zod validation issues, DB error details). + * @type {any} + */ + readonly error_details?: any; +}; diff --git a/libs/bootstrap/src/setups/swagger.ts b/libs/bootstrap/src/setups/swagger.ts new file mode 100644 index 00000000..9f5820bc --- /dev/null +++ b/libs/bootstrap/src/setups/swagger.ts @@ -0,0 +1,67 @@ +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { GlobalErrorResponse } from '@shared/error/schema'; +import { cleanupOpenApiDoc } from 'nestjs-zod'; + +import { SWAGGER_DEFAULTS } from '../configs/swagger'; + +import type { SwaggerOptions } from '../interfaces'; +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; + +async function getCustomCSS() { + const rawUrl = 'https://gist.githubusercontent.com/soorq/f745e5c44cfe27aa928048d6d4ccb18a/raw'; + const res = await fetch(rawUrl); + if (!res.ok) { + return ''; + } + return res.text(); +} + +export async function setupSwagger(app: NestFastifyApplication, options: SwaggerOptions = {}) { + const { + title = 'Api', + description = '', + version = 'v0.0.1', + path = 'api', + server, + } = { + ...SWAGGER_DEFAULTS, + ...options, + }; + + const { domain, port, stage } = server || {}; + + const builder = new DocumentBuilder() + .setTitle(title) + .setDescription(description) + .setVersion(version) + .addBearerAuth(); + + if ((!stage || !domain) && port) { + builder.addServer(`http://localhost:${port}`, 'Local'); + } + if (stage) { + builder.addServer(`https://api.${stage}`, 'Staging'); + } + if (domain) { + builder.addServer(`https://api.${domain}`, 'Production'); + } + + const document = SwaggerModule.createDocument(app, builder.build(), { + extraModels: [GlobalErrorResponse.Output], + }); + + const customCss = await getCustomCSS().catch(() => ''); + + SwaggerModule.setup(path, app, cleanupOpenApiDoc(document), { + jsonDocumentUrl: `${path}/s/json`, + yamlDocumentUrl: `${path}/s/yaml`, + useGlobalPrefix: true, + ui: true, + customCss, + swaggerOptions: { + persistAuthorization: true, + tagsSorter: 'alpha', + operationsSorter: 'alpha', + }, + }); +} diff --git a/libs/bootstrap/src/setups/throttler.ts b/libs/bootstrap/src/setups/throttler.ts new file mode 100644 index 00000000..a3456b04 --- /dev/null +++ b/libs/bootstrap/src/setups/throttler.ts @@ -0,0 +1,18 @@ +import { Module, type Type } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerGuard, ThrottlerModule, type ThrottlerModuleOptions } from '@nestjs/throttler'; + +export function setupThrottler(module: Type, options: ThrottlerModuleOptions) { + @Module({ + imports: [module, ThrottlerModule.forRoot(options)], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], + }) + class RootModule {} + + return RootModule; +} diff --git a/libs/bootstrap/tsconfig.lib.json b/libs/bootstrap/tsconfig.lib.json new file mode 100644 index 00000000..909ede0a --- /dev/null +++ b/libs/bootstrap/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/bootstrap" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/libs/config/src/config.module.ts b/libs/config/src/config.module.ts new file mode 100644 index 00000000..be8e560f --- /dev/null +++ b/libs/config/src/config.module.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-console */ +import * as path from 'node:path'; + +import { Module } from '@nestjs/common'; +import { ConfigModule as NestConfigModule } from '@nestjs/config'; +import { ZodError } from 'zod/v4'; + +import { ConfigSchema } from './config.schema'; + +const validateConfig = (config: Record) => { + try { + return ConfigSchema.parse(config); + } catch (error) { + if (error instanceof ZodError) { + console.group('\nENVIRONMENT_VALIDATION_ERROR\n'); + + error.issues.forEach((issue) => { + const field = issue.path.join('.') || 'ROOT'; + + console.group(`Field: ${field}`); + console.error(`Message: ${issue.message}`); + console.error(`Code: ${issue.code.toUpperCase()}`); + console.groupEnd(); + console.error('\n'); + }); + + console.groupEnd(); + + throw new Error('Invalid environment configuration', { cause: error }); + } + throw error; + } +}; + +@Module({ + imports: [ + NestConfigModule.forRoot({ + isGlobal: true, + envFilePath: path.resolve(process.cwd(), '.env'), + validate: validateConfig, + validationOptions: { + abortEarly: true, + }, + }), + ], + exports: [NestConfigModule], +}) +export class ConfigModule {} diff --git a/libs/config/src/config.schema.ts b/libs/config/src/config.schema.ts new file mode 100644 index 00000000..d29c1524 --- /dev/null +++ b/libs/config/src/config.schema.ts @@ -0,0 +1,225 @@ +import { z } from 'zod/v4'; + +import { jwtSecretValidation } from './helpers/jwt-secren-validation'; + +const timeStringSchema = z.string().regex(/^[0-9]+[smhdw]$/, { + message: + 'Неверный формат времени. Используйте суффиксы: s, m, h, d, w (например: 15m, 24h, 30d)', +}); + +const domainRegex = /^[a-z0-9.-]+\.[a-z]{2,}$/; + +export const ConfigSchema = z.object({ + PORT: z.coerce.number().int({ error: 'Порт (PORT) должен быть числом' }).default(3000), + + NODE_ENV: z + .enum(['development', 'production', 'test'], { + error: 'NODE_ENV должен быть одним из значений: development, production, test', + }) + .default('development'), + + COOKIE_SECRET: z + .string({ + error: 'Критическая ошибка: COOKIE_SECRET не задан в окружении', + }) + .min(10, 'COOKIE_SECRET слишком короткий, должен быть не менее 10 символов'), + + DB_SCHEMA: z + .string({ + error: 'Не указана схема базы данных (DB_SCHEMA)', + }) + .min(1, 'Имя схемы DB_SCHEMA не может быть пустым'), + + DATABASE_URL: z + .string({ + error: 'Отсутствует строка подключения DATABASE_URL', + }) + .url( + 'DATABASE_URL должен быть валидным URL-адресом подключения (например, postgresql://...)', + ), + + REDIS_HOST: z.string().default('redis'), + REDIS_PORT: z.coerce.number().default(6379), + REDIS_PASSWORD: z.string().optional(), + + IMAGOR_SECRET: z.string().optional(), + IMAGOR_URL: z + .string({ + error: 'Адрес сервера Imagor (IMAGOR_URL) обязателен', + }) + .url('IMAGOR_URL должен быть корректным URL-адресом'), + + DOMAIN: z + .string() + .toLowerCase() + .regex(domainRegex, { + message: 'DOMAIN должен быть валидным именем хоста (например, example.com)', + }) + .optional(), + + STAGE_DOMAIN: z + .string() + .toLowerCase() + .regex(domainRegex, { message: 'STAGE_DOMAIN должен быть валидным именем хоста' }) + .optional(), + + CORS_ALLOWED_ORIGINS: z + .string({ + error: 'Необходимо указать разрешенные CORS_ALLOWED_ORIGINS (через запятую)', + }) + .min(1, 'Список CORS_ALLOWED_ORIGINS не может быть пустым') + .transform((val) => val.split(',').map((s) => s.trim())) + .pipe( + z.array( + z.string().url('Каждая ссылка в CORS_ALLOWED_ORIGINS должна быть валидным URL'), + ), + ), + + JWT_ISSUER: z + .string({ + error: 'Параметр JWT_ISSUER обязателен для проверки токенов', + }) + .min(1, 'JWT_ISSUER не может быть пустым'), + JWT_AUDIENCE: z + .string({ + error: 'Параметр JWT_AUDIENCE обязателен для проверки токенов', + }) + .min(1, 'JWT_AUDIENCE не может быть пустым'), + JWT_ACCESS_SECRET: z + .string({ error: 'Ключ JWT_ACCESS_SECRET обязателен для безопасности' }) + .refine(jwtSecretValidation, { + message: + 'JWT_ACCESS_SECRET должен быть не менее 32 символов ИЛИ содержать минимум 5 слов через дефис', + }), + JWT_REFRESH_SECRET: z + .string({ error: 'Ключ JWT_REFRESH_SECRET обязателен для безопасности' }) + .refine(jwtSecretValidation, { + message: + 'JWT_REFRESH_SECRET должен быть не менее 32 символов ИЛИ содержать минимум 5 слов через дефис', + }), + JWT_ACCESS_EXPIRES_IN: timeStringSchema.default('15m'), + JWT_REFRESH_EXPIRES_IN: timeStringSchema.default('30d'), + + MAIL_HOST: z + .string({ + error: 'Адрес почтового сервера (MAIL_HOST) не указан', + }) + .min(1, 'MAIL_HOST не может быть пустым'), + + MAIL_PORT: z.coerce + .number({ + error: 'Порт почтового сервера (MAIL_PORT) не указан', + }) + .int({ error: 'MAIL_PORT должен быть числом' }), + + MAIL_USER: z + .string({ + error: 'Имя пользователя почты (MAIL_USER) не указано', + }) + .email('MAIL_USER должен быть валидным email-адресом'), + + MAIL_PASSWORD: z + .string({ + error: 'Пароль от почты (MAIL_PASSWORD) обязателен', + }) + .min(1, 'Пароль от почты не может быть пустым'), + + MAIL_FROM_NAME: z + .string({ + error: 'Имя отправителя (MAIL_FROM_NAME) не указано', + }) + .min(1, 'Имя отправителя не может быть пустым'), + + MAIL_FROM_EMAIL: z.string().email('Неверный формат email в MAIL_FROM_EMAIL').optional(), + + S3_BUCKET_NAME: z + .string({ + error: "Имя бакета S3_BUCKET_NAME обязательно. Пример: 'avatars'", + }) + .min(1, 'Имя бакета не может быть пустым'), + + S3_ENDPOINT: z + .string({ + error: "S3_ENDPOINT обязателен. Пример: 'http://localhost:9000'", + }) + .url('S3_ENDPOINT должен быть валидным URL-адресом'), + + S3_REGION: z.string().default('us-east-1'), + + S3_ACCESS_KEY: z + .string({ + error: 'S3_ACCESS_KEY отсутствует (MinIO root user или IAM access key)', + }) + .min(1, 'S3_ACCESS_KEY не может быть пустым'), + + S3_SECRET_KEY: z + .string({ + error: 'S3_SECRET_KEY отсутствует (MinIO root password или IAM secret key)', + }) + .min(1, 'S3_SECRET_KEY не может быть пустым'), + + GOOGLE_CLIENT_ID: z + .string({ + error: 'Идентификатор клиента Google (GOOGLE_CLIENT_ID) отсутствует в переменных окружения', + }) + .min(1, 'GOOGLE_CLIENT_ID не может быть пустым. Получите его в Google Cloud Console'), + + GOOGLE_CLIENT_SECRET: z + .string({ + error: 'Секретный ключ Google (GOOGLE_CLIENT_SECRET) отсутствует в переменных окружения', + }) + .min(1, 'GOOGLE_CLIENT_SECRET не может быть пустым. Защитите им свои OAuth-запросы'), + + GITHUB_CLIENT_ID: z + .string({ + error: 'Идентификатор клиента GitHub (GITHUB_CLIENT_ID) отсутствует в переменных окружения', + }) + .min( + 1, + 'GITHUB_CLIENT_ID не может быть пустым. Получите его в настройках Developer Settings на GitHub', + ) + .optional(), + + GITHUB_CLIENT_SECRET: z + .string({ + error: 'Секретный ключ GitHub (GITHUB_CLIENT_SECRET) отсутствует в переменных окружения', + }) + .min(1, 'GITHUB_CLIENT_SECRET не может быть пустым') + .optional(), + + YANDEX_CLIENT_ID: z + .string({ + error: 'Идентификатор приложения Яндекс (YANDEX_CLIENT_ID) отсутствует в переменных окружения', + }) + .min( + 1, + 'YANDEX_CLIENT_ID не может быть пустым. Создайте приложение на Яндекс ID для разработчиков', + ) + .optional(), + + YANDEX_CLIENT_SECRET: z + .string({ + error: 'Секретный ключ Яндекса (YANDEX_CLIENT_SECRET) отсутствует в переменных окружения', + }) + .min(1, 'YANDEX_CLIENT_SECRET не может быть пустым') + .optional(), + + VKONTAKTE_CLIENT_ID: z + .string({ + error: 'Идентификатор приложения Вконтакте (VKONTAKTE_CLIENT_ID) отсутствует в переменных окружения', + }) + .min( + 1, + 'VKONTAKTE_CLIENT_ID не может быть пустым. Создайте приложение на Вконтакте ID для разработчиков', + ) + .optional(), + + VKONTAKTE_CLIENT_SECRET: z + .string({ + error: 'Секретный ключ Вконтакте (VKONTAKTE_CLIENT_SECRET) отсутствует в переменных окружения', + }) + .min(1, 'VKONTAKTE_CLIENT_SECRET не может быть пустым') + .optional(), +}); + +export type Config = z.infer; diff --git a/libs/config/src/config.types.d.ts b/libs/config/src/config.types.d.ts new file mode 100644 index 00000000..d387eab1 --- /dev/null +++ b/libs/config/src/config.types.d.ts @@ -0,0 +1,17 @@ +import '@nestjs/config'; +import { Config } from './config.schema'; + +declare module '@nestjs/config' { + interface ConfigService<_K = unknown, _WasValidated extends boolean = false> { + /** + * Переопределяем метод get, чтобы он предлагал ключи из нашей схемы + */ + get(key: T): Config[T]; + get(key: T, defaultValue: Config[T]): Config[T]; + + /** + * Переопределяем метод getOrThrow, чтобы он предлагал ключи из нашей схемы + */ + getOrThrow(key: T): Config[T]; + } +} diff --git a/libs/config/src/helpers/jwt-secren-validation.ts b/libs/config/src/helpers/jwt-secren-validation.ts new file mode 100644 index 00000000..27a4e184 --- /dev/null +++ b/libs/config/src/helpers/jwt-secren-validation.ts @@ -0,0 +1,7 @@ +export function jwtSecretValidation(val: string) { + const isLongEnough = val.length >= 32; + const words = val.split('-'); + const hasFiveWords = words.length >= 5 && words.every((word) => word.length > 0); + + return isLongEnough || hasFiveWords; +} diff --git a/libs/config/src/index.ts b/libs/config/src/index.ts new file mode 100644 index 00000000..0c710779 --- /dev/null +++ b/libs/config/src/index.ts @@ -0,0 +1,2 @@ +export * from './config.module'; +export * from './config.schema'; diff --git a/libs/config/tsconfig.lib.json b/libs/config/tsconfig.lib.json new file mode 100644 index 00000000..855908d0 --- /dev/null +++ b/libs/config/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/config" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/libs/database/src/constants.ts b/libs/database/src/constants.ts new file mode 100644 index 00000000..087de13c --- /dev/null +++ b/libs/database/src/constants.ts @@ -0,0 +1,2 @@ +export const DATABASE_SERVICE = 'DATABASE_SERVICE'; +export const SQL_CLIENT = 'SQL_CLIENT'; diff --git a/libs/database/src/database-health.service.ts b/libs/database/src/database-health.service.ts new file mode 100644 index 00000000..82211d81 --- /dev/null +++ b/libs/database/src/database-health.service.ts @@ -0,0 +1,20 @@ +import { SQL_CLIENT } from '@libs/database/constants'; +import { Inject, Injectable } from '@nestjs/common'; +import { Sql } from 'postgres'; + +@Injectable() +export class DatabaseHealthService { + constructor( + @Inject(SQL_CLIENT) + private readonly sql: Sql, + ) {} + + async isAlive() { + try { + await this.sql`SELECT 1`; + return true; + } catch { + return false; + } + } +} diff --git a/libs/database/src/database.module-definition.ts b/libs/database/src/database.module-definition.ts new file mode 100644 index 00000000..bb742bc8 --- /dev/null +++ b/libs/database/src/database.module-definition.ts @@ -0,0 +1,17 @@ +import { ConfigurableModuleBuilder } from '@nestjs/common'; + +import type { DatabaseModuleOptions } from './interfaces'; + +export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = + new ConfigurableModuleBuilder() + .setClassMethodName('register') + .setExtras( + { + global: false, + }, + (definition, extras) => ({ + ...definition, + global: extras.global, + }), + ) + .build(); diff --git a/libs/database/src/database.module.ts b/libs/database/src/database.module.ts new file mode 100644 index 00000000..4c7f11c7 --- /dev/null +++ b/libs/database/src/database.module.ts @@ -0,0 +1,84 @@ +import { DatabaseHealthService } from '@libs/database/database-health.service'; +import { Inject, Logger, Module, OnApplicationShutdown } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; + +import { DATABASE_SERVICE, SQL_CLIENT } from './constants'; +import { + ConfigurableModuleClass, + MODULE_OPTIONS_TOKEN, + OPTIONS_TYPE, +} from './database.module-definition'; +import { MigrationService } from './migration.service'; + +@Module({ + providers: [ + MigrationService, + DatabaseHealthService, + { + provide: SQL_CLIENT, + inject: [ConfigService, MODULE_OPTIONS_TOKEN], + useFactory: (configService: ConfigService, opts: typeof OPTIONS_TYPE) => { + const baseUrl = configService.getOrThrow('DATABASE_URL'); + const url = new URL(baseUrl); + + if (opts.schemaName) { + url.searchParams.set('options', `-c search_path=${opts.schemaName}`); + } + + return postgres(url.toString(), { + onnotice: (msg) => new Logger('PostgresJS').verbose(msg), + backoff: (attempt) => Math.min(attempt * 100, 3000), + target_session_attrs: 'read-write', + publications: 'alltables', + connect_timeout: 2, + idle_timeout: 5, + max_lifetime: 60 * 60, + keep_alive: 30, + transform: { + undefined: null, + }, + ...opts.pool, + }); + }, + }, + { + provide: DATABASE_SERVICE, + inject: [SQL_CLIENT, MODULE_OPTIONS_TOKEN], + useFactory: (sql: postgres.Sql, opts: typeof OPTIONS_TYPE) => { + const logger = new Logger('Drizzle'); + + return drizzle(sql, { + schema: opts.schema, + logger: opts.logging + ? { + logQuery(query, params) { + logger.debug(`SQL: ${query}`); + if (params?.length) { + logger.debug(`Params: ${JSON.stringify(params)}`); + } + }, + } + : false, + }); + }, + }, + ], + exports: [DATABASE_SERVICE, DatabaseHealthService], +}) +export class DatabaseModule extends ConfigurableModuleClass implements OnApplicationShutdown { + private readonly logger = new Logger(DatabaseModule.name); + + constructor( + @Inject(SQL_CLIENT) + private readonly sql: postgres.Sql, + ) { + super(); + } + + async onApplicationShutdown() { + this.logger.log('Closing database connections...'); + await this.sql.end(); + } +} diff --git a/libs/database/src/index.ts b/libs/database/src/index.ts new file mode 100644 index 00000000..da99ae2c --- /dev/null +++ b/libs/database/src/index.ts @@ -0,0 +1,4 @@ +export * from './database.module'; +export { DATABASE_SERVICE } from './constants'; +export type { DatabaseService } from './interfaces'; +export { DatabaseHealthService } from './database-health.service'; diff --git a/libs/database/src/interfaces/index.ts b/libs/database/src/interfaces/index.ts new file mode 100644 index 00000000..5e3751a4 --- /dev/null +++ b/libs/database/src/interfaces/index.ts @@ -0,0 +1 @@ +export * from './module.interface'; diff --git a/libs/database/src/interfaces/module.interface.ts b/libs/database/src/interfaces/module.interface.ts new file mode 100644 index 00000000..7c89571d --- /dev/null +++ b/libs/database/src/interfaces/module.interface.ts @@ -0,0 +1,67 @@ +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import type { Options, PostgresType } from 'postgres'; + +export interface DatabaseModuleOptions { + /** + * Схема базы данных PostgreSQL (устанавливает `search_path`). + * * Все запросы без явного указания схемы будут выполняться в этом пространстве имен. + * @default 'public' + * @example 'auth_service' + */ + readonly schemaName?: string; + + /** + * Объект схемы Drizzle, содержащий определения таблиц и связей. + * * Рекомендуется импортировать целиком: `import * as schema from './schema'`. + * @example schema + */ + readonly schema: Record; + + /** + * Настройки драйвера `postgres.js`. + * * Позволяет настроить пул соединений, таймауты и SSL. + * * **Внимание:** Параметры отличаются от драйвера `pg`. + * @see https://github.com/porsager/postgres#options + * @example { max: 20, idle_timeout: 30, connect_timeout: 5 } + */ + readonly pool?: Options<{ + [key: string]: PostgresType; + }>; + + /** + * Включение или выключение логирования SQL-запросов в консоль через NestJS Logger. + * @default false + */ + readonly logging?: boolean; + + /** + * Флаг для автоматического запуска миграций при старте приложения. + * * Полезно для локальной разработки и стейджинга. + * @default true + */ + readonly runMigrations?: boolean; + + /** + * Абсолютный путь к директории с файлами миграций (SQL или JS/TS). + * * Если не указано, используется путь `./migrations` от корня проекта. + * @default path.resolve(process.cwd(), 'migrations') + */ + readonly migrationsPath?: string; +} + +export type DatabaseSchema = Record; + +/** + * Тип для внедрения Drizzle ORM в репозитории. + * Использует драйвер postgres-js под капотом. + * + * @example + * // В репозитории: + * constructor( + * @Inject(DATABASE_SERVICE) private readonly db: DatabaseService + * ) {} + * + * @template TSchema - Тип вашей схемы данных (например, `typeof schema`). + */ +export type DatabaseService = + PostgresJsDatabase; diff --git a/libs/database/src/migration.service.ts b/libs/database/src/migration.service.ts new file mode 100644 index 00000000..31fe50e1 --- /dev/null +++ b/libs/database/src/migration.service.ts @@ -0,0 +1,40 @@ +import * as path from 'node:path'; + +import { Inject, Injectable, OnModuleInit, Logger } from '@nestjs/common'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; + +import { DATABASE_SERVICE } from './constants'; +import { MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } from './database.module-definition'; + +import type { DatabaseService } from './interfaces'; + +@Injectable() +export class MigrationService implements OnModuleInit { + private readonly logger = new Logger(MigrationService.name); + + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + @Inject(MODULE_OPTIONS_TOKEN) + private readonly options: typeof OPTIONS_TYPE, + ) {} + + async onModuleInit() { + if (this.options.runMigrations === false) { + return; + } + + const migrationsFolder = path.resolve(process.cwd(), 'migrations'); + + this.logger.debug('Checking for database migrations...'); + try { + await migrate(this.db, { + migrationsFolder, + }); + this.logger.debug('Migrations completed or already up to date'); + } catch (error) { + this.logger.error('Migration failed', error); + process.exit(1); + } + } +} diff --git a/libs/database/tsconfig.lib.json b/libs/database/tsconfig.lib.json new file mode 100644 index 00000000..0add6d4f --- /dev/null +++ b/libs/database/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/database" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/libs/health/src/controller/health.controller.ts b/libs/health/src/controller/health.controller.ts new file mode 100644 index 00000000..dc00b2ab --- /dev/null +++ b/libs/health/src/controller/health.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Get, HttpStatus } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; +import { BaseException } from '@shared/error'; + +import { HealthService } from '../health.service'; + +import { GetHealthSwagger, GetPingSwagger } from './health.swagger'; + +@SkipThrottle() +@Controller() +@ApiTags('System') +export class HealthController { + constructor(private readonly service: HealthService) {} + + @Get('health') + @GetHealthSwagger() + async checkHealth() { + const pingData = await this.service.getHealthData(); + + if (!pingData.status) { + throw new BaseException( + { + code: 'SERVICE_UNHEALTHY', + message: `Сервис ${pingData.service} временно недоступен или работает некорректно`, + details: [ + { + target: pingData.service, + status: pingData.status, + timestamp: new Date().toISOString(), + }, + ], + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + return { status: 'healthy' }; + } + + @Get('ping') + @GetPingSwagger() + async ping() { + return this.service.getHealthData(); + } +} diff --git a/libs/health/src/controller/health.controlller.spec.ts b/libs/health/src/controller/health.controlller.spec.ts new file mode 100644 index 00000000..86ce0040 --- /dev/null +++ b/libs/health/src/controller/health.controlller.spec.ts @@ -0,0 +1,68 @@ +import { HttpStatus, Logger } from '@nestjs/common'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { HealthController } from './health.controller'; + +describe('HealthController', () => { + let controller: HealthController; + let healthServiceMock: { getHealthData: ReturnType }; + + const SERVICE_NAME = 'MyService'; + + beforeEach(() => { + healthServiceMock = { + getHealthData: vi.fn(), + }; + + controller = new HealthController(healthServiceMock as any); + + vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + }); + + it('should throw SERVICE_UNAVAILABLE when service status is false (down)', async () => { + healthServiceMock.getHealthData.mockResolvedValue({ + service: SERVICE_NAME, + status: false, + components: { database: 'down' }, + }); + + await expect(controller.checkHealth()).rejects.toMatchObject({ + status: HttpStatus.SERVICE_UNAVAILABLE, + response: { + code: 'SERVICE_UNHEALTHY', + message: expect.stringContaining(SERVICE_NAME), + details: expect.arrayContaining([ + expect.objectContaining({ + target: SERVICE_NAME, + status: false, + }), + ]), + }, + }); + }); + + it('should return "healthy" when status is true', async () => { + healthServiceMock.getHealthData.mockResolvedValue({ service: SERVICE_NAME, status: true }); + + const result = await controller.checkHealth(); + + expect(result.status).toBe('healthy'); + }); + + describe('ping', () => { + it('should return the full health payload', async () => { + const mockPayload = { + service: SERVICE_NAME, + status: true, + components: {}, + time: { uptime: '1h 0m 0s' }, + }; + healthServiceMock.getHealthData.mockResolvedValue(mockPayload); + + const result = await controller.ping(); + + expect(result).toEqual(mockPayload); + expect(healthServiceMock.getHealthData).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/libs/health/src/controller/health.swagger.ts b/libs/health/src/controller/health.swagger.ts new file mode 100644 index 00000000..f2ee9663 --- /dev/null +++ b/libs/health/src/controller/health.swagger.ts @@ -0,0 +1,36 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; + +import { HealthResponse, HealthDetailedResponse } from '../dtos'; + +export const GetHealthSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Краткий статус (Health Check)', + description: 'Используется внешними системами для проверки доступности сервиса.', + }), + ApiResponse({ + status: 200, + description: 'Сервис работает нормально', + type: HealthResponse.Output, + }), + ApiResponse({ status: 503, description: 'Сервис недоступен или критическая ошибка' }), + + SetMetadata(ZOD_RESPONSE_TOKEN, HealthResponse), + ); + +export const GetPingSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Детальный дамп состояния', + description: 'Возвращает аптайм, время старта и метрики памяти.', + }), + ApiResponse({ + status: 200, + description: 'Полная статистика сервиса', + type: HealthDetailedResponse.Output, + }), + + SetMetadata(ZOD_RESPONSE_TOKEN, HealthDetailedResponse), + ); diff --git a/libs/health/src/dtos/health.dto.ts b/libs/health/src/dtos/health.dto.ts new file mode 100644 index 00000000..ea5ad96f --- /dev/null +++ b/libs/health/src/dtos/health.dto.ts @@ -0,0 +1,39 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +const HealthDetailedResponseSchema = z.object({ + service: z.string().describe('Название сервиса'), + status: z.boolean().describe('Общий статус работоспособности (true — ок, false — есть сбои)'), + components: z + .record(z.string(), z.enum(['up', 'down'])) + .describe('Статусы отдельных компонентов (например, database, redis)'), + info: z.object({ + version: z.string().describe('Версия приложения'), + node: z.string().describe('Версия Node.js'), + }), + time: z.object({ + now: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Текущее время сервера (ISO)'), + startedAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Время старта сервера (ISO)'), + uptime: z.string().describe('Аптайм в читаемом формате (h m s)'), + uptimeSeconds: z.number().describe('Аптайм в секундах'), + }), + loaded: z.string().describe('Средняя нагрузка на CPU за последнюю минуту (Load Average)'), +}); + +export class HealthDetailedResponse extends createZodDto(HealthDetailedResponseSchema) {} + +export const HealthResponseSchema = z.object({ + status: z.literal('healthy').describe('Статус работоспособности сервиса'), +}); + +export class HealthResponse extends createZodDto(HealthResponseSchema) {} diff --git a/libs/health/src/dtos/index.ts b/libs/health/src/dtos/index.ts new file mode 100644 index 00000000..a788b1e5 --- /dev/null +++ b/libs/health/src/dtos/index.ts @@ -0,0 +1 @@ +export { HealthResponse, HealthDetailedResponse } from './health.dto'; diff --git a/libs/health/src/health.module-definition.ts b/libs/health/src/health.module-definition.ts new file mode 100644 index 00000000..1d3eae53 --- /dev/null +++ b/libs/health/src/health.module-definition.ts @@ -0,0 +1,17 @@ +import { ConfigurableModuleBuilder } from '@nestjs/common'; + +import type { HealthModuleOptions } from './interfaces'; + +export const { ASYNC_OPTIONS_TYPE, ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } = + new ConfigurableModuleBuilder() + .setClassMethodName('register') + .setExtras( + { + global: false, + }, + (definition, extras) => ({ + ...definition, + global: extras.global, + }), + ) + .build(); diff --git a/libs/health/src/health.module.ts b/libs/health/src/health.module.ts new file mode 100644 index 00000000..91b7e89e --- /dev/null +++ b/libs/health/src/health.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { HealthController } from './controller/health.controller'; +import { ConfigurableModuleClass } from './health.module-definition'; +import { HealthService } from './health.service'; + +@Module({ + controllers: [HealthController], + providers: [HealthService], + exports: [HealthService], +}) +export class HealthModule extends ConfigurableModuleClass {} diff --git a/libs/health/src/health.service.spec.ts b/libs/health/src/health.service.spec.ts new file mode 100644 index 00000000..03867bf7 --- /dev/null +++ b/libs/health/src/health.service.spec.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { HealthService } from './health.service'; + +import type { HealthModuleOptions } from './interfaces'; + +vi.mock('os', async () => { + const actual = await vi.importActual('os'); + return { + ...actual, + loadavg: () => [1.23, 0.5, 0.1], + }; +}); + +describe('HealthService', () => { + const BASE_TIME = new Date('2026-05-15T10:00:00.000Z'); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(BASE_TIME); + vi.spyOn(process, 'uptime').mockReturnValue(3661); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('returns healthy payload when all indicators are ok', async () => { + const options: HealthModuleOptions = { + serviceName: 'MyService', + version: 'v2.0.0', + indicators: { + database: () => true, + redis: () => true, + }, + }; + + const service = new HealthService(options); + const data = await service.getHealthData(); + + expect(data).toMatchObject({ + service: 'MyService', + status: true, + components: { database: 'up', redis: 'up' }, + info: { version: 'v2.0.0', node: process.version }, + time: { + now: BASE_TIME.toISOString(), + startedAt: BASE_TIME.toISOString(), + uptime: '1h 1m 1s', + uptimeSeconds: 3661, + }, + loaded: '1.23', + }); + }); + + it('marks status as false when any indicator fails or throws', async () => { + const options: HealthModuleOptions = { + serviceName: 'MyService', + indicators: { + cache: () => true, + database: () => false, + storage: () => { + throw new Error('boom'); + }, + }, + }; + + const service = new HealthService(options); + const data = await service.getHealthData(); + + expect(data.status).toBe(false); + expect(data.components).toEqual({ database: 'down', storage: 'down', cache: 'up' }); + }); + + it('marks indicator as down on timeout', async () => { + const options: HealthModuleOptions = { + serviceName: 'MyService', + indicators: { + http: () => new Promise(() => {}), + }, + }; + + const service = new HealthService(options); + const resultPromise = service.getHealthData(); + + await vi.advanceTimersByTimeAsync(5000); + + const data = await resultPromise; + + expect(data.status).toBe(false); + expect(data.components).toEqual({ http: 'down' }); + }); +}); diff --git a/libs/health/src/health.service.ts b/libs/health/src/health.service.ts new file mode 100644 index 00000000..210f427b --- /dev/null +++ b/libs/health/src/health.service.ts @@ -0,0 +1,78 @@ +import * as os from 'node:os'; + +import { Inject, Injectable } from '@nestjs/common'; + +import { MODULE_OPTIONS_TOKEN } from './health.module-definition'; + +import type { HealthModuleOptions } from './interfaces'; + +@Injectable() +export class HealthService { + private readonly startTime: Date; + + constructor( + @Inject(MODULE_OPTIONS_TOKEN) + private readonly options: HealthModuleOptions, + ) { + this.startTime = new Date(); + } + + async getHealthData() { + const { serviceName, version = 'v1.0.0', indicators = {} } = this.options; + + const uptimeSeconds = Math.floor(process.uptime()); + + const results = await Promise.all( + Object.entries(indicators).map(async ([name, check]) => { + if (!check || typeof check !== 'function') { + return { name, ok: false, error: 'Health check not configured' }; + } + + let timeoutId: NodeJS.Timeout | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('Timeout')), 5000); + }); + + try { + const result = await Promise.race([check(), timeoutPromise]); + return { name, ok: !!result }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { name, ok: false, error: message }; + } finally { + clearTimeout(timeoutId); + } + }), + ); + + const isAllOk = results.every((r) => r.ok); + const components = Object.fromEntries(results.map((r) => [r.name, r.ok ? 'up' : 'down'])); + + const loaded = os.loadavg()[0]; + + return { + service: serviceName, + status: isAllOk, + components, + info: { + version, + node: process.version, + }, + time: { + now: new Date().toISOString(), + startedAt: this.startTime.toISOString(), + uptime: this.formatUptime(uptimeSeconds), + uptimeSeconds, + }, + loaded: loaded?.toFixed(2), + }; + } + + private formatUptime(seconds: number) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return `${h}h ${m}m ${s}s`; + } +} diff --git a/libs/health/src/index.ts b/libs/health/src/index.ts new file mode 100644 index 00000000..f0f04213 --- /dev/null +++ b/libs/health/src/index.ts @@ -0,0 +1 @@ +export * from './health.module'; diff --git a/libs/health/src/interfaces/index.ts b/libs/health/src/interfaces/index.ts new file mode 100644 index 00000000..22f53a1a --- /dev/null +++ b/libs/health/src/interfaces/index.ts @@ -0,0 +1 @@ +export type * from './module.interface'; diff --git a/libs/health/src/interfaces/module.interface.ts b/libs/health/src/interfaces/module.interface.ts new file mode 100644 index 00000000..f0c08c76 --- /dev/null +++ b/libs/health/src/interfaces/module.interface.ts @@ -0,0 +1,9 @@ +export type HealthIndicatorsServices = 'redis' | 'database' | 'storage' | 'http'; +export type HealthIndicatorKey = HealthIndicatorsServices | (string & NonNullable); +export type HealthIndicatorFn = () => boolean | Promise; + +export interface HealthModuleOptions { + readonly serviceName: string; + readonly version?: string; + readonly indicators?: Partial>; +} diff --git a/libs/health/tsconfig.lib.json b/libs/health/tsconfig.lib.json new file mode 100644 index 00000000..5c0ebaa5 --- /dev/null +++ b/libs/health/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/health" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/libs/imagor/src/imagor.module-definition.ts b/libs/imagor/src/imagor.module-definition.ts new file mode 100644 index 00000000..9a15c496 --- /dev/null +++ b/libs/imagor/src/imagor.module-definition.ts @@ -0,0 +1,17 @@ +import { ConfigurableModuleBuilder } from '@nestjs/common'; + +import type { ImagorModuleOptions } from './interfaces'; + +export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = + new ConfigurableModuleBuilder() + .setClassMethodName('forRoot') + .setExtras( + { + global: true, + }, + (definition, extras) => ({ + ...definition, + global: extras.global, + }), + ) + .build(); diff --git a/libs/imagor/src/imagor.module.ts b/libs/imagor/src/imagor.module.ts new file mode 100644 index 00000000..e5b3b277 --- /dev/null +++ b/libs/imagor/src/imagor.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { ConfigurableModuleClass } from './imagor.module-definition'; +import { ImagorService } from './imagor.service'; + +@Module({ + providers: [ImagorService], + exports: [ImagorService], +}) +export class ImagorModule extends ConfigurableModuleClass {} diff --git a/libs/imagor/src/imagor.service.ts b/libs/imagor/src/imagor.service.ts new file mode 100644 index 00000000..affdcfb0 --- /dev/null +++ b/libs/imagor/src/imagor.service.ts @@ -0,0 +1,89 @@ +import { createHmac } from 'node:crypto'; + +import { HttpService } from '@nestjs/axios'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { AxiosError } from 'axios'; +import { catchError, firstValueFrom, throwError } from 'rxjs'; + +import { MODULE_OPTIONS_TOKEN } from './imagor.module-definition'; +import { ImagorPathBuilder } from './utils'; + +import type { ImagorModuleOptions, Filters } from './interfaces'; + +@Injectable() +export class ImagorService { + private readonly logger = new Logger(ImagorService.name); + + constructor( + @Inject(MODULE_OPTIONS_TOKEN) + private readonly options: ImagorModuleOptions, + private readonly http: HttpService, + ) {} + + /** + * Выполняет GET запрос к Imagor с применением фильтров и пресетов + * @param {string} path - Путь к исходному файлу в хранилище + * @param {string | Filters} [presetOrFilters] - Название пресета или объект с фильтрами (width, height, smart и т.д.) + */ + async get(path: string, presetOrFilters?: string | Filters): Promise { + const host = this.options.url.replace(/\/+$/, ''); + const transformPath = this.buildTransformPath(path, presetOrFilters); + const signature = this.getFullSignedPath(transformPath); + const url = `${host}/${signature}`; + + this.logger.debug(url); + const response = await firstValueFrom( + this.http.get(url, { responseType: 'arraybuffer' }).pipe( + catchError((error: AxiosError) => { + console.error('Imagor Get Error:', error.response?.data || error.message); + return throwError(() => error); + }), + ), + ); + + return Buffer.from(response.data); + } + + private buildTransformPath(path: string, presetOrFilters?: string | Filters): string { + const builder = new ImagorPathBuilder(path, this.options.storageRoot); + + const globalFilters = this.options.filters || {}; + let localFilters: Filters = {}; + + if (typeof presetOrFilters === 'string') { + localFilters = this.options.presets?.[presetOrFilters] || {}; + } else if (presetOrFilters) { + localFilters = presetOrFilters; + } + + const merged = { ...globalFilters, ...localFilters }; + + if (merged.width || merged.height) { + builder.resize(merged.width ?? 0, merged.height ?? 0); + } + if (merged.smart) { + builder.smart(true); + } + if (merged.fit) { + builder.fit(merged.fit); + } + + builder.applyFilters(merged); + + return builder.build(); + } + + private getFullSignedPath(path: string): string { + if (!this.options.secret) { + return `unsafe/${path}`; + } + + const hash = createHmac('sha1', this.options.secret) + .update(path) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + return `${hash}/${path}`; + } +} diff --git a/libs/imagor/src/index.ts b/libs/imagor/src/index.ts new file mode 100644 index 00000000..f32b3e47 --- /dev/null +++ b/libs/imagor/src/index.ts @@ -0,0 +1,2 @@ +export { ImagorModule } from './imagor.module'; +export { ImagorService } from './imagor.service'; diff --git a/libs/imagor/src/interfaces/filters.interface.ts b/libs/imagor/src/interfaces/filters.interface.ts new file mode 100644 index 00000000..f13c4e3f --- /dev/null +++ b/libs/imagor/src/interfaces/filters.interface.ts @@ -0,0 +1,173 @@ +import type { Format } from './formats.interface'; + +/** + * Режимы вписывания изображения в заданные размеры. + * - 'fit-in': Вписывает изображение целиком, сохраняя пропорции (могут появиться пустые поля). + * - 'stretch': Растягивает изображение строго под размеры, игнорируя пропорции. + * - 'dashed': Специфический режим Imagor для обработки прозрачности или границ. + */ +type Fit = 'fit-in' | 'stretch' | 'dashed'; + +/** + * Набор фильтров и трансформаций Imagor. + * Порядок применения фильтров в URL обычно соответствует порядку их перечисления. + * @see https://github.com/cshum/imagor#filters + */ +export interface Filters { + /** + * Ширина выходного изображения в пикселях. + * Используйте 'orig', чтобы сохранить исходную ширину. + */ + readonly width?: number | 'orig'; + + /** + * Высота выходного изображения в пикселях. + * Используйте 'orig', чтобы сохранить исходную высоту. + */ + readonly height?: number | 'orig'; + + /** + * Включает умную обрезку (Smart Cropping). + * Imagor попытается найти наиболее важные области (лица, контрастные объекты) и сфокусироваться на них. + */ + readonly smart?: boolean; + + /** + * Режим вписывания. + * Если не указан, по умолчанию используется обрезка (Crop) для заполнения всей области. + */ + readonly fit?: Fit; + + /** + * Устанавливает качество выходного изображения. + * @param {number} quality Число от 0 до 100. + */ + readonly quality?: number; + + /** + * Принудительно устанавливает формат выходного изображения. + * WebP и AVIF рекомендуются для лучшего сжатия. + */ + readonly format?: Format; + + /** + * Если true, автоматически конвертирует изображения с прозрачностью в JPEG, + * заменяя прозрачные области фоном (белым по умолчанию). + */ + readonly autojpg?: boolean; + + /** Удаляет EXIF метаданные из выходного изображения. Полезно для приватности и уменьшения размера. */ + readonly strip_exif?: boolean; + + /** Удаляет ICC профили цвета. */ + readonly strip_icc?: boolean; + + /** + * Регулирует яркость изображения. + * @param {number} brightness Число от -100 до 100. Положительные — ярче, отрицательные — темнее. + */ + readonly brightness?: number; + + /** + * Регулирует контрастность изображения. + * @param {number} contrast Число от -100 до 100. + */ + readonly contrast?: number; + + /** Преобразует изображение в черно-белое (grayscale). */ + readonly grayscale?: boolean; + + /** + * Настройка цветовых каналов RGB. + * @property {number} r Красный (-100 до 100) + * @property {number} g Зеленый (-100 до 100) + * @property {number} b Синий (-100 до 100) + */ + readonly rgb?: { readonly r: number; readonly g: number; readonly b: number }; + + /** + * Изменяет общую насыщенность цветов. + * @param {number} proportion Число от 0 до 100. + */ + readonly proportion?: number; + + /** + * Применяет размытие Гаусса. + * Можно передать число (радиус) или объект для более точной настройки сигмы. + */ + readonly blur?: number | { readonly radius: number; readonly sigma?: number }; + + /** + * Повышает резкость изображения. + * @property {number} amount Степень резкости. + * @property {number} radius Радиус фильтра. + * @property {number} threshold Порог срабатывания. + */ + readonly sharpen?: { + readonly amount: number; + readonly radius: number; + readonly threshold: number; + }; + + /** + * Добавляет шум на изображение. + * @param {number} noise Уровень шума от 0 до 100. + */ + readonly noise?: number; + + /** Поворачивает изображение на заданный угол по часовой стрелке. */ + readonly rotate?: 90 | 180 | 270; + + /** + * Определяет цвет заполнения пустых областей при использовании режима 'fit-in'. + * @example 'ff0000' (hex), 'white' (name) или 'auto' (главный цвет изображения). + */ + readonly fill?: string; + + /** Устанавливает цвет фона для прозрачных изображений (например, PNG). */ + readonly background_color?: string; + + /** + * Наложение водяного знака поверх основного изображения. + */ + readonly watermark?: { + /** Путь к файлу водяного знака в хранилище. */ + readonly image: string; + /** Позиция по горизонтали или смещение в пикселях. */ + readonly x?: number | 'center' | 'left' | 'right'; + /** Позиция по вертикали или смещение в пикселях. */ + readonly y?: number | 'center' | 'top' | 'bottom'; + /** Прозрачность водяного знака (0 - прозрачный, 100 - непрозрачный). */ + readonly alpha?: number; + /** Относительная ширина знака в процентах (0.0 - 1.0) от основного изображения. */ + readonly w_ratio?: number; + /** Относительная высота знака в процентах (0.0 - 1.0). */ + readonly h_ratio?: number; + }; + + /** + * Указывает точку фокуса для кропа. + * Полезно, если вы знаете координаты лица или важного объекта. + */ + readonly focal?: { readonly x: number; readonly y: number }; + + /** + * Скругление углов изображения. + * @property {number} radius Радиус скругления в пикселях. + * @property {string} color Цвет заливки углов (например, 'transparent' или 'ffffff'). + */ + readonly round_corner?: { + readonly radius: number; + readonly color?: string; + }; + + /** + * Ограничивает размер файла (в байтах). Imagor будет снижать качество, пока не впишется в лимит. + */ + readonly max_bytes?: number; + + /** + * Запрещает увеличивать изображение, если его исходные размеры меньше запрошенных (width/height). + */ + readonly no_upscale?: boolean; +} diff --git a/libs/imagor/src/interfaces/formats.interface.ts b/libs/imagor/src/interfaces/formats.interface.ts new file mode 100644 index 00000000..d9b68972 --- /dev/null +++ b/libs/imagor/src/interfaces/formats.interface.ts @@ -0,0 +1,10 @@ +export const FORMATS = { + JPEG: 'jpeg', + PNG: 'png', + WEBP: 'webp', + AVIF: 'avif', + JP2: 'jp2', + GIF: 'gif', +} as const; + +export type Format = (typeof FORMATS)[keyof typeof FORMATS]; diff --git a/libs/imagor/src/interfaces/index.ts b/libs/imagor/src/interfaces/index.ts new file mode 100644 index 00000000..5ea0a929 --- /dev/null +++ b/libs/imagor/src/interfaces/index.ts @@ -0,0 +1,2 @@ +export type * from './module.interface'; +export type * from './filters.interface'; diff --git a/libs/imagor/src/interfaces/module.interface.ts b/libs/imagor/src/interfaces/module.interface.ts new file mode 100644 index 00000000..7338255b --- /dev/null +++ b/libs/imagor/src/interfaces/module.interface.ts @@ -0,0 +1,27 @@ +import type { Filters } from './filters.interface'; + +/** + * Опции конфигурации модуля Imagor + */ +export interface ImagorModuleOptions { + /** Базовый URL вашего инстанса Imagor (например, https://imagor.example.com) */ + readonly url: string; + + /** Секретный ключ для генерации HMAC подписи (безопасные URL) */ + readonly secret?: string; + + /** Глобальные фильтры, которые будут применяться ко всем изображениям по умолчанию */ + readonly filters?: Filters; + + /** Базовый путь в S3/хранилище (например, 'products/') */ + readonly storageRoot?: string; + + /** + * Именованные пресеты для часто используемых трансформаций. + * @example { 'thumb': { width: 150, height: 150, smart: true } } + */ + readonly presets?: Record; + + /** Включает логирование процесса генерации URL для отладки */ + readonly debug?: boolean; +} diff --git a/libs/imagor/src/utils/imagor-path-builder.ts b/libs/imagor/src/utils/imagor-path-builder.ts new file mode 100644 index 00000000..bafc7bc9 --- /dev/null +++ b/libs/imagor/src/utils/imagor-path-builder.ts @@ -0,0 +1,163 @@ +import type { Filters } from '../interfaces'; + +export class ImagorPathBuilder { + private _width: number | 'orig' = 0; + private _height: number | 'orig' = 0; + private _isSmart = false; + private _fitMode?: 'fit-in' | 'stretch' | 'dashed'; + private _filters: Filters = {}; + + constructor( + private readonly path: string, + private readonly storageRoot?: string, + ) {} + + resize(width: number | 'orig', height: number | 'orig' = 0): this { + this._width = width; + this._height = height; + return this; + } + + smart(enabled = true): this { + this._isSmart = enabled; + return this; + } + + fit(mode: 'fit-in' | 'stretch' | 'dashed'): this { + this._fitMode = mode; + return this; + } + + applyFilters(filters: Filters): this { + this._filters = { ...this._filters, ...filters }; + return this; + } + + build(): string { + const parts: string[] = []; + + if (this._fitMode) { + parts.push(this._fitMode); + } + + if (this._width || this._height) { + parts.push(`${this._width}x${this._height}`); + } + + if (this._isSmart) { + parts.push('smart'); + } + + const filterString = this.serializeAllFilters(this._filters); + if (filterString) { + parts.push(filterString); + } + + const fullPath = this.storageRoot + ? `${this.storageRoot}/${this.path}`.replace(/\/+/g, '/') + : this.path; + + parts.push(fullPath.replace(/^\/+/, '')); + + return parts.join('/'); + } + + private serializeAllFilters(f: Filters): string { + const segments: string[] = []; + + this.addBasicFilters(f, segments); + this.addAdjustmentFilters(f, segments); + this.addEffectFilters(f, segments); + this.addTransformationFilters(f, segments); + this.addWatermarkFilter(f, segments); + + return segments.length ? `filters:${segments.join(':')}` : ''; + } + + private addBasicFilters(f: Filters, segments: string[]): void { + if (f.quality) { + segments.push(`quality(${f.quality})`); + } + if (f.format) { + segments.push(`format(${f.format})`); + } + if (f.autojpg) { + segments.push('autojpg()'); + } + if (f.strip_exif) { + segments.push('strip_exif()'); + } + if (f.strip_icc) { + segments.push('strip_icc()'); + } + if (f.no_upscale) { + segments.push('no_upscale()'); + } + if (f.max_bytes) { + segments.push(`max_bytes(${f.max_bytes})`); + } + } + + private addAdjustmentFilters(f: Filters, segments: string[]): void { + if (f.brightness !== undefined) { + segments.push(`brightness(${f.brightness})`); + } + if (f.contrast !== undefined) { + segments.push(`contrast(${f.contrast})`); + } + if (f.grayscale) { + segments.push('grayscale()'); + } + if (f.proportion !== undefined) { + segments.push(`proportion(${f.proportion})`); + } + if (f.rgb) { + segments.push(`rgb(${f.rgb.r},${f.rgb.g},${f.rgb.b})`); + } + } + + private addEffectFilters(f: Filters, segments: string[]): void { + if (f.blur) { + segments.push( + typeof f.blur === 'number' + ? `blur(${f.blur})` + : `blur(${f.blur.radius},${f.blur.sigma ?? 0})`, + ); + } + if (f.sharpen) { + const { amount, radius, threshold } = f.sharpen; + segments.push(`sharpen(${amount},${radius},${threshold})`); + } + if (f.noise !== undefined) { + segments.push(`noise(${f.noise})`); + } + } + + private addTransformationFilters(f: Filters, segments: string[]): void { + if (f.rotate !== undefined) { + segments.push(`rotate(${f.rotate})`); + } + if (f.fill) { + segments.push(`fill(${f.fill})`); + } + if (f.background_color) { + segments.push(`background_color(${f.background_color})`); + } + if (f.focal) { + segments.push(`focal(${f.focal.x}x${f.focal.y})`); + } + if (f.round_corner) { + const { radius, color } = f.round_corner; + segments.push(`round_corner(${radius}${color ? `,${color}` : ''})`); + } + } + + private addWatermarkFilter(f: Filters, segments: string[]): void { + if (!f.watermark) { + return; + } + + const { image, x = 0, y = 0, alpha = 0, w_ratio = 0, h_ratio = 0 } = f.watermark; + segments.push(`watermark(${image},${x},${y},${alpha},${w_ratio},${h_ratio})`); + } +} diff --git a/libs/imagor/src/utils/index.ts b/libs/imagor/src/utils/index.ts new file mode 100644 index 00000000..dc2c7ef2 --- /dev/null +++ b/libs/imagor/src/utils/index.ts @@ -0,0 +1 @@ +export { ImagorPathBuilder } from './imagor-path-builder'; diff --git a/libs/imagor/tsconfig.lib.json b/libs/imagor/tsconfig.lib.json new file mode 100644 index 00000000..1895e7a1 --- /dev/null +++ b/libs/imagor/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/imagor" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/libs/metrics/src/index.ts b/libs/metrics/src/index.ts new file mode 100644 index 00000000..3841f244 --- /dev/null +++ b/libs/metrics/src/index.ts @@ -0,0 +1 @@ +export * from './metrics.module'; diff --git a/libs/metrics/src/metrics.controller.ts b/libs/metrics/src/metrics.controller.ts new file mode 100644 index 00000000..180ff9c2 --- /dev/null +++ b/libs/metrics/src/metrics.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, Res } from '@nestjs/common'; +import { SkipContract } from '@shared/decorators'; +import { FastifyReply } from 'fastify'; +import * as client from 'prom-client'; + +@Controller('metrics') +export class MetricsController { + @Get() + @SkipContract() + async getMetrics(@Res() reply: FastifyReply) { + const metrics = await client.register.metrics(); + reply.type(client.register.contentType).send(metrics); + } +} diff --git a/libs/metrics/src/metrics.module.ts b/libs/metrics/src/metrics.module.ts new file mode 100644 index 00000000..c5c8a395 --- /dev/null +++ b/libs/metrics/src/metrics.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { HttpMetricsInterceptor } from '@shared/interceptors'; +import { makeHistogramProvider, PrometheusModule } from '@willsoto/nestjs-prometheus'; + +import { MetricsController } from './metrics.controller'; + +@Module({ + imports: [ + PrometheusModule.register({ + controller: MetricsController, + defaultMetrics: { + enabled: process.env['NODE_ENV'] !== 'test', + }, + }), + ], + providers: [ + makeHistogramProvider({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status'], + buckets: [0.005, 0.01, 0.05, 0.1, 0.5, 1, 2.5, 5], + }), + { + provide: APP_INTERCEPTOR, + useClass: HttpMetricsInterceptor, + }, + ], +}) +export class MetricsModule {} diff --git a/libs/metrics/tsconfig.lib.json b/libs/metrics/tsconfig.lib.json new file mode 100644 index 00000000..c8214358 --- /dev/null +++ b/libs/metrics/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/metrics" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/libs/s3/src/constants.ts b/libs/s3/src/constants.ts new file mode 100644 index 00000000..58fc36c3 --- /dev/null +++ b/libs/s3/src/constants.ts @@ -0,0 +1 @@ +export const S3_CLIENT = 'S3_CLIENT'; diff --git a/libs/s3/src/index.ts b/libs/s3/src/index.ts new file mode 100644 index 00000000..d819c358 --- /dev/null +++ b/libs/s3/src/index.ts @@ -0,0 +1,2 @@ +export * from './s3.module'; +export * from './s3.service'; diff --git a/libs/s3/src/interfaces/index.ts b/libs/s3/src/interfaces/index.ts new file mode 100644 index 00000000..dc081954 --- /dev/null +++ b/libs/s3/src/interfaces/index.ts @@ -0,0 +1 @@ +export type { S3ModuleOptions } from './module.interface'; diff --git a/libs/s3/src/interfaces/module.interface.ts b/libs/s3/src/interfaces/module.interface.ts new file mode 100644 index 00000000..c62cd030 --- /dev/null +++ b/libs/s3/src/interfaces/module.interface.ts @@ -0,0 +1,14 @@ +import type { S3ClientConfig } from '@aws-sdk/client-s3'; + +export interface S3Connection extends Pick { + readonly endpoint: string; + readonly region: string; +} + +export type S3Config = Omit; + +export interface S3ModuleOptions { + readonly connection: S3Connection; + readonly bucket: string; + readonly config?: S3Config; +} diff --git a/libs/s3/src/s3.module-definition.ts b/libs/s3/src/s3.module-definition.ts new file mode 100644 index 00000000..e91f27b0 --- /dev/null +++ b/libs/s3/src/s3.module-definition.ts @@ -0,0 +1,17 @@ +import { ConfigurableModuleBuilder } from '@nestjs/common'; + +import type { S3ModuleOptions } from './interfaces'; + +export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = + new ConfigurableModuleBuilder() + .setClassMethodName('register') + .setExtras( + { + global: false, + }, + (definition, extras) => ({ + ...definition, + global: extras.global, + }), + ) + .build(); diff --git a/libs/s3/src/s3.module.ts b/libs/s3/src/s3.module.ts new file mode 100644 index 00000000..68f89c46 --- /dev/null +++ b/libs/s3/src/s3.module.ts @@ -0,0 +1,38 @@ +import { S3Client } from '@aws-sdk/client-s3'; +import { Inject, Module, OnApplicationShutdown } from '@nestjs/common'; + +import { S3_CLIENT } from './constants'; +import { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } from './s3.module-definition'; +import { S3Service } from './s3.service'; + +import type { S3ModuleOptions } from './interfaces'; + +@Module({ + providers: [ + { + provide: S3_CLIENT, + inject: [MODULE_OPTIONS_TOKEN], + useFactory: (options: S3ModuleOptions) => { + const { connection, config } = options; + + return new S3Client({ + ...connection, + ...config, + }); + }, + }, + S3Service, + ], + exports: [S3Service], +}) +export class S3Module extends ConfigurableModuleClass implements OnApplicationShutdown { + constructor(@Inject(S3_CLIENT) private readonly client: S3Client) { + super(); + } + + onApplicationShutdown() { + if (this.client.destroy) { + this.client.destroy(); + } + } +} diff --git a/libs/s3/src/s3.service.ts b/libs/s3/src/s3.service.ts new file mode 100644 index 00000000..5e8e4699 --- /dev/null +++ b/libs/s3/src/s3.service.ts @@ -0,0 +1,93 @@ +import { randomUUID } from 'node:crypto'; +import { extname } from 'node:path'; + +import { + DeleteObjectCommand, + HeadBucketCommand, + S3Client, + PutObjectCommand, +} from '@aws-sdk/client-s3'; +import { Inject, Injectable } from '@nestjs/common'; + +import { S3_CLIENT } from './constants'; +import { S3ModuleOptions } from './interfaces'; +import { MODULE_OPTIONS_TOKEN } from './s3.module-definition'; + +@Injectable() +export class S3Service { + constructor( + @Inject(S3_CLIENT) + private readonly s3Client: S3Client, + @Inject(MODULE_OPTIONS_TOKEN) + private readonly options: S3ModuleOptions, + ) {} + + private get bucket(): string { + return this.options.bucket; + } + + async isAlive(): Promise { + try { + await this.s3Client.send( + new HeadBucketCommand({ + Bucket: this.bucket, + }), + ); + return true; + } catch { + return false; + } + } + + async delete(fileUrl: string): Promise { + try { + const url = new URL(fileUrl); + const pathParts = url.pathname.split('/'); + const key = pathParts.slice(2).join('/'); + + await this.s3Client.send( + new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }), + ); + } catch (error) { + console.error('S3 Rollback failed:', error); + } + } + + async upload( + file: Buffer, + fileOptions: { + original: string; + mimetype: string; + cacheControl?: string; + path?: + | { + folder: string; + key?: string; + } + | string; + }, + ): Promise { + const { mimetype, original, path, cacheControl } = fileOptions; + + const folder = typeof path === 'object' ? path.folder : ''; + const key = + (typeof path === 'object' ? path.key : path) || `${randomUUID()}${extname(original)}`; + + const fileName = [folder, key].filter(Boolean).join('/').replace(/\/+/g, '/'); + + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: fileName, + Body: file, + CacheControl: cacheControl || 'public, max-age=31536000, immutable', + ContentType: mimetype, + }); + + await this.s3Client.send(command); + + return fileName; + } +} diff --git a/libs/s3/tsconfig.lib.json b/libs/s3/tsconfig.lib.json new file mode 100644 index 00000000..0cd20faf --- /dev/null +++ b/libs/s3/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/s3" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/migrations/0000_initial_migration.sql b/migrations/0000_initial_migration.sql new file mode 100644 index 00000000..44c1dc24 --- /dev/null +++ b/migrations/0000_initial_migration.sql @@ -0,0 +1,38 @@ +DROP SCHEMA IF EXISTS "base" CASCADE; + +CREATE SCHEMA "base"; + +CREATE TYPE "base"."team_role" AS ENUM ( + 'owner', + 'admin', + 'lead', + 'moderator', + 'member', + 'viewer' +); + +CREATE TYPE "base"."member_status" AS ENUM ('active', 'banned', 'inactive'); + +CREATE TYPE "base"."layout_type" AS ENUM ('kanban', 'list', 'calendar', 'gantt'); + +CREATE TYPE "base"."project_status" AS ENUM ('active', 'archived', 'template', 'deleted'); + +CREATE TYPE "base"."project_visibility" AS ENUM ('public', 'private'); + +CREATE TYPE "base"."state_category" AS ENUM ( + 'backlog', + 'active', + 'review', + 'completed', + 'archived' +); + +CREATE TYPE "base"."state_type" AS ENUM ( + 'backlog', + 'todo', + 'in_progress', + 'review', + 'done', + 'archived', + 'custom' +); \ No newline at end of file diff --git a/migrations/0001_add_user.sql b/migrations/0001_add_user.sql new file mode 100644 index 00000000..c899abe2 --- /dev/null +++ b/migrations/0001_add_user.sql @@ -0,0 +1,104 @@ +CREATE TABLE + "base"."user_activity" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "event_type" varchar(50) NOT NULL, + "entity_id" varchar, + "metadata" jsonb, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL + ); + +CREATE TABLE "base"."user_notifications" ( + "user_id" text PRIMARY KEY NOT NULL, + "settings" jsonb DEFAULT '{"email":{"task_assigned":true,"mentions":true,"daily_summary":false},"push":{"task_assigned":true,"reminders":true}}'::jsonb NOT NULL +); + +CREATE TABLE + "base"."user_preferences" ( + "user_id" text PRIMARY KEY NOT NULL, + "theme" text DEFAULT 'system', + "timezone" varchar(50) DEFAULT 'UTC' NOT NULL, + "language" varchar(5) DEFAULT 'ru' NOT NULL + ); + +CREATE TABLE + "base"."user_security" ( + "user_id" text PRIMARY KEY NOT NULL, + "password_hash" varchar(255), + "recovery_email" varchar(255), + "is_2fa_enabled" boolean DEFAULT false NOT NULL, + "two_factor_secret" text, + "last_login_at" timestamp + with + time zone, + "last_password_change" timestamp + with + time zone DEFAULT now () NOT NULL + ); + +CREATE TABLE + "base"."users" ( + "id" text PRIMARY KEY NOT NULL, + "username" varchar(50), + "headline" varchar(200), + "location" varchar(255), + "first_name" varchar(50) NOT NULL, + "last_name" varchar(50) NOT NULL, + "middle_name" varchar(50), + "email" varchar(255) NOT NULL, + "bio" text, + "phone" varchar(20), + "vacation_start" timestamp + with + time zone, + "vacation_end" timestamp + with + time zone, + "vacation_message" varchar(255), + "gender" text DEFAULT 'none', + "pronouns" text DEFAULT 'none', + "pronouns_custom" varchar(50), + "avatar_url" varchar(512), + "email_verified" boolean DEFAULT false NOT NULL, + "email_verified_at" timestamp + with + time zone, + "last_team_id" text, + "deleted_at" timestamp + with + time zone, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "updated_at" timestamp + with + time zone DEFAULT now () NOT NULL, + CONSTRAINT "users_username_unique" UNIQUE ("username"), + CONSTRAINT "users_email_unique" UNIQUE ("email") + ); + +CREATE TABLE + "base"."user_identities" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "provider" varchar(50) NOT NULL, + "provider_user_id" varchar(255) NOT NULL, + "email" varchar(255) NOT NULL, + "avatar_url" varchar(255), + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + CONSTRAINT "provider_user_id_idx" UNIQUE ("provider", "provider_user_id") + ); + +ALTER TABLE "base"."user_activity" ADD CONSTRAINT "user_activity_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."user_notifications" ADD CONSTRAINT "user_notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."user_preferences" ADD CONSTRAINT "user_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."user_security" ADD CONSTRAINT "user_security_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/migrations/0002_add_session.sql b/migrations/0002_add_session.sql new file mode 100644 index 00000000..29efe1ca --- /dev/null +++ b/migrations/0002_add_session.sql @@ -0,0 +1,24 @@ +CREATE TABLE + "base"."sessions" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "device_type" varchar(20), + "browser" varchar(50), + "os" varchar(50), + "user_agent" text NOT NULL, + "ip" varchar(45) NOT NULL, + "city" varchar(100), + "country_code" varchar(5), + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "updated_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "expires_at" timestamp + with + time zone NOT NULL, + "is_revoked" boolean DEFAULT false NOT NULL + ); + +ALTER TABLE "base"."sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/migrations/0003_add_team.sql b/migrations/0003_add_team.sql new file mode 100644 index 00000000..fa6dab1c --- /dev/null +++ b/migrations/0003_add_team.sql @@ -0,0 +1,47 @@ +CREATE TABLE + "base"."team_members" ( + "team_id" text NOT NULL, + "user_id" text NOT NULL, + "role" "base"."team_role" DEFAULT 'member' NOT NULL, + "status" "base"."member_status" DEFAULT 'inactive' NOT NULL, + "joined_at" timestamp + with + time zone, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + CONSTRAINT "team_members_team_id_user_id_pk" PRIMARY KEY ("team_id", "user_id") + ); + +CREATE TABLE + "base"."teams" ( + "id" text PRIMARY KEY NOT NULL, + "name" varchar(100) NOT NULL, + "description" text, + "avatar_url" text, + "cover_url" text, + "owner_id" text, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "updated_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "deleted_at" timestamp + with + time zone + ); + +ALTER TABLE "base"."team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "base"."teams" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."teams" ADD CONSTRAINT "teams_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "base"."users" ("id") ON DELETE set null ON UPDATE no action; + +CREATE INDEX "member_status_idx" ON "base"."team_members" USING btree ("status"); + +CREATE INDEX "member_role_idx" ON "base"."team_members" USING btree ("user_id", "role"); + +CREATE INDEX "team_owner_idx" ON "base"."teams" USING btree ("owner_id"); + +CREATE INDEX "team_deleted_at_idx" ON "base"."teams" USING btree ("deleted_at"); \ No newline at end of file diff --git a/migrations/0004_add_project.sql b/migrations/0004_add_project.sql new file mode 100644 index 00000000..a5d7eaa4 --- /dev/null +++ b/migrations/0004_add_project.sql @@ -0,0 +1,113 @@ +CREATE TABLE + "base"."project_members" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "user_id" text NOT NULL, + "role" varchar(20) DEFAULT 'member' NOT NULL, + "added_by" text, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL + ); + +CREATE TABLE + "base"."project_settings" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "default_view" "base"."layout_type" DEFAULT 'kanban' NOT NULL, + "task_prefix" varchar(10), + "auto_close_days" integer, + "max_tasks_per_area" integer, + "max_members" integer, + "max_areas" integer, + "allow_guests" boolean DEFAULT false, + "time_tracking" boolean DEFAULT false, + "time_tracking_mode" varchar(20) DEFAULT 'optional', + "default_assignee_id" text, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "updated_at" timestamp + with + time zone DEFAULT now () NOT NULL, + CONSTRAINT "project_settings_project_id_unique" UNIQUE ("project_id") + ); + +CREATE TABLE + "base"."project_shares" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp + with + time zone, + "created_by" text, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + CONSTRAINT "project_shares_token_unique" UNIQUE ("token") + ); + +CREATE TABLE + "base"."projects" ( + "id" text PRIMARY KEY NOT NULL, + "team_id" text NOT NULL, + "slug" varchar(100) NOT NULL, + "name" varchar(100) NOT NULL, + "description" text, + "descriptionHtml" text, + "icon" varchar(255), + "color" varchar(7), + "status" "base"."project_status" DEFAULT 'active' NOT NULL, + "sequence" integer DEFAULT 0, + "owner_id" text, + "visibility" "base"."project_visibility" DEFAULT 'public' NOT NULL, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "updated_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "deleted_at" timestamp + with + time zone, + CONSTRAINT "projects_slug_unique" UNIQUE ("slug") + ); + +ALTER TABLE "base"."project_members" ADD CONSTRAINT "project_members_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "base"."projects" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."project_members" ADD CONSTRAINT "project_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "base"."users" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."project_members" ADD CONSTRAINT "project_members_added_by_users_id_fk" FOREIGN KEY ("added_by") REFERENCES "base"."users" ("id") ON DELETE no action ON UPDATE no action; + +ALTER TABLE "base"."project_settings" ADD CONSTRAINT "project_settings_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "base"."projects" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."project_settings" ADD CONSTRAINT "project_settings_default_assignee_id_users_id_fk" FOREIGN KEY ("default_assignee_id") REFERENCES "base"."users" ("id") ON DELETE set null ON UPDATE no action; + +ALTER TABLE "base"."project_shares" ADD CONSTRAINT "project_shares_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "base"."projects" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."project_shares" ADD CONSTRAINT "project_shares_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "base"."users" ("id") ON DELETE no action ON UPDATE no action; + +ALTER TABLE "base"."projects" ADD CONSTRAINT "projects_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "base"."teams" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."projects" ADD CONSTRAINT "projects_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "base"."users" ("id") ON DELETE set null ON UPDATE no action; + +CREATE UNIQUE INDEX "project_member_unique_idx" ON "base"."project_members" USING btree ("project_id", "user_id"); + +CREATE INDEX "project_member_user_idx" ON "base"."project_members" USING btree ("user_id"); + +CREATE INDEX "project_member_project_idx" ON "base"."project_members" USING btree ("project_id"); + +CREATE UNIQUE INDEX "project_settings_project_idx" ON "base"."project_settings" USING btree ("project_id"); + +CREATE INDEX "token_idx" ON "base"."project_shares" USING btree ("token"); + +CREATE INDEX "project_share_project_id_idx" ON "base"."project_shares" USING btree ("project_id"); + +CREATE UNIQUE INDEX "project_team_slug_idx" ON "base"."projects" USING btree ("team_id", "slug") +WHERE + "base"."projects"."deleted_at" is null; + +CREATE INDEX "project_owner_id_idx" ON "base"."projects" USING btree ("owner_id"); + +CREATE INDEX "project_team_id_idx" ON "base"."projects" USING btree ("team_id"); \ No newline at end of file diff --git a/migrations/0005_add_area.sql b/migrations/0005_add_area.sql new file mode 100644 index 00000000..554c3934 --- /dev/null +++ b/migrations/0005_add_area.sql @@ -0,0 +1,94 @@ +CREATE TABLE + "base"."areas" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text, + "title" text NOT NULL, + "slug" varchar(100) NOT NULL, + "description" text, + "description_html" text, + "color" varchar(10), + "tasks_count" integer DEFAULT 0 NOT NULL, + "default_view" varchar(20) DEFAULT 'kanban' NOT NULL, + "icon" varchar(20), + "position" integer DEFAULT 0 NOT NULL, + "max_tasks_limit" integer, + "is_locked" boolean DEFAULT false, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "updated_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "created_by" text, + "deleted_at" timestamp + with + time zone, + CONSTRAINT "areas_slug_unique" UNIQUE ("slug") + ); + +CREATE TABLE + "base"."states" ( + "id" text PRIMARY KEY NOT NULL, + "area_id" text, + "title" text NOT NULL, + "description" text, + "state_type" "state_type" DEFAULT 'custom' NOT NULL, + "category" "state_category" DEFAULT 'active' NOT NULL, + "color" varchar(10), + "icon" varchar(20), + "position" integer DEFAULT 0 NOT NULL, + "is_visible" boolean DEFAULT true NOT NULL, + "max_tasks_limit" integer, + "auto_transition_to" text, + "notify_on_enter" boolean DEFAULT false, + "notify_on_exit" boolean DEFAULT false, + "is_locked" boolean DEFAULT false, + "created_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "updated_at" timestamp + with + time zone DEFAULT now () NOT NULL, + "created_by" text, + "deleted_at" timestamp + with + time zone + ); + +ALTER TABLE "base"."areas" ADD CONSTRAINT "areas_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "base"."projects" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."areas" ADD CONSTRAINT "areas_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "base"."users" ("id") ON DELETE no action ON UPDATE no action; + +ALTER TABLE "base"."states" ADD CONSTRAINT "states_area_id_areas_id_fk" FOREIGN KEY ("area_id") REFERENCES "base"."areas" ("id") ON DELETE cascade ON UPDATE no action; + +ALTER TABLE "base"."states" ADD CONSTRAINT "states_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "base"."users" ("id") ON DELETE no action ON UPDATE no action; + +CREATE INDEX "idx_areas_slug" ON "base"."areas" USING btree ("slug"); + +CREATE INDEX "idx_areas_project_active" ON "base"."areas" USING btree ("project_id", "position") +WHERE + "base"."areas"."deleted_at" is null; + +CREATE INDEX "idx_areas_created_by" ON "base"."areas" USING btree ("created_by") +WHERE + "base"."areas"."deleted_at" is null; + +CREATE INDEX "idx_areas_deleted_at" ON "base"."areas" USING btree ("deleted_at") +WHERE + "base"."areas"."deleted_at" is not null; + +CREATE INDEX "idx_states_position" ON "base"."states" USING btree ("area_id", "position"); + +CREATE INDEX "idx_states_title" ON "base"."states" USING btree ("area_id", "title"); + +CREATE INDEX "idx_states_created_at" ON "base"."states" USING btree ("area_id", "created_at"); + +CREATE INDEX "idx_states_search" ON "base"."states" USING btree ("area_id", "title"); + +CREATE UNIQUE INDEX "idx_states_unique_title" ON "base"."states" USING btree ("area_id", "title") +WHERE + "base"."states"."deleted_at" is null; + +CREATE INDEX "idx_states_deleted_at" ON "base"."states" USING btree ("deleted_at") +WHERE + "base"."states"."deleted_at" is not null; \ No newline at end of file diff --git a/migrations/meta/0000_snapshot.json b/migrations/meta/0000_snapshot.json new file mode 100644 index 00000000..b824cd41 --- /dev/null +++ b/migrations/meta/0000_snapshot.json @@ -0,0 +1,94 @@ +{ + "id": "c4cd8525-a0b5-4d36-aa6c-48e3d0329e89", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": {}, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template", + "deleted" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + }, + "base.state_category": { + "name": "state_category", + "schema": "base", + "values": [ + "backlog", + "active", + "review", + "completed", + "archived" + ] + }, + "base.state_type": { + "name": "state_type", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "review", + "done", + "archived", + "custom" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/0001_snapshot.json b/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..26c435cf --- /dev/null +++ b/migrations/meta/0001_snapshot.json @@ -0,0 +1,579 @@ +{ + "id": "baf56135-60f0-4ed5-a29f-f15e9ddde163", + "prevId": "c4cd8525-a0b5-4d36-aa6c-48e3d0329e89", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_preferences": { + "name": "user_preferences", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'system'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "recovery_email": { + "name": "recovery_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "vacation_start": { + "name": "vacation_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_end": { + "name": "vacation_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_message": { + "name": "vacation_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns_custom": { + "name": "pronouns_custom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_team_id": { + "name": "last_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_identities": { + "name": "user_identities", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_user_id_idx": { + "name": "provider_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template", + "deleted" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + }, + "base.state_category": { + "name": "state_category", + "schema": "base", + "values": [ + "backlog", + "active", + "review", + "completed", + "archived" + ] + }, + "base.state_type": { + "name": "state_type", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "review", + "done", + "archived", + "custom" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/0002_snapshot.json b/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..3d6d5808 --- /dev/null +++ b/migrations/meta/0002_snapshot.json @@ -0,0 +1,688 @@ +{ + "id": "ee6e2d28-596c-4b4b-9ecc-6b82516e92a5", + "prevId": "baf56135-60f0-4ed5-a29f-f15e9ddde163", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_preferences": { + "name": "user_preferences", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'system'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "recovery_email": { + "name": "recovery_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "vacation_start": { + "name": "vacation_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_end": { + "name": "vacation_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_message": { + "name": "vacation_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns_custom": { + "name": "pronouns_custom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_team_id": { + "name": "last_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.sessions": { + "name": "sessions", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_identities": { + "name": "user_identities", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_user_id_idx": { + "name": "provider_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template", + "deleted" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + }, + "base.state_category": { + "name": "state_category", + "schema": "base", + "values": [ + "backlog", + "active", + "review", + "completed", + "archived" + ] + }, + "base.state_type": { + "name": "state_type", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "review", + "done", + "archived", + "custom" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/0003_snapshot.json b/migrations/meta/0003_snapshot.json new file mode 100644 index 00000000..4f1c2741 --- /dev/null +++ b/migrations/meta/0003_snapshot.json @@ -0,0 +1,931 @@ +{ + "id": "1d56485a-be26-4f3d-b9d4-929c725cb84b", + "prevId": "ee6e2d28-596c-4b4b-9ecc-6b82516e92a5", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_preferences": { + "name": "user_preferences", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'system'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "recovery_email": { + "name": "recovery_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "vacation_start": { + "name": "vacation_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_end": { + "name": "vacation_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_message": { + "name": "vacation_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns_custom": { + "name": "pronouns_custom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_team_id": { + "name": "last_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.sessions": { + "name": "sessions", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_identities": { + "name": "user_identities", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_user_id_idx": { + "name": "provider_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.team_members": { + "name": "team_members", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_role", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_status_idx": { + "name": "member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_role_idx": { + "name": "member_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_members_team_id_user_id_pk": { + "name": "team_members_team_id_user_id_pk", + "columns": [ + "team_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams": { + "name": "teams", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_owner_idx": { + "name": "team_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_deleted_at_idx": { + "name": "team_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template", + "deleted" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + }, + "base.state_category": { + "name": "state_category", + "schema": "base", + "values": [ + "backlog", + "active", + "review", + "completed", + "archived" + ] + }, + "base.state_type": { + "name": "state_type", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "review", + "done", + "archived", + "custom" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/0004_snapshot.json b/migrations/meta/0004_snapshot.json new file mode 100644 index 00000000..fa640925 --- /dev/null +++ b/migrations/meta/0004_snapshot.json @@ -0,0 +1,1552 @@ +{ + "id": "67369ea6-fc42-4bb1-b9d7-589a4bdbc919", + "prevId": "1d56485a-be26-4f3d-b9d4-929c725cb84b", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_preferences": { + "name": "user_preferences", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'system'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "recovery_email": { + "name": "recovery_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "vacation_start": { + "name": "vacation_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_end": { + "name": "vacation_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_message": { + "name": "vacation_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns_custom": { + "name": "pronouns_custom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_team_id": { + "name": "last_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.sessions": { + "name": "sessions", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_identities": { + "name": "user_identities", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_user_id_idx": { + "name": "provider_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.team_members": { + "name": "team_members", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_role", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_status_idx": { + "name": "member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_role_idx": { + "name": "member_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_members_team_id_user_id_pk": { + "name": "team_members_team_id_user_id_pk", + "columns": [ + "team_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams": { + "name": "teams", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_owner_idx": { + "name": "team_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_deleted_at_idx": { + "name": "team_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_members": { + "name": "project_members", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_member_unique_idx": { + "name": "project_member_unique_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_user_idx": { + "name": "project_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_project_idx": { + "name": "project_member_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_added_by_users_id_fk": { + "name": "project_members_added_by_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_settings": { + "name": "project_settings", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_view": { + "name": "default_view", + "type": "layout_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "task_prefix": { + "name": "task_prefix", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "auto_close_days": { + "name": "auto_close_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tasks_per_area": { + "name": "max_tasks_per_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_areas": { + "name": "max_areas", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "allow_guests": { + "name": "allow_guests", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking": { + "name": "time_tracking", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking_mode": { + "name": "time_tracking_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'optional'" + }, + "default_assignee_id": { + "name": "default_assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_settings_project_idx": { + "name": "project_settings_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_settings_project_id_projects_id_fk": { + "name": "project_settings_project_id_projects_id_fk", + "tableFrom": "project_settings", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_settings_default_assignee_id_users_id_fk": { + "name": "project_settings_default_assignee_id_users_id_fk", + "tableFrom": "project_settings", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "default_assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_settings_project_id_unique": { + "name": "project_settings_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_shares": { + "name": "project_shares", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_share_project_id_idx": { + "name": "project_share_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_shares_project_id_projects_id_fk": { + "name": "project_shares_project_id_projects_id_fk", + "tableFrom": "project_shares", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_shares_created_by_users_id_fk": { + "name": "project_shares_created_by_users_id_fk", + "tableFrom": "project_shares", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_shares_token_unique": { + "name": "project_shares_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.projects": { + "name": "projects", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "descriptionHtml": { + "name": "descriptionHtml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "project_visibility", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_team_slug_idx": { + "name": "project_team_slug_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"projects\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_id_idx": { + "name": "project_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_id_idx": { + "name": "project_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template", + "deleted" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + }, + "base.state_category": { + "name": "state_category", + "schema": "base", + "values": [ + "backlog", + "active", + "review", + "completed", + "archived" + ] + }, + "base.state_type": { + "name": "state_type", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "review", + "done", + "archived", + "custom" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/0005_snapshot.json b/migrations/meta/0005_snapshot.json new file mode 100644 index 00000000..7b6b9ebf --- /dev/null +++ b/migrations/meta/0005_snapshot.json @@ -0,0 +1,2068 @@ +{ + "id": "69248628-6c77-4c0b-bc3f-913a61c68731", + "prevId": "67369ea6-fc42-4bb1-b9d7-589a4bdbc919", + "version": "7", + "dialect": "postgresql", + "tables": { + "base.user_activity": { + "name": "user_activity", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_activity_user_id_users_id_fk": { + "name": "user_activity_user_id_users_id_fk", + "tableFrom": "user_activity", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_notifications": { + "name": "user_notifications", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"email\":{\"task_assigned\":true,\"mentions\":true,\"daily_summary\":false},\"push\":{\"task_assigned\":true,\"reminders\":true}}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_preferences": { + "name": "user_preferences", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'system'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "language": { + "name": "language", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_security": { + "name": "user_security", + "schema": "base", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "recovery_email": { + "name": "recovery_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_2fa_enabled": { + "name": "is_2fa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_password_change": { + "name": "last_password_change", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_security_user_id_users_id_fk": { + "name": "user_security_user_id_users_id_fk", + "tableFrom": "user_security", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.users": { + "name": "users", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "headline": { + "name": "headline", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "middle_name": { + "name": "middle_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "vacation_start": { + "name": "vacation_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_end": { + "name": "vacation_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "vacation_message": { + "name": "vacation_message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns": { + "name": "pronouns", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'none'" + }, + "pronouns_custom": { + "name": "pronouns_custom", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_team_id": { + "name": "last_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.sessions": { + "name": "sessions", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_revoked": { + "name": "is_revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.user_identities": { + "name": "user_identities", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_user_id_idx": { + "name": "provider_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.team_members": { + "name": "team_members", + "schema": "base", + "columns": { + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_role", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_status_idx": { + "name": "member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_role_idx": { + "name": "member_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_members_team_id_user_id_pk": { + "name": "team_members_team_id_user_id_pk", + "columns": [ + "team_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.teams": { + "name": "teams", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_owner_idx": { + "name": "team_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_deleted_at_idx": { + "name": "team_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_members": { + "name": "project_members", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_member_unique_idx": { + "name": "project_member_unique_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_user_idx": { + "name": "project_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_member_project_idx": { + "name": "project_member_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_added_by_users_id_fk": { + "name": "project_members_added_by_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_settings": { + "name": "project_settings", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_view": { + "name": "default_view", + "type": "layout_type", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "task_prefix": { + "name": "task_prefix", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "auto_close_days": { + "name": "auto_close_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tasks_per_area": { + "name": "max_tasks_per_area", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_areas": { + "name": "max_areas", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "allow_guests": { + "name": "allow_guests", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking": { + "name": "time_tracking", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_tracking_mode": { + "name": "time_tracking_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'optional'" + }, + "default_assignee_id": { + "name": "default_assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_settings_project_idx": { + "name": "project_settings_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_settings_project_id_projects_id_fk": { + "name": "project_settings_project_id_projects_id_fk", + "tableFrom": "project_settings", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_settings_default_assignee_id_users_id_fk": { + "name": "project_settings_default_assignee_id_users_id_fk", + "tableFrom": "project_settings", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "default_assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_settings_project_id_unique": { + "name": "project_settings_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.project_shares": { + "name": "project_shares", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_share_project_id_idx": { + "name": "project_share_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_shares_project_id_projects_id_fk": { + "name": "project_shares_project_id_projects_id_fk", + "tableFrom": "project_shares", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_shares_created_by_users_id_fk": { + "name": "project_shares_created_by_users_id_fk", + "tableFrom": "project_shares", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_shares_token_unique": { + "name": "project_shares_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.projects": { + "name": "projects", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "descriptionHtml": { + "name": "descriptionHtml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "project_visibility", + "typeSchema": "base", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_team_slug_idx": { + "name": "project_team_slug_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"projects\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_id_idx": { + "name": "project_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_team_id_idx": { + "name": "project_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "schemaTo": "base", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.areas": { + "name": "areas", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_html": { + "name": "description_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tasks_count": { + "name": "tasks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "default_view": { + "name": "default_view", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'kanban'" + }, + "icon": { + "name": "icon", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_tasks_limit": { + "name": "max_tasks_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_areas_slug": { + "name": "idx_areas_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_project_active": { + "name": "idx_areas_project_active", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_created_by": { + "name": "idx_areas_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_areas_deleted_at": { + "name": "idx_areas_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"areas\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "areas_project_id_projects_id_fk": { + "name": "areas_project_id_projects_id_fk", + "tableFrom": "areas", + "tableTo": "projects", + "schemaTo": "base", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "areas_created_by_users_id_fk": { + "name": "areas_created_by_users_id_fk", + "tableFrom": "areas", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "areas_slug_unique": { + "name": "areas_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "base.states": { + "name": "states", + "schema": "base", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "area_id": { + "name": "area_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_type": { + "name": "state_type", + "type": "state_type", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "category": { + "name": "category", + "type": "state_category", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_visible": { + "name": "is_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_tasks_limit": { + "name": "max_tasks_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_transition_to": { + "name": "auto_transition_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notify_on_enter": { + "name": "notify_on_enter", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "notify_on_exit": { + "name": "notify_on_exit", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_states_position": { + "name": "idx_states_position", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_title": { + "name": "idx_states_title", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_created_at": { + "name": "idx_states_created_at", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_search": { + "name": "idx_states_search", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_unique_title": { + "name": "idx_states_unique_title", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"base\".\"states\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_states_deleted_at": { + "name": "idx_states_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"base\".\"states\".\"deleted_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "states_area_id_areas_id_fk": { + "name": "states_area_id_areas_id_fk", + "tableFrom": "states", + "tableTo": "areas", + "schemaTo": "base", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "states_created_by_users_id_fk": { + "name": "states_created_by_users_id_fk", + "tableFrom": "states", + "tableTo": "users", + "schemaTo": "base", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "base.team_role": { + "name": "team_role", + "schema": "base", + "values": [ + "owner", + "admin", + "lead", + "moderator", + "member", + "viewer" + ] + }, + "base.member_status": { + "name": "member_status", + "schema": "base", + "values": [ + "active", + "banned", + "inactive" + ] + }, + "base.layout_type": { + "name": "layout_type", + "schema": "base", + "values": [ + "kanban", + "list", + "calendar", + "gantt" + ] + }, + "base.project_status": { + "name": "project_status", + "schema": "base", + "values": [ + "active", + "archived", + "template", + "deleted" + ] + }, + "base.project_visibility": { + "name": "project_visibility", + "schema": "base", + "values": [ + "public", + "private" + ] + }, + "base.state_category": { + "name": "state_category", + "schema": "base", + "values": [ + "backlog", + "active", + "review", + "completed", + "archived" + ] + }, + "base.state_type": { + "name": "state_type", + "schema": "base", + "values": [ + "backlog", + "todo", + "in_progress", + "review", + "done", + "archived", + "custom" + ] + } + }, + "schemas": { + "base": "base" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json new file mode 100644 index 00000000..1fc04a1a --- /dev/null +++ b/migrations/meta/_journal.json @@ -0,0 +1,48 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1781462966790, + "tag": "0000_initial_migration", + "breakpoints": false + }, + { + "idx": 1, + "version": "7", + "when": 1781463057149, + "tag": "0001_add_user", + "breakpoints": false + }, + { + "idx": 2, + "version": "7", + "when": 1781463078471, + "tag": "0002_add_session", + "breakpoints": false + }, + { + "idx": 3, + "version": "7", + "when": 1781463089195, + "tag": "0003_add_team", + "breakpoints": false + }, + { + "idx": 4, + "version": "7", + "when": 1781463097728, + "tag": "0004_add_project", + "breakpoints": false + }, + { + "idx": 5, + "version": "7", + "when": 1781463108615, + "tag": "0005_add_area", + "breakpoints": false + } + ] +} \ No newline at end of file diff --git a/nest-cli.json b/nest-cli.json index f9aa683b..631e79c2 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -1,8 +1,74 @@ { - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "webpack": true + }, + "projects": { + "bootstrap": { + "type": "library", + "root": "libs/bootstrap", + "entryFile": "index", + "sourceRoot": "libs/bootstrap/src", + "compilerOptions": { + "tsConfigPath": "libs/bootstrap/tsconfig.lib.json" + } + }, + "config": { + "type": "library", + "root": "libs/config", + "entryFile": "index", + "sourceRoot": "libs/config/src", + "compilerOptions": { + "tsConfigPath": "libs/config/tsconfig.lib.json" + } + }, + "database": { + "type": "library", + "root": "libs/database", + "entryFile": "index", + "sourceRoot": "libs/database/src", + "compilerOptions": { + "tsConfigPath": "libs/database/tsconfig.lib.json" + } + }, + "health": { + "type": "library", + "root": "libs/health", + "entryFile": "index", + "sourceRoot": "libs/health/src", + "compilerOptions": { + "tsConfigPath": "libs/health/tsconfig.lib.json" + } + }, + "s3": { + "type": "library", + "root": "libs/s3", + "entryFile": "index", + "sourceRoot": "libs/s3/src", + "compilerOptions": { + "tsConfigPath": "libs/s3/tsconfig.lib.json" + } + }, + "imagor": { + "type": "library", + "root": "libs/imagor", + "entryFile": "index", + "sourceRoot": "libs/imagor/src", + "compilerOptions": { + "tsConfigPath": "libs/imagor/tsconfig.lib.json" + } + }, + "metrics": { + "type": "library", + "root": "libs/metrics", + "entryFile": "index", + "sourceRoot": "libs/metrics/src", + "compilerOptions": { + "tsConfigPath": "libs/metrics/tsconfig.lib.json" + } + } + } } diff --git a/package.json b/package.json index 1b859d34..276012fa 100644 --- a/package.json +++ b/package.json @@ -1,70 +1,121 @@ { - "name": "task-backend", - "version": "0.0.1", - "description": "", - "author": "", - "private": true, - "license": "UNLICENSED", - "scripts": { - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" - }, - "dependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1" - }, - "devDependencies": { - "@nestjs/cli": "^10.0.0", - "@nestjs/schematics": "^10.0.0", - "@nestjs/testing": "^10.0.0", - "@types/express": "^4.17.17", - "@types/jest": "^29.5.2", - "@types/node": "^20.3.1", - "@types/supertest": "^6.0.0", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", - "eslint": "^8.42.0", - "eslint-config-prettier": "^9.0.0", - "eslint-plugin-prettier": "^5.0.0", - "jest": "^29.5.0", - "prettier": "^3.0.0", - "source-map-support": "^0.5.21", - "supertest": "^6.3.3", - "ts-jest": "^29.1.0", - "ts-loader": "^9.4.3", - "ts-node": "^10.9.1", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.1.3" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "name": "task-backend", + "version": "0.0.1", + "description": "Основной API-сервис управления задачами (NestJS + Fastify + Drizzle ORM)", + "author": "", + "private": true, + "license": "MIT", + "scripts": { + "build": "nest build", + "format": "prettier --write \".\"", + "start:prod": "nest start", + "start:dev": "nest start -w", + "start:debug": "nest start -d -w", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", + "test": "vitest run", + "test:w": "vitest", + "test:c": "vitest run --coverage", + "test:d": "vitest --inspect-brk --inspect --logHeapUsage --threads=false", + "test:e2e": "vitest run -c vitest.config.e2e.ts", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "prepare": "husky", + "k6:all": "pnpm --filter @project/performance-tests test:all", + "k6:auth": "pnpm --filter @project/performance-tests test:auth", + "k6:teams": "pnpm --filter @project/performance-tests test:teams", + "k6:projects": "pnpm --filter @project/performance-tests test:projects", + "k6:teams-members": "pnpm --filter @project/performance-tests test:teams-members", + "k6:teams-invitations": "pnpm --filter @project/performance-tests test:teams-invitations", + "k6:teams-me": "pnpm --filter @project/performance-tests test:teams-me", + "k6:users": "pnpm --filter @project/performance-tests test:users", + "k6:boards": "pnpm --filter @project/performance-tests test:boards:all", + "k6:tasks": "pnpm --filter @project/performance-tests test:tasks", + "k6:smoke": "pnpm --filter @project/performance-tests smoke", + "k6:seed": "npx tsx infra/k6/scripts/seed-k6-data.ts", + "k6:clear": "npx tsx infra/k6/scripts/clear-k6-data.ts" }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" - }, - "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" + "dependencies": { + "@aws-sdk/client-s3": "^3.1029.0", + "@aws-sdk/s3-request-presigner": "^3.1029.0", + "@fastify/compress": "^8.3.1", + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.2.0", + "@fastify/csrf-protection": "^7.1.0", + "@fastify/multipart": "^10.0.0", + "@fastify/static": "^9.1.0", + "@nestjs-modules/ioredis": "^2.2.1", + "@nestjs/axios": "^4.0.1", + "@nestjs/bullmq": "^11.0.4", + "@nestjs/common": "^11.1.18", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.18", + "@nestjs/event-emitter": "^3.1.0", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-fastify": "^11.1.18", + "@nestjs/swagger": "^11.2.7", + "@nestjs/throttler": "^6.5.0", + "@paralleldrive/cuid2": "^3.3.0", + "@willsoto/nestjs-prometheus": "^6.1.0", + "argon2": "^0.44.0", + "axios": "^1.16.0", + "bullmq": "^5.73.4", + "dotenv": "^17.4.2", + "drizzle-orm": "^0.45.2", + "fastify": "^5.8.4", + "handlebars": "^4.7.9", + "ioredis": "^5.10.1", + "nest-winston": "^1.10.2", + "nestjs-zod": "^5.3.0", + "nodemailer": "^8.0.5", + "otplib": "^13.4.0", + "passport": "^0.7.0", + "passport-github": "^1.1.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "passport-oauth2": "^1.8.0", + "postgres": "^3.4.9", + "prom-client": "^15.1.3", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1", + "slugify": "^1.6.9", + "ua-parser-js": "^2.0.9", + "winston": "^3.19.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@commitlint/cli": "^20.5.0", + "@commitlint/config-conventional": "^20.5.0", + "@eslint/js": "^10.0.1", + "@nestjs/cli": "^11.0.19", + "@nestjs/schematics": "^11.0.10", + "@nestjs/testing": "^11.1.18", + "@types/node": "^20.3.1", + "@types/nodemailer": "^8.0.0", + "@types/passport-github": "^1.1.13", + "@types/passport-google-oauth20": "^2.0.17", + "@types/passport-jwt": "^4.0.1", + "@types/passport-oauth2": "^1.8.0", + "@types/ua-parser-js": "^0.7.39", + "@typescript-eslint/eslint-plugin": "^8.61.0", + "@typescript-eslint/parser": "^8.61.0", + "@vitest/coverage-v8": "^4.1.4", + "drizzle-kit": "^0.31.10", + "eslint": "^10.5.0", + "eslint-plugin-functional": "^10.0.0", + "eslint-plugin-jsdoc": "^63.0.2", + "eslint-plugin-perfectionist": "^5.9.0", + "eslint-plugin-security": "^4.0.1", + "eslint-plugin-sonarjs": "^4.0.3", + "eslint-plugin-unicorn": "^66.0.0", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", + "prettier": "^3.0.0", + "ts-loader": "^9.4.3", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.3", + "typescript-eslint": "^8.61.0", + "vitest": "^4.1.4" + }, + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39f752a8..8e10d5fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,270 +8,475 @@ importers: .: dependencies: - '@nestjs/common': + '@aws-sdk/client-s3': + specifier: ^3.1029.0 + version: 3.1029.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1029.0 + version: 3.1029.0 + '@fastify/compress': + specifier: ^8.3.1 + version: 8.3.1 + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 + '@fastify/cors': + specifier: ^11.2.0 + version: 11.2.0 + '@fastify/csrf-protection': + specifier: ^7.1.0 + version: 7.1.0 + '@fastify/multipart': specifier: ^10.0.0 - version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 10.0.0 + '@fastify/static': + specifier: ^9.1.0 + version: 9.1.0 + '@nestjs-modules/ioredis': + specifier: ^2.2.1 + version: 2.2.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2))(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(ioredis@5.10.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/axios': + specifier: ^4.0.1 + version: 4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2) + '@nestjs/bullmq': + specifier: ^11.0.4 + version: 11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.73.4) + '@nestjs/common': + specifier: ^11.1.18 + version: 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.4 + version: 4.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': - specifier: ^10.0.0 - version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/platform-express': - specifier: ^10.0.0 - version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + specifier: ^11.1.18 + version: 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: ^3.1.0 + version: 3.1.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: ^11.0.5 + version: 11.0.5(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/platform-fastify': + specifier: ^11.1.18 + version: 11.1.18(@fastify/static@9.1.0)(@fastify/view@11.1.1)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/swagger': + specifier: ^11.2.7 + version: 11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + '@nestjs/throttler': + specifier: ^6.5.0 + version: 6.5.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + '@paralleldrive/cuid2': + specifier: ^3.3.0 + version: 3.3.0 + '@willsoto/nestjs-prometheus': + specifier: ^6.1.0 + version: 6.1.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(prom-client@15.1.3) + argon2: + specifier: ^0.44.0 + version: 0.44.0 + axios: + specifier: ^1.16.0 + version: 1.16.0 + bullmq: + specifier: ^5.73.4 + version: 5.73.4 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0)(postgres@3.4.9) + fastify: + specifier: ^5.8.4 + version: 5.8.4 + handlebars: + specifier: ^4.7.9 + version: 4.7.9 + ioredis: + specifier: ^5.10.1 + version: 5.10.1 + nest-winston: + specifier: ^1.10.2 + version: 1.10.2(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(winston@3.19.0) + nestjs-zod: + specifier: ^5.3.0 + version: 5.3.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6) + nodemailer: + specifier: ^8.0.5 + version: 8.0.5 + otplib: + specifier: ^13.4.0 + version: 13.4.0 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-github: + specifier: ^1.1.0 + version: 1.1.0 + passport-google-oauth20: + specifier: ^2.0.0 + version: 2.0.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + passport-oauth2: + specifier: ^1.8.0 + version: 1.8.0 + postgres: + specifier: ^3.4.9 + version: 3.4.9 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 reflect-metadata: specifier: ^0.2.0 version: 0.2.2 rxjs: specifier: ^7.8.1 version: 7.8.2 + slugify: + specifier: ^1.6.9 + version: 1.6.9 + ua-parser-js: + specifier: ^2.0.9 + version: 2.0.9 + winston: + specifier: ^3.19.0 + version: 3.19.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: + '@commitlint/cli': + specifier: ^20.5.0 + version: 20.5.0(@types/node@20.19.39)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + '@commitlint/config-conventional': + specifier: ^20.5.0 + version: 20.5.0 + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.5.0(jiti@2.6.1)) '@nestjs/cli': - specifier: ^10.0.0 - version: 10.4.9 + specifier: ^11.0.19 + version: 11.0.19(@swc/core@1.15.24)(@types/node@20.19.39)(esbuild@0.27.7) '@nestjs/schematics': - specifier: ^10.0.0 - version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) + specifier: ^11.0.10 + version: 11.0.10(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': - specifier: ^10.0.0 - version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22) - '@types/express': - specifier: ^4.17.17 - version: 4.17.25 - '@types/jest': - specifier: ^29.5.2 - version: 29.5.14 + specifier: ^11.1.18 + version: 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@types/node': specifier: ^20.3.1 version: 20.19.39 - '@types/supertest': - specifier: ^6.0.0 - version: 6.0.3 + '@types/nodemailer': + specifier: ^8.0.0 + version: 8.0.0 + '@types/passport-github': + specifier: ^1.1.13 + version: 1.1.13 + '@types/passport-google-oauth20': + specifier: ^2.0.17 + version: 2.0.17 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + '@types/passport-oauth2': + specifier: ^1.8.0 + version: 1.8.0 + '@types/ua-parser-js': + specifier: ^0.7.39 + version: 0.7.39 '@typescript-eslint/eslint-plugin': - specifier: ^6.0.0 - version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + specifier: ^8.61.0 + version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^6.0.0 - version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + specifier: ^8.61.0 + version: 8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@vitest/coverage-v8': + specifier: ^4.1.4 + version: 4.1.4(vitest@4.1.4) + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 eslint: - specifier: ^8.42.0 - version: 8.57.1 - eslint-config-prettier: - specifier: ^9.0.0 - version: 9.1.2(eslint@8.57.1) - eslint-plugin-prettier: - specifier: ^5.0.0 - version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.8.2) - jest: - specifier: ^29.5.0 - version: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) + specifier: ^10.5.0 + version: 10.5.0(jiti@2.6.1) + eslint-plugin-functional: + specifier: ^10.0.0 + version: 10.0.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-jsdoc: + specifier: ^63.0.2 + version: 63.0.2(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-perfectionist: + specifier: ^5.9.0 + version: 5.9.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-security: + specifier: ^4.0.1 + version: 4.0.1 + eslint-plugin-sonarjs: + specifier: ^4.0.3 + version: 4.0.3(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-unicorn: + specifier: ^66.0.0 + version: 66.0.0(eslint@10.5.0(jiti@2.6.1)) + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^16.4.0 + version: 16.4.0 prettier: specifier: ^3.0.0 version: 3.8.2 - source-map-support: - specifier: ^0.5.21 - version: 0.5.21 - supertest: - specifier: ^6.3.3 - version: 6.3.4 - ts-jest: - specifier: ^29.1.0 - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.4.3 - version: 9.5.7(typescript@5.9.3)(webpack@5.97.1) - ts-node: - specifier: ^10.9.1 - version: 10.9.2(@types/node@20.19.39)(typescript@5.9.3) + version: 9.5.7(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 typescript: specifier: ^5.1.3 version: 5.9.3 + typescript-eslint: + specifier: ^8.61.0 + version: 8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + vitest: + specifier: ^4.1.4 + version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + + infra/k6: {} packages: - '@angular-devkit/core@17.3.11': - resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==} - engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@angular-devkit/core@19.2.23': + resolution: {integrity: sha512-RazHPQkUEsNU/OZ75w9UeHxGFMthRiuAW2B/uA7eXExBj/1meHrrBfoCA56ujW2GUxVjRtSrMjylKh4R4meiYA==} + 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.2.24': + resolution: {integrity: sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==} + 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: ^3.5.2 + chokidar: ^4.0.0 peerDependenciesMeta: chokidar: optional: true - '@angular-devkit/schematics-cli@17.3.11': - resolution: {integrity: sha512-kcOMqp+PHAKkqRad7Zd7PbpqJ0LqLaNZdY1+k66lLWmkEBozgq8v4ASn/puPWf9Bo0HpCiK+EzLf0VHE8Z/y6Q==} - 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-cli@19.2.24': + resolution: {integrity: sha512-bsStZQG67J1HBqTmWxtIcobvgrn32L4UOdL7hGyOru5VxDWPNA8pRnDYavT3hnJeBkJYPoQIw8u7Dm0ecoQprw==} + 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.11': - resolution: {integrity: sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==} - 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.2.23': + resolution: {integrity: sha512-Jzs7YM4X6azmHU7Mw5tQSPMuvaqYS8SLnZOJbtiXCy1JyuW9bm/WBBecNHMiuZ8LHXKhvQ6AVX1tKrzF6uiDmw==} + 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'} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} + '@angular-devkit/schematics@19.2.24': + resolution: {integrity: sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==} + 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'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} + '@aws-sdk/client-s3@3.1029.0': + resolution: {integrity: sha512-OuA8RZTxsAaHDcI25j2NGLMaYFI2WpJdDzK3uLmVBmaHwjQKQZOUDVVBcln8pNo3IgkY+HRSJhRR4/xlM//UyQ==} + engines: {node: '>=20.0.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} + '@aws-sdk/core@3.973.27': + resolution: {integrity: sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A==} + engines: {node: '>=20.0.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} + '@aws-sdk/crc64-nvme@3.972.6': + resolution: {integrity: sha512-NMbiqKdruhwwgI6nzBVe2jWMkXjaoQz2YOs3rFX+2F3gGyrJDkDPwMpV/RsTFeq2vAQ055wZNtOXFK4NYSkM8g==} + engines: {node: '>=20.0.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} + '@aws-sdk/credential-provider-env@3.972.25': + resolution: {integrity: sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A==} + engines: {node: '>=20.0.0'} - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} - engines: {node: '>=6.9.0'} + '@aws-sdk/credential-provider-http@3.972.27': + resolution: {integrity: sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ==} + engines: {node: '>=20.0.0'} - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true + '@aws-sdk/credential-provider-ini@3.972.29': + resolution: {integrity: sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw==} + engines: {node: '>=20.0.0'} - '@babel/plugin-syntax-async-generators@7.8.4': - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@aws-sdk/credential-provider-login@3.972.29': + resolution: {integrity: sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ==} + engines: {node: '>=20.0.0'} - '@babel/plugin-syntax-bigint@7.8.3': - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@aws-sdk/credential-provider-node@3.972.30': + resolution: {integrity: sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw==} + engines: {node: '>=20.0.0'} - '@babel/plugin-syntax-class-properties@7.12.13': - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@aws-sdk/credential-provider-process@3.972.25': + resolution: {integrity: sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ==} + engines: {node: '>=20.0.0'} - '@babel/plugin-syntax-class-static-block@7.14.5': - resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@aws-sdk/credential-provider-sso@3.972.29': + resolution: {integrity: sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig==} + engines: {node: '>=20.0.0'} - '@babel/plugin-syntax-import-attributes@7.28.6': - resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@aws-sdk/credential-provider-web-identity@3.972.29': + resolution: {integrity: sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew==} + engines: {node: '>=20.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 + '@aws-sdk/middleware-bucket-endpoint@3.972.9': + resolution: {integrity: sha512-COToYKgquDyligbcAep7ygs48RK+mwe/IYprq4+TSrVFzNOYmzWvHf6werpnKV5VYpRiwdn+Wa5ZXkPqLVwcTg==} + engines: {node: '>=20.0.0'} - '@babel/plugin-syntax-json-strings@7.8.3': - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@aws-sdk/middleware-expect-continue@3.972.9': + resolution: {integrity: sha512-V/FNCjFxnh4VGu+HdSiW4Yg5GELihA1MIDSAdsEPvuayXBVmr0Jaa6jdLAZLH38KYXl/vVjri9DQJWnTAujHEA==} + engines: {node: '>=20.0.0'} - '@babel/plugin-syntax-jsx@7.28.6': - resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@aws-sdk/middleware-flexible-checksums@3.974.7': + resolution: {integrity: sha512-uU4/ch2CLHB8Phu1oTKnnQ4e8Ujqi49zEnQYBhWYT53zfFvtJCdGsaOoypBr8Fm/pmCBssRmGoIQ4sixgdLP9w==} + engines: {node: '>=20.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 + '@aws-sdk/middleware-host-header@3.972.9': + resolution: {integrity: sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ==} + engines: {node: '>=20.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 + '@aws-sdk/middleware-location-constraint@3.972.9': + resolution: {integrity: sha512-TyfOi2XNdOZpNKeTJwRUsVAGa+14nkyMb2VVGG+eDgcWG/ed6+NUo72N3hT6QJioxym80NSinErD+LBRF0Ir1w==} + engines: {node: '>=20.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 + '@aws-sdk/middleware-logger@3.972.9': + resolution: {integrity: sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog==} + engines: {node: '>=20.0.0'} - '@babel/plugin-syntax-object-rest-spread@7.8.3': - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@aws-sdk/middleware-recursion-detection@3.972.10': + resolution: {integrity: sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ==} + engines: {node: '>=20.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 + '@aws-sdk/middleware-sdk-s3@3.972.28': + resolution: {integrity: sha512-qJHcJQH9UNPUrnPlRtCozKjtqAaypQ5IgQxTNoPsVYIQeuwNIA8Rwt3NvGij1vCDYDfCmZaPLpnJEHlZXeFqmg==} + engines: {node: '>=20.0.0'} - '@babel/plugin-syntax-optional-chaining@7.8.3': - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} - peerDependencies: - '@babel/core': ^7.0.0-0 + '@aws-sdk/middleware-ssec@3.972.9': + resolution: {integrity: sha512-wSA2BR7L0CyBNDJeSrleIIzC+DzL93YNTdfU0KPGLiocK6YsRv1nPAzPF+BFSdcs0Qa5ku5Kcf4KvQcWwKGenQ==} + engines: {node: '>=20.0.0'} - '@babel/plugin-syntax-private-property-in-object@7.14.5': - resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} - engines: {node: '>=6.9.0'} + '@aws-sdk/middleware-user-agent@3.972.29': + resolution: {integrity: sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.996.19': + resolution: {integrity: sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.11': + resolution: {integrity: sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1029.0': + resolution: {integrity: sha512-YbHPaha4DYgJWdPorGV5ZSCCqHafGj4GiyqXmXFlCJSsqlOd3xEcemhOZGjrB9epdiVEUtB3DDJXGYYj55ITdQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.16': + resolution: {integrity: sha512-EMdXYB4r/k5RWq86fugjRhid5JA+Z6MpS7n4sij4u5/C+STrkvuf9aFu41rJA9MjUzxCLzv8U2XL8cH2GSRYpQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1026.0': + resolution: {integrity: sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.7': + resolution: {integrity: sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-arn-parser@3.972.3': + resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.6': + resolution: {integrity: sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-format-url@3.972.9': + resolution: {integrity: sha512-fNJXHrs0ZT7Wx0KGIqKv7zLxlDXt2vqjx9z6oKUQFmpE5o4xxnSryvVHfHpIifYHWKz94hFccIldJ0YSZjlCBw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.9': + resolution: {integrity: sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw==} + + '@aws-sdk/util-user-agent-node@3.973.15': + resolution: {integrity: sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w==} + engines: {node: '>=20.0.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.17': + resolution: {integrity: sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg==} + engines: {node: '>=20.0.0'} - '@babel/plugin-syntax-top-level-await@7.14.5': - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-syntax-typescript@7.28.6': - resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@0.2.3': - resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} @@ -280,3200 +485,6268 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@commitlint/cli@20.5.0': + resolution: {integrity: sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==} + engines: {node: '>=v18'} + hasBin: true - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@commitlint/config-conventional@20.5.0': + resolution: {integrity: sha512-t3Ni88rFw1XMa4nZHgOKJ8fIAT9M2j5TnKyTqJzsxea7FUetlNdYFus9dz+MhIRZmc16P0PPyEfh6X2d/qw8SA==} + engines: {node: '>=v18'} - '@eslint/eslintrc@2.1.4': - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} + engines: {node: '>=v18'} - '@eslint/js@8.57.1': - resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@commitlint/ensure@20.5.0': + resolution: {integrity: sha512-IpHqAUesBeW1EDDdjzJeaOxU9tnogLAyXLRBn03SHlj1SGENn2JGZqSWGkFvBJkJzfXAuCNtsoYzax+ZPS+puw==} + engines: {node: '>=v18'} - '@humanwhocodes/config-array@0.13.0': - resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead + '@commitlint/execute-rule@20.0.0': + resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} + engines: {node: '>=v18'} - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} + engines: {node: '>=v18'} - '@humanwhocodes/object-schema@2.0.3': - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} + engines: {node: '>=v18'} - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} + '@commitlint/lint@20.5.0': + resolution: {integrity: sha512-jiM3hNUdu04jFBf1VgPdjtIPvbuVfDTBAc6L98AWcoLjF5sYqkulBHBzlVWll4rMF1T5zeQFB6r//a+s+BBKlA==} + engines: {node: '>=v18'} - '@istanbuljs/load-nyc-config@1.1.0': - resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} - engines: {node: '>=8'} + '@commitlint/load@20.5.0': + resolution: {integrity: sha512-sLhhYTL/KxeOTZjjabKDhwidGZan84XKK1+XFkwDYL/4883kIajcz/dZFAhBJmZPtL8+nBx6bnkzA95YxPeDPw==} + engines: {node: '>=v18'} - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} - engines: {node: '>=8'} + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} + engines: {node: '>=v18'} - '@jest/console@29.7.0': - resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} + engines: {node: '>=v18'} - '@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 + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} + engines: {node: '>=v18'} - '@jest/environment@29.7.0': - resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@commitlint/resolve-extends@20.5.0': + resolution: {integrity: sha512-3SHPWUW2v0tyspCTcfSsYml0gses92l6TlogwzvM2cbxDgmhSRc+fldDjvGkCXJrjSM87BBaWYTPWwwyASZRrg==} + engines: {node: '>=v18'} - '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@commitlint/rules@20.5.0': + resolution: {integrity: sha512-5NdQXQEdnDPT5pK8O39ZA7HohzPRHEsDGU23cyVCNPQy4WegAbAwrQk3nIu7p2sl3dutPk8RZd91yKTrMTnRkQ==} + engines: {node: '>=v18'} - '@jest/expect@29.7.0': - resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@commitlint/to-lines@20.0.0': + resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} + engines: {node: '>=v18'} - '@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} + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} + engines: {node: '>=v18'} - '@jest/globals@29.7.0': - resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} + engines: {node: '>=v18'} - '@jest/reporters@29.7.0': - resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 peerDependenciesMeta: - node-notifier: + conventional-commits-filter: + optional: true + conventional-commits-parser: 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} + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} - '@jest/source-map@29.6.3': - resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} - '@jest/test-result@29.7.0': - resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} - '@jest/test-sequencer@29.7.0': - resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} - '@jest/transform@29.7.0': - resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@es-joy/jsdoccomment@0.87.0': + resolution: {integrity: sha512-mFXZloZMzuJZXSHUmAFu/pXTk0ZJTJBluuAkrvbzidpTN8W6F2bpRFuedSH+85kbdlRLJqc+gfN+kD3JOLJK5g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' - '@jridgewell/source-map@0.3.11': - resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] - '@ljharb/through@2.3.14': - resolution: {integrity: sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==} - engines: {node: '>= 0.4'} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] - '@lukeed/csprng@1.1.0': - resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} - engines: {node: '>=8'} + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] - '@nestjs/cli@10.4.9': - resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==} - engines: {node: '>= 16.14'} - hasBin: true - peerDependencies: - '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 - '@swc/core': ^1.3.62 - peerDependenciesMeta: - '@swc/cli': - optional: true - '@swc/core': - optional: true + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] - '@nestjs/common@10.4.22': - resolution: {integrity: sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==} - 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 + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] - '@nestjs/core@10.4.22': - resolution: {integrity: sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==} - 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 + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] - '@nestjs/platform-express@10.4.22': - resolution: {integrity: sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==} - peerDependencies: - '@nestjs/common': ^10.0.0 - '@nestjs/core': ^10.0.0 + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] - '@nestjs/schematics@10.2.3': - resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==} - peerDependencies: - typescript: '>=4.8.2' + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] - '@nestjs/testing@10.4.22': - resolution: {integrity: sha512-HO9aPus3bAedAC+jKVAA8jTdaj4fs5M9fing4giHrcYV2txe9CvC1l1WAjwQ9RDhEHdugjY4y+FZA/U/YqPZrA==} - 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 + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] - '@nuxtjs/opencollective@0.3.2': - resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==} - engines: {node: '>=8.0.0', npm: '>=5.0.0'} - hasBin: true + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] - '@paralleldrive/cuid2@2.3.1': - resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] - '@pkgr/core@0.2.9': - resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] - '@sinclair/typebox@0.27.10': - resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] - '@sinonjs/commons@3.0.1': - resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] - '@sinonjs/fake-timers@10.3.0': - resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] - '@tokenizer/inflate@0.2.7': - resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] - '@tokenizer/token@0.3.0': - resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] - '@tsconfig/node10@1.0.12': - resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] - '@types/cookiejar@2.1.5': - resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] - '@types/eslint-scope@3.7.7': - resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] - '@types/eslint@9.6.1': - resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] - '@types/express-serve-static-core@4.19.8': - resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] - '@types/express@4.17.25': - resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] - '@types/graceful-fs@4.1.9': - resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] - '@types/jest@29.5.14': - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] - '@types/methods@1.1.4': - resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] - '@types/node@20.19.39': - resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] - '@types/qs@6.15.0': - resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] - '@types/send@0.17.6': - resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] - '@types/serve-static@1.15.10': - resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] - '@types/superagent@8.1.9': - resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] - '@types/supertest@6.0.3': - resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] - '@types/yargs@17.0.35': - resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] - '@typescript-eslint/eslint-plugin@6.21.0': - resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] - '@typescript-eslint/parser@6.21.0': - resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] - '@typescript-eslint/scope-manager@6.21.0': - resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} - engines: {node: ^16.0.0 || >=18.0.0} + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] - '@typescript-eslint/type-utils@6.21.0': - resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} - engines: {node: ^16.0.0 || >=18.0.0} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@typescript-eslint/types@6.21.0': - resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} - engines: {node: ^16.0.0 || >=18.0.0} + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@typescript-eslint/typescript-estree@6.21.0': - resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} - engines: {node: ^16.0.0 || >=18.0.0} + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: - typescript: '*' + eslint: ^10.0.0 peerDependenciesMeta: - typescript: + eslint: optional: true - '@typescript-eslint/utils@6.21.0': - resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@typescript-eslint/visitor-keys@6.21.0': - resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} - engines: {node: ^16.0.0 || >=18.0.0} + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} - '@webassemblyjs/ast@1.14.1': - resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} - '@webassemblyjs/floating-point-hex-parser@1.13.2': - resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} - '@webassemblyjs/helper-api-error@1.13.2': - resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + '@fastify/compress@8.3.1': + resolution: {integrity: sha512-BUpItLr6MUX9e9ukg5Y6xekyA/7pBFG8QWtFCrUDm9ctoBc3R2/nA16yOaOWtVoccpXGjdDEYA/MxAb5+8cxag==} - '@webassemblyjs/helper-buffer@1.14.1': - resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} - '@webassemblyjs/helper-numbers@1.13.2': - resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} - '@webassemblyjs/helper-wasm-bytecode@1.13.2': - resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + '@fastify/csrf-protection@7.1.0': + resolution: {integrity: sha512-I2TDd4SRRYQivKCMHdB/8py+CPO9DT0e63lh4DO8MDCJh8NROq8HD/iO0IjYtwhsD3bZhr0cBXsFdfPvyTmzNw==} - '@webassemblyjs/helper-wasm-section@1.14.1': - resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + '@fastify/csrf@8.0.1': + resolution: {integrity: sha512-dAmCrdfJ3CV/A/hHHK/rRBjjLRRSIltgJB0BxiVfbhr/31G6fgF8l2I8evtH8mjS5kTIvd0JOh7MOA3HA6eYDw==} - '@webassemblyjs/ieee754@1.13.2': - resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} - '@webassemblyjs/leb128@1.13.2': - resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} - '@webassemblyjs/utf8@1.13.2': - resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} - '@webassemblyjs/wasm-edit@1.14.1': - resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} - '@webassemblyjs/wasm-gen@1.14.1': - resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} - '@webassemblyjs/wasm-opt@1.14.1': - resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} - '@webassemblyjs/wasm-parser@1.14.1': - resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + '@fastify/multipart@10.0.0': + resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==} - '@webassemblyjs/wast-printer@1.14.1': - resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - '@xtuc/ieee754@1.2.0': - resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} - '@xtuc/long@4.2.2': - resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@fastify/static@9.1.0': + resolution: {integrity: sha512-EPRNQYqEYEYTK8yyGbcM0iHpyJaupb94bey5O6iCQfLTADr02kaZU+qeHSdd9H9TiMwTBVkrMa59V8CMbn3avQ==} - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} + '@fastify/view@11.1.1': + resolution: {integrity: sha512-GiHqT3R2eKJgWmy0s45eELTC447a4+lTM2o+8fSWeKwBe9VToeePuHJcKtOEXPrKGSddGO0RsNayULiS3aeHeQ==} - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} - acorn-walk@8.3.5: - resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} - engines: {node: '>=0.4.0'} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} - ajv-formats@2.1.1: - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} peerDependencies: - ajv: ^8.0.0 + '@types/node': '>=18' peerDependenciesMeta: - ajv: + '@types/node': optional: true - ajv-keywords@3.5.2: - resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} peerDependencies: - ajv: ^6.9.1 + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - ajv-keywords@5.1.0: - resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} peerDependencies: - ajv: ^8.8.2 - - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - ajv@8.12.0: - resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - ansi-colors@4.1.3: - resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} - engines: {node: '>=6'} + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} + '@inquirer/prompts@7.3.2': + resolution: {integrity: sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - append-field@1.0.0: - resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - array-timsort@1.0.3: - resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} - asap@2.0.6: - resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - 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 + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} - babel-plugin-istanbul@6.1.1: - resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} 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} + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} - babel-preset-current-node-syntax@1.2.0: - resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} - peerDependencies: - '@babel/core': ^7.0.0 || ^8.0.0-0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] - 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 + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] - baseline-browser-mapping@2.10.17: - resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} - engines: {node: '>=6.0.0'} - hasBin: true + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 - body-parser@1.20.4: - resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + '@nestjs-modules/ioredis@2.2.1': + resolution: {integrity: sha512-wQ08XvlV2s9V+01SKcC5XmFoQ2hMAHP0KuVja8UFZyE/dM0bKI5HSHr+3wQ5ChRpsyhfxF/vKrlPXMlJIr7FIg==} + peerDependencies: + '@nestjs/common': '>=6.7.0' + '@nestjs/core': '>=6.7.0' + ioredis: '>=5.0.0' - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + '@nestjs/axios@4.0.1': + resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + axios: ^1.3.1 + rxjs: ^7.0.0 - brace-expansion@2.0.3: - resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + '@nestjs/bull-shared@11.0.4': + resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} + '@nestjs/bullmq@11.0.4': + resolution: {integrity: sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0 - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + '@nestjs/cli@11.0.19': + resolution: {integrity: sha512-9htODqTVVNH4lJqyeIotsAgfeaYngDi020cVCd6JhJRKuOT83c/t4JDSky6+xr0lhHyNTNMgZmulxqcMNZFfrw==} + engines: {node: '>= 20.11'} hasBin: true + peerDependencies: + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 + '@swc/core': ^1.3.62 + peerDependenciesMeta: + '@swc/cli': + optional: true + '@swc/core': + optional: true - bs-logger@0.2.6: - resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} - engines: {node: '>= 6'} + '@nestjs/common@11.1.18': + resolution: {integrity: sha512-0sLq8Z+TIjLnz1Tqp0C/x9BpLbqpt1qEu0VcH4/fkE0y3F5JxhfK1AdKQ/SPbKhKgwqVDoY4gS8GQr2G6ujaWg==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true - bser@2.1.1: - resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + '@nestjs/config@4.0.4': + resolution: {integrity: sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + '@nestjs/core@11.1.18': + resolution: {integrity: sha512-wR3DtGyk/LUAiPtbXDuWJJwVkWElKBY0sqnTzf9d4uM3+X18FRZhK7WFc47czsIGOdWuRsMeLYV+1Z9dO4zDEQ==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.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 - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + '@nestjs/event-emitter@3.1.0': + resolution: {integrity: sha512-DOY/4XBGyIjYyOJKkO6jl1kzFE0ZfX0wV+M2HR5NWymPT9Z0zdCEcZGxTXXkoMRwPtglnvCGJALSjOpXPIcM3g==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} + '@nestjs/jwt@11.0.2': + resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} + '@nestjs/passport@11.0.5': + resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + passport: ^0.5.0 || ^0.6.0 || ^0.7.0 - call-bind@1.0.9: - resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} - engines: {node: '>= 0.4'} + '@nestjs/platform-fastify@11.1.18': + resolution: {integrity: sha512-iJtbqQz51k7Z1vOTUEHO1mU8PsDO1WdgPSJ/6CuXBnazkrkePXoszhefFaPwJreBVn35GE3WTd/6ou7bFwnhmA==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@fastify/view': ^10.0.0 || ^11.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + '@fastify/view': + optional: true - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} + '@nestjs/schematics@11.0.10': + resolution: {integrity: sha512-q9lr0wGwgBHLarD4uno3XiW4JX60WPlg2VTgbqPHl/6bT4u1IEEzj+q9Tad3bVnqL5mlDF3vrZ2tj+x13CJpmw==} + peerDependencies: + typescript: '>=4.8.2' - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} + '@nestjs/swagger@11.2.7': + resolution: {integrity: sha512-+e1KWSyZMAQeyZ8nbQSvm3fhzqdxxBNQENvpjO2dVyD7KJmLTTQyXpRb1nM5O04oFdDTUtG3SHMl4+e+zgCK2A==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} + '@nestjs/terminus@11.1.1': + resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^2.0.0 || ^3.0.0 || ^4.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/mongoose': ^11.0.0 + '@nestjs/sequelize': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} + '@nestjs/testing@11.1.18': + resolution: {integrity: sha512-frzwNlpBgtAzI3hp/qo57DZoRO4RMTH1wST3QUYEhRTHyfPkLpzkWz3jV/mhApXjD0yT56Ptlzn6zuYPLh87Lw==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true - caniuse-lite@1.0.30001787: - resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + '@nuxt/opencollective@0.4.1': + resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} + engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} + hasBin: true - char-regex@1.0.2: - resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} - engines: {node: '>=10'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} - chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + '@otplib/core@13.4.0': + resolution: {integrity: sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==} - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} + '@otplib/hotp@13.4.0': + resolution: {integrity: sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==} - chrome-trace-event@1.0.4: - resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} - engines: {node: '>=6.0'} + '@otplib/plugin-base32-scure@13.4.0': + resolution: {integrity: sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==} - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} + '@otplib/plugin-crypto-noble@13.4.0': + resolution: {integrity: sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==} - cjs-module-lexer@1.4.3: - resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + '@otplib/totp@13.4.0': + resolution: {integrity: sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==} - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} + '@otplib/uri@13.4.0': + resolution: {integrity: sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==} - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} + '@oxc-project/types@0.124.0': + resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} - engines: {node: 10.* || >= 12.*} + '@paralleldrive/cuid2@3.3.0': + resolution: {integrity: sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw==} + hasBin: true - cli-width@3.0.0: - resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} - engines: {node: '>= 10'} + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} + '@rolldown/binding-android-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] - co@4.6.0: - resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] - collect-v8-coverage@1.0.3: - resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.15': + resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@scure/base@2.0.0': + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} + '@smithy/chunked-blob-reader-native@4.2.3': + resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} + engines: {node: '>=18.0.0'} - commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + '@smithy/chunked-blob-reader@5.2.2': + resolution: {integrity: sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==} + engines: {node: '>=18.0.0'} - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} + '@smithy/config-resolver@4.4.14': + resolution: {integrity: sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ==} + engines: {node: '>=18.0.0'} - comment-json@4.2.5: - resolution: {integrity: sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==} - engines: {node: '>= 6'} + '@smithy/core@3.23.14': + resolution: {integrity: sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==} + engines: {node: '>=18.0.0'} - component-emitter@1.3.1: - resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + '@smithy/credential-provider-imds@4.2.13': + resolution: {integrity: sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ==} + engines: {node: '>=18.0.0'} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + '@smithy/eventstream-codec@4.2.13': + resolution: {integrity: sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ==} + engines: {node: '>=18.0.0'} - concat-stream@2.0.0: - resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} - engines: {'0': node >= 6.0} + '@smithy/eventstream-serde-browser@4.2.13': + resolution: {integrity: sha512-wwybfcOX0tLqCcBP378TIU9IqrDuZq/tDV48LlZNydMpCnqnYr+hWBAYbRE+rFFf/p7IkDJySM3bgiMKP2ihPg==} + engines: {node: '>=18.0.0'} - consola@2.15.3: - resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} + '@smithy/eventstream-serde-config-resolver@4.3.13': + resolution: {integrity: sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA==} + engines: {node: '>=18.0.0'} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} + '@smithy/eventstream-serde-node@4.2.13': + resolution: {integrity: sha512-hFyK+ORJrxAN3RYoaD6+gsGDQjeix8HOEkosoajvXYZ4VeqonM3G4jd9IIRm/sWGXUKmudkY9KdYjzosUqdM8A==} + engines: {node: '>=18.0.0'} - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} + '@smithy/eventstream-serde-universal@4.2.13': + resolution: {integrity: sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA==} + engines: {node: '>=18.0.0'} - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + '@smithy/fetch-http-handler@5.3.16': + resolution: {integrity: sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==} + engines: {node: '>=18.0.0'} - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + '@smithy/hash-blob-browser@4.2.14': + resolution: {integrity: sha512-rtQ5es8r/5v4rav7q5QTsfx9CtCyzrz/g7ZZZBH2xtMmd6G/KQrLOWfSHTvFOUPlVy59RQvxeBYJaLRoybMEyA==} + engines: {node: '>=18.0.0'} - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} + '@smithy/hash-node@4.2.13': + resolution: {integrity: sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA==} + engines: {node: '>=18.0.0'} - cookiejar@2.1.4: - resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + '@smithy/hash-stream-node@4.2.13': + resolution: {integrity: sha512-WdQ7HwUjINXETeh6dqUeob1UHIYx8kAn9PSp1HhM2WWegiZBYVy2WXIs1lB07SZLan/udys9SBnQGt9MQbDpdg==} + engines: {node: '>=18.0.0'} - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + '@smithy/invalid-dependency@4.2.13': + resolution: {integrity: sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg==} + engines: {node: '>=18.0.0'} - cors@2.8.5: - resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} - engines: {node: '>= 0.10'} + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} - 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 + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} - create-jest@29.7.0: - resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true + '@smithy/md5-js@4.2.13': + resolution: {integrity: sha512-cNm7I9NXolFxtS20ojROddOEpSAeI1Obq6pd1Kj5HtHws3s9Fkk8DdHDfQSs5KuxCewZuVK6UqrJnfJmiMzDuQ==} + engines: {node: '>=18.0.0'} - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + '@smithy/middleware-content-length@4.2.13': + resolution: {integrity: sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig==} + engines: {node: '>=18.0.0'} - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} + '@smithy/middleware-endpoint@4.4.29': + resolution: {integrity: sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==} + engines: {node: '>=18.0.0'} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + '@smithy/middleware-retry@4.5.1': + resolution: {integrity: sha512-/zY+Gp7Qj2D2hVm3irkCyONER7E9MiX3cUUm/k2ZmhkzZkrPgwVS4aJ5NriZUEN/M0D1hhjrgjUmX04HhRwdWA==} + engines: {node: '>=18.0.0'} - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + '@smithy/middleware-serde@4.2.17': + resolution: {integrity: sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==} + engines: {node: '>=18.0.0'} - dedent@1.7.2: - resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true + '@smithy/middleware-stack@4.2.13': + resolution: {integrity: sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==} + engines: {node: '>=18.0.0'} - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + '@smithy/node-config-provider@4.3.13': + resolution: {integrity: sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==} + engines: {node: '>=18.0.0'} - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} + '@smithy/node-http-handler@4.5.2': + resolution: {integrity: sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==} + engines: {node: '>=18.0.0'} - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + '@smithy/property-provider@4.2.13': + resolution: {integrity: sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==} + engines: {node: '>=18.0.0'} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} + '@smithy/protocol-http@5.3.13': + resolution: {integrity: sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==} + engines: {node: '>=18.0.0'} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} + '@smithy/querystring-builder@4.2.13': + resolution: {integrity: sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==} + engines: {node: '>=18.0.0'} - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} + '@smithy/querystring-parser@4.2.13': + resolution: {integrity: sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==} + engines: {node: '>=18.0.0'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + '@smithy/service-error-classification@4.2.13': + resolution: {integrity: sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==} + engines: {node: '>=18.0.0'} - detect-newline@3.1.0: - resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} - engines: {node: '>=8'} + '@smithy/shared-ini-file-loader@4.4.8': + resolution: {integrity: sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==} + engines: {node: '>=18.0.0'} - dezalgo@1.0.4: - resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + '@smithy/signature-v4@5.3.13': + resolution: {integrity: sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg==} + engines: {node: '>=18.0.0'} - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@smithy/smithy-client@4.12.9': + resolution: {integrity: sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==} + engines: {node: '>=18.0.0'} - diff@4.0.4: - resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} - engines: {node: '>=0.3.1'} + '@smithy/types@4.14.0': + resolution: {integrity: sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==} + engines: {node: '>=18.0.0'} - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} + '@smithy/url-parser@4.2.13': + resolution: {integrity: sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==} + engines: {node: '>=18.0.0'} - doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} - electron-to-chromium@1.5.334: - resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} - emittery@0.13.1: - resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} - engines: {node: '>=12'} + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + '@smithy/util-defaults-mode-browser@4.3.45': + resolution: {integrity: sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw==} + engines: {node: '>=18.0.0'} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + '@smithy/util-defaults-mode-node@4.2.49': + resolution: {integrity: sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ==} + engines: {node: '>=18.0.0'} - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} + '@smithy/util-endpoints@3.3.4': + resolution: {integrity: sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog==} + engines: {node: '>=18.0.0'} - enhanced-resolve@5.20.1: - resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} - engines: {node: '>=10.13.0'} + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + '@smithy/util-middleware@4.2.13': + resolution: {integrity: sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==} + engines: {node: '>=18.0.0'} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} + '@smithy/util-retry@4.3.1': + resolution: {integrity: sha512-FwmicpgWOkP5kZUjN3y+3JIom8NLGqSAJBeoIgK0rIToI817TEBHCrd0A2qGeKQlgDeP+Jzn4i0H/NLAXGy9uQ==} + engines: {node: '>=18.0.0'} - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} + '@smithy/util-stream@4.5.22': + resolution: {integrity: sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==} + engines: {node: '>=18.0.0'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} + '@smithy/util-waiter@4.2.15': + resolution: {integrity: sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg==} + engines: {node: '>=18.0.0'} - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + '@swc/core-darwin-arm64@1.15.24': + resolution: {integrity: sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==} engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] - eslint-config-prettier@9.1.2: - resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' + '@swc/core-darwin-x64@1.15.24': + resolution: {integrity: sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] - eslint-plugin-prettier@5.5.5: - resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - '@types/eslint': '>=8.0.0' - eslint: '>=8.0.0' - eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' - prettier: '>=3.0.0' - peerDependenciesMeta: - '@types/eslint': - optional: true - eslint-config-prettier: - optional: true + '@swc/core-linux-arm-gnueabihf@1.15.24': + resolution: {integrity: sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} + '@swc/core-linux-arm64-gnu@1.15.24': + resolution: {integrity: sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] - eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@swc/core-linux-arm64-musl@1.15.24': + resolution: {integrity: sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] - 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@8.57.1: - resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. - hasBin: true - - espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} + '@swc/core-linux-ppc64-gnu@1.15.24': + resolution: {integrity: sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} + '@swc/core-linux-s390x-gnu@1.15.24': + resolution: {integrity: sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} + '@swc/core-linux-x64-gnu@1.15.24': + resolution: {integrity: sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} + '@swc/core-linux-x64-musl@1.15.24': + resolution: {integrity: sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} + '@swc/core-win32-arm64-msvc@1.15.24': + resolution: {integrity: sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} + '@swc/core-win32-ia32-msvc@1.15.24': + resolution: {integrity: sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + '@swc/core-win32-x64-msvc@1.15.24': + resolution: {integrity: sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==} engines: {node: '>=10'} + cpu: [x64] + os: [win32] - exit@0.1.2: - resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} - engines: {node: '>= 0.8.0'} + '@swc/core@1.15.24': + resolution: {integrity: sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true - expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - express@4.22.1: - resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} - engines: {node: '>= 0.10.0'} + '@swc/types@0.1.26': + resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} - external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} - engines: {node: '>=4'} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} - fb-watchman@2.0.2: - resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - figures@3.2.0: - resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} - engines: {node: '>=8'} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} - file-type@20.4.1: - resolution: {integrity: sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==} - engines: {node: '>=18'} + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} + '@types/node@20.19.39': + resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} + '@types/nodemailer@8.0.0': + resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} - 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 + '@types/oauth@0.9.6': + resolution: {integrity: sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} + '@types/passport-github@1.1.13': + resolution: {integrity: sha512-iAm6r82cEcmNVo9Uro5U2lhPpCXaKP4YW5ARdAwQS5gLleWp+ox4eGTplRAph71TKpWTdNibUjOPpHZN3AnJSg==} - formidable@2.1.5: - resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} + '@types/passport-google-oauth20@2.0.17': + resolution: {integrity: sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==} - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} + '@types/passport-oauth2@1.8.0': + resolution: {integrity: sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==} - fs-extra@10.1.0: - resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} - engines: {node: '>=12'} + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} - fs-monkey@1.1.0: - resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] + '@types/qs@6.15.0': + resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} + '@types/ua-parser-js@0.7.39': + resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} + '@typescript-eslint/eslint-plugin@8.61.0': + resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.61.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} + '@typescript-eslint/parser@8.61.0': + resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} + '@typescript-eslint/project-service@8.61.0': + resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} + '@typescript-eslint/scope-manager@8.61.0': + resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + '@typescript-eslint/tsconfig-utils@8.61.0': + resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true + '@typescript-eslint/type-utils@8.61.0': + resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + '@typescript-eslint/types@8.61.0': + resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} + '@typescript-eslint/typescript-estree@8.61.0': + resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} + '@typescript-eslint/utils@8.61.0': + resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} + '@typescript-eslint/visitor-keys@8.61.0': + resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + '@vitest/coverage-v8@4.1.4': + resolution: {integrity: sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==} + peerDependencies: + '@vitest/browser': 4.1.4 + vitest: 4.1.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + '@vitest/expect@4.1.4': + resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} - engines: {node: '>=0.4.7'} - hasBin: true + '@vitest/mocker@4.1.4': + resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} + '@vitest/pretty-format@4.1.4': + resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} - has-own-prop@2.0.0: - resolution: {integrity: sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==} - engines: {node: '>=8'} + '@vitest/runner@4.1.4': + resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + '@vitest/snapshot@4.1.4': + resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} + '@vitest/spy@4.1.4': + resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} + '@vitest/utils@4.1.4': + resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} - import-local@3.2.0: - resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} - engines: {node: '>=8'} - hasBin: true + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} - 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. + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} - inquirer@8.2.6: - resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} - engines: {node: '>=12.0.0'} + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} - inquirer@9.2.15: - resolution: {integrity: sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==} - engines: {node: '>=18'} + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + '@willsoto/nestjs-prometheus@6.1.0': + resolution: {integrity: sha512-lrCEnJBBSzUIYWGR+PsZw1YXs1B9jzxFEuNAa3RzTxuFAFdI+sW7Fp52il/U/dX2MWoHc32x06OS0nm56QwyzQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + prom-client: ^15.0.0 - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} - is-generator-fn@2.1.0: - resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} - engines: {node: '>=6'} + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - istanbul-lib-instrument@5.2.1: - resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} - engines: {node: '>=8'} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} - istanbul-lib-source-maps@4.0.1: - resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} - engines: {node: '>=10'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - iterare@1.2.1: - resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} - engines: {node: '>=6'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} - jest-changed-files@29.7.0: - resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} - 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} + are-docs-informative@0.0.2: + resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} + engines: {node: '>=14'} - 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 + argon2@0.44.0: + resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==} + engines: {node: '>=16.17.0'} - 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 + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} - jest-docblock@29.7.0: - resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} - 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} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} - jest-environment-node@29.7.0: - resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} - jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - jest-haste-map@29.7.0: - resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - jest-leak-detector@29.7.0: - resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.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} + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} - 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} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} - jest-mock@29.7.0: - resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - jest-pnp-resolver@1.2.3: - resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} - engines: {node: '>=6'} - peerDependencies: - jest-resolve: '*' - peerDependenciesMeta: - jest-resolve: - optional: true + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} - jest-regex-util@29.6.3: - resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - jest-resolve-dependencies@29.7.0: - resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + base64url@3.0.1: + resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} + engines: {node: '>=6.0.0'} - jest-resolve@29.7.0: - resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + baseline-browser-mapping@2.10.17: + resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} + engines: {node: '>=6.0.0'} + hasBin: true - jest-runner@29.7.0: - resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} - jest-runtime@29.7.0: - resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} - jest-snapshot@29.7.0: - resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - jest-validate@29.7.0: - resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} - jest-watcher@29.7.0: - resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - jest-worker@27.5.1: - resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} - engines: {node: '>= 10.13.0'} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} - jest-worker@29.7.0: - resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} - jest@29.7.0: - resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + builtin-modules@3.3.0: + resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} + engines: {node: '>=6'} - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + builtin-modules@5.2.0: + resolution: {integrity: sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ==} + engines: {node: '>=18.20'} - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + bullmq@5.73.4: + resolution: {integrity: sha512-Q+NeFLtdKSD3GDPYSX4pH+Mc9E4OZVKimXwrnZ5WmndNy31COMy4vQV9zfhgfHGSUFrlpsBicfKYbSjx9FbO+A==} - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - hasBin: true - jsonc-parser@3.2.1: - resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} - jsonc-parser@3.3.1: - resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + caniuse-lite@1.0.30001787: + resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} - loader-runner@4.3.1: - resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} - engines: {node: '>=6.11.5'} + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} - lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} - magic-string@0.30.8: - resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} - makeerror@1.0.12: - resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - memfs@3.5.3: - resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} - engines: {node: '>= 4.0.0'} + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true + comment-json@4.6.2: + resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} + engines: {node: '>= 6'} - mime@2.6.0: - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} - engines: {node: '>=4.0.0'} - hasBin: true + comment-parser@1.4.7: + resolution: {integrity: sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==} + engines: {node: '>= 12.0.0'} - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - minimatch@9.0.3: - resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} - engines: {node: '>=16 || 14 >=14.17'} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} + engines: {node: '>=18'} - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} hasBin: true - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} - multer@2.0.2: - resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} - engines: {node: '>= 10.16.0'} + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} - mute-stream@0.0.8: - resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - mute-stream@1.0.0: - resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + 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 - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} - node-abort-controller@3.1.1: - resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true - node-emoji@1.11.0: - resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} peerDependencies: - encoding: ^0.1.0 + supports-color: '*' peerDependenciesMeta: - encoding: + supports-color: optional: true - node-int64@0.4.0: - resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - node-releases@2.0.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} 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==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} + detect-europe-js@0.1.2: + resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==} - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} + detect-indent@7.0.2: + resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} + engines: {node: '>=12.20'} - os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + engines: {node: '>=12'} - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} + duplexify@3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} + electron-to-chromium@1.5.334: + resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - path-to-regexp@0.1.13: - resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} - path-to-regexp@3.3.0: - resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} - picomatch@4.0.1: - resolution: {integrity: sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==} - engines: {node: '>=12'} + error-causes@3.0.2: + resolution: {integrity: sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==} - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} - pluralize@8.0.0: - resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} - engines: {node: '>=4'} + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - prettier-linter-helpers@1.0.1: - resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} - engines: {node: '>=6.0.0'} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} - prettier@3.8.2: - resolution: {integrity: sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==} - engines: {node: '>=14'} - hasBin: true + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} - engines: {node: '>=0.6'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} - qs@6.15.1: - resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} - engines: {node: '>=0.6'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + eslint-plugin-functional@10.0.0: + resolution: {integrity: sha512-D/BWdGUvPz5uIC+kZM1BWrELhjHpDiYxIYjzF7a6TZ62KINajjKgOiSYGbGv4fpMT1enEC9yG+0kOaIRTZzpTg==} + engines: {node: '>=v20.0.0'} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + typescript: '>=4.7.4' + peerDependenciesMeta: + typescript: + optional: true - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} + eslint-plugin-jsdoc@63.0.2: + resolution: {integrity: sha512-0TchoK1uS4VxHSo3P4CyWQ31Lm+6zsT+xkHMC5KbFKwgOf8YrXPf1Bl8EP7kpgw1wfe/Ui5jz5mSX7ou8WAVuw==} + engines: {node: ^22.13.0 || >=24} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} + eslint-plugin-perfectionist@5.9.0: + resolution: {integrity: sha512-8TWzg02zmnBdZwCkWLi8jhzqXI+fE7Z/RwV8SL6xD45tJ8Bp3wGuYL2XtQgfe/Wd0eBqOUX+s6ey73IyszvKTA==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + eslint-plugin-security@4.0.1: + resolution: {integrity: sha512-/lZCkOxPOWaf1jXAqgICrS8St3BMBccIPvhOSUYuV6VCr1o5nFVG998FnTLt6w2Nxb8Uo0nM8fzmnhp+GY/aEg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} + eslint-plugin-sonarjs@4.0.3: + resolution: {integrity: sha512-5drkJKLC9qQddIiaATV0e8+ygbUc7b0Ti6VB7M2d3jmKNh3X0RaiIJYTs3dr9xnlhlrxo+/s1FoO3Jgv6O/c7g==} + peerDependencies: + eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} + eslint-plugin-unicorn@66.0.0: + resolution: {integrity: sha512-+ywdy8T3foyZ2t3nRBujGa3vfOVMobHIi5iLB0L+fogdVO3EiUJ4BAyIacogWytnweLw3hgT70LQL9KoKTY/kA==} + engines: {node: '>=22'} + peerDependencies: + eslint: '>=10.4' - reflect-metadata@0.2.2: - resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} - repeat-string@1.6.1: - resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} - engines: {node: '>=0.10'} + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.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} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} - resolve-cwd@3.0.0: - resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} - engines: {node: '>=8'} + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} + hasBin: true - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} - - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} - run-async@2.4.1: - resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} - engines: {node: '>=0.12.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - run-async@3.0.0: - resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} - engines: {node: '>=0.12.0'} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} - rxjs@7.8.1: - resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} - schema-utils@3.3.0: - resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} - engines: {node: '>= 10.13.0'} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} - schema-utils@4.3.3: - resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} - engines: {node: '>= 10.13.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true + fast-json-stringify@6.3.0: + resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} + fast-xml-parser@5.5.8: + resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==} + hasBin: true - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - engines: {node: '>= 0.4'} + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} + fastify@5.8.4: + resolution: {integrity: sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==} - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - 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'} + find-my-way@9.5.0: + resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} + engines: {node: '>=20'} - source-map@0.7.4: - resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} - engines: {node: '>= 8'} + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true - string-length@4.0.2: - resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} - engines: {node: '>=10'} + fork-ts-checker-webpack-plugin@9.1.0: + resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} + engines: {node: '>=14.21.3'} + peerDependencies: + typescript: '>3.6.0' + webpack: ^5.11.0 - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + fs-monkey@1.1.0: + resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} - strip-bom@4.0.0: - resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} - engines: {node: '>=8'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} - strtok3@10.3.5: - resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} - engines: {node: '>=18'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} - superagent@8.1.2: - resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} - engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} - supertest@6.3.4: - resolution: {integrity: sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==} - engines: {node: '>=6.4.0'} - deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} + hasBin: true - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} - symbol-observable@4.0.0: - resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} - engines: {node: '>=0.10'} + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} - synckit@0.11.12: - resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} - engines: {node: ^14.18.0 || >=16.0.0} + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} - tapable@2.3.2: - resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} - engines: {node: '>=6'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} - terser-webpack-plugin@5.4.0: - resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} - 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 + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - terser@5.46.1: - resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==} - engines: {node: '>=10'} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} hasBin: true - test-exclude@6.0.0: - resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} - through@2.3.8: - resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} - tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} - engines: {node: '>=0.6.0'} + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} - tmpl@1.0.5: - resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} - token-types@6.1.2: - resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} - engines: {node: '>=14.16'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ts-api-utils@1.4.3: - resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} - engines: {node: '>=16'} - peerDependencies: - typescript: '>=4.2.0' + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} - ts-jest@29.4.9: - resolution: {integrity: sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==} - 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 || ^30.0.0 - '@jest/types': ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - esbuild: '*' - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: '>=4.3 <7' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/transform': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} - ts-loader@9.5.7: - resolution: {integrity: sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==} - engines: {node: '>=12.0.0'} - peerDependencies: - typescript: '*' - webpack: ^5.0.0 + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-builtin-module@5.0.0: + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} + engines: {node: '>=18.20'} + + 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@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-immutable-type@5.0.4: + resolution: {integrity: sha512-tDf0vB9dJJt/1USS2nvHJb8UsIAhs+pF6z8UZLNdhrzB+PKMrdcN45je9jhuFpaJOjJpoRhueFmFrRs5g/TNMA==} 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 + eslint: '*' + typescript: '>=4.7.4' - tsconfig-paths-webpack-plugin@4.2.0: - resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} - engines: {node: '>=10.13.0'} + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} - tsconfig-paths@4.2.0: - resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} - engines: {node: '>=6'} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} - type-detect@4.0.8: - resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} - engines: {node: '>=4'} + is-standalone-pwa@0.1.1: + resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==} - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + 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-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} - typedarray@0.0.6: - resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} - typescript@5.7.2: - resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} - engines: {node: '>=14.17'} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} + jsdoc-type-pratt-parser@7.2.0: + resolution: {integrity: sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==} + engines: {node: '>=20.0.0'} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} hasBin: true - uid@2.0.2: - resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} - engines: {node: '>=8'} + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - uint8array-extras@1.5.0: - resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} - engines: {node: '>=18'} + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + 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 - peerDependencies: - browserslist: '>= 4.21.0' - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + jsx-ast-utils-x@0.1.0: + resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - v8-to-istanbul@9.3.0: - resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} - engines: {node: '>=10.12.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - walker@1.0.8: - resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - watchpack@2.5.1: - resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} - engines: {node: '>=10.13.0'} + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} - webpack-node-externals@3.0.0: - resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} - engines: {node: '>=6'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] - webpack-sources@3.3.4: - resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} - engines: {node: '>=10.13.0'} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] - 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 + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} hasBin: true - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} + loader-runner@4.3.1: + resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} + engines: {node: '>=6.11.5'} - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - 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} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} -snapshots: + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - '@angular-devkit/core@17.3.11(chokidar@3.6.0)': - 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: 3.6.0 + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} - '@angular-devkit/schematics-cli@17.3.11(chokidar@3.6.0)': - dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) - ansi-colors: 4.1.3 - inquirer: 9.2.15 - symbol-observable: 4.0.0 - yargs-parser: 21.1.1 - transitivePeerDependencies: - - chokidar + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - '@angular-devkit/schematics@17.3.11(chokidar@3.6.0)': - dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - jsonc-parser: 3.2.1 - magic-string: 0.30.8 - ora: 5.4.1 - rxjs: 7.8.1 - transitivePeerDependencies: - - chokidar + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} - '@babel/compat-data@7.29.0': {} + lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + 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'} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.3.3: + resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==} + engines: {node: 20 || >=22} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + 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@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + natural-orderby@5.0.0: + resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} + engines: {node: '>=18'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + nest-winston@1.10.2: + resolution: {integrity: sha512-Z9IzL/nekBOF/TEwBHUJDiDPMaXUcFquUQOFavIRet6xF0EbuWnOzslyN/ksgzG+fITNgXhMdrL/POp9SdaFxA==} + peerDependencies: + '@nestjs/common': ^5.0.0 || ^6.6.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + winston: ^3.0.0 + + nestjs-zod@5.3.0: + resolution: {integrity: sha512-QY6imXm9heMOpWigjFHgMWPvc1ZQHeNQ7pdogo9Q5xj5F8HpqZ972vKlVdkaTyzYlOXJP/yVy3wlF1EjubDQPg==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0 + rxjs: ^7.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@nestjs/swagger': + optional: true + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-addon-api@8.7.0: + resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} + engines: {node: ^18 || ^20 || >= 21} + + node-emoji@1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + nodemailer@8.0.5: + resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==} + engines: {node: '>=6.0.0'} + + oauth@0.10.2: + resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==} + + object-deep-merge@2.0.1: + resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + 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'} + + otplib@13.4.0: + resolution: {integrity: sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + + passport-github@1.1.0: + resolution: {integrity: sha512-XARXJycE6fFh/dxF+Uut8OjlwbFEXgbPVj/+V+K7cvriRK7VcAOm+NgBmbiLM9Qv3SSxEAV+V6fIk89nYHXa8A==} + engines: {node: '>= 0.4.0'} + + passport-google-oauth20@2.0.0: + resolution: {integrity: sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==} + engines: {node: '>= 0.4.0'} + + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-oauth2@1.8.0: + resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==} + engines: {node: '>= 0.4.0'} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + + peek-stream@1.1.3: + resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.13.0: + resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.14.0: + resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + 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'} + + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.2: + resolution: {integrity: sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==} + engines: {node: '>=14'} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + pumpify@2.0.1: + resolution: {integrity: sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + 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'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.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'} + + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + + 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'} + + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + + 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-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + 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'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.0.0-rc.15: + resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + 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-regex2@5.1.0: + resolution: {integrity: sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==} + hasBin: true + + safe-regex@2.1.1: + resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + 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.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + 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'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + 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'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@4.0.0: + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + 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.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + 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'} + + swagger-ui-dist@5.32.2: + resolution: {integrity: sha512-t6Ns52nS8LU2hqi0+rezMjFO1ZrCsCrnommXrU7Nfrg2va2dWahdvM6TuSwzdHpG29v6BHJyU1c/UWFhgVZzVQ==} + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + + terser-webpack-plugin@5.4.0: + resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} + 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.46.1: + resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==} + engines: {node: '>=10'} + hasBin: true + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toad-cache@3.7.1: + resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==} + engines: {node: '>=20'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-declaration-location@1.0.7: + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} + peerDependencies: + typescript: '>=4.0.0' + + ts-loader@9.5.7: + resolution: {integrity: sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==} + engines: {node: '>=12.0.0'} + peerDependencies: + typescript: '*' + webpack: ^5.0.0 + + 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.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typescript-eslint@8.61.0: + resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-is-frozen@0.1.2: + resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==} + + ua-parser-js@2.0.9: + resolution: {integrity: sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + uid2@0.0.4: + resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + 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@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + vite@8.0.8: + resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.4: + resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.4 + '@vitest/browser-preview': 4.1.4 + '@vitest/browser-webdriverio': 4.1.4 + '@vitest/coverage-istanbul': 4.1.4 + '@vitest/coverage-v8': 4.1.4 + '@vitest/ui': 4.1.4 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + watchpack@2.5.1: + resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.3.4: + resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} + engines: {node: '>=10.13.0'} + + webpack@5.106.0: + resolution: {integrity: sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.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@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@angular-devkit/core@19.2.23(chokidar@4.0.3)': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + jsonc-parser: 3.3.1 + picomatch: 4.0.4 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/core@19.2.24(chokidar@4.0.3)': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + jsonc-parser: 3.3.1 + picomatch: 4.0.4 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/schematics-cli@19.2.24(@types/node@20.19.39)(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) + '@inquirer/prompts': 7.3.2(@types/node@20.19.39) + ansi-colors: 4.1.3 + symbol-observable: 4.0.0 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@types/node' + - chokidar + + '@angular-devkit/schematics@19.2.23(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.23(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@19.2.24(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.7 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1029.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.27 + '@aws-sdk/credential-provider-node': 3.972.30 + '@aws-sdk/middleware-bucket-endpoint': 3.972.9 + '@aws-sdk/middleware-expect-continue': 3.972.9 + '@aws-sdk/middleware-flexible-checksums': 3.974.7 + '@aws-sdk/middleware-host-header': 3.972.9 + '@aws-sdk/middleware-location-constraint': 3.972.9 + '@aws-sdk/middleware-logger': 3.972.9 + '@aws-sdk/middleware-recursion-detection': 3.972.10 + '@aws-sdk/middleware-sdk-s3': 3.972.28 + '@aws-sdk/middleware-ssec': 3.972.9 + '@aws-sdk/middleware-user-agent': 3.972.29 + '@aws-sdk/region-config-resolver': 3.972.11 + '@aws-sdk/signature-v4-multi-region': 3.996.16 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-endpoints': 3.996.6 + '@aws-sdk/util-user-agent-browser': 3.972.9 + '@aws-sdk/util-user-agent-node': 3.973.15 + '@smithy/config-resolver': 4.4.14 + '@smithy/core': 3.23.14 + '@smithy/eventstream-serde-browser': 4.2.13 + '@smithy/eventstream-serde-config-resolver': 4.3.13 + '@smithy/eventstream-serde-node': 4.2.13 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/hash-blob-browser': 4.2.14 + '@smithy/hash-node': 4.2.13 + '@smithy/hash-stream-node': 4.2.13 + '@smithy/invalid-dependency': 4.2.13 + '@smithy/md5-js': 4.2.13 + '@smithy/middleware-content-length': 4.2.13 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/middleware-retry': 4.5.1 + '@smithy/middleware-serde': 4.2.17 + '@smithy/middleware-stack': 4.2.13 + '@smithy/node-config-provider': 4.3.13 + '@smithy/node-http-handler': 4.5.2 + '@smithy/protocol-http': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.45 + '@smithy/util-defaults-mode-node': 4.2.49 + '@smithy/util-endpoints': 3.3.4 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-retry': 4.3.1 + '@smithy/util-stream': 4.5.22 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.2.15 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.973.27': + dependencies: + '@aws-sdk/types': 3.973.7 + '@aws-sdk/xml-builder': 3.972.17 + '@smithy/core': 3.23.14 + '@smithy/node-config-provider': 4.3.13 + '@smithy/property-provider': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/signature-v4': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/crc64-nvme@3.972.6': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.25': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.27': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/node-http-handler': 4.5.2 + '@smithy/property-provider': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/util-stream': 4.5.22 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/credential-provider-env': 3.972.25 + '@aws-sdk/credential-provider-http': 3.972.27 + '@aws-sdk/credential-provider-login': 3.972.29 + '@aws-sdk/credential-provider-process': 3.972.25 + '@aws-sdk/credential-provider-sso': 3.972.29 + '@aws-sdk/credential-provider-web-identity': 3.972.29 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/credential-provider-imds': 4.2.13 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.30': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.25 + '@aws-sdk/credential-provider-http': 3.972.27 + '@aws-sdk/credential-provider-ini': 3.972.29 + '@aws-sdk/credential-provider-process': 3.972.25 + '@aws-sdk/credential-provider-sso': 3.972.29 + '@aws-sdk/credential-provider-web-identity': 3.972.29 + '@aws-sdk/types': 3.973.7 + '@smithy/credential-provider-imds': 4.2.13 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.25': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/token-providers': 3.1026.0 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/node-config-provider': 4.3.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.974.7': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.973.27 + '@aws-sdk/crc64-nvme': 3.972.6 + '@aws-sdk/types': 3.973.7 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/node-config-provider': 4.3.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-stream': 4.5.22 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.7 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.28': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/core': 3.23.14 + '@smithy/node-config-provider': 4.3.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/signature-v4': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-stream': 4.5.22 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.29': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-endpoints': 3.996.6 + '@smithy/core': 3.23.14 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-retry': 4.3.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.996.19': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.27 + '@aws-sdk/middleware-host-header': 3.972.9 + '@aws-sdk/middleware-logger': 3.972.9 + '@aws-sdk/middleware-recursion-detection': 3.972.10 + '@aws-sdk/middleware-user-agent': 3.972.29 + '@aws-sdk/region-config-resolver': 3.972.11 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-endpoints': 3.996.6 + '@aws-sdk/util-user-agent-browser': 3.972.9 + '@aws-sdk/util-user-agent-node': 3.973.15 + '@smithy/config-resolver': 4.4.14 + '@smithy/core': 3.23.14 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/hash-node': 4.2.13 + '@smithy/invalid-dependency': 4.2.13 + '@smithy/middleware-content-length': 4.2.13 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/middleware-retry': 4.5.1 + '@smithy/middleware-serde': 4.2.17 + '@smithy/middleware-stack': 4.2.13 + '@smithy/node-config-provider': 4.3.13 + '@smithy/node-http-handler': 4.5.2 + '@smithy/protocol-http': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.45 + '@smithy/util-defaults-mode-node': 4.2.49 + '@smithy/util-endpoints': 3.3.4 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-retry': 4.3.1 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/config-resolver': 4.4.14 + '@smithy/node-config-provider': 4.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1029.0': + dependencies: + '@aws-sdk/signature-v4-multi-region': 3.996.16 + '@aws-sdk/types': 3.973.7 + '@aws-sdk/util-format-url': 3.972.9 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/protocol-http': 5.3.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.16': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.972.28 + '@aws-sdk/types': 3.973.7 + '@smithy/protocol-http': 5.3.13 + '@smithy/signature-v4': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1026.0': + dependencies: + '@aws-sdk/core': 3.973.27 + '@aws-sdk/nested-clients': 3.996.19 + '@aws-sdk/types': 3.973.7 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.7': + dependencies: + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.972.3': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.6': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-endpoints': 3.3.4 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/querystring-builder': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.9': + dependencies: + '@aws-sdk/types': 3.973.7 + '@smithy/types': 4.14.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.15': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.29 + '@aws-sdk/types': 3.973.7 + '@smithy/node-config-provider': 4.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.17': + dependencies: + '@smithy/types': 4.14.0 + fast-xml-parser: 5.5.8 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@borewit/text-codec@0.2.2': {} + + '@colors/colors@1.5.0': + optional: true + + '@colors/colors@1.6.0': {} + + '@commitlint/cli@20.5.0(@types/node@20.19.39)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.0 + '@commitlint/load': 20.5.0(@types/node@20.19.39)(typescript@5.9.3) + '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.1.1 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.1 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.18.0 + + '@commitlint/ensure@20.5.0': + dependencies: + '@commitlint/types': 20.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@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.7.4 + + '@commitlint/lint@20.5.0': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.0 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.0(@types/node@20.19.39)(typescript@5.9.3)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.0 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.1(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@20.19.39)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + is-plain-obj: 4.1.0 + lodash.mergewith: 4.6.2 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.1.1 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.0': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + global-directory: 4.0.1 + import-meta-resolve: 4.2.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.0': + dependencies: + '@commitlint/ensure': 20.5.0 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.4.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.7.4 + optionalDependencies: + conventional-commits-parser: 6.4.0 + + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + + '@drizzle-team/brocli@0.10.2': {} + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@epic-web/invariant@1.0.0': {} + + '@es-joy/jsdoccomment@0.87.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.61.0 + comment-parser: 1.4.7 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.2.0 + + '@es-joy/resolve.exports@1.2.0': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.6.1))': + dependencies: + eslint: 10.5.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 + minimatch: 10.2.5 transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.5.0(jiti@2.6.1))': + optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + + '@fastify/busboy@3.2.0': {} + + '@fastify/compress@8.3.1': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + fastify-plugin: 5.1.0 + mime-db: 1.52.0 + minipass: 7.1.3 + peek-stream: 1.1.3 + pump: 3.0.4 + pumpify: 2.0.1 + readable-stream: 4.7.0 + + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/csrf-protection@7.1.0': + dependencies: + '@fastify/csrf': 8.0.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + + '@fastify/csrf@8.0.1': {} + + '@fastify/deepmerge@3.2.1': {} + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.3.0 + + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/multipart@10.0.0': + dependencies: + '@fastify/busboy': 3.2.0 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + secure-json-parse: 4.1.0 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.1.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@fastify/view@11.1.1': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.1 + optional: true + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@20.19.39)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.39) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/confirm@5.1.21(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/core@10.3.2(@types/node@20.19.39)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.39) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/editor@4.2.23(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/external-editor': 1.0.3(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/expand@4.0.23(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/external-editor@1.0.3(@types/node@20.19.39)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/number@3.0.23(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/password@4.0.23(@types/node@20.19.39)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/prompts@7.10.1(@types/node@20.19.39)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@20.19.39) + '@inquirer/confirm': 5.1.21(@types/node@20.19.39) + '@inquirer/editor': 4.2.23(@types/node@20.19.39) + '@inquirer/expand': 4.0.23(@types/node@20.19.39) + '@inquirer/input': 4.3.1(@types/node@20.19.39) + '@inquirer/number': 3.0.23(@types/node@20.19.39) + '@inquirer/password': 4.0.23(@types/node@20.19.39) + '@inquirer/rawlist': 4.1.11(@types/node@20.19.39) + '@inquirer/search': 3.2.2(@types/node@20.19.39) + '@inquirer/select': 4.4.2(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/prompts@7.3.2(@types/node@20.19.39)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@20.19.39) + '@inquirer/confirm': 5.1.21(@types/node@20.19.39) + '@inquirer/editor': 4.2.23(@types/node@20.19.39) + '@inquirer/expand': 4.0.23(@types/node@20.19.39) + '@inquirer/input': 4.3.1(@types/node@20.19.39) + '@inquirer/number': 3.0.23(@types/node@20.19.39) + '@inquirer/password': 4.0.23(@types/node@20.19.39) + '@inquirer/rawlist': 4.1.11(@types/node@20.19.39) + '@inquirer/search': 3.2.2(@types/node@20.19.39) + '@inquirer/select': 4.4.2(@types/node@20.19.39) + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/rawlist@4.1.11(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/type': 3.0.10(@types/node@20.19.39) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/search@3.2.2(@types/node@20.19.39)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.39) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/select@4.4.2(@types/node@20.19.39)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.39) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.39) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.39 + + '@inquirer/type@3.0.10(@types/node@20.19.39)': + optionalDependencies: + '@types/node': 20.19.39 + + '@ioredis/commands@1.5.1': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.28.6': + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lukeed/csprng@1.1.0': {} - '@babel/helper-globals@7.28.0': {} + '@lukeed/ms@2.0.2': {} - '@babel/helper-module-imports@7.28.6': + '@microsoft/tsdoc@0.16.0': {} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@nestjs-modules/ioredis@2.2.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2))(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(ioredis@5.10.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + ioredis: 5.10.1 + optionalDependencies: + '@nestjs/terminus': 11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2))(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) transitivePeerDependencies: - - supports-color + - '@grpc/grpc-js' + - '@grpc/proto-loader' + - '@mikro-orm/core' + - '@mikro-orm/nestjs' + - '@nestjs/axios' + - '@nestjs/microservices' + - '@nestjs/mongoose' + - '@nestjs/sequelize' + - '@nestjs/typeorm' + - '@prisma/client' + - mongoose + - reflect-metadata + - rxjs + - sequelize + - typeorm + + '@nestjs/axios@4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + axios: 1.16.0 + rxjs: 7.8.2 - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.73.4)': + dependencies: + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + bullmq: 5.73.4 + tslib: 2.8.1 + + '@nestjs/cli@11.0.19(@swc/core@1.15.24)(@types/node@20.19.39)(esbuild@0.27.7)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.24(@types/node@20.19.39)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@20.19.39) + '@nestjs/schematics': 11.0.10(chokidar@4.0.3)(typescript@5.9.3) + ansis: 4.2.0 + chokidar: 4.0.3 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)) + glob: 13.0.6 + node-emoji: 1.11.0 + ora: 5.4.1 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.9.3 + webpack: 5.106.0(@swc/core@1.15.24)(esbuild@0.27.7) + webpack-node-externals: 3.0.0 + optionalDependencies: + '@swc/core': 1.15.24 + transitivePeerDependencies: + - '@types/node' + - esbuild + - uglify-js + - webpack-cli + + '@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.28.6': {} + '@nestjs/config@4.0.4(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.4.1 + dotenv-expand: 12.0.3 + lodash: 4.18.1 + rxjs: 7.8.2 + + '@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxt/opencollective': 0.4.1 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + + '@nestjs/event-emitter@3.1.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@nestjs/passport@11.0.5(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + + '@nestjs/platform-fastify@11.1.18(@fastify/static@9.1.0)(@fastify/view@11.1.1)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@fastify/cors': 11.2.0 + '@fastify/formbody': 8.0.2 + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-querystring: 1.1.2 + fastify: 5.8.4 + fastify-plugin: 5.1.0 + find-my-way: 9.5.0 + light-my-request: 6.6.0 + path-to-regexp: 8.4.2 + reusify: 1.1.0 + tslib: 2.8.1 + optionalDependencies: + '@fastify/static': 9.1.0 + '@fastify/view': 11.1.1 + + '@nestjs/schematics@11.0.10(chokidar@4.0.3)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 19.2.23(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.23(chokidar@4.0.3) + comment-json: 4.6.2 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - chokidar + + '@nestjs/swagger@11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.2 + optionalDependencies: + '@fastify/static': 9.1.0 + + '@nestjs/terminus@11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2))(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + optionalDependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.0)(rxjs@7.8.2) + optional: true + + '@nestjs/testing@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + + '@nestjs/throttler@6.5.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@noble/hashes@2.0.1': {} + + '@nuxt/opencollective@0.4.1': + dependencies: + consola: 3.4.2 + + '@opentelemetry/api@1.9.1': {} + + '@otplib/core@13.4.0': {} + + '@otplib/hotp@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@otplib/uri': 13.4.0 + + '@otplib/plugin-base32-scure@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@scure/base': 2.0.0 + + '@otplib/plugin-crypto-noble@13.4.0': + dependencies: + '@noble/hashes': 2.0.1 + '@otplib/core': 13.4.0 + + '@otplib/totp@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + '@otplib/hotp': 13.4.0 + '@otplib/uri': 13.4.0 + + '@otplib/uri@13.4.0': + dependencies: + '@otplib/core': 13.4.0 + + '@oxc-project/types@0.124.0': {} + + '@paralleldrive/cuid2@3.3.0': + dependencies: + '@noble/hashes': 2.0.1 + bignumber.js: 9.3.1 + error-causes: 3.0.2 + + '@phc/format@1.0.0': {} + + '@pinojs/redact@0.4.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + optional: true - '@babel/helper-string-parser@7.27.1': {} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + optional: true - '@babel/helper-validator-identifier@7.28.5': {} + '@rolldown/pluginutils@1.0.0-rc.15': {} - '@babel/helper-validator-option@7.27.1': {} + '@scarf/scarf@1.4.0': {} - '@babel/helpers@7.29.2': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@scure/base@2.0.0': {} - '@babel/parser@7.29.2': + '@simple-libs/child-process-utils@1.0.2': dependencies: - '@babel/types': 7.29.0 + '@simple-libs/stream-utils': 1.2.0 - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@simple-libs/stream-utils@1.2.0': {} - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@sindresorhus/base62@1.0.0': {} - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + '@smithy/chunked-blob-reader-native@4.2.3': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + '@smithy/chunked-blob-reader@5.2.2': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + tslib: 2.8.1 - '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + '@smithy/config-resolver@4.4.14': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/node-config-provider': 4.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.3.4 + '@smithy/util-middleware': 4.2.13 + tslib: 2.8.1 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/core@3.23.14': + dependencies: + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-stream': 4.5.22 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + '@smithy/credential-provider-imds@4.2.13': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/node-config-provider': 4.3.13 + '@smithy/property-provider': 4.2.13 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + tslib: 2.8.1 - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + '@smithy/eventstream-codec@4.2.13': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.0 + '@smithy/util-hex-encoding': 4.2.2 + tslib: 2.8.1 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + '@smithy/eventstream-serde-browser@4.2.13': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/eventstream-serde-universal': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + '@smithy/eventstream-serde-config-resolver@4.3.13': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + '@smithy/eventstream-serde-node@4.2.13': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/eventstream-serde-universal': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + '@smithy/eventstream-serde-universal@4.2.13': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/eventstream-codec': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + '@smithy/fetch-http-handler@5.3.16': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/protocol-http': 5.3.13 + '@smithy/querystring-builder': 4.2.13 + '@smithy/types': 4.14.0 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + '@smithy/hash-blob-browser@4.2.14': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/chunked-blob-reader': 5.2.2 + '@smithy/chunked-blob-reader-native': 4.2.3 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + '@smithy/hash-node@4.2.13': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/types': 4.14.0 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + '@smithy/hash-stream-node@4.2.13': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/types': 4.14.0 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + '@smithy/invalid-dependency@4.2.13': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@babel/template@7.28.6': + '@smithy/is-array-buffer@2.2.0': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + tslib: 2.8.1 - '@babel/traverse@7.29.0': + '@smithy/is-array-buffer@4.2.2': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color + tslib: 2.8.1 - '@babel/types@7.29.0': + '@smithy/md5-js@4.2.13': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@bcoe/v8-coverage@0.2.3': {} - - '@borewit/text-codec@0.2.2': {} - - '@colors/colors@1.5.0': - optional: true + '@smithy/types': 4.14.0 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 - '@cspotcode/source-map-support@0.8.1': + '@smithy/middleware-content-length@4.2.13': dependencies: - '@jridgewell/trace-mapping': 0.3.9 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': + '@smithy/middleware-endpoint@4.4.29': dependencies: - eslint: 8.57.1 - eslint-visitor-keys: 3.4.3 + '@smithy/core': 3.23.14 + '@smithy/middleware-serde': 4.2.17 + '@smithy/node-config-provider': 4.3.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + '@smithy/url-parser': 4.2.13 + '@smithy/util-middleware': 4.2.13 + tslib: 2.8.1 - '@eslint-community/regexpp@4.12.2': {} + '@smithy/middleware-retry@4.5.1': + dependencies: + '@smithy/core': 3.23.14 + '@smithy/node-config-provider': 4.3.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/service-error-classification': 4.2.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-retry': 4.3.1 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 - '@eslint/eslintrc@2.1.4': + '@smithy/middleware-serde@4.2.17': dependencies: - ajv: 6.14.0 - debug: 4.4.3 - espree: 9.6.1 - globals: 13.24.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@8.57.1': {} + '@smithy/core': 3.23.14 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@humanwhocodes/config-array@0.13.0': + '@smithy/middleware-stack@4.2.13': dependencies: - '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3 - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/object-schema@2.0.3': {} + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@isaacs/cliui@8.0.2': + '@smithy/node-config-provider@4.3.13': dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 + '@smithy/property-provider': 4.2.13 + '@smithy/shared-ini-file-loader': 4.4.8 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@istanbuljs/load-nyc-config@1.1.0': + '@smithy/node-http-handler@4.5.2': dependencies: - camelcase: 5.3.1 - find-up: 4.1.0 - get-package-type: 0.1.0 - js-yaml: 3.14.2 - resolve-from: 5.0.0 - - '@istanbuljs/schema@0.1.3': {} + '@smithy/protocol-http': 5.3.13 + '@smithy/querystring-builder': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@jest/console@29.7.0': + '@smithy/property-provider@4.2.13': dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - chalk: 4.1.2 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - slash: 3.0.0 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3))': + '@smithy/protocol-http@5.3.13': 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': 20.19.39 - 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@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - 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.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@jest/environment@29.7.0': + '@smithy/querystring-builder@4.2.13': dependencies: - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - jest-mock: 29.7.0 + '@smithy/types': 4.14.0 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 - '@jest/expect-utils@29.7.0': + '@smithy/querystring-parser@4.2.13': dependencies: - jest-get-type: 29.6.3 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@jest/expect@29.7.0': + '@smithy/service-error-classification@4.2.13': dependencies: - expect: 29.7.0 - jest-snapshot: 29.7.0 - transitivePeerDependencies: - - supports-color + '@smithy/types': 4.14.0 - '@jest/fake-timers@29.7.0': + '@smithy/shared-ini-file-loader@4.4.8': dependencies: - '@jest/types': 29.6.3 - '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.19.39 - jest-message-util: 29.7.0 - jest-mock: 29.7.0 - jest-util: 29.7.0 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@jest/globals@29.7.0': + '@smithy/signature-v4@5.3.13': dependencies: - '@jest/environment': 29.7.0 - '@jest/expect': 29.7.0 - '@jest/types': 29.6.3 - jest-mock: 29.7.0 - transitivePeerDependencies: - - supports-color + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.13 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 - '@jest/reporters@29.7.0': + '@smithy/smithy-client@4.12.9': 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.31 - '@types/node': 20.19.39 - chalk: 4.1.2 - collect-v8-coverage: 1.0.3 - 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.2.0 - 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 + '@smithy/core': 3.23.14 + '@smithy/middleware-endpoint': 4.4.29 + '@smithy/middleware-stack': 4.2.13 + '@smithy/protocol-http': 5.3.13 + '@smithy/types': 4.14.0 + '@smithy/util-stream': 4.5.22 + tslib: 2.8.1 - '@jest/schemas@29.6.3': + '@smithy/types@4.14.0': dependencies: - '@sinclair/typebox': 0.27.10 + tslib: 2.8.1 - '@jest/source-map@29.6.3': + '@smithy/url-parser@4.2.13': dependencies: - '@jridgewell/trace-mapping': 0.3.31 - callsites: 3.1.0 - graceful-fs: 4.2.11 + '@smithy/querystring-parser': 4.2.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@jest/test-result@29.7.0': + '@smithy/util-base64@4.3.2': dependencies: - '@jest/console': 29.7.0 - '@jest/types': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - collect-v8-coverage: 1.0.3 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 - '@jest/test-sequencer@29.7.0': + '@smithy/util-body-length-browser@4.2.2': dependencies: - '@jest/test-result': 29.7.0 - graceful-fs: 4.2.11 - jest-haste-map: 29.7.0 - slash: 3.0.0 + tslib: 2.8.1 - '@jest/transform@29.7.0': + '@smithy/util-body-length-node@4.2.3': dependencies: - '@babel/core': 7.29.0 - '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.31 - 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.8 - pirates: 4.0.7 - slash: 3.0.0 - write-file-atomic: 4.0.2 - transitivePeerDependencies: - - supports-color + tslib: 2.8.1 - '@jest/types@29.6.3': + '@smithy/util-buffer-from@2.2.0': dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.39 - '@types/yargs': 17.0.35 - chalk: 4.1.2 + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 - '@jridgewell/gen-mapping@0.3.13': + '@smithy/util-buffer-from@4.2.2': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 - '@jridgewell/remapping@2.3.5': + '@smithy/util-config-provider@4.2.2': dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} + tslib: 2.8.1 - '@jridgewell/source-map@0.3.11': + '@smithy/util-defaults-mode-browser@4.3.45': dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/sourcemap-codec@1.5.5': {} + '@smithy/property-provider': 4.2.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@jridgewell/trace-mapping@0.3.31': + '@smithy/util-defaults-mode-node@4.2.49': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@smithy/config-resolver': 4.4.14 + '@smithy/credential-provider-imds': 4.2.13 + '@smithy/node-config-provider': 4.3.13 + '@smithy/property-provider': 4.2.13 + '@smithy/smithy-client': 4.12.9 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@jridgewell/trace-mapping@0.3.9': + '@smithy/util-endpoints@3.3.4': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@smithy/node-config-provider': 4.3.13 + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@ljharb/through@2.3.14': + '@smithy/util-hex-encoding@4.2.2': dependencies: - call-bind: 1.0.9 - - '@lukeed/csprng@1.1.0': {} + tslib: 2.8.1 - '@nestjs/cli@10.4.9': + '@smithy/util-middleware@4.2.13': dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics-cli': 17.3.11(chokidar@3.6.0) - '@nestjs/schematics': 10.2.3(chokidar@3.6.0)(typescript@5.7.2) - chalk: 4.1.2 - chokidar: 3.6.0 - cli-table3: 0.6.5 - commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1) - glob: 10.4.5 - inquirer: 8.2.6 - 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.2 - webpack: 5.97.1 - webpack-node-externals: 3.0.0 - transitivePeerDependencies: - - esbuild - - uglify-js - - webpack-cli + '@smithy/types': 4.14.0 + tslib: 2.8.1 - '@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@smithy/util-retry@4.3.1': dependencies: - file-type: 20.4.1 - iterare: 1.2.1 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 + '@smithy/service-error-classification': 4.2.13 + '@smithy/types': 4.14.0 tslib: 2.8.1 - uid: 2.0.2 - transitivePeerDependencies: - - supports-color - '@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@smithy/util-stream@4.5.22': dependencies: - '@nestjs/common': 10.4.22(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 + '@smithy/fetch-http-handler': 5.3.16 + '@smithy/node-http-handler': 4.5.2 + '@smithy/types': 4.14.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - uid: 2.0.2 - optionalDependencies: - '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) - transitivePeerDependencies: - - encoding - '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': + '@smithy/util-uri-escape@4.2.2': dependencies: - '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) - body-parser: 1.20.4 - cors: 2.8.5 - express: 4.22.1 - multer: 2.0.2 tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)': + '@smithy/util-utf8@2.3.0': dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) - comment-json: 4.2.5 - jsonc-parser: 3.3.1 - pluralize: 8.0.0 - typescript: 5.7.2 - transitivePeerDependencies: - - chokidar + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 - '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.9.3)': + '@smithy/util-utf8@4.2.2': dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) - comment-json: 4.2.5 - jsonc-parser: 3.3.1 - pluralize: 8.0.0 - typescript: 5.9.3 - transitivePeerDependencies: - - chokidar + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 - '@nestjs/testing@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)': + '@smithy/util-waiter@4.2.15': dependencies: - '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@smithy/types': 4.14.0 tslib: 2.8.1 - optionalDependencies: - '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) - '@noble/hashes@1.8.0': {} - - '@nodelib/fs.scandir@2.1.5': + '@smithy/uuid@1.1.2': dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} + tslib: 2.8.1 - '@nodelib/fs.walk@1.2.8': + '@so-ric/colorspace@1.1.6': dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + color: 5.0.3 + text-hex: 1.0.0 - '@nuxtjs/opencollective@0.3.2': - dependencies: - chalk: 4.1.2 - consola: 2.15.3 - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding + '@standard-schema/spec@1.1.0': {} - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 + '@swc/core-darwin-arm64@1.15.24': + optional: true - '@pkgjs/parseargs@0.11.0': + '@swc/core-darwin-x64@1.15.24': optional: true - '@pkgr/core@0.2.9': {} + '@swc/core-linux-arm-gnueabihf@1.15.24': + optional: true - '@sinclair/typebox@0.27.10': {} + '@swc/core-linux-arm64-gnu@1.15.24': + optional: true - '@sinonjs/commons@3.0.1': - dependencies: - type-detect: 4.0.8 + '@swc/core-linux-arm64-musl@1.15.24': + optional: true - '@sinonjs/fake-timers@10.3.0': - dependencies: - '@sinonjs/commons': 3.0.1 + '@swc/core-linux-ppc64-gnu@1.15.24': + optional: true - '@tokenizer/inflate@0.2.7': - dependencies: - debug: 4.4.3 - fflate: 0.8.2 - token-types: 6.1.2 - transitivePeerDependencies: - - supports-color + '@swc/core-linux-s390x-gnu@1.15.24': + optional: true - '@tokenizer/token@0.3.0': {} + '@swc/core-linux-x64-gnu@1.15.24': + optional: true - '@tsconfig/node10@1.0.12': {} + '@swc/core-linux-x64-musl@1.15.24': + optional: true - '@tsconfig/node12@1.0.11': {} + '@swc/core-win32-arm64-msvc@1.15.24': + optional: true - '@tsconfig/node14@1.0.3': {} + '@swc/core-win32-ia32-msvc@1.15.24': + optional: true - '@tsconfig/node16@1.0.4': {} + '@swc/core-win32-x64-msvc@1.15.24': + optional: true - '@types/babel__core@7.20.5': + '@swc/core@1.15.24': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 + '@swc/counter': 0.1.3 + '@swc/types': 0.1.26 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.24 + '@swc/core-darwin-x64': 1.15.24 + '@swc/core-linux-arm-gnueabihf': 1.15.24 + '@swc/core-linux-arm64-gnu': 1.15.24 + '@swc/core-linux-arm64-musl': 1.15.24 + '@swc/core-linux-ppc64-gnu': 1.15.24 + '@swc/core-linux-s390x-gnu': 1.15.24 + '@swc/core-linux-x64-gnu': 1.15.24 + '@swc/core-linux-x64-musl': 1.15.24 + '@swc/core-win32-arm64-msvc': 1.15.24 + '@swc/core-win32-ia32-msvc': 1.15.24 + '@swc/core-win32-x64-msvc': 1.15.24 + optional: true + + '@swc/counter@0.1.3': + optional: true - '@types/babel__generator@7.27.0': + '@swc/types@0.1.26': dependencies: - '@babel/types': 7.29.0 + '@swc/counter': 0.1.3 + optional: true - '@types/babel__template@7.4.4': + '@tokenizer/inflate@0.4.1': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} - '@types/babel__traverse@7.28.0': + '@tybys/wasm-util@0.10.2': dependencies: - '@babel/types': 7.29.0 + tslib: 2.8.1 + optional: true '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 '@types/node': 20.19.39 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 20.19.39 - '@types/cookiejar@2.1.5': {} + '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': dependencies: @@ -3485,181 +6758,254 @@ snapshots: '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.8': {} - '@types/express-serve-static-core@4.19.8': + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 20.19.39 + '@types/qs': 6.15.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': dependencies: + '@types/ms': 2.1.0 '@types/node': 20.19.39 - '@types/qs': 6.15.0 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - '@types/express@4.17.25': + '@types/ms@2.1.0': {} + + '@types/node@20.19.39': dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 4.19.8 - '@types/qs': 6.15.0 - '@types/serve-static': 1.15.10 + undici-types: 6.21.0 - '@types/graceful-fs@4.1.9': + '@types/node@20.19.43': dependencies: - '@types/node': 20.19.39 + undici-types: 6.21.0 + optional: true - '@types/http-errors@2.0.5': {} + '@types/nodemailer@8.0.0': + dependencies: + '@types/node': 20.19.39 - '@types/istanbul-lib-coverage@2.0.6': {} + '@types/oauth@0.9.6': + dependencies: + '@types/node': 20.19.39 - '@types/istanbul-lib-report@3.0.3': + '@types/passport-github@1.1.13': dependencies: - '@types/istanbul-lib-coverage': 2.0.6 + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + '@types/passport-oauth2': 1.8.0 - '@types/istanbul-reports@3.0.4': + '@types/passport-google-oauth20@2.0.17': dependencies: - '@types/istanbul-lib-report': 3.0.3 + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + '@types/passport-oauth2': 1.8.0 - '@types/jest@29.5.14': + '@types/passport-jwt@4.0.1': dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 - '@types/json-schema@7.0.15': {} + '@types/passport-oauth2@1.8.0': + dependencies: + '@types/express': 5.0.6 + '@types/oauth': 0.9.6 + '@types/passport': 1.0.17 - '@types/methods@1.1.4': {} + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 - '@types/mime@1.3.5': {} + '@types/passport@1.0.17': + dependencies: + '@types/express': 5.0.6 - '@types/node@20.19.39': + '@types/pg@8.20.0': dependencies: - undici-types: 6.21.0 + '@types/node': 20.19.43 + pg-protocol: 1.14.0 + pg-types: 2.2.0 + optional: true '@types/qs@6.15.0': {} '@types/range-parser@1.2.7': {} - '@types/semver@7.7.1': {} - - '@types/send@0.17.6': - dependencies: - '@types/mime': 1.3.5 - '@types/node': 20.19.39 - '@types/send@1.2.1': dependencies: '@types/node': 20.19.39 - '@types/serve-static@1.15.10': + '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 '@types/node': 20.19.39 - '@types/send': 0.17.6 - - '@types/stack-utils@2.0.3': {} - - '@types/superagent@8.1.9': - dependencies: - '@types/cookiejar': 2.1.5 - '@types/methods': 1.1.4 - '@types/node': 20.19.39 - form-data: 4.0.5 - '@types/supertest@6.0.3': - dependencies: - '@types/methods': 1.1.4 - '@types/superagent': 8.1.9 + '@types/triple-beam@1.3.5': {} - '@types/yargs-parser@21.0.3': {} + '@types/ua-parser-js@0.7.39': {} - '@types/yargs@17.0.35': + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@types/yargs-parser': 21.0.3 + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.0 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 6.21.0 + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.0 debug: 4.4.3 - eslint: 8.57.1 - graphemer: 1.4.0 - ignore: 5.3.2 - natural-compare: 1.4.0 - semver: 7.7.4 - ts-api-utils: 1.4.3(typescript@5.9.3) - optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/project-service@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 6.21.0 + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 debug: 4.4.3 - eslint: 8.57.1 - optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@6.21.0': + '@typescript-eslint/scope-manager@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 + + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/visitor-keys': 6.21.0 + typescript: 5.9.3 - '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) - '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 8.57.1 - ts-api-utils: 1.4.3(typescript@5.9.3) - optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@6.21.0': {} + '@typescript-eslint/types@8.61.0': {} - '@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/visitor-keys': 6.21.0 + '@typescript-eslint/project-service': 8.61.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 debug: 4.4.3 - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.3 + minimatch: 10.2.5 semver: 7.7.4 - ts-api-utils: 1.4.3(typescript@5.9.3) - optionalDependencies: + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) - '@types/json-schema': 7.0.15 - '@types/semver': 7.7.1 - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) - eslint: 8.57.1 - semver: 7.7.4 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - - typescript - '@typescript-eslint/visitor-keys@6.21.0': + '@typescript-eslint/visitor-keys@8.61.0': dependencies: - '@typescript-eslint/types': 6.21.0 - eslint-visitor-keys: 3.4.3 + '@typescript-eslint/types': 8.61.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/coverage-v8@4.1.4(vitest@4.1.4)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.4 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.0.0 + tinyrainbow: 3.1.0 + vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + + '@vitest/expect@4.1.4': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + + '@vitest/pretty-format@4.1.4': + dependencies: + tinyrainbow: 3.1.0 - '@ungap/structured-clone@1.3.0': {} + '@vitest/runner@4.1.4': + dependencies: + '@vitest/utils': 4.1.4 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + '@vitest/utils': 4.1.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.4': {} + + '@vitest/utils@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 '@webassemblyjs/ast@1.14.1': dependencies: @@ -3737,30 +7083,36 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@willsoto/nestjs-prometheus@6.1.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(prom-client@15.1.3)': + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + prom-client: 15.1.3 + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} - accepts@1.3.8: + abort-controller@3.0.0: dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 + event-target-shim: 5.0.1 - acorn-jsx@5.3.2(acorn@8.16.0): + abstract-logging@2.0.1: {} + + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: acorn: 8.16.0 - acorn-walk@8.3.5: + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 acorn@8.16.0: {} - ajv-formats@2.1.1(ajv@8.12.0): + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: - ajv: 8.12.0 + ajv: 8.18.0 - ajv-formats@2.1.1(ajv@8.18.0): + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -3780,13 +7132,6 @@ snapshots: 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.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -3794,11 +7139,16 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + optional: true + ansi-colors@4.1.3: {} - ansi-escapes@4.3.2: + ansi-escapes@7.3.0: dependencies: - type-fest: 0.21.3 + environment: 1.1.0 ansi-regex@5.0.1: {} @@ -3808,97 +7158,65 @@ snapshots: dependencies: color-convert: 2.0.1 - ansi-styles@5.2.0: {} - ansi-styles@6.2.3: {} - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - - append-field@1.0.0: {} + ansis@4.2.0: {} - arg@4.1.3: {} + are-docs-informative@0.0.2: {} - argparse@1.0.10: + argon2@0.44.0: dependencies: - sprintf-js: 1.0.3 + '@phc/format': 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 argparse@2.0.1: {} - array-flatten@1.1.1: {} + array-ify@1.0.0: {} array-timsort@1.0.3: {} - array-union@2.1.0: {} + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 - asap@2.0.6: {} + async@3.2.6: {} asynckit@0.4.0: {} - babel-jest@29.7.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@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.29.0) - chalk: 4.1.2 - graceful-fs: 4.2.11 - slash: 3.0.0 - transitivePeerDependencies: - - supports-color + atomic-sleep@1.0.0: {} - babel-plugin-istanbul@6.1.1: + avvio@9.2.0: dependencies: - '@babel/helper-plugin-utils': 7.28.6 - '@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 + '@fastify/error': 4.2.0 + fastq: 1.20.1 - babel-plugin-jest-hoist@29.6.3: + axios@1.16.0: dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - '@types/babel__core': 7.20.5 - '@types/babel__traverse': 7.28.0 - - babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) - - babel-preset-jest@29.6.3(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0 - babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: {} + base64url@3.0.1: {} + baseline-browser-mapping@2.10.17: {} - binary-extensions@2.3.0: {} + bignumber.js@9.3.1: {} + + bintrees@1.0.2: {} bl@4.1.0: dependencies: @@ -3906,31 +7224,28 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@1.20.4: + bowser@2.14.1: {} + + boxen@5.1.2: 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.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.14.2 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + optional: true brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.3: + brace-expansion@5.0.5: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -3944,13 +7259,7 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) - bs-logger@0.2.6: - dependencies: - fast-json-stable-stringify: 2.1.0 - - bser@2.1.1: - dependencies: - node-int64: 0.4.0 + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -3959,9 +7268,26 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - busboy@1.6.0: + buffer@6.0.3: dependencies: - streamsearch: 1.1.0 + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-modules@3.3.0: {} + + builtin-modules@5.2.0: {} + + bullmq@5.73.4: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.10.1 + msgpackr: 1.11.5 + node-abort-controller: 3.1.1 + semver: 7.7.4 + tslib: 2.8.1 + uuid: 11.1.0 + transitivePeerDependencies: + - supports-color bytes@3.1.2: {} @@ -3970,59 +7296,46 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.9: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - callsites@3.1.0: {} - camelcase@5.3.1: {} - - camelcase@6.3.0: {} + camelcase@6.3.0: + optional: true caniuse-lite@1.0.30001787: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - chalk@5.6.2: {} + change-case@5.4.4: {} - char-regex@1.0.2: {} + chardet@2.1.1: {} - chardet@0.7.0: {} + check-disk-space@3.4.0: + optional: true - chokidar@3.6.0: + chokidar@4.0.3: 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 + readdirp: 4.1.2 chrome-trace-event@1.0.4: {} - ci-info@3.9.0: {} + ci-info@4.4.0: {} - cjs-module-lexer@1.4.3: {} + cli-boxes@2.2.1: + optional: true 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: @@ -4031,7 +7344,10 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 - cli-width@3.0.0: {} + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.0 cli-width@4.1.0: {} @@ -4043,91 +7359,115 @@ snapshots: clone@1.0.4: {} - co@4.6.0: {} - - collect-v8-coverage@1.0.3: {} + cluster-key-slot@1.1.2: {} color-convert@2.0.1: dependencies: color-name: 1.1.4 + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + color-name@1.1.4: {} + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + + colorette@2.0.20: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 + commander@14.0.3: {} + commander@2.20.3: {} commander@4.1.1: {} - comment-json@4.2.5: + comment-json@4.6.2: 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 - component-emitter@1.3.1: {} + comment-parser@1.4.7: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 concat-map@0.0.1: {} - concat-stream@2.0.0: - dependencies: - buffer-from: 1.1.2 - inherits: 2.0.4 - readable-stream: 3.6.2 - typedarray: 0.0.6 + consola@3.4.2: {} - consola@2.15.3: {} + content-disposition@1.1.0: {} - content-disposition@0.5.4: + conventional-changelog-angular@8.3.1: dependencies: - safe-buffer: 5.2.1 + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 - content-type@1.0.5: {} + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 convert-source-map@2.0.0: {} - cookie-signature@1.0.7: {} + cookie@1.1.1: {} - cookie@0.7.2: {} - - cookiejar@2.1.4: {} + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 core-util-is@1.0.3: {} - cors@2.8.5: + cosmiconfig-typescript-loader@6.3.0(@types/node@20.19.39)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): dependencies: - object-assign: 4.1.1 - vary: 1.1.2 + '@types/node': 20.19.39 + cosmiconfig: 9.0.1(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 - cosmiconfig@8.3.6(typescript@5.7.2): + cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 js-yaml: 4.1.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: - typescript: 5.7.2 + typescript: 5.9.3 - create-jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)): + cosmiconfig@9.0.1(typescript@5.9.3): 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@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 - create-require@1.1.1: {} + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 cross-spawn@7.0.6: dependencies: @@ -4135,54 +7475,61 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - debug@2.6.9: - dependencies: - ms: 2.0.0 - debug@4.4.3: dependencies: ms: 2.1.3 - dedent@1.7.2: {} - deep-is@0.1.4: {} + deepmerge-ts@7.1.5: {} + 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.1 - es-errors: 1.3.0 - gopd: 1.2.0 - delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} - destroy@1.2.0: {} + dequal@2.0.3: {} - detect-newline@3.1.0: {} + detect-europe-js@0.1.2: {} - dezalgo@1.0.4: - dependencies: - asap: 2.0.6 - wrappy: 1.0.2 + detect-indent@7.0.2: {} - diff-sequences@29.6.3: {} + detect-libc@2.1.2: {} - diff@4.0.4: {} + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 - dir-glob@3.0.1: + dotenv-expand@12.0.3: dependencies: - path-type: 4.0.0 + dotenv: 16.6.1 - doctrine@3.0.0: + dotenv@16.6.1: {} + + dotenv@17.4.1: {} + + dotenv@17.4.2: {} + + drizzle-kit@0.31.10: dependencies: - esutils: 2.0.3 + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.21.0 + + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0)(postgres@3.4.9): + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/pg': 8.20.0 + pg: 8.20.0 + postgres: 3.4.9 dunder-proto@1.0.1: dependencies: @@ -4190,25 +7537,47 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} + duplexify@3.7.1: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 2.3.8 + stream-shift: 1.0.3 + + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 - ee-first@1.1.1: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 electron-to-chromium@1.5.334: {} - emittery@0.13.1: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} + enabled@2.0.0: {} - encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 tapable: 2.3.2 + env-paths@2.2.1: {} + + environment@1.1.0: {} + + error-causes@3.0.2: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -4217,7 +7586,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} es-object-atoms@1.1.1: dependencies: @@ -4228,92 +7597,240 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 escalade@3.2.0: {} escape-html@1.0.3: {} - escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-plugin-functional@10.0.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + deepmerge-ts: 7.1.5 + escape-string-regexp: 5.0.0 + eslint: 10.5.0(jiti@2.6.1) + is-immutable-type: 5.0.4(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + ts-api-utils: 2.5.0(typescript@5.9.3) + ts-declaration-location: 1.0.7(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + eslint-plugin-jsdoc@63.0.2(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.87.0 + '@es-joy/resolve.exports': 1.2.0 + are-docs-informative: 0.0.2 + comment-parser: 1.4.7 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.6.1) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.1 + parse-imports-exports: 0.2.4 + semver: 7.8.4 + spdx-expression-parse: 4.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color - escape-string-regexp@2.0.0: {} + eslint-plugin-perfectionist@5.9.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + natural-orderby: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript - escape-string-regexp@4.0.0: {} + eslint-plugin-security@4.0.1: + dependencies: + safe-regex: 2.1.1 - eslint-config-prettier@9.1.2(eslint@8.57.1): + eslint-plugin-sonarjs@4.0.3(eslint@10.5.0(jiti@2.6.1)): dependencies: - eslint: 8.57.1 + '@eslint-community/regexpp': 4.12.2 + builtin-modules: 3.3.0 + bytes: 3.1.2 + eslint: 10.5.0(jiti@2.6.1) + functional-red-black-tree: 1.0.1 + globals: 17.6.0 + jsx-ast-utils-x: 0.1.0 + lodash.merge: 4.6.2 + minimatch: 10.2.5 + scslre: 0.3.0 + semver: 7.7.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 - eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.8.2): + eslint-plugin-unicorn@66.0.0(eslint@10.5.0(jiti@2.6.1)): dependencies: - eslint: 8.57.1 - prettier: 3.8.2 - prettier-linter-helpers: 1.0.1 - synckit: 0.11.12 - optionalDependencies: - '@types/eslint': 9.6.1 - eslint-config-prettier: 9.1.2(eslint@8.57.1) + '@babel/helper-validator-identifier': 7.29.7 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + browserslist: 4.28.2 + change-case: 5.4.4 + ci-info: 4.4.0 + core-js-compat: 3.49.0 + detect-indent: 7.0.2 + eslint: 10.5.0(jiti@2.6.1) + find-up-simple: 1.0.1 + globals: 17.6.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regjsparser: 0.13.2 + semver: 7.8.4 + strip-indent: 4.1.1 eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 estraverse: 4.3.0 - eslint-scope@7.2.2: + eslint-scope@9.1.2: dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 esrecurse: 4.3.0 estraverse: 5.3.0 eslint-visitor-keys@3.4.3: {} - eslint@8.57.1: + eslint-visitor-keys@5.0.1: {} + + eslint@10.5.0(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.1 - '@humanwhocodes/config-array': 0.13.0 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.3.0 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 ajv: 6.14.0 - chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 - doctrine: 3.0.0 escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 + file-entry-cache: 8.0.0 find-up: 5.0.0 glob-parent: 6.0.2 - globals: 13.24.0 - graphemer: 1.4.0 ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.1 json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 + minimatch: 10.2.5 natural-compare: 1.4.0 optionator: 0.9.4 - strip-ansi: 6.0.1 - text-table: 0.2.0 + optionalDependencies: + jiti: 2.6.1 transitivePeerDependencies: - supports-color - espree@9.6.1: + espree@11.2.0: dependencies: acorn: 8.16.0 acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 3.4.3 + eslint-visitor-keys: 5.0.1 esprima@4.0.1: {} @@ -4329,117 +7846,94 @@ snapshots: estraverse@5.3.0: {} - esutils@2.0.3: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 - etag@1.8.1: {} + esutils@2.0.3: {} - events@3.3.0: {} + event-target-shim@5.0.1: {} - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - 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 + eventemitter2@6.4.9: {} - exit@0.1.2: {} + eventemitter3@5.0.4: {} - 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 + events@3.3.0: {} - express@4.22.1: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.4 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.2 - fresh: 0.5.2 - http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.13 - proxy-addr: 2.0.7 - qs: 6.14.2 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 - statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color + expect-type@1.3.0: {} - external-editor@3.1.0: - dependencies: - chardet: 0.7.0 - iconv-lite: 0.4.24 - tmp: 0.0.33 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} - fast-diff@1.3.0: {} + fast-json-stable-stringify@2.1.0: {} - fast-glob@3.3.3: + fast-json-stringify@6.3.0: 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: {} + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-safe-stringify@2.1.1: {} fast-uri@3.1.0: {} - fastq@1.20.1: + fast-xml-builder@1.1.4: dependencies: - reusify: 1.1.0 + path-expression-matcher: 1.5.0 - fb-watchman@2.0.2: + fast-xml-parser@5.5.8: dependencies: - bser: 2.1.1 + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.5.0 + strnum: 2.2.3 - fflate@0.8.2: {} + fastify-plugin@5.1.0: {} - figures@3.2.0: + fastify@5.8.4: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.5.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.0 + + fastq@1.20.1: dependencies: - escape-string-regexp: 1.0.5 + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fecha@4.2.3: {} - file-entry-cache@6.0.1: + file-entry-cache@8.0.0: dependencies: - flat-cache: 3.2.0 + flat-cache: 4.0.1 - file-type@20.4.1: + file-type@21.3.4: dependencies: - '@tokenizer/inflate': 0.2.7 + '@tokenizer/inflate': 0.4.1 strtok3: 10.3.5 token-types: 6.1.2 uint8array-extras: 1.5.0 @@ -4450,47 +7944,36 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.2: + find-my-way@9.5.0: 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.2 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.0 - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 + find-up-simple@1.0.1: {} find-up@5.0.0: dependencies: locate-path: 6.0.0 path-exists: 4.0.0 - flat-cache@3.2.0: + flat-cache@4.0.1: dependencies: flatted: 3.4.2 keyv: 4.5.4 - rimraf: 3.0.2 flatted@3.4.2: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 + fn.name@1.1.0: {} - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1): + follow-redirects@1.16.0: {} + + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)): dependencies: '@babel/code-frame': 7.29.0 chalk: 4.1.2 - chokidar: 3.6.0 - cosmiconfig: 8.3.6(typescript@5.7.2) + chokidar: 4.0.3 + cosmiconfig: 8.3.6(typescript@5.9.3) deepmerge: 4.3.1 fs-extra: 10.1.0 memfs: 3.5.3 @@ -4499,28 +7982,17 @@ snapshots: schema-utils: 3.3.0 semver: 7.7.4 tapable: 2.3.2 - typescript: 5.7.2 - webpack: 5.97.1 + typescript: 5.9.3 + webpack: 5.106.0(@swc/core@1.15.24)(esbuild@0.27.7) form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.3 mime-types: 2.1.35 - formidable@2.1.5: - dependencies: - '@paralleldrive/cuid2': 2.3.1 - dezalgo: 1.0.4 - once: 1.4.0 - qs: 6.15.1 - - forwarded@0.2.0: {} - - fresh@0.5.2: {} - fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -4529,17 +8001,17 @@ snapshots: fs-monkey@1.1.0: {} - fs.realpath@1.0.0: {} - fsevents@2.3.3: optional: true function-bind@1.1.2: {} - gensync@1.0.0-beta.2: {} + functional-red-black-tree@1.0.1: {} get-caller-file@2.0.5: {} + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4550,21 +8022,25 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 math-intrinsics: 1.1.0 - get-package-type@0.1.0: {} - get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-stream@6.0.1: {} + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 - glob-parent@5.1.2: + git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): dependencies: - is-glob: 4.0.3 + '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser glob-parent@6.0.2: dependencies: @@ -4572,43 +8048,22 @@ snapshots: glob-to-regexp@0.4.1: {} - glob@10.4.5: + glob@13.0.6: dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 + minimatch: 10.2.5 minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 + path-scurry: 2.0.2 - globals@13.24.0: + global-directory@4.0.1: dependencies: - type-fest: 0.20.2 + ini: 4.1.1 - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 + globals@17.6.0: {} gopd@1.2.0: {} graceful-fs@4.2.11: {} - graphemer@1.4.0: {} - handlebars@4.7.9: dependencies: minimist: 1.2.8 @@ -4620,469 +8075,131 @@ snapshots: has-flag@4.0.0: {} - has-own-prop@2.0.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - has-symbols@1.1.0: {} has-tostringtag@1.0.2: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - html-escaper@2.0.2: {} - - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - human-signals@2.1.0: {} - - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - - ieee754@1.2.1: {} - - ignore@5.3.2: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - import-local@3.2.0: - dependencies: - pkg-dir: 4.2.0 - resolve-cwd: 3.0.0 - - imurmurhash@0.1.4: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - inquirer@8.2.6: - dependencies: - ansi-escapes: 4.3.2 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-width: 3.0.0 - external-editor: 3.1.0 - figures: 3.2.0 - lodash: 4.18.1 - mute-stream: 0.0.8 - ora: 5.4.1 - run-async: 2.4.1 - rxjs: 7.8.2 - string-width: 4.2.3 - strip-ansi: 6.0.1 - through: 2.3.8 - wrap-ansi: 6.2.0 - - inquirer@9.2.15: - dependencies: - '@ljharb/through': 2.3.14 - ansi-escapes: 4.3.2 - chalk: 5.6.2 - cli-cursor: 3.1.0 - cli-width: 4.1.0 - external-editor: 3.1.0 - figures: 3.2.0 - lodash: 4.18.1 - mute-stream: 1.0.0 - ora: 5.4.1 - run-async: 3.0.0 - rxjs: 7.8.2 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - - ipaddr.js@1.9.1: {} - - is-arrayish@0.2.1: {} - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.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-path-inside@3.0.3: {} - - is-stream@2.0.1: {} - - is-unicode-supported@0.1.0: {} - - isexe@2.0.0: {} - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-instrument@5.2.1: - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 - '@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.29.0 - '@babel/parser': 7.29.2 - '@istanbuljs/schema': 0.1.3 - istanbul-lib-coverage: 3.2.2 - semver: 7.7.4 - 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.3 - istanbul-lib-coverage: 3.2.2 - source-map: 0.6.1 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - iterare@1.2.1: {} - - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - - 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': 20.19.39 - chalk: 4.1.2 - co: 4.6.0 - dedent: 1.7.2 - 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@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - 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@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)): - dependencies: - '@babel/core': 7.29.0 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) - 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.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.39 - ts-node: 10.9.2(@types/node@20.19.39)(typescript@5.9.3) - 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: + hasown@2.0.3: dependencies: - detect-newline: 3.1.0 + function-bind: 1.1.2 - 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 + html-entities@2.6.0: {} + + html-escaper@2.0.2: {} - jest-environment-node@29.7.0: + http-errors@2.0.1: dependencies: - '@jest/environment': 29.7.0 - '@jest/fake-timers': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - jest-mock: 29.7.0 - jest-util: 29.7.0 + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 - jest-get-type@29.6.3: {} + husky@9.1.7: {} - jest-haste-map@29.7.0: + iconv-lite@0.7.2: dependencies: - '@jest/types': 29.6.3 - '@types/graceful-fs': 4.1.9 - '@types/node': 20.19.39 - 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.8 - walker: 1.0.8 - optionalDependencies: - fsevents: 2.3.3 + safer-buffer: 2.1.2 - jest-leak-detector@29.7.0: - dependencies: - jest-get-type: 29.6.3 - pretty-format: 29.7.0 + ieee754@1.2.1: {} - 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 + ignore@5.3.2: {} - jest-message-util@29.7.0: - dependencies: - '@babel/code-frame': 7.29.0 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 + ignore@7.0.5: {} - jest-mock@29.7.0: + import-fresh@3.3.1: dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - jest-util: 29.7.0 + parent-module: 1.0.1 + resolve-from: 4.0.0 - jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): - optionalDependencies: - jest-resolve: 29.7.0 + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + indent-string@5.0.0: {} - jest-regex-util@29.6.3: {} + inherits@2.0.4: {} + + ini@4.1.1: {} - jest-resolve-dependencies@29.7.0: + ioredis@5.10.1: dependencies: - jest-regex-util: 29.6.3 - jest-snapshot: 29.7.0 + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + 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 - jest-resolve@29.7.0: + ipaddr.js@2.3.0: {} + + is-arrayish@0.2.1: {} + + is-builtin-module@5.0.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.11 - resolve.exports: 2.0.3 - 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': 20.19.39 - 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 + builtin-modules: 5.2.0 + + is-extglob@2.1.1: {} - jest-runtime@29.7.0: + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.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': 20.19.39 - chalk: 4.1.2 - cjs-module-lexer: 1.4.3 - collect-v8-coverage: 1.0.3 - 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 + get-east-asian-width: 1.5.0 - jest-snapshot@29.7.0: + is-glob@4.0.3: dependencies: - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - '@babel/types': 7.29.0 - '@jest/expect-utils': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) - 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.4 + is-extglob: 2.1.1 + + is-immutable-type@5.0.4(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/type-utils': 8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + ts-declaration-location: 1.0.7(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - jest-util@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 2.3.2 + is-interactive@1.0.0: {} + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-plain-obj@4.1.0: {} + + is-standalone-pwa@0.1.1: {} - jest-validate@29.7.0: + is-stream@2.0.1: {} + + is-unicode-supported@0.1.0: {} + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: 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 + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 - jest-watcher@29.7.0: + istanbul-reports@3.2.0: dependencies: - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.39 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - emittery: 0.13.1 - jest-util: 29.7.0 - string-length: 4.0.2 + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterare@1.2.1: {} jest-worker@27.5.1: dependencies: @@ -5090,42 +8207,28 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest-worker@29.7.0: - dependencies: - '@types/node': 20.19.39 - jest-util: 29.7.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 + jiti@2.6.1: {} - jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node + js-tokens@10.0.0: {} js-tokens@4.0.0: {} - js-yaml@3.14.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - js-yaml@4.1.1: dependencies: argparse: 2.0.1 + jsdoc-type-pratt-parser@7.2.0: {} + jsesc@3.1.0: {} json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -5134,8 +8237,6 @@ snapshots: json5@2.2.3: {} - jsonc-parser@3.2.1: {} - jsonc-parser@3.3.1: {} jsonfile@6.2.0: @@ -5144,35 +8245,158 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + 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.7.4 + + jsx-ast-utils-x@0.1.0: {} + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 - kleur@3.0.3: {} - - leven@3.1.0: {} + kuler@2.0.0: {} levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lines-and-columns@1.2.4: {} - loader-runner@4.3.1: {} + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.1.1 + yaml: 2.8.3 - locate-path@5.0.0: + listr2@9.0.5: dependencies: - p-locate: 4.1.0 + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + load-esm@1.0.3: {} + + loader-runner@4.3.1: {} locate-path@6.0.0: dependencies: p-locate: 5.0.0 - lodash.memoize@4.1.2: {} + lodash.camelcase@4.3.0: {} + + lodash.defaults@4.2.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.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.upperfirst@4.3.1: {} + lodash@4.18.1: {} log-symbols@4.1.0: @@ -5180,42 +8404,55 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 - lru-cache@10.4.3: {} + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 - lru-cache@5.1.1: + logform@2.7.0: dependencies: - yallist: 3.1.1 + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + + lru-cache@11.3.3: {} + + luxon@3.7.2: {} - magic-string@0.30.8: + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - make-dir@4.0.0: + magic-string@0.30.21: dependencies: - semver: 7.7.4 + '@jridgewell/sourcemap-codec': 1.5.5 - make-error@1.3.6: {} + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 - makeerror@1.0.12: + make-dir@4.0.0: dependencies: - tmpl: 1.0.5 + semver: 7.7.4 math-intrinsics@1.1.0: {} - media-typer@0.3.0: {} - memfs@3.5.3: dependencies: fs-monkey: 1.1.0 - merge-descriptors@1.0.3: {} + meow@13.2.0: {} merge-stream@2.0.0: {} - merge2@1.4.1: {} - - methods@1.1.2: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -5227,92 +8464,110 @@ snapshots: dependencies: mime-db: 1.52.0 - mime@1.6.0: {} - - mime@2.6.0: {} + mime@3.0.0: {} mimic-fn@2.1.0: {} - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.13 + mimic-function@5.0.1: {} - minimatch@9.0.3: + minimatch@10.2.5: dependencies: - brace-expansion: 2.0.3 + brace-expansion: 5.0.5 - minimatch@9.0.9: + minimatch@3.1.5: dependencies: - brace-expansion: 2.0.3 + brace-expansion: 1.1.13 minimist@1.2.8: {} minipass@7.1.3: {} - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - - ms@2.0.0: {} - ms@2.1.3: {} - multer@2.0.2: + msgpackr-extract@3.0.3: dependencies: - append-field: 1.0.0 - busboy: 1.6.0 - concat-stream: 2.0.0 - mkdirp: 0.5.6 - object-assign: 4.1.1 - type-is: 1.6.18 - xtend: 4.0.2 + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true - mute-stream@0.0.8: {} + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + + mute-stream@2.0.0: {} - mute-stream@1.0.0: {} + nanoid@3.3.12: {} natural-compare@1.4.0: {} - negotiator@0.6.3: {} + natural-orderby@5.0.0: {} neo-async@2.6.2: {} + nest-winston@1.10.2(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(winston@3.19.0): + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-safe-stringify: 2.1.1 + winston: 3.19.0 + + nestjs-zod@5.3.0(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6): + dependencies: + '@nestjs/common': 11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2) + deepmerge: 4.3.1 + rxjs: 7.8.2 + zod: 4.3.6 + optionalDependencies: + '@nestjs/swagger': 11.2.7(@fastify/static@9.1.0)(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + node-abort-controller@3.1.1: {} + node-addon-api@8.7.0: {} + node-emoji@1.11.0: dependencies: lodash: 4.18.1 - node-fetch@2.7.0: + node-gyp-build-optional-packages@5.2.2: dependencies: - whatwg-url: 5.0.0 + detect-libc: 2.1.2 + optional: true - node-int64@0.4.0: {} + node-gyp-build@4.8.4: {} node-releases@2.0.37: {} - normalize-path@3.0.0: {} + nodemailer@8.0.5: {} - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 + oauth@0.10.2: {} - object-assign@4.1.1: {} + object-deep-merge@2.0.1: {} - object-inspect@1.13.4: {} + obug@2.1.1: {} - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 + on-exit-leak-free@2.1.2: {} once@1.4.0: dependencies: wrappy: 1.0.2 + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5334,122 +8589,229 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - os-tmpdir@1.0.2: {} + otplib@13.4.0: + dependencies: + '@otplib/core': 13.4.0 + '@otplib/hotp': 13.4.0 + '@otplib/plugin-base32-scure': 13.4.0 + '@otplib/plugin-crypto-noble': 13.4.0 + '@otplib/totp': 13.4.0 + '@otplib/uri': 13.4.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-statements@1.0.11: {} + + passport-github@1.1.0: + dependencies: + passport-oauth2: 1.8.0 + + passport-google-oauth20@2.0.0: + dependencies: + passport-oauth2: 1.8.0 + + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.3 + passport-strategy: 1.0.0 + + passport-oauth2@1.8.0: + dependencies: + base64url: 3.0.1 + oauth: 0.10.2 + passport-strategy: 1.0.0 + uid2: 0.0.4 + utils-merge: 1.0.1 + + passport-strategy@1.0.0: {} - p-limit@2.3.0: + passport@0.7.0: dependencies: - p-try: 2.2.0 + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 + path-exists@4.0.0: {} - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 + path-expression-matcher@1.5.0: {} - p-locate@5.0.0: + path-key@3.1.1: {} + + path-scurry@2.0.2: dependencies: - p-limit: 3.1.0 + lru-cache: 11.3.3 + minipass: 7.1.3 - p-try@2.2.0: {} + path-to-regexp@8.4.2: {} - package-json-from-dist@1.0.1: {} + path-type@4.0.0: {} - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 + pathe@2.0.3: {} - parse-json@5.2.0: + pause@0.0.1: {} + + peek-stream@1.1.3: dependencies: - '@babel/code-frame': 7.29.0 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 + buffer-from: 1.1.2 + duplexify: 3.7.1 + through2: 2.0.5 - parseurl@1.3.3: {} + pg-cloudflare@1.4.0: + optional: true - path-exists@4.0.0: {} + pg-connection-string@2.13.0: + optional: true - path-is-absolute@1.0.1: {} + pg-int8@1.0.1: + optional: true - path-key@3.1.1: {} + pg-pool@3.14.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + optional: true - path-parse@1.0.7: {} + pg-protocol@1.14.0: + optional: true - path-scurry@1.11.1: + pg-types@2.2.0: dependencies: - lru-cache: 10.4.3 - minipass: 7.1.3 - - path-to-regexp@0.1.13: {} + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + optional: true - path-to-regexp@3.3.0: {} + pg@8.20.0: + dependencies: + pg-connection-string: 2.13.0 + pg-pool: 3.14.0(pg@8.20.0) + pg-protocol: 1.14.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + optional: true - path-type@4.0.0: {} + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + optional: true picocolors@1.1.1: {} picomatch@2.3.2: {} - picomatch@4.0.1: {} + picomatch@4.0.4: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 - pirates@4.0.7: {} + pino-std-serializers@7.1.0: {} - pkg-dir@4.2.0: + pino@10.3.1: dependencies: - find-up: 4.1.0 + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 pluralize@8.0.0: {} - prelude-ls@1.2.1: {} + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: + optional: true + + postgres-bytea@1.0.1: + optional: true - prettier-linter-helpers@1.0.1: + postgres-date@1.0.7: + optional: true + + postgres-interval@1.2.0: dependencies: - fast-diff: 1.3.0 + xtend: 4.0.2 + optional: true + + postgres@3.4.9: {} + + prelude-ls@1.2.1: {} prettier@3.8.2: {} - pretty-format@29.7.0: - dependencies: - '@jest/schemas': 29.6.3 - ansi-styles: 5.2.0 - react-is: 18.3.1 + process-nextick-args@2.0.1: {} - prompts@2.4.2: - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 + process-warning@4.0.1: {} - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 + process-warning@5.0.0: {} - punycode@2.3.1: {} + process@0.11.10: {} + + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.1 + tdigest: 0.1.2 - pure-rand@6.1.0: {} + proxy-from-env@2.1.0: {} - qs@6.14.2: + pump@3.0.4: dependencies: - side-channel: 1.1.0 + end-of-stream: 1.4.5 + once: 1.4.0 - qs@6.15.1: + pumpify@2.0.1: dependencies: - side-channel: 1.1.0 + duplexify: 4.1.3 + inherits: 2.0.4 + pump: 3.0.4 - queue-microtask@1.2.3: {} + punycode@2.3.1: {} - range-parser@1.2.1: {} + quick-format-unescaped@4.0.4: {} - raw-body@2.5.3: + readable-stream@2.3.8: dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - - react-is@18.3.1: {} + 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: @@ -5457,52 +8819,89 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 - readdirp@3.6.0: + readable-stream@4.7.0: dependencies: - picomatch: 2.3.2 + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdirp@4.1.2: {} + + real-require@0.2.0: {} + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 reflect-metadata@0.2.2: {} - repeat-string@1.6.1: {} + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + + regexp-tree@0.1.27: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 require-directory@2.1.1: {} require-from-string@2.0.2: {} - resolve-cwd@3.0.0: - dependencies: - resolve-from: 5.0.0 + reserved-identifiers@1.2.0: {} resolve-from@4.0.0: {} resolve-from@5.0.0: {} - resolve.exports@2.0.3: {} - - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 + resolve-pkg-maps@1.0.0: {} restore-cursor@3.1.0: dependencies: onetime: 5.1.2 signal-exit: 3.0.7 - reusify@1.1.0: {} - - rimraf@3.0.2: + restore-cursor@5.1.0: dependencies: - glob: 7.2.3 + onetime: 7.0.0 + signal-exit: 4.1.0 - run-async@2.4.1: {} + ret@0.5.0: {} + + reusify@1.1.0: {} - run-async@3.0.0: {} + rfdc@1.4.1: {} - run-parallel@1.2.0: + rolldown@1.0.0-rc.15: dependencies: - queue-microtask: 1.2.3 + '@oxc-project/types': 0.124.0 + '@rolldown/pluginutils': 1.0.0-rc.15 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.15 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 + '@rolldown/binding-darwin-x64': 1.0.0-rc.15 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 rxjs@7.8.1: dependencies: @@ -5512,8 +8911,20 @@ snapshots: dependencies: tslib: 2.8.1 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} + safe-regex2@5.1.0: + dependencies: + ret: 0.5.0 + + safe-regex@2.1.1: + dependencies: + regexp-tree: 0.1.27 + + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} schema-utils@3.3.0: @@ -5529,45 +8940,19 @@ snapshots: ajv-formats: 2.1.1(ajv@8.18.0) ajv-keywords: 5.1.0(ajv@8.18.0) - semver@6.3.1: {} + scslre@0.3.0: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 - semver@7.7.4: {} + secure-json-parse@4.1.0: {} - send@0.19.2: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color + semver@7.7.4: {} - serve-static@1.16.3: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2 - transitivePeerDependencies: - - supports-color + semver@7.8.4: {} - 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.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 + set-cookie-parser@2.7.2: {} setprototypeof@1.2.0: {} @@ -5577,46 +8962,29 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} signal-exit@4.1.0: {} - sisteransi@1.0.5: {} + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 - slash@3.0.0: {} + slugify@1.6.9: {} - source-map-support@0.5.13: + sonic-boom@4.2.1: dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} source-map-support@0.5.21: dependencies: @@ -5629,20 +8997,30 @@ snapshots: source-map@0.7.6: {} - sprintf-js@1.0.3: {} + spdx-exceptions@2.5.0: {} - stack-utils@2.0.6: + spdx-expression-parse@4.0.0: dependencies: - escape-string-regexp: 2.0.0 + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + split2@4.2.0: {} + + stack-trace@0.0.10: {} + + stackback@0.0.2: {} + + standard-as-callback@2.1.0: {} statuses@2.0.2: {} - streamsearch@1.1.0: {} + std-env@4.0.0: {} - string-length@4.0.2: - dependencies: - char-regex: 1.0.2 - strip-ansi: 6.0.1 + stream-shift@1.0.3: {} + + string-argv@0.3.2: {} string-width@4.2.3: dependencies: @@ -5650,12 +9028,21 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 + get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -5670,38 +9057,14 @@ snapshots: strip-bom@3.0.0: {} - strip-bom@4.0.0: {} + strip-indent@4.1.1: {} - strip-final-newline@2.0.0: {} - - strip-json-comments@3.1.1: {} + strnum@2.2.3: {} strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 - superagent@8.1.2: - dependencies: - component-emitter: 1.3.1 - cookiejar: 2.1.4 - debug: 4.4.3 - fast-safe-stringify: 2.1.1 - form-data: 4.0.5 - formidable: 2.1.5 - methods: 1.1.2 - mime: 2.6.0 - qs: 6.15.1 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - - supertest@6.3.4: - dependencies: - methods: 1.1.2 - superagent: 8.1.2 - transitivePeerDependencies: - - supports-color - supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -5710,23 +9073,28 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} + swagger-ui-dist@5.32.2: + dependencies: + '@scarf/scarf': 1.4.0 symbol-observable@4.0.0: {} - synckit@0.11.12: - dependencies: - '@pkgr/core': 0.2.9 - tapable@2.3.2: {} - terser-webpack-plugin@5.4.0(webpack@5.97.1): + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + + terser-webpack-plugin@5.4.0(@swc/core@1.15.24)(esbuild@0.27.7)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.1 - webpack: 5.97.1 + webpack: 5.106.0(@swc/core@1.15.24)(esbuild@0.27.7) + optionalDependencies: + '@swc/core': 1.15.24 + esbuild: 0.27.7 terser@5.46.1: dependencies: @@ -5735,26 +9103,47 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - test-exclude@6.0.0: + text-hex@1.0.0: {} + + thread-stream@4.0.0: dependencies: - '@istanbuljs/schema': 0.1.3 - glob: 7.2.3 - minimatch: 3.1.5 + real-require: 0.2.0 + + through2@2.0.5: + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 - text-table@0.2.0: {} + tinybench@2.9.0: {} - through@2.3.8: {} + tinyexec@1.1.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 - tmp@0.0.33: + tinyglobby@0.2.17: dependencies: - os-tmpdir: 1.0.2 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 - tmpl@1.0.5: {} + tinyrainbow@3.1.0: {} to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + + toad-cache@3.7.0: {} + + toad-cache@3.7.1: + optional: true + toidentifier@1.0.1: {} token-types@6.1.2: @@ -5763,35 +9152,18 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 - tr46@0.0.3: {} - - tree-kill@1.2.2: {} + triple-beam@1.4.1: {} - ts-api-utils@1.4.3(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 - ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)))(typescript@5.9.3): + ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.9 - jest: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.4 - type-fest: 4.41.0 + picomatch: 4.0.4 typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.29.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) - jest-util: 29.7.0 - ts-loader@9.5.7(typescript@5.9.3)(webpack@5.97.1): + ts-loader@9.5.7(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)): dependencies: chalk: 4.1.2 enhanced-resolve: 5.20.1 @@ -5799,25 +9171,7 @@ snapshots: semver: 7.7.4 source-map: 0.7.6 typescript: 5.9.3 - webpack: 5.97.1 - - ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.39 - acorn: 8.16.0 - acorn-walk: 8.3.5 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.4 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 + webpack: 5.106.0(@swc/core@1.15.24)(esbuild@0.27.7) tsconfig-paths-webpack-plugin@4.2.0: dependencies: @@ -5834,32 +9188,46 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - type-detect@4.0.8: {} - - type-fest@0.20.2: {} - - type-fest@0.21.3: {} - - type-fest@4.41.0: {} + type-fest@0.20.2: + optional: true - type-is@1.6.18: + typescript-eslint@8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color - typedarray@0.0.6: {} + typescript@5.9.3: {} - typescript@5.7.2: {} + ua-is-frozen@0.1.2: {} - typescript@5.9.3: {} + ua-parser-js@2.0.9: + dependencies: + detect-europe-js: 0.1.2 + is-standalone-pwa: 0.1.1 + ua-is-frozen: 0.1.2 uglify-js@3.19.3: optional: true + uid2@0.0.4: {} + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 @@ -5870,8 +9238,6 @@ snapshots: universalify@2.0.1: {} - unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -5886,19 +9252,52 @@ snapshots: utils-merge@1.0.1: {} - v8-compile-cache-lib@3.0.1: {} - - v8-to-istanbul@9.3.0: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@types/istanbul-lib-coverage': 2.0.6 - convert-source-map: 2.0.0 - - vary@1.1.2: {} + uuid@11.1.0: {} - walker@1.0.8: + vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: - makeerror: 1.0.12 + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.0-rc.15 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.39 + esbuild: 0.27.7 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.46.1 + tsx: 4.21.0 + yaml: 2.8.3 + + vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.4 + '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.4 + '@vitest/runner': 4.1.4 + '@vitest/snapshot': 4.1.4 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.8(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 20.19.39 + '@vitest/coverage-v8': 4.1.4(vitest@4.1.4) + transitivePeerDependencies: + - msw watchpack@2.5.1: dependencies: @@ -5909,24 +9308,24 @@ snapshots: dependencies: defaults: 1.0.4 - webidl-conversions@3.0.1: {} - webpack-node-externals@3.0.0: {} webpack-sources@3.3.4: {} - webpack@5.97.1: + webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.2 chrome-trace-event: 1.0.4 enhanced-resolve: 5.20.1 - es-module-lexer: 1.7.0 + es-module-lexer: 2.0.0 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 @@ -5935,9 +9334,9 @@ snapshots: loader-runner: 4.3.1 mime-types: 2.1.35 neo-async: 2.6.2 - schema-utils: 3.3.0 + schema-utils: 4.3.3 tapable: 2.3.2 - terser-webpack-plugin: 5.4.0(webpack@5.97.1) + terser-webpack-plugin: 5.4.0(@swc/core@1.15.24)(esbuild@0.27.7)(webpack@5.106.0(@swc/core@1.15.24)(esbuild@0.27.7)) watchpack: 2.5.1 webpack-sources: 3.3.4 transitivePeerDependencies: @@ -5945,15 +9344,40 @@ snapshots: - esbuild - uglify-js - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + optional: true + + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.19.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + word-wrap@1.2.5: {} wordwrap@1.0.0: {} @@ -5970,24 +9394,19 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@8.1.0: + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 - string-width: 5.1.2 + string-width: 7.2.0 strip-ansi: 7.2.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@5.0.8: {} - yallist@3.1.1: {} + yaml@2.8.3: {} yargs-parser@21.1.1: {} @@ -6001,6 +9420,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yn@3.1.1: {} - yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + zod@4.3.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..7d1b24d2 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - '.' + - 'infra/k6' diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts deleted file mode 100644 index d22f3890..00000000 --- a/src/app.controller.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/src/app.controller.ts b/src/app.controller.ts deleted file mode 100644 index cce879ee..00000000 --- a/src/app.controller.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { AppService } from './app.service'; - -@Controller() -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get() - getHello(): string { - return this.appService.getHello(); - } -} diff --git a/src/app.module.ts b/src/app.module.ts index 86628031..3288e742 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,91 @@ +import { MediaModule } from '@core/media'; +import { ConfigModule } from '@libs/config'; +import { DatabaseModule, DatabaseHealthService } from '@libs/database'; +import { HealthModule } from '@libs/health'; +import { MetricsModule } from '@libs/metrics'; +import { S3Service } from '@libs/s3'; +import { HttpModule } from '@nestjs/axios'; +import { BullModule } from '@nestjs/bullmq'; import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { ConfigService } from '@nestjs/config'; +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { CacheModule } from '@shared/adapters/cache/module'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { MailModule } from '@shared/adapters/mail'; +import { GlobalExceptionFilter } from '@shared/error'; +import { ZodValidationInterceptor } from '@shared/interceptors'; +import { ZodValidationPipe } from 'nestjs-zod'; + +import { AreaModule } from './area'; +import { AuthModule } from './auth/auth.module'; +import { ProjectModule } from './project'; +import * as schema from './shared/entities'; +import { TeamsModule } from './teams'; +import { UserModule } from './user'; @Module({ - imports: [], - controllers: [AppController], - providers: [AppService], + imports: [ + ConfigModule, + DatabaseModule.registerAsync({ + global: true, + inject: [ConfigService], + useFactory: (cfg: ConfigService) => ({ + schema, + schemaName: cfg.getOrThrow('DB_SCHEMA'), + logging: true, + }), + }), + BullModule.forRootAsync({ + inject: [ConfigService], + useFactory: (cfg: ConfigService) => ({ + connection: { + password: cfg.get('REDIS_PASSWORD'), + host: cfg.getOrThrow('REDIS_HOST'), + port: cfg.get('REDIS_PORT'), + }, + }), + }), + CacheModule, + MediaModule, + HttpModule.register({ global: true }), + MailModule, + AuthModule, + UserModule, + TeamsModule, + ProjectModule, + AreaModule, + MetricsModule, + HealthModule.registerAsync({ + inject: [DatabaseHealthService, S3Service, CACHE_SERVICE], + useFactory: (db: DatabaseHealthService, s3: S3Service, cache: ICacheService) => { + const version = process.env['npm_package_version'] ?? ''; + + return { + serviceName: 'gateway', + version, + indicators: { + database: () => db.isAlive(), + cache: () => cache.isAlive(), + storage: () => s3.isAlive(), + }, + }; + }, + }), + ], + providers: [ + { + provide: APP_PIPE, + useClass: ZodValidationPipe, + }, + { + provide: APP_FILTER, + useClass: GlobalExceptionFilter, + }, + { + provide: APP_INTERCEPTOR, + useClass: ZodValidationInterceptor, + }, + ], }) export class AppModule {} diff --git a/src/app.service.ts b/src/app.service.ts deleted file mode 100644 index 927d7cca..00000000 --- a/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/src/area/application/area.facade.ts b/src/area/application/area.facade.ts new file mode 100644 index 00000000..2886ed6e --- /dev/null +++ b/src/area/application/area.facade.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; + +import { + CreateStateDto, + UpdateStateDto, + ReordersStatesDto, + CreateAreaDto, + UpdateAreaDto, + QueryParamsDto, +} from './dtos'; +import { + CreateAreaUseCase, + DeleteAreaUseCase, + GetAreaQuery, + GetAreasQuery, + UpdateAreaUseCase, +} from './use-cases'; +import { + CreateStateUseCase, + DeleteStateUseCase, + GetStateQuery, + GetStatesQuery, + ReorderStateUseCase, + RestoreStateUseCase, + UpdateStateUseCase, +} from './use-cases/states'; + +@Injectable() +export class AreaFacade { + constructor( + private readonly createAreaUC: CreateAreaUseCase, + private readonly updateAreaUC: UpdateAreaUseCase, + private readonly deleteAreaUC: DeleteAreaUseCase, + private readonly getAreasQ: GetAreasQuery, + private readonly getAreaQ: GetAreaQuery, + + private readonly getStatesQ: GetStatesQuery, + private readonly getStateDetailQ: GetStateQuery, + private readonly createStateUC: CreateStateUseCase, + private readonly updateStateUC: UpdateStateUseCase, + private readonly deleteStateUC: DeleteStateUseCase, + private readonly restoreStateUC: RestoreStateUseCase, + private readonly reorderStateUC: ReorderStateUseCase, + ) {} + + public async createArea(slug: string, dto: CreateAreaDto, userId: string) { + return this.createAreaUC.execute(slug, dto, userId); + } + + public async updateArea(slug: string, key: string, dto: UpdateAreaDto, userId: string) { + return this.updateAreaUC.execute(slug, key, dto, userId); + } + + public async deleteArea(slug: string, key: string, userId: string) { + return this.deleteAreaUC.execute(slug, key, userId); + } + + public async getAreas(slug: string, userId: string, query: unknown) { + return this.getAreasQ.execute(slug, userId, query); + } + + public async getArea(slug: string, key: string, userId: string) { + return this.getAreaQ.execute({ projectSlug: slug, key }, userId); + } + + public async createState(slug: string, dto: CreateStateDto, userId: string) { + return this.createStateUC.execute(slug, dto, userId); + } + + public async deleteState(slug: string, stateId: string, userId: string) { + return this.deleteStateUC.execute(slug, stateId, userId); + } + + public async updateState(slug: string, stateId: string, dto: UpdateStateDto, userId: string) { + return this.updateStateUC.execute(slug, stateId, dto, userId); + } + + public async getDetailState(slug: string, stateId: string, userId: string) { + return this.getStateDetailQ.execute(slug, stateId, userId); + } + + public async getStates(slug: string, query: QueryParamsDto, userId: string) { + return this.getStatesQ.execute(slug, userId, query); + } + + public async restoreState(slug: string, stateId: string, userId: string) { + return this.restoreStateUC.execute(slug, stateId, userId); + } + + public async reoderStates(slug: string, dto: ReordersStatesDto, userId: string) { + return this.reorderStateUC.execute(slug, dto, userId); + } +} diff --git a/src/area/application/controllers/area/controller.ts b/src/area/application/controllers/area/controller.ts new file mode 100644 index 00000000..37a5a8e2 --- /dev/null +++ b/src/area/application/controllers/area/controller.ts @@ -0,0 +1,69 @@ +import { Post, Body, Get, Query, Param, Delete, Put } from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; + +import { AreaFacade } from '../../area.facade'; +import { CreateAreaDto, UpdateAreaDto } from '../../dtos'; + +import { + CreateAreaSwagger, + DeleteAreaSwagger, + FindAllAreasSwagger, + FindOneAreaSwagger, + UpdateAreaSwagger, +} from './swagger'; + +@ApiBaseController('projects/:slug/area', 'Project Areas', true) +export class AreaController { + constructor(private readonly facade: AreaFacade) {} + + @Post() + @CreateAreaSwagger() + async create( + @Param('slug') slug: string, + @Body() dto: CreateAreaDto, + @GetUserId() userId: string, + ) { + return this.facade.createArea(slug, dto, userId); + } + + @Get() + @FindAllAreasSwagger() + async findAll( + @Param('slug') slug: string, + @GetUserId() userId: string, + @Query('deleted') deleted?: string, + ) { + return this.facade.getAreas(slug, userId, deleted === 'true'); + } + + @Get(':key') + @FindOneAreaSwagger() + async findOne( + @Param('slug') slug: string, + @Param('key') key: string, + @GetUserId() userId: string, + ) { + return this.facade.getArea(slug, key, userId); + } + + @Delete(':key') + @DeleteAreaSwagger() + async delete( + @Param('slug') slug: string, + @Param('key') key: string, + @GetUserId() userId: string, + ) { + return this.facade.deleteArea(slug, key, userId); + } + + @Put(':key') + @UpdateAreaSwagger() + async updateArea( + @Param('slug') slug: string, + @Param('key') key: string, + @Body() dto: UpdateAreaDto, + @GetUserId('id') userId: string, + ) { + return this.facade.updateArea(slug, key, dto, userId); + } +} diff --git a/src/area/application/controllers/area/swagger.ts b/src/area/application/controllers/area/swagger.ts new file mode 100644 index 00000000..0cf2579f --- /dev/null +++ b/src/area/application/controllers/area/swagger.ts @@ -0,0 +1,275 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { + ApiUnauthorized, + ApiNotFound, + ApiValidationError, + ApiForbidden, + ApiConflict, +} from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { CreateAreaDto, UpdateAreaDto, AreaResponse, AreasResponse } from '../../dtos'; + +export const CreateAreaSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Создать область (пространство) внутри проекта', + description: [ + 'Создаёт новую область — это как отдельная доска или пространство внутри вашего проекта.', + '', + 'Пример: в проекте разработки ПО можно создать области:', + '- «Бэкенд»', + '- «Фронтенд»', + '- «Дизайн»', + '- «Документация»', + '', + 'У каждой области свой workflow (свои колонки-статусы), настройки и права доступа.', + 'Области позволяют изолировать разные направления работы внутри одного проекта.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiBody({ + type: CreateAreaDto.Output, + description: 'Данные для создания области', + }), + ApiResponse({ + status: 201, + description: 'Область успешно создана', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden('Нет прав для создания области в этом проекте'), + ApiConflict('Область с таким названием или slug уже существует'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const FindAllAreasSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список всех областей в проекте', + description: [ + 'Возвращает все области (пространства) внутри проекта с их настройками.', + 'Это как посмотреть все доски в проекте.', + '', + 'Доступные фильтры:', + '- `deleted=true` — показать мягко удалённые области', + '- `includeCounts=true` — добавить количество задач в каждой области', + '', + 'Полезно для навигации по проекту и отображения сводки по всем направлениям работы.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiQuery({ + name: 'deleted', + required: false, + type: Boolean, + description: 'Показать мягко удалённые области', + example: 'false', + }), + ApiQuery({ + name: 'includeCounts', + required: false, + type: Boolean, + description: 'Добавить количество задач в каждой области (tasksCount)', + example: 'true', + }), + ApiResponse({ + status: 200, + description: 'Список областей получен', + type: AreasResponse.Output, + }), + ApiUnauthorized(), + ApiNotFound('Проект не найден'), + + SetMetadata(ZOD_RESPONSE_TOKEN, AreasResponse), + ); + +export const FindOneAreaSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить детали конкретной области', + description: [ + 'Возвращает полную информацию об одной области по её slug или ID.', + 'Вы можете обратиться как по человекопонятному slug, так и по внутреннему ID.', + '', + 'Информация включает:', + '- название', + '- описание', + '- цвет и иконку', + '- настройки workflow', + '- количество задач и метрики по области', + '', + 'По сути, это получение всех настроек конкретной доски.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiParam({ + name: 'key', + type: 'string', + description: 'Slug или ID области', + example: 'development', + }), + ApiResponse({ + status: 200, + description: 'Информация об области получена', + type: AreaResponse.Output, + }), + ApiNotFound('Область не найдена'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, AreaResponse), + ); + +export const UpdateAreaSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить настройки области', + description: [ + 'Изменяет параметры существующей области.', + '', + 'Что можно изменить:', + '- название', + '- описание', + '- цветовую метку', + '- иконку', + '- лимит задач (максимум задач в этой области)', + '- другие настройки', + '', + 'Пример: переименовать область «Дизайн» в «Дизайн и прототипирование»', + 'или изменить её цвет в интерфейсе.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiParam({ + name: 'key', + type: 'string', + description: 'Slug или ID области', + example: 'development', + }), + ApiBody({ + type: UpdateAreaDto.Output, + description: 'Обновляемые поля', + }), + ApiResponse({ + status: 200, + description: 'Область обновлена', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Область не найдена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для обновления этой области'), + ApiConflict('Область с таким названием или slug уже существует'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const DeleteAreaSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить область', + description: [ + 'Мягкое удаление области — она перестаёт отображаться, но данные сохраняются.', + '', + 'Важные ограничения:', + '- удалить область можно только если в ней НЕТ задач', + '- это защита от случайной потери данных', + '', + 'Если в области есть задачи:', + '- их нужно сначала переместить в другую область', + '- или удалить', + '', + 'Удалённую область можно потом восстановить через метод восстановления.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiParam({ + name: 'key', + type: 'string', + description: 'Slug или ID области', + example: 'development', + }), + ApiResponse({ + status: 200, + description: 'Область удалена', + type: ActionResponse.Output, + }), + ApiNotFound('Область не найдена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для удаления этой области'), + ApiConflict('Нельзя удалить область, в которой есть задачи'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const RestoreAreaSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Восстановить удалённую область', + description: [ + 'Восстанавливает мягко удалённую область.', + '', + 'Что восстанавливается:', + '- сама область', + '- все её состояния (колонки на доске)', + '', + 'Что НЕ восстанавливается автоматически:', + '- задачи, которые были в области', + '', + 'Полезно, если:', + '- область удалили по ошибке', + '- решили вернуть архивное направление работы', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiParam({ + name: 'key', + type: 'string', + description: 'Slug или ID области', + example: 'development', + }), + ApiResponse({ + status: 200, + description: 'Область восстановлена', + type: ActionResponse.Output, + }), + ApiNotFound('Удалённая область не найдена'), + ApiUnauthorized(), + ApiForbidden('Нет прав для восстановления'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/area/application/controllers/index.ts b/src/area/application/controllers/index.ts new file mode 100644 index 00000000..e67b9bfc --- /dev/null +++ b/src/area/application/controllers/index.ts @@ -0,0 +1,4 @@ +import { AreaController } from './area/controller'; +import { StateController } from './state/controller'; + +export const CONTROLLERS = [AreaController, StateController]; diff --git a/src/area/application/controllers/state/controller.ts b/src/area/application/controllers/state/controller.ts new file mode 100644 index 00000000..c0fa0347 --- /dev/null +++ b/src/area/application/controllers/state/controller.ts @@ -0,0 +1,92 @@ +import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiBaseController, GetUserId, Public } from '@shared/decorators'; + +import { AreaFacade } from '../../area.facade'; +import { CreateStateDto, QueryParamsDto, ReordersStatesDto, UpdateStateDto } from '../../dtos'; + +import { + CreateStateSwagger, + FindAllStatesSwagger, + FindOneStateSwagger, + RemoveStateSwagger, + ReorderStatesSwagger, + RestoreStateSwagger, + UpdateStateSwagger, +} from './swagger'; + +@ApiBaseController('area/:slug/states', 'Area States', true) +export class StateController { + constructor(private readonly facade: AreaFacade) {} + + @Get() + @Public() + @FindAllStatesSwagger() + async getAll( + @Param('slug') slug: string, + @GetUserId() userId: string, + @Query() query: QueryParamsDto, + ) { + return this.facade.getStates(slug, query, userId); + } + + @Get(':stateId') + @FindOneStateSwagger() + async findOne( + @Param('slug') slug: string, + @Param('stateId') stateId: string, + @GetUserId() userId: string, + ) { + return this.facade.getDetailState(slug, stateId, userId); + } + + @Post() + @CreateStateSwagger() + async create( + @Param('slug') slug: string, + @Body() dto: CreateStateDto, + @GetUserId() userId: string, + ) { + return this.facade.createState(slug, dto, userId); + } + + @Delete(':stateId') + @RemoveStateSwagger() + async delete( + @Param('slug') slug: string, + @Param('stateId') stateId: string, + @GetUserId() userId: string, + ) { + return this.facade.deleteState(slug, stateId, userId); + } + + @Patch('reorder') + @ReorderStatesSwagger() + async reorder( + @Param('slug') slug: string, + @Body() dto: ReordersStatesDto, + @GetUserId() userId: string, + ) { + return this.facade.reoderStates(slug, dto, userId); + } + + @Patch(':stateId') + @UpdateStateSwagger() + async update( + @Param('slug') slug: string, + @Param('stateId') stateId: string, + @Body() dto: UpdateStateDto, + @GetUserId() userId: string, + ) { + return this.facade.updateState(slug, stateId, dto, userId); + } + + @Post(':stateId/restore') + @RestoreStateSwagger() + async restore( + @Param('slug') slug: string, + @Param('stateId') stateId: string, + @GetUserId() userId: string, + ) { + return this.facade.restoreState(slug, stateId, userId); + } +} diff --git a/src/area/application/controllers/state/swagger.ts b/src/area/application/controllers/state/swagger.ts new file mode 100644 index 00000000..c4ed2797 --- /dev/null +++ b/src/area/application/controllers/state/swagger.ts @@ -0,0 +1,311 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { ApiListQuery } from '@shared/decorators'; +import { + ApiUnauthorized, + ApiNotFound, + ApiValidationError, + ApiForbidden, + ApiConflict, +} from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { + CreateStateDto, + UpdateStateDto, + ReordersStatesDto, + CreateStateResponse, + StateResponse, + StatesResponse, +} from '../../dtos'; + +export const FindAllStatesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список всех колонок (статусов) на доске проекта', + description: [ + 'Возвращает все статусы, которые есть в проекте.', + 'В канбан-доске это колонки вроде «К выполнению», «В работе», «На ревью», «Готово».', + 'Метод позволяет фильтровать по категориям (бэклог, активные, завершённые),', + 'а также опционально подгружать количество задач в каждой колонке,', + 'сколько из них просрочено и сколько задач назначено на текущего пользователя.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiQuery({ + name: 'hidden', + required: false, + type: Boolean, + description: 'Показать скрытые статусы (isVisible = false)', + example: false, + }), + ApiQuery({ + name: 'category', + required: false, + enum: ['backlog', 'active', 'review', 'completed', 'archived'], + description: 'Фильтр по категории статусов', + example: 'active', + }), + ApiQuery({ + name: 'isLocked', + required: false, + type: Boolean, + description: 'Фильтр по заблокированным статусам', + example: false, + }), + ApiQuery({ + name: 'counts', + required: false, + type: Boolean, + description: 'Добавить количество задач в каждом статусе (tasksCount)', + example: true, + }), + ApiQuery({ + name: 'my', + required: false, + type: Boolean, + description: 'Показать только мои задачи (добавляет myTasksCount)', + example: false, + }), + ApiListQuery({ + sortableFields: ['order', 'title', 'tasksCount', 'createdAt'], + defaultSortField: 'order', + defaultSortOrder: 'asc', + }), + ApiQuery({ + name: 'overdue', + required: false, + type: Boolean, + description: + 'Добавить информацию о просроченных задачах (hasOverdueTasks, overdueTasksCount)', + example: true, + }), + ApiResponse({ + status: 200, + description: 'Список состояний получен', + type: StatesResponse.Output, + }), + ApiUnauthorized(), + ApiNotFound('Проект не найден'), + + SetMetadata(ZOD_RESPONSE_TOKEN, StatesResponse), + ); + +export const FindOneStateSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить детали одной колонки (статуса)', + description: [ + 'Возвращает полную информацию о конкретной колонке на доске:', + 'её название, цвет, иконку, тип (системная / кастомная),', + 'WIP-лимит (ограничение на число задач), а также порядок отображения.', + 'Полезно, например, при редактировании настроек колонки.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiParam({ + name: 'stateId', + type: 'string', + description: 'State id состояния', + example: 'clv123456', + }), + ApiResponse({ + status: 200, + description: 'Информация о состоянии получена', + type: StateResponse.Output, + }), + ApiNotFound('Состояние не найдено'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, StateResponse), + ); + +export const CreateStateSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Создать новую колонку на доске', + description: [ + 'Добавляет новый статус (колонку) в проект.', + 'Например, вы хотите создать этап «Дизайн» или «Тестирование» между «В работе» и «Готово».', + 'Можно задать название, иконку, цвет, категорию, а также WIP-лимит —', + 'максимальное количество задач, которые могут одновременно находиться в этой колонке.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiBody({ + type: CreateStateDto.Output, + description: 'Данные для создания состояния', + }), + ApiResponse({ + status: 201, + description: 'Состояние успешно создано', + type: CreateStateResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + ApiForbidden('Нет прав для создания состояния в этом проекте'), + ApiConflict('Состояние с таким названием или типом уже существует'), + + SetMetadata(ZOD_RESPONSE_TOKEN, CreateStateResponse), + ); + +export const UpdateStateSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить настройки колонки', + description: [ + 'Изменяет параметры существующей колонки: название, цвет, иконку, WIP-лимит.', + 'Системные (защищённые) статусы, например «Архив», нельзя переименовать или удалить,', + 'но можно поменять их внешний вид (цвет/иконку), чтобы они вписывались в дизайн вашей доски.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiParam({ + name: 'stateId', + type: 'string', + description: 'State id состояния', + example: 'clv123456', + }), + ApiBody({ + type: UpdateStateDto.Output, + description: 'Обновляемые поля', + }), + ApiResponse({ + status: 200, + description: 'Состояние обновлено', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Состояние не найдено'), + ApiUnauthorized(), + ApiForbidden('Нельзя изменить системный статус'), + ApiConflict('Состояние с таким названием уже существует'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const ReorderStatesSwagger = () => + applyDecorators( + ApiOperation({ + deprecated: true, + summary: 'Изменить порядок колонок на доске', + description: [ + 'Позволяет переставить колонки на канбан-доске так, как вам удобно.', + 'Вы просто передаёте массив ID колонок в нужном порядке —', + 'сервер сохранит эту последовательность.', + 'Например, вы хотите, чтобы колонка «Готово» была не последней, а перед «Архивом».', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiBody({ + type: ReordersStatesDto.Output, + description: 'Массив ID состояний в правильном порядке', + }), + ApiResponse({ + status: 200, + description: 'Порядок обновлён', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Одно или несколько состояний не найдены'), + ApiUnauthorized(), + ApiForbidden('Нет прав для изменения порядка'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const RemoveStateSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить колонку (если в ней нет задач)', + description: [ + 'Мягкое удаление статуса (колонка перестаёт отображаться на доске).', + 'Важное ограничение: удалить колонку можно только тогда,', + 'когда в ней не находится ни одной задачи.', + 'Системные статусы удалять нельзя — это защита от случайной поломки логики проекта.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiParam({ + name: 'stateId', + type: 'string', + description: 'State id состояния', + example: 'clv123456', + }), + ApiResponse({ + status: 200, + description: 'Состояние удалено', + type: ActionResponse.Output, + }), + ApiNotFound('Состояние не найдено'), + ApiUnauthorized(), + ApiForbidden('Нельзя удалить системный статус'), + ApiConflict('Нельзя удалить статус, в котором есть задачи'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const RestoreStateSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Вернуть удалённую колонку обратно на доску', + description: [ + 'Восстанавливает ранее мягко удалённый статус.', + 'Все настройки колонки (название, цвет, порядок) возвращаются как были.', + 'Полезно, если колонку удалили по ошибке или она снова понадобилась.', + ].join('\n'), + }), + ApiParam({ + name: 'slug', + type: 'string', + description: 'Slug проекта', + example: 'super-project', + }), + ApiParam({ + name: 'stateId', + type: 'string', + description: 'State id состояния', + example: 'clv123456', + }), + ApiResponse({ + status: 200, + description: 'Состояние восстановлено', + type: ActionResponse.Output, + }), + ApiNotFound('Удалённое состояние не найдено'), + ApiUnauthorized(), + ApiForbidden('Нет прав для восстановления'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/area/application/dtos/area.dto.ts b/src/area/application/dtos/area.dto.ts new file mode 100644 index 00000000..5490c0a6 --- /dev/null +++ b/src/area/application/dtos/area.dto.ts @@ -0,0 +1,132 @@ +import { DEFAULT_VIEWS } from '@core/area/domain/entities'; +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const DefaultViewSchema = z + .enum(DEFAULT_VIEWS) + .default('kanban') + .describe('Тип отображения по умолчанию для области'); + +export const AreaSchema = z.object({ + id: z.string().min(1, 'ID не может быть пустым').describe('Уникальный идентификатор области'), + projectId: z + .string() + .min(1, 'ID проекта обязателен') + .describe('ID проекта, к которому принадлежит область'), + title: z + .string() + .min(1, 'Название области обязательно') + .max(255, 'Название не должно превышать 255 символов') + .describe('Отображаемое название области (например: "Разработка", "Согласование")'), + slug: z + .string() + .min(1, 'Slug обязателен') + .max(100, 'Slug не должен превышать 100 символов') + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug должен быть в формате kebab-case') + .describe('URL-дружественный идентификатор (например: "development", "contract-approval")'), + description: z + .string() + .nullable() + .optional() + .describe('Markdown-описание области, её цели и правила работы'), + descriptionHtml: z + .string() + .nullable() + .optional() + .describe('Сгенерированный HTML из Markdown описания'), + color: z + .string() + .regex( + /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)', + ) + .nullable() + .optional() + .describe('HEX-код цвета для визуального выделения области'), + icon: z + .string() + .max(20, 'Иконка должна быть не длиннее 20 символов') + .nullable() + .optional() + .describe('Emoji или иконка для визуального обозначения (например: "💻", "📝", "🎨")'), + tasksCount: z + .number() + .int('Количество задач должно быть целым числом') + .min(0, 'Количество задач не может быть отрицательным') + .default(0) + .describe('Общее количество задач в этой области (денормализованное поле)'), + defaultView: DefaultViewSchema.describe('Представление по умолчанию для области'), + position: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной') + .default(0) + .describe('Порядок отображения области в списке (меньше число — выше)'), + maxTasksLimit: z + .number() + .int('Лимит задач должен быть целым числом') + .positive('Лимит задач должен быть положительным числом') + .nullable() + .optional() + .describe('Максимальное количество задач во всей области. Null — без лимита'), + isLocked: z + .boolean() + .default(false) + .describe('Заблокирована для изменений (нельзя добавлять/удалять задачи)'), + createdAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время создания области (ISO 8601 с таймзоной)'), + updatedAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время последнего обновления области'), + createdBy: z.string().nullable().optional().describe('ID пользователя, создавшего область'), + deletedAt: z + .string() + .datetime({ offset: true }) + .nullable() + .optional() + .describe('Дата мягкого удаления (null — не удалено)'), +}); + +export const CreateAreaSchema = AreaSchema.omit({ + id: true, + projectId: true, + tasksCount: true, + createdAt: true, + updatedAt: true, + createdBy: true, + deletedAt: true, +}) + .partial({ + description: true, + descriptionHtml: true, + color: true, + icon: true, + position: true, + maxTasksLimit: true, + defaultView: true, + }) + .extend({ + slug: z + .string() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug должен быть в формате kebab-case') + .optional() + .describe('Опциональный slug. Если не указан — генерируется из title'), + }) + .describe('Схема для создания новой области'); + +export const UpdateAreaSchema = CreateAreaSchema.partial() + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) + .describe('Схема для обновления области'); + +export const AreasSchema = z.array(AreaSchema); + +export class AreaResponse extends createZodDto(AreaSchema) {} +export class AreasResponse extends createZodDto(AreasSchema) {} +export class CreateAreaDto extends createZodDto(CreateAreaSchema) {} +export class UpdateAreaDto extends createZodDto(UpdateAreaSchema) {} diff --git a/src/area/application/dtos/index.ts b/src/area/application/dtos/index.ts new file mode 100644 index 00000000..adb3890b --- /dev/null +++ b/src/area/application/dtos/index.ts @@ -0,0 +1,2 @@ +export * from './state.dto'; +export * from './area.dto'; diff --git a/src/area/application/dtos/state.dto.ts b/src/area/application/dtos/state.dto.ts new file mode 100644 index 00000000..58d7f4a5 --- /dev/null +++ b/src/area/application/dtos/state.dto.ts @@ -0,0 +1,161 @@ +import { STATE_CATEGORIES, STATE_TYPES } from '@core/area/domain/entities'; +import { createSortingSchema, PaginationBaseSchema, ActionResponseSchema } from '@shared/schemas'; +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const StateTypeSchema = z + .enum(STATE_TYPES) + .describe('Тип состояния: системный или кастомный'); + +export const StateCategorySchema = z + .enum(STATE_CATEGORIES) + .describe('Категория состояния: активное, завершённое или отменённое'); + +export const StateSchema = z.object({ + id: z + .string() + .min(1, 'ID не может быть пустым') + .describe('Уникальный идентификатор состояния (UUID или наноид)'), + title: z + .string() + .min(1, 'Название состояния обязательно') + .max(255, 'Название не должно превышать 255 символов') + .describe('Отображаемое название состояния (например: "To Do", "In Progress", "Done")'), + description: z + .string() + .nullable() + .optional() + .describe('Описание состояния, его назначение и правила использования в workflow'), + stateType: StateTypeSchema.default('custom').describe( + 'Тип состояния: custom — пользовательское, default — системное (нельзя удалить)', + ), + category: StateCategorySchema.default('active').describe( + 'Группа для аналитики и фильтрации: backlog, active, done, closed', + ), + color: z + .string() + .regex( + /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)', + ) + .nullable() + .optional() + .describe('HEX-код цвета для визуального отображения на доске (например: "#4A90E2")'), + icon: z + .string() + .max(20, 'Иконка должна быть не длиннее 20 символов') + .nullable() + .optional() + .describe('Emoji или иконка для визуального обозначения (например: "📋", "🚀", "✅")'), + orderIndex: z + .number() + .int('Порядковый номер должен быть целым числом') + .min(0, 'Порядковый номер не может быть отрицательным') + .default(0) + .describe('Порядок отображения на доске (меньше число — левее/выше)'), + isVisible: z + .boolean() + .default(true) + .describe('Видимость состояния на доске и в выпадающих списках (можно скрыть, не удаляя)'), + maxTasksLimit: z + .number() + .int('Лимит задач должен быть целым числом') + .positive('Лимит задач должен быть положительным числом') + .nullable() + .optional() + .describe( + 'Максимальное количество задач в этом состоянии (WIP лимит для Kanban). Null — без лимита', + ), + autoTransitionTo: z + .string() + .nullable() + .optional() + .describe('Автоматический переход в другое состояние при достижении лимита или по условию'), + notifyOnEnter: z + .boolean() + .default(false) + .describe('Отправлять уведомление, когда задача попадает в это состояние'), + notifyOnExit: z + .boolean() + .default(false) + .describe('Отправлять уведомление, когда задача покидает это состояние'), + isLocked: z + .boolean() + .default(false) + .describe('Заблокировано для изменений (нельзя перемещать задачи в/из этого состояния)'), + createdAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время создания состояния (ISO 8601 с таймзоной)'), + updatedAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время последнего обновления состояния'), + createdBy: z.string().nullable().optional().describe('ID пользователя, создавшего состояние'), + deletedAt: z + .string() + .datetime({ offset: true }) + .nullable() + .optional() + .describe('Дата мягкого удаления (null — не удалено)'), +}); + +export const CreateStateResponseSchema = ActionResponseSchema.extend({ + stateId: z.string().describe('ID созданного состояния'), +}); + +export const StatesSchema = z.array(StateSchema); + +export const CreateStateSchema = StateSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + createdBy: true, + deletedAt: true, +}) + .partial({ + description: true, + color: true, + icon: true, + maxTasksLimit: true, + autoTransitionTo: true, + }) + .describe('Схема для создания нового состояния'); + +export const ReorderStateItemSchema = z.object({ + id: z.string().describe('ID состояния'), + orderIndex: z.number().min(0).describe('Новый порядковый индекс'), +}); + +export const ReorderStatesSchema = z.object({ + items: z.array(ReorderStateItemSchema).min(1).describe('Массив состояний с новыми индексами'), +}); + +export class StateResponse extends createZodDto(StateSchema) {} +export class StatesResponse extends createZodDto(StatesSchema) {} +export class CreateStateDto extends createZodDto(CreateStateSchema) {} +export class UpdateStateDto extends createZodDto(CreateStateSchema.partial()) {} +export class CreateStateResponse extends createZodDto(CreateStateResponseSchema) {} +export class ReordersStatesDto extends createZodDto(ReorderStatesSchema) {} + +export const QueryParamsSchema = z + .object({ + hidden: z.boolean().optional().default(false).describe('Скрытые записи'), + counts: z.boolean().optional().default(false).describe('Показывать счетчики'), + my: z.boolean().optional().default(false).describe('Только мои записи'), + category: z.string().optional().describe('Фильтр по категории'), + overdue: z.boolean().optional().default(false).describe('Только просроченные'), + }) + .extend(PaginationBaseSchema.shape) + .extend(createSortingSchema(['order', 'title', 'tasksCount', 'createdAt']).shape) + .transform((data) => { + if (data.page > 1 && data.offset === 0) { + return { + ...data, + offset: (data.page - 1) * (data.limit || 20), + }; + } + return data; + }); + +export class QueryParamsDto extends createZodDto(QueryParamsSchema) {} diff --git a/src/area/application/use-cases/areas/create.use-case.ts b/src/area/application/use-cases/areas/create.use-case.ts new file mode 100644 index 00000000..3f0c8666 --- /dev/null +++ b/src/area/application/use-cases/areas/create.use-case.ts @@ -0,0 +1,96 @@ +import { AreaErrorCodes, AreaErrorMessages } from '@core/area/domain/errors'; +import { IAreaRepository } from '@core/area/domain/repository'; +import { MAX_AREAS_PER_PROJECT } from '@core/area/infrastructure/constants'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import slugify from 'slugify'; + +import { CreateAreaDto } from '../../dtos'; + +@Injectable() +export class CreateAreaUseCase { + constructor( + @Inject('IAreaRepository') + private readonly areaRepo: IAreaRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, dto: CreateAreaDto, userId: string) { + try { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'admin', + 'owner', + ]); + + const baseSlug = dto.slug || dto.title; + const currentSlug = slugify(baseSlug, { + lower: true, + strict: true, + trim: true, + }); + + if (!currentSlug) { + throw new BaseException( + { + code: AreaErrorCodes.SLUG_INVALID, + message: AreaErrorMessages[AreaErrorCodes.SLUG_INVALID], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const existingArea = await this.areaRepo.findBySlug(project.id, currentSlug); + + if (existingArea) { + throw new BaseException( + { + code: AreaErrorCodes.SLUG_DUPLICATE, + message: AreaErrorMessages[AreaErrorCodes.SLUG_DUPLICATE], + }, + HttpStatus.CONFLICT, + ); + } + + await this.checkProjectLimits(project.id); + + const result = await this.areaRepo.create({ + ...dto, + projectId: project.id, + slug: currentSlug, + createdBy: userId, + }); + + return { + success: true, + message: `Пространство ${dto.title} успешно создано.`, + slug: result.slug, + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: AreaErrorCodes.CREATE_FAILED, + message: AreaErrorMessages[AreaErrorCodes.CREATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async checkProjectLimits(projectId: string): Promise { + const areasCount = await this.areaRepo.countByProject(projectId); + if (areasCount >= MAX_AREAS_PER_PROJECT) { + throw new BaseException( + { + code: AreaErrorCodes.MAX_LIMIT_REACHED, + message: AreaErrorMessages[AreaErrorCodes.MAX_LIMIT_REACHED], + }, + HttpStatus.FORBIDDEN, + ); + } + } +} diff --git a/src/area/application/use-cases/areas/delete.use-case.ts b/src/area/application/use-cases/areas/delete.use-case.ts new file mode 100644 index 00000000..35490331 --- /dev/null +++ b/src/area/application/use-cases/areas/delete.use-case.ts @@ -0,0 +1,76 @@ +import { AreaErrorCodes, AreaErrorMessages } from '@core/area/domain/errors'; +import { IAreaRepository } from '@core/area/domain/repository'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteAreaUseCase { + constructor( + @Inject('IAreaRepository') + private readonly areaRepo: IAreaRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, key: string, userId: string) { + try { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'admin', + 'owner', + ]); + + const area = await this.areaRepo.findBySlug(project.id, key); + + if (!area) { + throw new BaseException( + { + code: AreaErrorCodes.NOT_FOUND, + message: AreaErrorMessages[AreaErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + // 3. ⭐ БИЗНЕС-ПРОВЕРКИ + // 3.1 Проверка на наличие связанных задач + // Вариант А: Запретить удаление (безопасно) + // Вариант Б: Спросить подтверждение (через query параметр) + // Вариант В: Автоматически переместить задачи в дефолтную область + + await this.checkLastArea(project.id); + + const result = await this.areaRepo.delete(project.id, area.id); + + return { + success: result, + message: `Пространство ${area.title} успешно удалено.`, + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: AreaErrorCodes.DELETE_FAILED, + message: AreaErrorMessages[AreaErrorCodes.DELETE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async checkLastArea(projectId: string): Promise { + const areasCount = await this.areaRepo.countByProject(projectId); + + if (areasCount <= 1) { + throw new BaseException( + { + code: AreaErrorCodes.CANNOT_DELETE_LAST_AREA, + message: AreaErrorMessages[AreaErrorCodes.CANNOT_DELETE_LAST_AREA], + }, + HttpStatus.BAD_REQUEST, + ); + } + } +} diff --git a/src/area/application/use-cases/areas/get-all.query.ts b/src/area/application/use-cases/areas/get-all.query.ts new file mode 100644 index 00000000..e77076d2 --- /dev/null +++ b/src/area/application/use-cases/areas/get-all.query.ts @@ -0,0 +1,23 @@ +import { IAreaRepository } from '@core/area/domain/repository'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetAreasQuery { + constructor( + @Inject('IAreaRepository') + private readonly areaRepo: IAreaRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, userId: string, _query: unknown) { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId); + const areas = await this.areaRepo.findAll(project.id); + + return areas.map((a) => ({ + ...a, + createdAt: new Date(a.createdAt).toISOString(), + updatedAt: new Date(a.updatedAt).toISOString(), + })); + } +} diff --git a/src/area/application/use-cases/areas/get-one.query.ts b/src/area/application/use-cases/areas/get-one.query.ts new file mode 100644 index 00000000..4b23a1dc --- /dev/null +++ b/src/area/application/use-cases/areas/get-one.query.ts @@ -0,0 +1,63 @@ +import { AreaErrorCodes, AreaErrorMessages } from '@core/area/domain/errors'; +import { IAreaRepository } from '@core/area/domain/repository'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +export type GetOneAreaParams = + | { readonly projectSlug: string; readonly key: string } + | { readonly key: string }; + +@Injectable() +export class GetAreaQuery { + constructor( + @Inject('IAreaRepository') + private readonly areaRepo: IAreaRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(params: GetOneAreaParams, userId: string) { + if ('projectSlug' in params) { + return this.getAreaByProjectSlug(params.projectSlug, params.key, userId); + } + + return this.getAreaByKey(params.key); + } + + private async getAreaByProjectSlug(slug: string, key: string, userId: string) { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId); + + const area = await this.areaRepo.findBySlug(key, project.id); + if (!area) { + throw new BaseException( + { + code: AreaErrorCodes.NOT_FOUND, + message: AreaErrorMessages[AreaErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + return { + ...area, + createdAt: new Date(area.createdAt).toISOString(), + updatedAt: new Date(area.updatedAt).toISOString(), + }; + } + + private async getAreaByKey(key: string) { + const area = await this.areaRepo.findBySlug(key); + + if (!area) { + throw new BaseException( + { + code: AreaErrorCodes.NOT_FOUND, + message: AreaErrorMessages[AreaErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + return area; + } +} diff --git a/src/area/application/use-cases/areas/index.ts b/src/area/application/use-cases/areas/index.ts new file mode 100644 index 00000000..c372488c --- /dev/null +++ b/src/area/application/use-cases/areas/index.ts @@ -0,0 +1,19 @@ +import { CreateAreaUseCase } from './create.use-case'; +import { DeleteAreaUseCase } from './delete.use-case'; +import { GetAreasQuery } from './get-all.query'; +import { GetAreaQuery } from './get-one.query'; +import { UpdateAreaUseCase } from './update.use-case'; + +export * from './create.use-case'; +export * from './delete.use-case'; +export * from './get-all.query'; +export * from './get-one.query'; +export * from './update.use-case'; + +export const AreasUseCases = [ + CreateAreaUseCase, + DeleteAreaUseCase, + UpdateAreaUseCase, + GetAreaQuery, + GetAreasQuery, +]; diff --git a/src/area/application/use-cases/areas/update.use-case.ts b/src/area/application/use-cases/areas/update.use-case.ts new file mode 100644 index 00000000..246221f7 --- /dev/null +++ b/src/area/application/use-cases/areas/update.use-case.ts @@ -0,0 +1,134 @@ +import { AreaErrorCodes, AreaErrorMessages } from '@core/area/domain/errors'; +import { IAreaRepository } from '@core/area/domain/repository'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import slugify from 'slugify'; + +import { UpdateAreaDto } from '../../dtos'; + +import type { NewArea } from '../../../domain/entities'; + +@Injectable() +export class UpdateAreaUseCase { + constructor( + @Inject('IAreaRepository') + private readonly areaRepo: IAreaRepository, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, key: string, dto: UpdateAreaDto, userId: string) { + try { + const { project } = await this.projectPolicy.ensureProjectAccess(slug, userId, [ + 'admin', + 'owner', + ]); + + const area = await this.areaRepo.findBySlug(project.id, key); + + if (!area) { + throw new BaseException( + { + code: AreaErrorCodes.NOT_FOUND, + message: AreaErrorMessages[AreaErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const updateData: Partial = { + updatedAt: new Date().toISOString(), + ...(dto.title && dto.title !== area.title && { title: dto.title.trim() }), + ...(dto.description && + dto.description !== area.description && { + description: dto.description?.trim() || null, + }), + ...(dto.descriptionHtml && + dto.descriptionHtml !== area.descriptionHtml && { + descriptionHtml: dto.descriptionHtml?.trim() || null, + }), + ...(dto.color && dto.color !== area.color && { color: dto.color || null }), + ...(dto.icon && dto.icon !== area.icon && { icon: dto.icon || null }), + ...(dto.defaultView && + dto.defaultView !== area.defaultView && { defaultView: dto.defaultView }), + ...(dto.position && + dto.position !== area.position && + dto.position >= 0 && { position: dto.position }), + ...(dto.maxTasksLimit && + dto.maxTasksLimit !== area.maxTasksLimit && + dto.maxTasksLimit > 0 && { maxTasksLimit: dto.maxTasksLimit }), + ...(dto.isLocked && dto.isLocked !== area.isLocked && { isLocked: dto.isLocked }), + }; + + let hasChanges = false; + + if (dto.slug && dto.slug !== area.slug) { + let newSlug = dto.slug; + + if (newSlug) { + newSlug = slugify(newSlug, { + lower: true, + strict: true, + trim: true, + }); + + if (!newSlug) { + throw new BaseException( + { + code: AreaErrorCodes.SLUG_INVALID, + message: AreaErrorMessages[AreaErrorCodes.SLUG_INVALID], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const existingArea = await this.areaRepo.findBySlug(project.id, newSlug); + if (existingArea && existingArea.id !== area.id) { + throw new BaseException( + { + code: AreaErrorCodes.SLUG_DUPLICATE, + message: AreaErrorMessages[AreaErrorCodes.SLUG_DUPLICATE], + }, + HttpStatus.CONFLICT, + ); + } + + updateData.slug = newSlug; + } else { + updateData.slug = slugify(updateData.title || area.title, { + lower: true, + strict: true, + trim: true, + }); + } + hasChanges = true; + } + + if (!hasChanges) { + return { + success: true, + message: 'Нет изменений для обновления', + }; + } + + const result = await this.areaRepo.update(project.id, area.id, updateData); + + return { + success: result, + message: `Пространство ${dto.title || area.title} успешно обновлено`, + }; + } catch (e) { + if (e instanceof BaseException) { + throw e; + } + + throw new BaseException( + { + code: AreaErrorCodes.UPDATE_FAILED, + message: AreaErrorMessages[AreaErrorCodes.UPDATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/area/application/use-cases/index.ts b/src/area/application/use-cases/index.ts new file mode 100644 index 00000000..69b00cfb --- /dev/null +++ b/src/area/application/use-cases/index.ts @@ -0,0 +1,2 @@ +export * from './states'; +export * from './areas'; diff --git a/src/area/application/use-cases/states/create.use-case.ts b/src/area/application/use-cases/states/create.use-case.ts new file mode 100644 index 00000000..c6105468 --- /dev/null +++ b/src/area/application/use-cases/states/create.use-case.ts @@ -0,0 +1,89 @@ +import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; +import { IStateRepository } from '@core/area/domain/repository'; +import { MAX_STATES_PER_PROJECT } from '@core/area/infrastructure/constants'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { CreateStateDto } from '../../dtos'; +import { GetAreaQuery } from '../areas'; + +@Injectable() +export class CreateStateUseCase { + constructor( + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly getAreaQ: GetAreaQuery, + ) {} + + async execute(slug: string, dto: CreateStateDto, userId: string) { + try { + const area = await this.getAreaQ.execute({ key: slug }, userId); + + const currentCount = await this.stateRepo.countByArea(area.id); + if (currentCount >= MAX_STATES_PER_PROJECT) { + throw new BaseException( + { + code: StateErrorCodes.MAX_LIMIT_REACHED, + message: StateErrorMessages[StateErrorCodes.MAX_LIMIT_REACHED], + details: [{ current: currentCount, max: MAX_STATES_PER_PROJECT }], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + if (dto.title) { + const existingByTitle = await this.stateRepo.findByTitle(area.id, dto.title); + + if (existingByTitle) { + throw new BaseException( + { + code: StateErrorCodes.DUPLICATE_TITLE, + message: StateErrorMessages[StateErrorCodes.DUPLICATE_TITLE], + details: [{ title: dto.title }], + }, + HttpStatus.CONFLICT, + ); + } + } + + if (dto.stateType && dto.stateType !== 'custom') { + const existingByType = await this.stateRepo.findByType(area.id, dto.stateType); + + if (existingByType) { + throw new BaseException( + { + code: StateErrorCodes.DUPLICATE_TYPE, + message: StateErrorMessages[StateErrorCodes.DUPLICATE_TYPE], + details: [{ stateType: dto.stateType }], + }, + HttpStatus.CONFLICT, + ); + } + } + + const result = await this.stateRepo.create({ + ...dto, + areaId: area.id, + createdBy: userId, + }); + + return { + success: true, + message: 'Состояние успешно создано', + stateId: result.id, + }; + } catch (err) { + if (err instanceof BaseException) { + throw err; + } + + throw new BaseException( + { + code: StateErrorCodes.CREATE_FAILED, + message: StateErrorMessages[StateErrorCodes.CREATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/area/application/use-cases/states/delete.use-case.ts b/src/area/application/use-cases/states/delete.use-case.ts new file mode 100644 index 00000000..56abf254 --- /dev/null +++ b/src/area/application/use-cases/states/delete.use-case.ts @@ -0,0 +1,86 @@ +import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; +import { IStateRepository } from '@core/area/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { GetAreaQuery } from '../areas'; + +@Injectable() +export class DeleteStateUseCase { + constructor( + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly getAreaQ: GetAreaQuery, + ) {} + + async execute(slug: string, stateId: string, userId: string) { + try { + const area = await this.getAreaQ.execute({ key: slug }, userId); + + const state = await this.stateRepo.findOne(area.id, stateId); + if (!state) { + throw new BaseException( + { + code: StateErrorCodes.NOT_FOUND, + message: StateErrorMessages[StateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (state.stateType !== 'custom') { + throw new BaseException( + { + code: StateErrorCodes.CANNOT_DELETE_SYSTEM, + message: StateErrorMessages[StateErrorCodes.CANNOT_DELETE_SYSTEM], + details: [{ stateType: state.stateType }], + }, + HttpStatus.FORBIDDEN, + ); + } + + if (state.isLocked) { + throw new BaseException( + { + code: StateErrorCodes.LOCKED, + message: StateErrorMessages[StateErrorCodes.LOCKED], + }, + HttpStatus.CONFLICT, + ); + } + + // const taskCount = await this.taskRepo.countByState(state.id); + // if (taskCount > 0) { + // throw new BaseException( + // { + // code: StateErrorCodes.HAS_ACTIVE_TASKS, + // message: StateErrorMessages[StateErrorCodes.HAS_ACTIVE_TASKS], + // details: { taskCount }, + // }, + // HttpStatus.CONFLICT, + // ); + // } + + const result = await this.stateRepo.delete(area.id, state.id); + + return { + success: result, + message: result + ? 'Состояние успешно удалено' + : 'Не удалось удалить состояние: запись не найдена или уже удалена', + }; + } catch (err) { + if (err instanceof BaseException) { + throw err; + } + + throw new BaseException( + { + code: StateErrorCodes.DELETE_FAILED, + message: StateErrorMessages[StateErrorCodes.DELETE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/area/application/use-cases/states/get-all.query.ts b/src/area/application/use-cases/states/get-all.query.ts new file mode 100644 index 00000000..8d5b79d3 --- /dev/null +++ b/src/area/application/use-cases/states/get-all.query.ts @@ -0,0 +1,27 @@ +import { IStateRepository } from '@core/area/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +import { QueryParamsDto } from '../../dtos'; +import { GetAreaQuery } from '../areas'; + +@Injectable() +export class GetStatesQuery { + constructor( + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly getAreaQ: GetAreaQuery, + ) {} + + async execute(slug: string, userId: string, query: QueryParamsDto) { + const area = await this.getAreaQ.execute({ key: slug }, userId); + const states = await this.stateRepo.find(area.id, query); + + return states + .map((s) => ({ + ...s, + createdAt: new Date(s.createdAt).toISOString(), + updatedAt: new Date(s.updatedAt).toISOString(), + })) + .sort((a, b) => a.position - b.position); + } +} diff --git a/src/area/application/use-cases/states/get-one.query.ts b/src/area/application/use-cases/states/get-one.query.ts new file mode 100644 index 00000000..2e14a08e --- /dev/null +++ b/src/area/application/use-cases/states/get-one.query.ts @@ -0,0 +1,36 @@ +import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; +import { IStateRepository } from '@core/area/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { GetAreaQuery } from '../areas'; + +@Injectable() +export class GetStateQuery { + constructor( + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly getAreaQ: GetAreaQuery, + ) {} + + async execute(slug: string, stateId: string, userId: string) { + const area = await this.getAreaQ.execute({ key: slug }, userId); + const state = await this.stateRepo.findOne(area.id, stateId); + + if (!state) { + throw new BaseException( + { + code: StateErrorCodes.NOT_FOUND, + message: StateErrorMessages[StateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + return { + ...state, + createdAt: new Date(state.createdAt).toISOString(), + updatedAt: new Date(state.updatedAt).toISOString(), + }; + } +} diff --git a/src/area/application/use-cases/states/index.ts b/src/area/application/use-cases/states/index.ts new file mode 100644 index 00000000..21b070a3 --- /dev/null +++ b/src/area/application/use-cases/states/index.ts @@ -0,0 +1,26 @@ +import { CreateStateUseCase } from './create.use-case'; +import { DeleteStateUseCase } from './delete.use-case'; +import { GetStatesQuery } from './get-all.query'; +import { GetStateQuery } from './get-one.query'; +import { ReorderStateUseCase } from './reorder.use-case'; +import { RestoreStateUseCase } from './restore.use-state'; +import { UpdateStateUseCase } from './update.use-case'; + +export * from './create.use-case'; +export * from './delete.use-case'; +export * from './update.use-case'; +export * from './get-one.query'; +export * from './get-all.query'; +export * from './restore.use-state'; +export * from './update.use-case'; +export * from './reorder.use-case'; + +export const StatesUseCases = [ + CreateStateUseCase, + RestoreStateUseCase, + DeleteStateUseCase, + UpdateStateUseCase, + GetStateQuery, + GetStatesQuery, + ReorderStateUseCase, +]; diff --git a/src/area/application/use-cases/states/reorder.use-case.ts b/src/area/application/use-cases/states/reorder.use-case.ts new file mode 100644 index 00000000..f053663d --- /dev/null +++ b/src/area/application/use-cases/states/reorder.use-case.ts @@ -0,0 +1,55 @@ +import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; +import { IStateRepository } from '@core/area/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { ReordersStatesDto } from '../../dtos'; +import { GetAreaQuery } from '../areas'; + +@Injectable() +export class ReorderStateUseCase { + constructor( + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly getAreaQ: GetAreaQuery, + ) {} + + async execute(slug: string, _dto: ReordersStatesDto, userId: string) { + try { + const area = await this.getAreaQ.execute({ key: slug }, userId); + + const state = await this.stateRepo.findOne(area.id, slug); + + if (!state) { + throw new BaseException( + { + code: StateErrorCodes.NOT_FOUND, + message: StateErrorMessages[StateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const result = true; + + return { + success: result, + message: result + ? 'Состояние успешно восстановлено' + : 'Не удалось восстановить состояние: запись не найдена или уже активна', + }; + } catch (err) { + if (err instanceof BaseException) { + throw err; + } + + throw new BaseException( + { + code: StateErrorCodes.REORDER_FAILED, + message: StateErrorMessages[StateErrorCodes.REORDER_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/area/application/use-cases/states/restore.use-state.ts b/src/area/application/use-cases/states/restore.use-state.ts new file mode 100644 index 00000000..4b402357 --- /dev/null +++ b/src/area/application/use-cases/states/restore.use-state.ts @@ -0,0 +1,56 @@ +import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; +import { IStateRepository } from '@core/area/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { GetAreaQuery } from '../areas'; + +@Injectable() +export class RestoreStateUseCase { + constructor( + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly getAreaQ: GetAreaQuery, + ) {} + + async execute(slug: string, stateId: string, userId: string) { + try { + const area = await this.getAreaQ.execute({ key: slug }, userId); + + const state = await this.stateRepo.findOne(area.id, stateId, true); + + if (!state) { + throw new BaseException( + { + code: StateErrorCodes.NOT_FOUND, + message: StateErrorMessages[StateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const result = await this.stateRepo.update(area.id, stateId, { + deletedAt: null, + }); + + return { + success: result, + message: result + ? 'Состояние успешно восстановлено' + : 'Не удалось восстановить состояние: запись не найдена или уже активна', + }; + } catch (err) { + if (err instanceof BaseException) { + throw err; + } + + throw new BaseException( + { + code: StateErrorCodes.RESTORE_FAILED, + message: StateErrorMessages[StateErrorCodes.RESTORE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/area/application/use-cases/states/update.use-case.ts b/src/area/application/use-cases/states/update.use-case.ts new file mode 100644 index 00000000..7d88bff3 --- /dev/null +++ b/src/area/application/use-cases/states/update.use-case.ts @@ -0,0 +1,75 @@ +import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; +import { IStateRepository } from '@core/area/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { UpdateStateDto } from '../../dtos'; +import { GetAreaQuery } from '../areas'; + +@Injectable() +export class UpdateStateUseCase { + constructor( + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + private readonly getAreaQ: GetAreaQuery, + ) {} + + async execute(slug: string, stateId: string, dto: UpdateStateDto, userId: string) { + try { + const area = await this.getAreaQ.execute({ key: slug }, userId); + + const state = await this.stateRepo.findOne(area.id, stateId); + + if (!state) { + throw new BaseException( + { + code: StateErrorCodes.NOT_FOUND, + message: StateErrorMessages[StateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (state.isLocked) { + throw new BaseException( + { + code: StateErrorCodes.LOCKED, + message: StateErrorMessages[StateErrorCodes.LOCKED], + }, + HttpStatus.CONFLICT, + ); + } + + if (state.stateType !== 'custom' && dto.stateType === 'custom') { + throw new BaseException( + { + code: StateErrorCodes.SYSTEM_TYPE_IMMUTABLE, + message: StateErrorMessages[StateErrorCodes.SYSTEM_TYPE_IMMUTABLE], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + const result = await this.stateRepo.update(area.id, stateId, dto); + + return { + success: result, + message: result + ? 'Состояние успешно обновлено' + : 'Не удалось обновить состояние: запись не найдена', + }; + } catch (err) { + if (err instanceof BaseException) { + throw err; + } + + throw new BaseException( + { + code: StateErrorCodes.UPDATE_FAILED, + message: StateErrorMessages[StateErrorCodes.UPDATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/area/area.module.ts b/src/area/area.module.ts new file mode 100644 index 00000000..9dfbc90d --- /dev/null +++ b/src/area/area.module.ts @@ -0,0 +1,15 @@ +import { ProjectModule } from '@core/project'; +import { forwardRef, Module } from '@nestjs/common'; + +import { AreaFacade } from './application/area.facade'; +import { CONTROLLERS } from './application/controllers'; +import { AreasUseCases, StatesUseCases } from './application/use-cases'; +import { REPOSITORIES } from './infrastructure/persistence/repositories'; + +@Module({ + imports: [forwardRef(() => ProjectModule)], + controllers: [...CONTROLLERS], + providers: [...REPOSITORIES, ...StatesUseCases, ...AreasUseCases, AreaFacade], + exports: [], +}) +export class AreaModule {} diff --git a/src/area/domain/entities/area.domain.ts b/src/area/domain/entities/area.domain.ts new file mode 100644 index 00000000..07e61368 --- /dev/null +++ b/src/area/domain/entities/area.domain.ts @@ -0,0 +1,5 @@ +import type { areas } from '@core/area/infrastructure/persistence/models'; +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; + +export type Area = InferSelectModel; +export type NewArea = InferInsertModel; diff --git a/src/area/domain/entities/enum.ts b/src/area/domain/entities/enum.ts new file mode 100644 index 00000000..a8ae4b2c --- /dev/null +++ b/src/area/domain/entities/enum.ts @@ -0,0 +1,36 @@ +export const STATE_TYPES = { + BACKLOG: 'backlog', + TODO: 'todo', + IN_PROGRESS: 'in_progress', + REVIEW: 'review', + DONE: 'done', + ARCHIVED: 'archived', + CUSTOM: 'custom', +} as const; + +export type StateType = (typeof STATE_TYPES)[keyof typeof STATE_TYPES]; + +export const STATE_TYPES_LIST = Object.values(STATE_TYPES); + +export const STATE_CATEGORIES = { + BACKLOG: 'backlog', + ACTIVE: 'active', + REVIEW: 'review', + COMPLETED: 'completed', + ARCHIVED: 'archived', +} as const; + +export type StateCategory = (typeof STATE_CATEGORIES)[keyof typeof STATE_CATEGORIES]; + +export const STATE_CATEGORIES_LIST = Object.values(STATE_CATEGORIES); + +export const DEFAULT_VIEWS = { + KANBAN: 'kanban', + LIST: 'list', + CALENDAR: 'calendar', + GANTT: 'gantt', +} as const; + +export type DefaultView = (typeof DEFAULT_VIEWS)[keyof typeof DEFAULT_VIEWS]; + +export const DEFAULT_VIEWS_LIST = Object.values(DEFAULT_VIEWS); diff --git a/src/area/domain/entities/index.ts b/src/area/domain/entities/index.ts new file mode 100644 index 00000000..68a4cebc --- /dev/null +++ b/src/area/domain/entities/index.ts @@ -0,0 +1,3 @@ +export * from './area.domain'; +export * from './state.domain'; +export * from './enum'; diff --git a/src/area/domain/entities/state.domain.ts b/src/area/domain/entities/state.domain.ts new file mode 100644 index 00000000..69438b3c --- /dev/null +++ b/src/area/domain/entities/state.domain.ts @@ -0,0 +1,5 @@ +import type { states } from '@core/area/infrastructure/persistence/models'; +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; + +export type State = InferSelectModel; +export type NewState = InferInsertModel; diff --git a/src/area/domain/errors/area.errors.ts b/src/area/domain/errors/area.errors.ts new file mode 100644 index 00000000..10182e72 --- /dev/null +++ b/src/area/domain/errors/area.errors.ts @@ -0,0 +1,68 @@ +export const AreaErrorCodes = { + // 404 + NOT_FOUND: 'AREA.NOT_FOUND', + + // 409 — Conflict + SLUG_DUPLICATE: 'AREA.SLUG_DUPLICATE', + TITLE_DUPLICATE: 'AREA.TITLE_DUPLICATE', + ALREADY_LOCKED: 'AREA.ALREADY_LOCKED', + ALREADY_UNLOCKED: 'AREA.ALREADY_UNLOCKED', + + // 400 — Bad Request + SLUG_INVALID: 'AREA.SLUG_INVALID', + COLOR_INVALID: 'AREA.COLOR_INVALID', + ICON_INVALID: 'AREA.ICON_INVALID', + PROJECT_REQUIRED: 'AREA.PROJECT_REQUIRED', + DEFAULT_VIEW_INVALID: 'AREA.DEFAULT_VIEW_INVALID', + POSITION_INVALID: 'AREA.POSITION_INVALID', + MAX_TASKS_LIMIT_INVALID: 'AREA.MAX_TASKS_LIMIT_INVALID', + + // 403 — Forbidden + MAX_LIMIT_REACHED: 'AREA.MAX_LIMIT_REACHED', + LOCKED: 'AREA.LOCKED', + ACCESS_DENIED: 'AREA.ACCESS_DENIED', + + // 422 — Unprocessable + HAS_ACTIVE_TASKS: 'AREA.HAS_ACTIVE_TASKS', + CANNOT_DELETE_LAST_AREA: 'AREA.CANNOT_DELETE_LAST_AREA', + + // 500 — Internal + CREATE_FAILED: 'AREA.CREATE_FAILED', + UPDATE_FAILED: 'AREA.UPDATE_FAILED', + DELETE_FAILED: 'AREA.DELETE_FAILED', + RESTORE_FAILED: 'AREA.RESTORE_FAILED', + REORDER_FAILED: 'AREA.REORDER_FAILED', +} as const; + +export type AreaErrorCode = (typeof AreaErrorCodes)[keyof typeof AreaErrorCodes]; + +export const AreaErrorMessages: Record = { + [AreaErrorCodes.NOT_FOUND]: 'Область не найдена', + + [AreaErrorCodes.SLUG_DUPLICATE]: 'Область с таким ключом уже существует в проекте', + [AreaErrorCodes.TITLE_DUPLICATE]: 'Область с таким названием уже существует в проекте', + [AreaErrorCodes.ALREADY_LOCKED]: 'Область уже заблокирована', + [AreaErrorCodes.ALREADY_UNLOCKED]: 'Область уже разблокирована', + + [AreaErrorCodes.SLUG_INVALID]: + 'Ключ области должен быть в формате kebab-case: строчные латинские буквы, цифры и дефисы', + [AreaErrorCodes.COLOR_INVALID]: 'Цвет должен быть в формате HEX (например, #3b82f6)', + [AreaErrorCodes.ICON_INVALID]: 'Иконка слишком длинная (максимум 20 символов)', + [AreaErrorCodes.PROJECT_REQUIRED]: 'ID проекта обязателен', + [AreaErrorCodes.DEFAULT_VIEW_INVALID]: 'Недопустимый вид отображения по умолчанию', + [AreaErrorCodes.POSITION_INVALID]: 'Позиция должна быть неотрицательным целым числом', + [AreaErrorCodes.MAX_TASKS_LIMIT_INVALID]: 'Лимит задач должен быть положительным целым числом', + + [AreaErrorCodes.MAX_LIMIT_REACHED]: 'Достигнут лимит областей в проекте', + [AreaErrorCodes.LOCKED]: 'Область заблокирована и не может быть изменена', + [AreaErrorCodes.ACCESS_DENIED]: 'У вас нет доступа к управлению областями этого проекта', + + [AreaErrorCodes.HAS_ACTIVE_TASKS]: 'Нельзя удалить область, в которой есть задачи', + [AreaErrorCodes.CANNOT_DELETE_LAST_AREA]: 'Нельзя удалить последнюю область проекта', + + [AreaErrorCodes.CREATE_FAILED]: 'Не удалось создать область', + [AreaErrorCodes.UPDATE_FAILED]: 'Не удалось обновить область', + [AreaErrorCodes.DELETE_FAILED]: 'Не удалось удалить область', + [AreaErrorCodes.RESTORE_FAILED]: 'Не удалось восстановить область', + [AreaErrorCodes.REORDER_FAILED]: 'Не удалось изменить порядок областей', +} as const; diff --git a/src/area/domain/errors/index.ts b/src/area/domain/errors/index.ts new file mode 100644 index 00000000..265a6c2e --- /dev/null +++ b/src/area/domain/errors/index.ts @@ -0,0 +1,2 @@ +export * from './state.errors'; +export * from './area.errors'; diff --git a/src/area/domain/errors/state.errors.ts b/src/area/domain/errors/state.errors.ts new file mode 100644 index 00000000..acf9b80e --- /dev/null +++ b/src/area/domain/errors/state.errors.ts @@ -0,0 +1,119 @@ +export const StateErrorCodes = { + NOT_FOUND: 'STATE.NOT_FOUND', + UPDATE_FAILED: 'STATE.UPDATE_FAILED', + DUPLICATE_TITLE: 'STATE.DUPLICATE_TITLE', + DUPLICATE_TYPE: 'STATE.DUPLICATE_TYPE', + SYSTEM_TYPE_IMMUTABLE: 'STATE.SYSTEM_TYPE_IMMUTABLE', + MAX_LIMIT_REACHED: 'STATE.MAX_LIMIT_REACHED', + INVALID_TRANSITION: 'STATE.INVALID_TRANSITION', + LOCKED: 'STATE.LOCKED', + + CREATE_FAILED: 'STATE.CREATE_FAILED', + TITLE_REQUIRED: 'STATE.TITLE_REQUIRED', + TITLE_TOO_LONG: 'STATE.TITLE_TOO_LONG', + PROJECT_REQUIRED: 'STATE.PROJECT_REQUIRED', + SLUG_INVALID: 'STATE.SLUG_INVALID', + SLUG_DUPLICATE: 'STATE.SLUG_DUPLICATE', + COLOR_INVALID: 'STATE.COLOR_INVALID', + ICON_INVALID: 'STATE.ICON_INVALID', + DESCRIPTION_TOO_LONG: 'STATE.DESCRIPTION_TOO_LONG', + ORDER_INDEX_INVALID: 'STATE.ORDER_INDEX_INVALID', + MAX_TASKS_LIMIT_INVALID: 'STATE.MAX_TASKS_LIMIT_INVALID', + AUTO_TRANSITION_INVALID: 'STATE.AUTO_TRANSITION_INVALID', + SYSTEM_TYPE_REQUIRED: 'STATE.SYSTEM_TYPE_REQUIRED', + + DELETE_FAILED: 'STATE.DELETE_FAILED', + CANNOT_DELETE_SYSTEM: 'STATE.CANNOT_DELETE_SYSTEM', + CANNOT_DELETE_LAST_ACTIVE: 'STATE.CANNOT_DELETE_LAST_ACTIVE', + HAS_ACTIVE_TASKS: 'STATE.HAS_ACTIVE_TASKS', + ALREADY_DELETED: 'STATE.ALREADY_DELETED', + + RESTORE_FAILED: 'STATE.RESTORE_FAILED', + NOT_DELETED: 'STATE.NOT_DELETED', + + REORDER_FAILED: 'STATE.REORDER_FAILED', + CANNOT_REORDER_SYSTEM: 'STATE.CANNOT_REORDER_SYSTEM', + + CATEGORY_IMMUTABLE: 'STATE.CATEGORY_IMMUTABLE', + INVALID_CATEGORY: 'STATE.INVALID_CATEGORY', + + CANNOT_HIDE_SYSTEM: 'STATE.CANNOT_HIDE_SYSTEM', + + NOTIFY_ON_ENTER_INVALID: 'STATE.NOTIFY_ON_ENTER_INVALID', + NOTIFY_ON_EXIT_INVALID: 'STATE.NOTIFY_ON_EXIT_INVALID', + + VERSION_CONFLICT: 'STATE.VERSION_CONFLICT', + VERSION_REQUIRED: 'STATE.VERSION_REQUIRED', + + WIP_LIMIT_EXCEEDED: 'STATE.WIP_LIMIT_EXCEEDED', + WIP_LIMIT_NEGATIVE: 'STATE.WIP_LIMIT_NEGATIVE', + + AUTO_TRANSITION_SELF: 'STATE.AUTO_TRANSITION_SELF', + AUTO_TRANSITION_NOT_FOUND: 'STATE.AUTO_TRANSITION_NOT_FOUND', + + PARENT_STATE_NOT_FOUND: 'STATE.PARENT_STATE_NOT_FOUND', + CIRCULAR_DEPENDENCY: 'STATE.CIRCULAR_DEPENDENCY', +} as const; + +export type StateErrorCode = (typeof StateErrorCodes)[keyof typeof StateErrorCodes]; + +export const StateErrorMessages: Record = { + [StateErrorCodes.NOT_FOUND]: 'Состояние проекта не найдено', + [StateErrorCodes.UPDATE_FAILED]: 'Не удалось обновить состояние', + [StateErrorCodes.DUPLICATE_TITLE]: 'Состояние с таким названием уже существует в проекте', + [StateErrorCodes.DUPLICATE_TYPE]: 'Системный тип состояния уже используется в проекте', + [StateErrorCodes.SYSTEM_TYPE_IMMUTABLE]: 'Нельзя изменить тип системного состояния на custom', + [StateErrorCodes.MAX_LIMIT_REACHED]: 'Достигнут лимит состояний в проекте', + [StateErrorCodes.INVALID_TRANSITION]: 'Недопустимый переход между состояниями', + [StateErrorCodes.LOCKED]: 'Состояние заблокировано и не может быть изменено', + + [StateErrorCodes.CREATE_FAILED]: 'Не удалось создать состояние', + [StateErrorCodes.TITLE_REQUIRED]: 'Название состояния не может быть пустым', + [StateErrorCodes.TITLE_TOO_LONG]: 'Название состояния слишком длинное (максимум 255 символов)', + [StateErrorCodes.PROJECT_REQUIRED]: 'ID проекта обязателен', + [StateErrorCodes.SLUG_INVALID]: + 'Ключ должен содержать только строчные латинские буквы, цифры и _ (до 50 символов)', + [StateErrorCodes.SLUG_DUPLICATE]: 'Состояние с таким ключом уже существует в проекте', + [StateErrorCodes.COLOR_INVALID]: 'Цвет должен быть в формате HEX (например, #FFFFFF)', + [StateErrorCodes.ICON_INVALID]: 'Иконка слишком длинная (максимум 20 символов)', + [StateErrorCodes.DESCRIPTION_TOO_LONG]: 'Описание слишком длинное (максимум 2000 символов)', + [StateErrorCodes.ORDER_INDEX_INVALID]: 'Недопустимый индекс порядка', + [StateErrorCodes.MAX_TASKS_LIMIT_INVALID]: 'Лимит задач должен быть положительным числом', + [StateErrorCodes.AUTO_TRANSITION_INVALID]: 'Недопустимое состояние для автоперехода', + [StateErrorCodes.SYSTEM_TYPE_REQUIRED]: + 'Для проекта должен быть хотя бы один системный тип каждого вида (todo, in_progress, done)', + + [StateErrorCodes.DELETE_FAILED]: 'Не удалось удалить состояние', + [StateErrorCodes.CANNOT_DELETE_SYSTEM]: 'Нельзя удалить системное состояние', + [StateErrorCodes.CANNOT_DELETE_LAST_ACTIVE]: + 'Нельзя удалить последнее активное состояние проекта', + [StateErrorCodes.HAS_ACTIVE_TASKS]: 'Нельзя удалить состояние, в котором есть задачи', + [StateErrorCodes.ALREADY_DELETED]: 'Состояние уже удалено', + + [StateErrorCodes.RESTORE_FAILED]: 'Не удалось восстановить состояние', + [StateErrorCodes.NOT_DELETED]: 'Состояние не удалено, восстановление не требуется', + + [StateErrorCodes.REORDER_FAILED]: 'Не удалось изменить порядок состояний', + [StateErrorCodes.CANNOT_REORDER_SYSTEM]: 'Нельзя изменить порядок системных состояний', + + [StateErrorCodes.CATEGORY_IMMUTABLE]: 'Нельзя изменить категорию системного состояния', + [StateErrorCodes.INVALID_CATEGORY]: 'Недопустимая категория состояния', + + [StateErrorCodes.CANNOT_HIDE_SYSTEM]: 'Нельзя скрыть системное состояние', + + [StateErrorCodes.NOTIFY_ON_ENTER_INVALID]: 'Некорректная настройка уведомления при входе', + [StateErrorCodes.NOTIFY_ON_EXIT_INVALID]: 'Некорректная настройка уведомления при выходе', + + [StateErrorCodes.VERSION_CONFLICT]: + 'Состояние было изменено другим пользователем. Обновите страницу и попробуйте снова', + [StateErrorCodes.VERSION_REQUIRED]: 'Версия состояния обязательна для обновления', + + [StateErrorCodes.WIP_LIMIT_EXCEEDED]: 'Достигнут лимит задач в этом состоянии', + [StateErrorCodes.WIP_LIMIT_NEGATIVE]: 'Лимит задач не может быть отрицательным', + + [StateErrorCodes.AUTO_TRANSITION_SELF]: 'Нельзя настроить автопереход состояния на само себя', + [StateErrorCodes.AUTO_TRANSITION_NOT_FOUND]: 'Целевое состояние для автоперехода не найдено', + + [StateErrorCodes.PARENT_STATE_NOT_FOUND]: 'Родительское состояние не найдено', + [StateErrorCodes.CIRCULAR_DEPENDENCY]: 'Обнаружена циклическая зависимость между состояниями', +}; diff --git a/src/area/domain/repository/area.repository.interface.ts b/src/area/domain/repository/area.repository.interface.ts new file mode 100644 index 00000000..59f4c075 --- /dev/null +++ b/src/area/domain/repository/area.repository.interface.ts @@ -0,0 +1,12 @@ +import type { Area, NewArea } from '../entities'; + +export interface IAreaRepository { + create(dto: NewArea): Promise<{ readonly slug: string }>; + update(projectId: string, areaId: string, dto: Partial): Promise; + delete(projectId: string, areaId: string): Promise; + findOne(projectId: string, areaId: string, includeDeleted?: boolean): Promise; + findAll(projectId: string, includeDeleted?: boolean): Promise; + findBySlug(slug: string, projectId?: string): Promise; + + countByProject(projectId: string): Promise; +} diff --git a/src/area/domain/repository/index.ts b/src/area/domain/repository/index.ts new file mode 100644 index 00000000..dcd31bde --- /dev/null +++ b/src/area/domain/repository/index.ts @@ -0,0 +1,2 @@ +export * from './states.repository.interface'; +export * from './area.repository.interface'; diff --git a/src/area/domain/repository/states.repository.interface.ts b/src/area/domain/repository/states.repository.interface.ts new file mode 100644 index 00000000..93e35a11 --- /dev/null +++ b/src/area/domain/repository/states.repository.interface.ts @@ -0,0 +1,15 @@ +import type { NewState, State } from '../entities'; + +export interface IStateRepository { + create(dto: NewState): Promise<{ readonly id: string }>; + update(areaId: string, stateId: string, dto: Partial): Promise; + delete(areaId: string, stateId: string): Promise; + findOne(areaId: string, stateId: string, deleted?: boolean): Promise; + find(areaId: string, query?: unknown): Promise; + findByTitle(areaId: string, title: string): Promise; + findByType( + areaId: string, + type: 'custom' | 'archived' | 'backlog' | 'todo' | 'in_progress' | 'review' | 'done', + ): Promise; + countByArea(areaId: string): Promise; +} diff --git a/src/area/index.ts b/src/area/index.ts new file mode 100644 index 00000000..6f77033f --- /dev/null +++ b/src/area/index.ts @@ -0,0 +1 @@ +export * from './area.module'; diff --git a/src/area/infrastructure/constants/index.ts b/src/area/infrastructure/constants/index.ts new file mode 100644 index 00000000..204598d6 --- /dev/null +++ b/src/area/infrastructure/constants/index.ts @@ -0,0 +1,11 @@ +export const MAX_AREAS_PER_PROJECT = 50; +export const MAX_STATES_PER_PROJECT = 20; + +export const DEFAULT_STATES = [ + { title: 'Бэклог', type: 'backlog', category: 'backlog', position: 0, color: '#94A3B8' }, + { title: 'К выполнению', type: 'todo', category: 'active', position: 1, color: '#3B82F6' }, + { title: 'В работе', type: 'in_progress', category: 'active', position: 2, color: '#F59E0B' }, + { title: 'На ревью', type: 'review', category: 'review', position: 3, color: '#8B5CF6' }, + { title: 'Готово', type: 'done', category: 'completed', position: 4, color: '#10B981' }, + { title: 'Архив', type: 'archived', category: 'archived', position: 5, color: '#6B7280' }, +] as const; diff --git a/src/area/infrastructure/persistence/models/area.model.ts b/src/area/infrastructure/persistence/models/area.model.ts new file mode 100644 index 00000000..a7cc3e7e --- /dev/null +++ b/src/area/infrastructure/persistence/models/area.model.ts @@ -0,0 +1,41 @@ +import { createId } from '@paralleldrive/cuid2'; +import { baseSchema, projects, users } from '@shared/entities'; +import { isNotNull, isNull } from 'drizzle-orm'; +import { text, boolean, varchar, timestamp, integer, index } from 'drizzle-orm/pg-core'; + +export const areas = baseSchema.table( + 'areas', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + slug: varchar('slug', { length: 100 }).notNull().unique(), + description: text('description'), + descriptionHtml: text('description_html'), + color: varchar('color', { length: 10 }), + tasksCount: integer('tasks_count').notNull().default(0), + defaultView: varchar('default_view', { length: 20 }).notNull().default('kanban'), + icon: varchar('icon', { length: 20 }), + position: integer('position').notNull().default(0), + maxTasksLimit: integer('max_tasks_limit'), + isLocked: boolean('is_locked').default(false), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .notNull() + .defaultNow(), + createdBy: text('created_by').references(() => users.id), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), + }, + (t) => ({ + slugIdx: index('idx_areas_slug').on(t.slug), + projectActiveIdx: index('idx_areas_project_active') + .on(t.projectId, t.position) + .where(isNull(t.deletedAt)), + createdByIdx: index('idx_areas_created_by').on(t.createdBy).where(isNull(t.deletedAt)), + deletedAtIdx: index('idx_areas_deleted_at').on(t.deletedAt).where(isNotNull(t.deletedAt)), + }), +); diff --git a/src/area/infrastructure/persistence/models/enum.ts b/src/area/infrastructure/persistence/models/enum.ts new file mode 100644 index 00000000..9336290e --- /dev/null +++ b/src/area/infrastructure/persistence/models/enum.ts @@ -0,0 +1,5 @@ +import { STATE_CATEGORIES, STATE_TYPES } from '@core/area/domain/entities'; +import { baseSchema } from '@shared/entities'; + +export const stateTypeEnum = baseSchema.enum('state_type', STATE_TYPES); +export const stateCategoryEnum = baseSchema.enum('state_category', STATE_CATEGORIES); diff --git a/src/area/infrastructure/persistence/models/index.ts b/src/area/infrastructure/persistence/models/index.ts new file mode 100644 index 00000000..9c8a44fc --- /dev/null +++ b/src/area/infrastructure/persistence/models/index.ts @@ -0,0 +1,3 @@ +export { areas } from './area.model'; +export { states } from './state.model'; +export { stateCategoryEnum, stateTypeEnum } from './enum'; diff --git a/src/area/infrastructure/persistence/models/state.model.ts b/src/area/infrastructure/persistence/models/state.model.ts new file mode 100644 index 00000000..93ba3f42 --- /dev/null +++ b/src/area/infrastructure/persistence/models/state.model.ts @@ -0,0 +1,56 @@ +import { createId } from '@paralleldrive/cuid2'; +import { baseSchema, users } from '@shared/entities'; +import { isNotNull, isNull } from 'drizzle-orm'; +import { + text, + boolean, + varchar, + timestamp, + integer, + uniqueIndex, + index, +} from 'drizzle-orm/pg-core'; + +import { areas } from './area.model'; +import { stateCategoryEnum, stateTypeEnum } from './enum'; + +export const states = baseSchema.table( + 'states', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + areaId: text('area_id').references(() => areas.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + description: text('description'), + stateType: stateTypeEnum('state_type').notNull().default('custom'), + category: stateCategoryEnum('category').notNull().default('active'), + color: varchar('color', { length: 10 }), + icon: varchar('icon', { length: 20 }), + position: integer('position').notNull().default(0), + isVisible: boolean('is_visible').notNull().default(true), + maxTasksLimit: integer('max_tasks_limit'), + autoTransitionTo: text('auto_transition_to'), + notifyOnEnter: boolean('notify_on_enter').default(false), + notifyOnExit: boolean('notify_on_exit').default(false), + isLocked: boolean('is_locked').default(false), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .notNull() + .defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .notNull() + .defaultNow(), + createdBy: text('created_by').references(() => users.id), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), + }, + (t) => ({ + statePositionIdx: index('idx_states_position').on(t.areaId, t.position), + stateTitleIdx: index('idx_states_title').on(t.areaId, t.title), + stateCreatedAtIdx: index('idx_states_created_at').on(t.areaId, t.createdAt), + searchIdx: index('idx_states_search').on(t.areaId, t.title), + uniqueStateTitle: uniqueIndex('idx_states_unique_title') + .on(t.areaId, t.title) + .where(isNull(t.deletedAt)), + deletedAtIdx: index('idx_states_deleted_at').on(t.deletedAt).where(isNotNull(t.deletedAt)), + }), +); diff --git a/src/area/infrastructure/persistence/repositories/area.repository.ts b/src/area/infrastructure/persistence/repositories/area.repository.ts new file mode 100644 index 00000000..3aa05927 --- /dev/null +++ b/src/area/infrastructure/persistence/repositories/area.repository.ts @@ -0,0 +1,132 @@ +import { IAreaRepository } from '@core/area/domain/repository'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Inject, Injectable } from '@nestjs/common'; +import { and, count, eq, isNotNull, isNull } from 'drizzle-orm'; + +import { DEFAULT_STATES } from '../../constants'; +import * as schema from '../models'; + +import type { NewArea } from '@core/area/domain/entities'; + +@Injectable() +export class AreaRepository implements IAreaRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public async create(data: NewArea) { + const result = await this.db.transaction(async (tx) => { + const [area] = await tx + .insert(schema.areas) + .values(data) + .returning({ id: schema.areas.id, slug: schema.areas.slug }); + + if (!area) { + throw new Error('Failed to create area: no area returned'); + } + + const statesData = DEFAULT_STATES.map((state) => ({ + areaId: area.id, + title: state.title, + type: state.type, + category: state.category, + position: state.position, + color: state.color, + createdBy: data.createdBy, + })); + + await tx.insert(schema.states).values(statesData); + + return { slug: area.slug }; + }); + + return result; + } + + public async update(projectId: string, areaId: string, data: Partial) { + const result = await this.db + .update(schema.areas) + .set({ ...data, updatedAt: new Date().toISOString() }) + .where( + and( + eq(schema.areas.id, areaId), + eq(schema.areas.projectId, projectId), + isNull(schema.areas.deletedAt), + ), + ); + + return (result.count ?? 0) > 0; + } + + public async delete(projectId: string, areaId: string) { + const result = await this.db + .update(schema.areas) + .set({ deletedAt: new Date().toISOString() }) + .where( + and( + eq(schema.areas.id, areaId), + eq(schema.areas.projectId, projectId), + isNull(schema.areas.deletedAt), + ), + ); + + return (result.count ?? 0) > 0; + } + + public async findOne(projectId: string, areaId: string, includeDeleted = false) { + const [result] = await this.db + .select() + .from(schema.areas) + .where( + and( + eq(schema.areas.id, areaId), + eq(schema.areas.projectId, projectId), + includeDeleted + ? isNotNull(schema.areas.deletedAt) + : isNull(schema.areas.deletedAt), + ), + ); + + return result || null; + } + + public async findAll(projectId: string, includeDeleted = false) { + return this.db + .select() + .from(schema.areas) + .where( + and( + eq(schema.areas.projectId, projectId), + includeDeleted + ? isNotNull(schema.areas.deletedAt) + : isNull(schema.areas.deletedAt), + ), + ) + .orderBy(schema.areas.position); + } + + public async findBySlug(slug: string, projectId?: string) { + const [result] = await this.db + .select() + .from(schema.areas) + .where( + and( + projectId ? eq(schema.areas.projectId, projectId) : undefined, + eq(schema.areas.slug, slug), + isNull(schema.areas.deletedAt), + ), + ); + + return result || null; + } + + public async countByProject(projectId: string): Promise { + const [result] = await this.db + .select({ count: count().mapWith(Number) }) + .from(schema.areas) + .where(and(eq(schema.areas.projectId, projectId), isNull(schema.areas.deletedAt))); + + return result?.count ?? 0; + } +} diff --git a/src/area/infrastructure/persistence/repositories/index.ts b/src/area/infrastructure/persistence/repositories/index.ts new file mode 100644 index 00000000..f0606fa7 --- /dev/null +++ b/src/area/infrastructure/persistence/repositories/index.ts @@ -0,0 +1,13 @@ +import { AreaRepository } from './area.repository'; +import { StateRepository } from './state.repository'; + +export const REPOSITORIES = [ + { + provide: 'IAreaRepository', + useClass: AreaRepository, + }, + { + provide: 'IStateRepository', + useClass: StateRepository, + }, +]; diff --git a/src/area/infrastructure/persistence/repositories/state.repository.ts b/src/area/infrastructure/persistence/repositories/state.repository.ts new file mode 100644 index 00000000..261e67a2 --- /dev/null +++ b/src/area/infrastructure/persistence/repositories/state.repository.ts @@ -0,0 +1,124 @@ +import { IStateRepository } from '@core/area/domain/repository'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Inject, Injectable } from '@nestjs/common'; +import { and, count, eq, isNotNull, isNull } from 'drizzle-orm'; + +import * as schema from '../models'; + +import type { NewState } from '@core/area/domain/entities'; + +@Injectable() +export class StateRepository implements IStateRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public async create(data: NewState) { + const [result] = await this.db + .insert(schema.states) + .values(data) + .returning({ id: schema.states.id }); + + if (!result) { + throw new Error('Failed to create state: no state returned'); + } + + return result; + } + + public async delete(areaId: string, stateId: string) { + const result = await this.db + .update(schema.states) + .set({ deletedAt: new Date().toISOString() }) + .where( + and( + eq(schema.states.id, stateId), + eq(schema.states.areaId, areaId), + isNull(schema.states.deletedAt), + ), + ); + + return (result.count ?? 0) > 0; + } + + public async find(areaId: string, _query: unknown) { + return this.db + .select() + .from(schema.states) + .where(and(eq(schema.states.areaId, areaId), isNull(schema.states.deletedAt))); + } + + public async findOne(areaId: string, stateId: string, deleted?: boolean) { + const [result] = await this.db + .select() + .from(schema.states) + .where( + and( + eq(schema.states.id, stateId), + eq(schema.states.areaId, areaId), + deleted ? isNotNull(schema.states.deletedAt) : isNull(schema.states.deletedAt), + ), + ); + + return result ?? null; + } + + public async update(areaId: string, stateId: string, data: Partial) { + const result = await this.db + .update(schema.states) + .set(data) + .where( + and( + eq(schema.states.id, stateId), + eq(schema.states.areaId, areaId), + isNull(schema.states.deletedAt), + ), + ); + + return (result.count ?? 0) > 0; + } + + public async findByType( + areaId: string, + // TODO: ADD BASE ENUM TOO + stateType: 'custom' | 'archived' | 'backlog' | 'todo' | 'in_progress' | 'review' | 'done', + ) { + const [result] = await this.db + .select() + .from(schema.states) + .where( + and( + eq(schema.states.areaId, areaId), + eq(schema.states.stateType, stateType), + isNull(schema.states.deletedAt), + ), + ); + + return result ?? null; + } + + public async findByTitle(areaId: string, title: string) { + const [result] = await this.db + .select() + .from(schema.states) + .where( + and( + eq(schema.states.areaId, areaId), + eq(schema.states.title, title), + isNull(schema.states.deletedAt), + ), + ); + + return result ?? null; + } + + public readonly countByArea = async (areaId: string) => { + const [result] = await this.db + .select({ count: count() }) + .from(schema.states) + .where(and(eq(schema.states.areaId, areaId), isNull(schema.states.deletedAt))); + + return result?.count ?? 0; + }; +} diff --git a/src/auth/application/auth.facade.ts b/src/auth/application/auth.facade.ts new file mode 100644 index 00000000..c1d5b349 --- /dev/null +++ b/src/auth/application/auth.facade.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@nestjs/common'; + +import { + ExchangeDto, + OAuthResponse, + PasswordResetConfirmDto, + ResendCodeDto, + ResetPasswordDto, + SignInDto, + SignUpDto, + VerifyDto, + VerifyResetCodeDto, +} from './dtos'; +import { + SignInUseCase, + SignUpUseCase, + SignOutUseCase, + SignUpVerifyUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + VerifyResetPasswordUseCase, + ConfirmResetPasswordUseCase, + AuthenticateOAuthUseCase, + ConnectProviderUseCase, + DisconnectProviderUseCase, + GetConnectedProvidersQuery, + GetEnabledProvidersQuery, + ResendCodeUseCase, + ExchangeUseCase, +} from './use-cases'; + +import type { DeviceMetadata } from '../infrastructure/utils'; + +@Injectable() +export class AuthFacade { + constructor( + private readonly signInUseCase: SignInUseCase, + private readonly signUpUseCase: SignUpUseCase, + private readonly signOutUseCase: SignOutUseCase, + private readonly getEnabledProvidersQuery: GetEnabledProvidersQuery, + private readonly signUpVerifyUseCase: SignUpVerifyUseCase, + private readonly refreshTokensUseCase: RefreshTokensUseCase, + private readonly resetPasswordUseCase: ResetPasswordUseCase, + private readonly authenticateOAuthUseCase: AuthenticateOAuthUseCase, + private readonly verifyResetPasswordUseCase: VerifyResetPasswordUseCase, + private readonly connectProviderUseCase: ConnectProviderUseCase, + private readonly disconnectProviderUseCase: DisconnectProviderUseCase, + private readonly getConnectedProvidersQuery: GetConnectedProvidersQuery, + private readonly confirmResetPasswordUseCase: ConfirmResetPasswordUseCase, + private readonly resendCodeUseCase: ResendCodeUseCase, + private readonly exchangeTokenUC: ExchangeUseCase, + ) {} + + public async signIn(dto: SignInDto, device: DeviceMetadata) { + return this.signInUseCase.execute(dto, device); + } + + public async signUp(dto: SignUpDto) { + return this.signUpUseCase.execute(dto); + } + + public async resendCode(dto: ResendCodeDto) { + return this.resendCodeUseCase.execute(dto); + } + + public async verifySignUp(dto: VerifyDto, device: DeviceMetadata) { + return this.signUpVerifyUseCase.execute(dto, device); + } + + public async signOut(token?: string) { + return this.signOutUseCase.execute(token); + } + + public async refreshTokens(token: string | undefined, device: DeviceMetadata) { + return this.refreshTokensUseCase.execute(token, device); + } + + public async sendResetCode(dto: ResetPasswordDto) { + return this.resetPasswordUseCase.execute(dto); + } + + public async verifyResetCode(dto: VerifyResetCodeDto) { + return this.verifyResetPasswordUseCase.execute(dto); + } + + public async confirmNewPassword(dto: PasswordResetConfirmDto) { + return this.confirmResetPasswordUseCase.execute(dto); + } + + public async exchangeToken(dto: ExchangeDto, device: DeviceMetadata) { + return this.exchangeTokenUC.execute(dto, device); + } + + public async authenticateOAuth(dto: OAuthResponse, device: DeviceMetadata, state?: string) { + return this.authenticateOAuthUseCase.execute(dto, device, state); + } + + public async connectProvider(provider: string, userId: string) { + return this.connectProviderUseCase.execute(provider, userId); + } + + public async disconnectProvider(provider: string, userId: string) { + return this.disconnectProviderUseCase.execute(provider, userId); + } + + public async getConnectedProviders(userId: string) { + return this.getConnectedProvidersQuery.execute(userId); + } + + public async getEnabledProviders() { + return this.getEnabledProvidersQuery.execute(); + } +} diff --git a/src/auth/application/controllers/auth/controller.ts b/src/auth/application/controllers/auth/controller.ts new file mode 100644 index 00000000..25d76a1b --- /dev/null +++ b/src/auth/application/controllers/auth/controller.ts @@ -0,0 +1,120 @@ +import { getDeviceMeta } from '@core/auth/infrastructure/utils'; +import { Body, HttpCode, HttpStatus, Post, Req, Res, UseGuards } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ApiBaseController } from '@shared/decorators'; +import { BearerAuthGuard, CookieAuthGuard } from '@shared/guards'; + +import { AuthFacade } from '../../auth.facade'; +import { ResendCodeDto, SignInDto, SignUpDto, VerifyDto } from '../../dtos'; + +import { + PostLoginSwagger, + PostLogoutSwagger, + PostRefreshSwagger, + PostRegisterSwagger, + PostSignUpConfirmSwagger, + ResendCodeSwagger, +} from './swagger'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +@ApiBaseController('auth', 'Auth') +export class AuthController { + private readonly isProduction: boolean = false; + private readonly domain?: string | null = null; + + constructor( + private readonly facade: AuthFacade, + private readonly cfg: ConfigService, + ) { + this.isProduction = this.cfg.get('NODE_ENV') === 'production'; + this.domain = this.cfg.get('DOMAIN'); + } + + @Post('sign-up') + @PostRegisterSwagger() + @HttpCode(202) + async signUp(@Body() dto: SignUpDto) { + return this.facade.signUp(dto); + } + + @Post('resend') + @ResendCodeSwagger() + @HttpCode(200) + async resendCode(@Body() dto: ResendCodeDto) { + return this.facade.resendCode(dto); + } + + @Post('sign-up/confirm') + @PostSignUpConfirmSwagger() + @HttpCode(201) + async verifySignUp( + @Res({ passthrough: true }) res: FastifyReply, + @Req() req: FastifyRequest, + @Body() dto: VerifyDto, + ) { + const meta = getDeviceMeta(req); + const { tokens, expiresAt, ...response } = await this.facade.verifySignUp(dto, meta); + + this.setRefreshCookie(res, tokens.refresh, expiresAt); + + return { ...response, token: tokens.access }; + } + + @Post('sign-in') + @PostLoginSwagger() + async signIn( + @Res({ passthrough: true }) res: FastifyReply, + @Req() req: FastifyRequest, + @Body() dto: SignInDto, + ) { + const meta = getDeviceMeta(req); + const { tokens, expiresAt, ...response } = await this.facade.signIn(dto, meta); + + this.setRefreshCookie(res, tokens.refresh, expiresAt); + + return { ...response, token: tokens.access }; + } + + @Post('sign-out') + @HttpCode(HttpStatus.OK) + @UseGuards(BearerAuthGuard) + @PostLogoutSwagger() + async logout(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) { + const session = req.cookies?.['refresh']; + + const response = await this.facade.signOut(session); + + res.clearCookie('refresh', { + path: '/', + domain: this.domain ? `.${this.domain}` : undefined, + }); + + return response; + } + + @Post('refresh') + @UseGuards(CookieAuthGuard) + @PostRefreshSwagger() + @HttpCode(200) + async refresh(@Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest) { + const meta = getDeviceMeta(req); + const session = req.cookies?.['refresh']; + const { tokens, expiresAt, ...response } = await this.facade.refreshTokens(session, meta); + + this.setRefreshCookie(res, tokens.refresh, expiresAt); + + return { token: tokens.access, ...response }; + } + + private setRefreshCookie(res: FastifyReply, refreshToken: string, expires: Date) { + res.setCookie('refresh', refreshToken, { + httpOnly: true, + secure: this.isProduction, + path: '/', + expires, + sameSite: 'lax', + domain: this.domain ? `.${this.domain}` : undefined, + }); + } +} diff --git a/src/auth/application/controllers/auth/swagger.ts b/src/auth/application/controllers/auth/swagger.ts new file mode 100644 index 00000000..826ddf48 --- /dev/null +++ b/src/auth/application/controllers/auth/swagger.ts @@ -0,0 +1,164 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiBadRequest, + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiTooManyRequests, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { + SignInDto, + SignResponse, + SignUpDto, + VerifyDto, + SessionsResponse, + SessionResponse, + ResendCodeDto, + ResendCodeResponse, +} from '../../dtos'; + +export const PostRegisterSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Регистрация нового пользователя', + description: 'Создает пользователя, базовые настройки безопасности и уведомлений.', + }), + ApiBody({ type: SignUpDto.Output }), + ApiResponse({ + status: 201, + description: 'Пользователь успешно зарегистрирован.', + type: ActionResponse.Output, + }), + ApiValidationError('Ошибка валидации данных (например, неверный формат email)'), + ApiConflict('Пользователь с таким email уже существует'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const PostLoginSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Вход в систему', + description: + 'Возвращает Access/Refresh токены. Если у пользователя включена 2FA, вернет временный токен.', + }), + ApiBody({ type: SignInDto.Output }), + ApiResponse({ + status: 200, + description: 'Успешный вход.', + type: SignResponse.Output, + }), + ApiBadRequest('Неверный формат email'), + ApiUnauthorized('Неверный email или пароль'), + + SetMetadata(ZOD_RESPONSE_TOKEN, SignResponse), + ); + +export const PostRefreshSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновление токенов', + description: 'Выдает новую пару Access и Refresh токенов.', + }), + ApiResponse({ + status: 200, + description: 'Токены успешно обновлены.', + type: SignResponse.Output, + }), + ApiBadRequest('Ошибка валидации (не передан refresh токен)'), + ApiUnauthorized('Refresh токен недействителен, истек или отозван'), + + SetMetadata(ZOD_RESPONSE_TOKEN, SignResponse), + ); + +export const PostLogoutSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Выход из системы', + description: 'Удаляет текущую сессию пользователя из Redis.', + }), + ApiResponse({ status: 200, description: 'Успешный выход.', type: ActionResponse.Output }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const PostSignUpConfirmSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Подтверждение регистрации по коду', + description: + 'Проверяет OTP из письма, создаёт аккаунт, выдаёт access-токен в теле ответа и устанавливает refresh в httpOnly cookie.', + }), + ApiBody({ type: VerifyDto.Output }), + ApiResponse({ + status: 201, + description: 'Аккаунт подтверждён, сессия создана.', + type: SignResponse.Output, + }), + ApiValidationError('Ошибка валидации (неверный формат email или длина кода)'), + ApiBadRequest('Срок регистрации истёк или сессия не найдена'), + ApiBadRequest('Неверный или истёкший код подтверждения'), + + SetMetadata(ZOD_RESPONSE_TOKEN, SignResponse), + ); + +export const GetSessionsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить активные сессии', + description: 'Возвращает список всех активных устройств/сессий пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список сессий успешно получен.', + type: [SessionResponse.Output], + }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, SessionsResponse), + ); + +export const DeleteSessionSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Завершить чужую сессию', + description: 'Принудительно удаляет указанную сессию из Redis.', + }), + ApiParam({ name: 'cuid', description: 'ID сессии, которую нужно завершить' }), + ApiResponse({ + status: 200, + description: 'Сессия успешно завершена.', + type: ActionResponse.Output, + }), + ApiUnauthorized(), + ApiForbidden(), + ApiNotFound('Сессия не найдена или уже истекла'), + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const ResendCodeSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Повторная отправка кода подтверждения', + description: + 'Отправляет новый код подтверждения на email, связанный с текущей сессией регистрации или сброса пароля.', + }), + ApiBody({ type: ResendCodeDto.Output }), + ApiResponse({ + status: 200, + description: 'Код успешно отправлен.', + type: ResendCodeResponse.Output, + }), + ApiBadRequest('Неверный формат email'), + ApiNotFound('Сессия регистрации или сброса пароля не найдена или истекла'), + ApiTooManyRequests('Превышено количество попыток запроса нового кода на email'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ResendCodeResponse), + ); diff --git a/src/auth/application/controllers/index.ts b/src/auth/application/controllers/index.ts new file mode 100644 index 00000000..5d52967c --- /dev/null +++ b/src/auth/application/controllers/index.ts @@ -0,0 +1,5 @@ +import { AuthController } from './auth/controller'; +import { OAuthController } from './oauth/controller'; +import { AuthRecoveryController } from './recovery/controller'; + +export const CONTROLLERS = [OAuthController, AuthRecoveryController, AuthController]; diff --git a/src/auth/application/controllers/oauth/controller.ts b/src/auth/application/controllers/oauth/controller.ts new file mode 100644 index 00000000..0870645e --- /dev/null +++ b/src/auth/application/controllers/oauth/controller.ts @@ -0,0 +1,165 @@ +import { getDeviceMeta } from '@core/auth/infrastructure/utils'; +import { + Body, + Delete, + Get, + HttpCode, + Param, + Post, + Query, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ApiBaseController, GetUserId, SkipContract } from '@shared/decorators'; +import { type IErrorOptions, isBaseException } from '@shared/error'; +import { BearerAuthGuard, OAuthGuard } from '@shared/guards'; + +import { AuthFacade } from '../../auth.facade'; +import { ExchangeDto, type TOAuthResponse } from '../../dtos'; + +import { + DisconnectOAuthProviderSwagger, + GetConnectedProvidersSwagger, + ConnectOAuthProviderSwagger, + GetOAuthProvidersSwagger, + OAuthCallbackSwagger, + OAuthLoginSwagger, + ExchangeSwagger, +} from './swagger'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +@ApiBaseController('oauth', 'OAuth') +export class OAuthController { + private readonly isProduction: boolean = false; + private readonly domain?: string | null = null; + + constructor( + private readonly facade: AuthFacade, + private readonly cfg: ConfigService, + ) { + this.isProduction = this.cfg.get('NODE_ENV') === 'production'; + this.domain = this.cfg.get('DOMAIN'); + } + + @Get(':provider') + @OAuthLoginSwagger() + @UseGuards(OAuthGuard) + @SkipContract() + async oauthLogin() {} + + @Get(':provider/callback') + @OAuthCallbackSwagger() + @UseGuards(OAuthGuard) + @SkipContract() + async oauthCallback( + @Query() query: { code?: string; state?: string }, + @Param('provider') provider: 'google' | 'yandex' | 'github' | 'vkontakte', + @Res({ passthrough: true }) res: FastifyReply, + @Req() req: FastifyRequest, + ) { + const meta = getDeviceMeta(req); + const body = req.user as unknown as TOAuthResponse; + const state = query?.state; + const baseUrl = `https://dev.${this.domain}`; + + try { + const dto = { + provider, + id: body.id, + first_name: body.first_name, + last_name: body.last_name, + email: body.email, + avatar_url: body.avatar_url, + sex: body.sex, + bio: body.bio, + }; + + const result = await this.facade.authenticateOAuth(dto, meta, state); + + if (result.isSign) { + res.redirect(`${baseUrl}/oauth?${result.query.toString()}`, 302); + } else { + res.redirect(`${baseUrl}/user/profile?${result.query.toString()}`, 302); + } + } catch (err) { + let message = 'Произошла ошибка при авторизации'; + let code = 'OAUTH_ERROR'; + + if (isBaseException(err)) { + const response = err.getResponse() as IErrorOptions; + message = response.message || message; + code = response.code || code; + } + + if (err instanceof Error) { + message = err.message || message; + } + + const errorQuery = new URLSearchParams({ + success: 'false', + message, + code, + }); + + res.redirect(`${baseUrl}/oauth?${errorQuery.toString()}`, 302); + } + } + + @Post('exchange') + @ExchangeSwagger() + @HttpCode(200) + async exchange( + @Body() dto: ExchangeDto, + @Res({ passthrough: true }) res: FastifyReply, + @Req() req: FastifyRequest, + ) { + const meta = getDeviceMeta(req); + + const { expiresAt, refresh, ...result } = await this.facade.exchangeToken(dto, meta); + + this.setRefreshCookie(res, refresh, expiresAt); + + return result; + } + + @Get('providers') + @GetOAuthProvidersSwagger() + async getEnabledProviders() { + return this.facade.getEnabledProviders(); + } + + @Get('providers/connected') + @GetConnectedProvidersSwagger() + @UseGuards(BearerAuthGuard) + async getConnected(@GetUserId() userId: string) { + return this.facade.getConnectedProviders(userId); + } + + @Post(':provider/connect') + @UseGuards(BearerAuthGuard) + @ConnectOAuthProviderSwagger() + async connect(@Param('provider') provider: string, @GetUserId() userId: string) { + return this.facade.connectProvider(provider, userId); + } + + @Delete(':provider/connect') + @DisconnectOAuthProviderSwagger() + @UseGuards(BearerAuthGuard) + async disconnect(@GetUserId() userId: string, @Param('provider') provider: string) { + return this.facade.disconnectProvider(provider, userId); + } + + private setRefreshCookie(res: FastifyReply, refreshToken: string, expires: Date) { + res.setCookie('refresh', refreshToken, { + httpOnly: true, + secure: this.isProduction, + path: '/', + expires, + sameSite: 'lax', + domain: this.domain ? `.${this.domain}` : undefined, + }); + } +} diff --git a/src/auth/application/controllers/oauth/swagger.ts b/src/auth/application/controllers/oauth/swagger.ts new file mode 100644 index 00000000..5c04d536 --- /dev/null +++ b/src/auth/application/controllers/oauth/swagger.ts @@ -0,0 +1,179 @@ +import { OAuthProvider } from '@core/auth/infrastructure/constants'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiBadRequest, + ApiConflict, + ApiForbidden, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { + ConnectedProviders, + ConnectProviderResponse, + ExchangeDto, + ExchangeResponse, + ProvidersResponse, +} from '../../dtos'; + +export const OAuthLoginSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Инициализация OAuth авторизации', + description: + 'Перенаправляет пользователя на страницу аутентификации выбранного провайдера (google, github и т.д.).', + }), + ApiParam({ + name: 'provider', + description: 'Название OAuth провайдера', + enum: OAuthProvider, + }), + ApiResponse({ + status: 302, + description: 'Успешное перенаправление на сторону провайдера.', + }), + ApiBadRequest('Указан незарегистрированный или неподдерживаемый провайдер'), + ); + +export const OAuthCallbackSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Callback для завершения OAuth авторизации', + description: + 'Обрабатывает ответ от провайдера, аутентифицирует пользователя, устанавливает refresh-токен в httpOnly cookie и возвращает результат.', + }), + ApiParam({ + name: 'provider', + description: 'Название OAuth провайдера', + enum: OAuthProvider, + }), + ApiResponse({ + status: 302, + description: 'Успешный вход. Перенаправление на фронтенд с параметрами авторизации.', + headers: { + Location: { + description: + 'URL фронтенда с query-параметрами. Пример: https://frontend.com/oauth?success=true&access=ey...', + schema: { + type: 'string', + }, + }, + }, + }), + ApiUnauthorized('Ошибка авторизации через сторонний сервис'), + ApiValidationError('Данные от провайдера не прошли валидацию'), + ); + +export const GetOAuthProvidersSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список активных OAuth провайдеров', + description: + 'Возвращает массив провайдеров, которые сейчас настроены на бэкенде (активны в .env). Используется фронтендом для динамической отрисовки кнопок входа.', + }), + ApiResponse({ + status: 200, + description: 'Список доступных провайдеров со ссылками на их иконки.', + type: ProvidersResponse.Output, + }), + + SetMetadata(ZOD_RESPONSE_TOKEN, ProvidersResponse), + ); + +export const ConnectOAuthProviderSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Привязать OAuth провайдера к аккаунту', + description: + 'Позволяет аутентифицированному пользователю привязать внешний OAuth-провайдер (Google, GitHub и т.д.) к своему существующему аккаунту. Полезно для добавления дополнительных способов входа.', + }), + ApiParam({ + name: 'provider', + description: 'Название OAuth провайдера для привязки', + enum: OAuthProvider, + required: true, + }), + ApiResponse({ + status: 200, + description: 'Провайдер успешно привязан к аккаунту.', + type: [ConnectProviderResponse.Output], + }), + ApiBadRequest( + 'Провайдер уже привязан к этому аккаунту или указан неподдерживаемый провайдер', + ), + ApiConflict( + 'Конфликт: провайдер уже используется другим пользователем (например, Google аккаунт уже привязан к другому пользователю в системе)', + ), + ApiUnauthorized(), + ApiValidationError(), + SetMetadata(ZOD_RESPONSE_TOKEN, ConnectProviderResponse), + ); + +export const DisconnectOAuthProviderSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Отвязать OAuth провайдера от аккаунта', + description: + 'Удаляет привязку OAuth-провайдера от текущего аккаунта пользователя. Важно: если это был единственный способ входа (и нет пароля), операция будет отклонена, чтобы пользователь не потерял доступ.', + }), + ApiParam({ + name: 'provider', + description: 'Название OAuth провайдера для отвязки', + enum: OAuthProvider, + required: true, + }), + ApiResponse({ + status: 200, + description: 'Провайдер успешно отвязан от аккаунта.', + type: ActionResponse.Output, + }), + ApiForbidden( + 'Запрещено: нельзя отвязать единственный способ входа (останетесь без доступа к аккаунту)', + ), + ApiBadRequest( + 'Провайдер не привязан к этому аккаунту или указан неподдерживаемый провайдер', + ), + ApiUnauthorized(), + ApiValidationError(), + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const GetConnectedProvidersSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список привязанных OAuth провайдеров пользователя', + description: + 'Возвращает массив OAuth-провайдеров, которые уже привязаны к текущему аккаунту пользователя. Используется на странице настроек аккаунта для отображения статуса привязок.', + }), + ApiResponse({ + status: 200, + description: 'Список привязанных провайдеров с метаданными.', + type: ConnectedProviders.Output, + }), + ApiUnauthorized('Пользователь не авторизован'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ConnectedProviders), + ); +export const ExchangeSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обменять одноразовый токен на сессию', + description: + 'Обменивает одноразовый exchange-токен, полученный после OAuth авторизации, на полноценную сессию с access и refresh токенами. Устанавливает refresh токен в httpOnly cookie.', + }), + ApiBody({ + type: ExchangeDto.Output, + }), + ApiResponse({ + status: 200, + description: 'Токен успешно обменян. Возвращает access токен и данные пользователя.', + type: ExchangeResponse.Output, + }), + ApiBadRequest('Неверный запрос. Токен отсутствует, истёк или имеет неверный формат.'), + ApiUnauthorized(), + ApiValidationError(), + SetMetadata(ZOD_RESPONSE_TOKEN, ExchangeResponse), + ); diff --git a/src/auth/application/controllers/recovery/controller.ts b/src/auth/application/controllers/recovery/controller.ts new file mode 100644 index 00000000..3c3a8976 --- /dev/null +++ b/src/auth/application/controllers/recovery/controller.ts @@ -0,0 +1,34 @@ +import { Body, Post } from '@nestjs/common'; +import { ApiBaseController } from '@shared/decorators'; + +import { AuthFacade } from '../../auth.facade'; +import { PasswordResetConfirmDto, ResetPasswordDto, VerifyResetCodeDto } from '../../dtos'; + +import { + PostPasswordResetConfirmSwagger, + PostPasswordResetSwagger, + PostPasswordResetVerifySwagger, +} from './swagger'; + +@ApiBaseController('auth', 'Auth Recovery') +export class AuthRecoveryController { + constructor(private readonly facade: AuthFacade) {} + + @Post('password/reset') + @PostPasswordResetSwagger() + async sendResetCode(@Body() dto: ResetPasswordDto) { + return this.facade.sendResetCode(dto); + } + + @Post('password/reset/verify') + @PostPasswordResetVerifySwagger() + async verifyResetCode(@Body() dto: VerifyResetCodeDto) { + return this.facade.verifyResetCode(dto); + } + + @Post('password/reset/confirm') + @PostPasswordResetConfirmSwagger() + async confirmNewPassword(@Body() dto: PasswordResetConfirmDto) { + return this.facade.confirmNewPassword(dto); + } +} diff --git a/src/auth/application/controllers/recovery/swagger.ts b/src/auth/application/controllers/recovery/swagger.ts new file mode 100644 index 00000000..926bf9a4 --- /dev/null +++ b/src/auth/application/controllers/recovery/swagger.ts @@ -0,0 +1,196 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiBadRequest, + ApiErrorResponse, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { + ChangePasswordDto, + Confirm2FaDto, + Disable2FaDto, + PasswordResetConfirmDto, + ResetPasswordDto, + VerifyResetCodeDto, + Enable2FaResponse, + SessionsResponse, + SessionResponse, +} from '../../dtos'; + +export const PostPasswordResetSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Запрос кода восстановления пароля', + description: 'Отправляет одноразовый код на email, если пользователь существует.', + }), + ApiBody({ type: ResetPasswordDto.Output }), + ApiResponse({ + status: 201, + description: 'Код отправлен на почту (при успешной обработке запроса).', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат email'), + ApiErrorResponse( + 422, + 'INVALID_EMAIL_FORMAT', + 'Указанный email адрес имеет некорректный формат', + ), + ApiNotFound('Пользователь с таким email не найден'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const PostPasswordResetVerifySwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Проверка кода восстановления пароля', + description: 'Проверяет код из письма и помечает сессию сброса как подтверждённую.', + }), + ApiBody({ type: VerifyResetCodeDto.Output }), + ApiResponse({ + status: 201, + description: 'Код подтверждён, можно задать новый пароль.', + type: ActionResponse.Output, + }), + ApiValidationError('Ошибка валидации (email или формат кода)'), + ApiBadRequest('Время подтверждения истекло или запрос не найден'), + ApiBadRequest('Неверный или истёкший код подтверждения'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const PostPasswordResetConfirmSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Установка нового пароля после сброса', + description: 'Доступно только после успешной проверки кода на шаге verify.', + }), + ApiBody({ type: PasswordResetConfirmDto.Output }), + ApiResponse({ + status: 201, + description: 'Пароль успешно изменён.', + type: ActionResponse.Output, + }), + ApiValidationError('Ошибка валидации (пароли не совпадают или неверная длина)'), + ApiBadRequest('Сессия восстановления не найдена или истекла'), + ApiForbidden(), + ApiErrorResponse( + 500, + 'PASSWORD_UPDATE_FAILED', + 'Не удалось обновить пароль. Попробуйте позже.', + ), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const GetSessionsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить активные сессии', + description: 'Возвращает список всех активных устройств/сессий пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список сессий успешно получен.', + type: [SessionResponse.Output], + }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, SessionsResponse), + ); + +export const DeleteSessionSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Завершить чужую сессию', + description: 'Принудительно удаляет указанную сессию из Redis.', + }), + ApiParam({ name: 'cuid', description: 'ID сессии, которую нужно завершить' }), + ApiResponse({ + status: 200, + description: 'Сессия успешно завершена.', + type: ActionResponse.Output, + }), + ApiUnauthorized(), + ApiForbidden(), + ApiNotFound('Сессия не найдена или уже истекла'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const PostChangePasswordSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Смена пароля', + description: 'Требует текущий и новый пароль. Инвалидирует все остальные сессии.', + }), + ApiBody({ type: ChangePasswordDto.Output }), + ApiResponse({ + status: 200, + description: 'Пароль успешно изменен.', + type: ActionResponse.Output, + }), + ApiBadRequest('Неверный старый пароль'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const PostEnable2faSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Генерация QR-кода для 2FA', + description: 'Создает секрет и возвращает ссылку (otpauth) для Google Authenticator.', + }), + ApiResponse({ + status: 200, + description: 'QR-код сгенерирован.', + type: Enable2FaResponse.Output, + }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, Enable2FaResponse), + ); + +export const PostDisable2faSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Подтверждение включения 2FA', + description: 'Проверяет первый код из приложения для окончательной активации 2FA.', + }), + ApiBody({ type: Confirm2FaDto.Output }), + ApiResponse({ + status: 200, + description: 'Двухфакторная аутентификация успешно включена.', + type: ActionResponse.Output, + }), + ApiBadRequest('Неверный код подтверждения'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const PostConfirm2faSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Отключение 2FA', + description: + 'Отключает двухфакторную аутентификацию (требует подтверждения паролем или текущим кодом).', + }), + ApiBody({ type: Disable2FaDto.Output }), + ApiResponse({ + status: 200, + description: '2FA успешно отключена.', + type: ActionResponse.Output, + }), + ApiBadRequest('Неверный код или пароль для отключения'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/auth/application/dtos/2fa.dto.ts b/src/auth/application/dtos/2fa.dto.ts new file mode 100644 index 00000000..e254451b --- /dev/null +++ b/src/auth/application/dtos/2fa.dto.ts @@ -0,0 +1,34 @@ +import { createZodDto } from 'nestjs-zod'; +import z from 'zod/v4'; + +export const Confirm2FaSchema = z + .object({ + code: z + .string() + .length(6, 'Код должен состоять ровно из 6 символов') + .describe('6-значный код из Google Authenticator'), + }) + .describe('Схема подтверждения 2FA'); + +export class Confirm2FaDto extends createZodDto(Confirm2FaSchema) {} + +export const Disable2FaSchema = z + .object({ + password: z.string().optional().describe('Текущий пароль для подтверждения (опционально)'), + code: z.string().optional().describe('Код из приложения (опционально)'), + }) + .refine((data) => data.password || data.code, { + message: 'Нужно передать либо пароль, либо код', + }) + .describe('Схема отключения 2FA'); + +export class Disable2FaDto extends createZodDto(Disable2FaSchema) {} + +export const Enable2FaResponseSchema = z + .object({ + secret: z.string().describe('Секрет для генерации кодов'), + qrCodeUrl: z.string().describe('Ссылка для приложения (otpauth) для привязки 2FA'), + }) + .describe('Ответ на запрос генерации 2FA'); + +export class Enable2FaResponse extends createZodDto(Enable2FaResponseSchema) {} diff --git a/src/auth/application/dtos/auth.dto.ts b/src/auth/application/dtos/auth.dto.ts new file mode 100644 index 00000000..44fa933d --- /dev/null +++ b/src/auth/application/dtos/auth.dto.ts @@ -0,0 +1,97 @@ +import { ActionResponseSchema } from '@shared/schemas'; +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const SignInSchema = z + .object({ + email: z.email('Некорректный формат email').describe('Email пользователя'), + password: z.string().describe('Пароль пользователя'), + }) + .describe('Схема входа в систему'); + +export class SignInDto extends createZodDto(SignInSchema) {} + +export const SignUpSchema = z + .object({ + email: z.email('Некорректный формат email').describe('Email пользователя'), + password: z + .string() + .min(8, 'Пароль должен содержать минимум 8 символов') + .max(32, 'Пароль должен содержать максимум 32 символа') + .describe('Пароль (минимум 8 символов)'), + firstName: z + .string() + .min(2, 'Имя должно содержать минимум 2 символа') + .max(50) + .trim() + .describe('Имя'), + lastName: z + .string() + .min(2, 'Фамилия должна содержать минимум 2 символа') + .max(50) + .trim() + .describe('Фамилия'), + middleName: z + .string() + .max(50) + .trim() + .optional() + .or(z.literal('')) + .describe('Отчество (опционально)'), + }) + .describe('Схема регистрации пользователя'); + +export class SignUpDto extends createZodDto(SignUpSchema) {} + +export const VerifySchema = z + .object({ + email: z + .string() + .email('Некорректный формат email') + .describe('Email пользователя, на который был отправлен код'), + code: z + .string() + .length(6, 'Код должен содержать ровно 6 символов') + .describe('6-значный OTP код подтверждения'), + }) + .describe('Схема верификации OTP кода'); + +export class VerifyDto extends createZodDto(VerifySchema) {} + +export const SignResponseSchema = ActionResponseSchema.extend({ + token: z.string().describe('JWT токен доступа пользователя'), +}); + +export class SignResponse extends createZodDto(SignResponseSchema) {} + +export const ResendCodeSchema = z.object({ + context: z + .enum(['sign-up', 'reset-password'], { + error: 'Выберите корректный контекст: sign-up или reset-password', + }) + .describe('Контекст, для которого нужно отправить код (регистрация или сброс пароля)'), + email: z.email('Некорректный формат email').describe('Email пользователя'), +}); + +export class ResendCodeDto extends createZodDto(ResendCodeSchema) {} + +export const ResendCodeResponseSchema = ActionResponseSchema.extend({ + nextResendAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Время, когда можно запросить повторную отправку кода (ISO 8601)'), + retryAfterSeconds: z + .number() + .int() + .positive() + .describe('Секунды до следующей доступной отправки'), + retries: z + .number() + .int() + .nonnegative() + .describe('Количество повторных отправок в текущем окне'), +}); + +export class ResendCodeResponse extends createZodDto(ResendCodeResponseSchema) {} diff --git a/src/auth/application/dtos/index.ts b/src/auth/application/dtos/index.ts new file mode 100644 index 00000000..9183737d --- /dev/null +++ b/src/auth/application/dtos/index.ts @@ -0,0 +1,5 @@ +export * from './auth.dto'; +export * from './2fa.dto'; +export * from './password.dto'; +export * from './session.dto'; +export * from './oauth.dto'; diff --git a/src/auth/application/dtos/oauth.dto.ts b/src/auth/application/dtos/oauth.dto.ts new file mode 100644 index 00000000..2a904767 --- /dev/null +++ b/src/auth/application/dtos/oauth.dto.ts @@ -0,0 +1,99 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +const OAuthResponseSchema = z.object({ + id: z.string(), + email: z.string().email().nonempty(), + first_name: z.string().nonempty(), + last_name: z.string().nullish(), + avatar_url: z.string().nullish(), + bio: z.string().nullish(), + sex: z.enum(['male', 'female']).or(z.string()), + provider: z.enum(['google', 'yandex', 'github', 'vkontakte']), +}); + +export class OAuthResponse extends createZodDto(OAuthResponseSchema) {} + +export type TOAuthResponse = z.infer; + +export const ProviderSchema = z.object({ + label: z + .string() + .describe( + 'Человекочитаемое название провайдера для отображения на фронтенде (например, "Google", "Яндекс")', + ), + value: z + .string() + .describe( + 'Системный идентификатор провайдера, используемый в URL и логике бэкенда (например, "google", "yandex")', + ), +}); + +export class ProvidersResponse extends createZodDto(z.array(ProviderSchema)) {} + +export const ConnectedProviderSchema = z + .object({ + email: z.string().describe('Email пользователя, полученный от OAuth-провайдера'), + avatarUrl: z + .string() + .nullable() + .describe( + 'URL аватара пользователя от провайдера (может быть пустым, если провайдер не предоставляет аватар)', + ), + provider: z + .string() + .describe('Название OAuth-провайдера (например, "google", "github", "facebook")'), + connectedAt: z + .string() + .describe('Дата и время привязки провайдера в ISO 8601 формате (UTC)'), + }) + .describe('Модель привязанного OAuth-провайдера для текущего пользователя'); + +export class ConnectedProviders extends createZodDto(z.array(ConnectedProviderSchema)) {} + +export const ConnectProviderSchema = z.object({ + success: z.boolean().describe('Успешность выполнения запроса'), + url: z.string().describe('URL для перенаправления на OAuth провайдера'), +}); + +export class ConnectProviderResponse extends createZodDto(ConnectProviderSchema) {} + +export const ExchangeSchema = z.object({ + token: z + .string() + .min(32, 'Token must be at least 32 characters') + .max(128, 'Token must not exceed 128 characters') + .regex(/^[a-f0-9]+$/, 'Token must be hexadecimal string'), +}); + +export class ExchangeDto extends createZodDto(ExchangeSchema) {} + +export interface IOAuthExchangeData { + userId: string; + isNewUser: boolean; + email: string; + provider: 'google' | 'yandex' | 'github' | 'vkontakte'; + ip: string; +} + +export const ExchangeResponseSchema = z.object({ + success: z.boolean().describe('Успешность операции'), + message: z + .string() + .min(1, 'message не может быть пустым') + .max(255, 'message не длиннее 255 символов') + .describe('Сообщение для тоста'), + access: z + .string() + .min(10, 'access токен слишком короткий') + .max(500, 'access токен слишком длинный') + .describe('JWT access токен'), + isNewUser: z.boolean().describe('Новый пользователь?'), + provider: z + .enum(['google', 'yandex', 'github', 'vkontakte'], { + message: 'provider должен быть: google, yandex, github или vkontakte', + }) + .describe('OAuth провайдер'), +}); + +export class ExchangeResponse extends createZodDto(ExchangeResponseSchema) {} diff --git a/src/auth/application/dtos/password.dto.ts b/src/auth/application/dtos/password.dto.ts new file mode 100644 index 00000000..e0a260fe --- /dev/null +++ b/src/auth/application/dtos/password.dto.ts @@ -0,0 +1,45 @@ +import { createZodDto } from 'nestjs-zod'; +import z from 'zod/v4'; + +export const ChangePasswordSchema = z + .object({ + oldPassword: z.string().describe('Текущий пароль'), + newPassword: z + .string() + .min(8, 'Новый пароль должен содержать минимум 8 символов') + .max(32, 'Новый пароль должен содержать максимум 32 символа') + .describe('Новый пароль (минимум 8 символов)'), + }) + .describe('Схема смены пароля'); + +export class ChangePasswordDto extends createZodDto(ChangePasswordSchema) {} + +export const ResetPasswordSchema = z.object({ + email: z.string().email('Некорректный формат email').describe('Email для восстановления'), +}); + +export class ResetPasswordDto extends createZodDto(ResetPasswordSchema) {} + +export const VerifyResetCodeSchema = z.object({ + email: z.string().email(), + code: z.string().length(6, 'Код должен содержать 6 цифр').describe('Код из письма'), +}); + +export class VerifyResetCodeDto extends createZodDto(VerifyResetCodeSchema) {} + +export const PasswordResetConfirmSchema = z + .object({ + email: z.string().email(), + password: z + .string() + .min(8, 'Минимум 8 символов') + .max(32, 'Максимум 32 символа') + .describe('Новый пароль'), + confirmPassword: z.string().describe('Повторите новый пароль'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Пароли не совпадают', + path: ['confirmPassword'], + }); + +export class PasswordResetConfirmDto extends createZodDto(PasswordResetConfirmSchema) {} diff --git a/src/auth/application/dtos/session.dto.ts b/src/auth/application/dtos/session.dto.ts new file mode 100644 index 00000000..f1b7a5f2 --- /dev/null +++ b/src/auth/application/dtos/session.dto.ts @@ -0,0 +1,25 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const SessionResponseSchema = z + .object({ + id: z.string().describe('ID сессии'), + device: z.string().describe('Устройство/браузер'), + ip: z.string().describe('IP адрес'), + lastActive: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата последней активности (ISO 8601)'), + isCurrent: z.boolean().describe('Флаг текущей сессии'), + }) + .describe('Схема ответа одной сессии'); + +export class SessionResponse extends createZodDto(SessionResponseSchema) {} + +export const SessionsResponseSchema = z + .array(SessionResponseSchema) + .describe('Список активных сессий'); + +export class SessionsResponse extends createZodDto(SessionsResponseSchema) {} diff --git a/src/auth/application/interfaces/cache-data.interface.ts b/src/auth/application/interfaces/cache-data.interface.ts new file mode 100644 index 00000000..4e89bc93 --- /dev/null +++ b/src/auth/application/interfaces/cache-data.interface.ts @@ -0,0 +1,20 @@ +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { SignUpDto } from '@core/auth/application/dtos'; + +export interface SignUpCacheData { + user: SignUpDto; + password: string; + otp: { + token: string; + secret: string; + }; +} + +export interface ResetPasswordCacheData { + email: string; + otp: { + secret: string; + token: string; + }; + isVerified: boolean; +} diff --git a/src/auth/application/interfaces/index.ts b/src/auth/application/interfaces/index.ts new file mode 100644 index 00000000..bdc8cd42 --- /dev/null +++ b/src/auth/application/interfaces/index.ts @@ -0,0 +1 @@ +export * from './cache-data.interface'; diff --git a/src/auth/application/strategies/index.ts b/src/auth/application/strategies/index.ts new file mode 100644 index 00000000..4c601266 --- /dev/null +++ b/src/auth/application/strategies/index.ts @@ -0,0 +1,14 @@ +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ResendCodeDto } from '../dtos'; + +import { ResetPasswordResendStrategy } from './reset-password-resend.strategy'; +import { SignUpResendStrategy } from './sign-up-resend.strategy'; + +import type { ResendCodeStrategy } from './resend-code.strategy'; + +export const RESEND_CODE_STRATEGIES: Record = { + 'sign-up': new SignUpResendStrategy(), + 'reset-password': new ResetPasswordResendStrategy(), +}; + +export { ResendCodeStrategy } from './resend-code.strategy'; diff --git a/src/auth/application/strategies/resend-code.strategy.ts b/src/auth/application/strategies/resend-code.strategy.ts new file mode 100644 index 00000000..77cd5c88 --- /dev/null +++ b/src/auth/application/strategies/resend-code.strategy.ts @@ -0,0 +1,28 @@ +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ResendCodeDto } from '../dtos'; + +import type { Queue } from 'bullmq'; + +export abstract class ResendCodeStrategy { + abstract context: ResendCodeDto['context']; + abstract successMessage: string; + abstract cacheNotFoundCode: string; + abstract cacheNotFoundMessage: string; + + abstract getCacheKey(email: string): string; + + abstract generateOtp(): Promise<{ token: string; secret: string }>; + + abstract buildNewCacheData( + cachedData: TCacheData, + newToken: string, + newSecret: string, + ): TCacheData; + + abstract dispatchEmail( + mailQueue: Queue, + email: string, + token: string, + cachedData: TCacheData, + ): Promise; +} diff --git a/src/auth/application/strategies/reset-password-resend.strategy.ts b/src/auth/application/strategies/reset-password-resend.strategy.ts new file mode 100644 index 00000000..115b7a92 --- /dev/null +++ b/src/auth/application/strategies/reset-password-resend.strategy.ts @@ -0,0 +1,62 @@ +import { AuthMailJobs } from '@core/auth/domain/enums'; +import { ResetPasswordEvent } from '@core/auth/domain/events'; +import { + EMAIL_CODE_TTL_SECONDS, + RESET_PASSWORD_CACHE_KEY, +} from '@core/auth/infrastructure/constants'; +import { generate, generateSecret } from 'otplib'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../domain/errors'; + +import { ResendCodeStrategy } from './resend-code.strategy'; + +import type { ResetPasswordCacheData } from '@core/auth/application/interfaces'; +import type { Queue } from 'bullmq'; + +export class ResetPasswordResendStrategy extends ResendCodeStrategy { + readonly context = 'reset-password' as const; + readonly successMessage = 'Повторный код для восстановления пароля отправлен на вашу почту'; + readonly cacheNotFoundCode = AuthErrorCodes.RESET_SESSION_EXPIRED; + readonly cacheNotFoundMessage = AuthErrorMessages[AuthErrorCodes.RESET_SESSION_EXPIRED]; + + getCacheKey(email: string): string { + return RESET_PASSWORD_CACHE_KEY(email); + } + + async generateOtp(): Promise<{ readonly token: string; readonly secret: string }> { + const secret = generateSecret(); + const token = await generate({ + secret, + digits: 6, + period: EMAIL_CODE_TTL_SECONDS, + strategy: 'totp', + }); + + return { token, secret }; + } + + buildNewCacheData( + cachedData: ResetPasswordCacheData, + newToken: string, + newSecret: string, + ): ResetPasswordCacheData { + return { + ...cachedData, + otp: { token: newToken, secret: newSecret }, + isVerified: false, + }; + } + + async dispatchEmail( + mailQueue: Queue, + email: string, + token: string, + _cachedData: ResetPasswordCacheData, + ): Promise { + const event = new ResetPasswordEvent(email, token); + await mailQueue.add(AuthMailJobs.SEND_RESET_PASSWORD, event, { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + }); + } +} diff --git a/src/auth/application/strategies/sign-up-resend.strategy.ts b/src/auth/application/strategies/sign-up-resend.strategy.ts new file mode 100644 index 00000000..a519ada2 --- /dev/null +++ b/src/auth/application/strategies/sign-up-resend.strategy.ts @@ -0,0 +1,59 @@ +import { AuthMailJobs } from '@core/auth/domain/enums'; +import { RegisterCodeEvent } from '@core/auth/domain/events'; +import { EMAIL_CODE_TTL_SECONDS, SIGNUP_CACHE_KEY } from '@core/auth/infrastructure/constants'; +import { generate, generateSecret } from 'otplib'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../domain/errors'; + +import { ResendCodeStrategy } from './resend-code.strategy'; + +import type { SignUpCacheData } from '@core/auth/application/interfaces'; +import type { Queue } from 'bullmq'; + +export class SignUpResendStrategy extends ResendCodeStrategy { + readonly context = 'sign-up' as const; + readonly successMessage = 'Повторный код подтверждения отправлен на вашу почту'; + readonly cacheNotFoundCode = AuthErrorCodes.REGISTRATION_EXPIRED; + readonly cacheNotFoundMessage = AuthErrorMessages[AuthErrorCodes.REGISTRATION_EXPIRED]; + + getCacheKey(email: string): string { + return SIGNUP_CACHE_KEY(email); + } + + async generateOtp(): Promise<{ readonly token: string; readonly secret: string }> { + const secret = generateSecret(); + const token = await generate({ + secret, + algorithm: 'sha256', + digits: 6, + period: EMAIL_CODE_TTL_SECONDS, + strategy: 'totp', + }); + + return { token, secret }; + } + + buildNewCacheData( + cachedData: SignUpCacheData, + newToken: string, + newSecret: string, + ): SignUpCacheData { + return { + ...cachedData, + otp: { token: newToken, secret: newSecret }, + }; + } + + async dispatchEmail( + mailQueue: Queue, + email: string, + token: string, + cachedData: SignUpCacheData, + ): Promise { + const event = new RegisterCodeEvent(email, cachedData.user.firstName, token); + await mailQueue.add(AuthMailJobs.SEND_REGISTER_CODE, event, { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + }); + } +} diff --git a/src/auth/application/use-cases/auth/confirm-reset-password.use-case.ts b/src/auth/application/use-cases/auth/confirm-reset-password.use-case.ts new file mode 100644 index 00000000..4cba5968 --- /dev/null +++ b/src/auth/application/use-cases/auth/confirm-reset-password.use-case.ts @@ -0,0 +1,83 @@ +import { UpdatePasswordUseCase } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import * as argon from 'argon2'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { PasswordResetConfirmDto } from '../../dtos'; + +@Injectable() +export class ConfirmResetPasswordUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + private readonly updatePasswordUserUC: UpdatePasswordUseCase, + ) {} + + async execute(dto: PasswordResetConfirmDto) { + const redisKey = `pass:reset:${dto.email}`; + const cachedData = await this.cacheService.getOne(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: AuthErrorCodes.RESET_SESSION_NOT_FOUND, + message: AuthErrorMessages[AuthErrorCodes.RESET_SESSION_NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const resetSession = JSON.parse(cachedData); + + if (!resetSession) { + await this.cacheService.removeOne(redisKey); + + throw new BaseException( + { + code: AuthErrorCodes.DATA_CORRUPTION, + message: AuthErrorMessages[AuthErrorCodes.DATA_CORRUPTION], + details: [{ target: 'cache', message: 'Поврежденные данные сброса пароля' }], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + if (!resetSession.isVerified) { + throw new BaseException( + { + code: AuthErrorCodes.CODE_NOT_VERIFIED, + message: AuthErrorMessages[AuthErrorCodes.CODE_NOT_VERIFIED], + details: [{ target: 'code', message: 'Код подтверждения не верифицирован' }], + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const hashed = await argon.hash(dto.password); + const result = await this.updatePasswordUserUC.execute(dto.email, hashed); + + await this.cacheService.removeOne(redisKey); + + return { + success: result, + message: 'Пароль успешно изменен. Теперь вы можете войти в аккаунт.', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.RESET_PASSWORD_FAILED, + message: AuthErrorMessages[AuthErrorCodes.RESET_PASSWORD_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/auth/refresh-tokens.use-case.ts b/src/auth/application/use-cases/auth/refresh-tokens.use-case.ts new file mode 100644 index 00000000..fedcdca4 --- /dev/null +++ b/src/auth/application/use-cases/auth/refresh-tokens.use-case.ts @@ -0,0 +1,115 @@ +import { FindUserQuery } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import { BaseException } from '@shared/error'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { ISessionRepository } from '../../../domain/repository'; +import { TokenService } from '../../../infrastructure/security'; + +import type { DeviceMetadata } from '../../../infrastructure/utils'; + +@Injectable() +export class RefreshTokensUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly findUserQ: FindUserQuery, + ) {} + + async execute(token: string | undefined, metadata: DeviceMetadata) { + if (!token) { + throw new BaseException( + { + code: AuthErrorCodes.UNAUTHORIZED, + message: AuthErrorMessages[AuthErrorCodes.UNAUTHORIZED], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const payload = await this.tokenService.validateToken(token, 'refresh'); + + if (!payload?.jti) { + throw new BaseException( + { + code: AuthErrorCodes.TOKEN_INVALID, + message: AuthErrorMessages[AuthErrorCodes.TOKEN_INVALID], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const session = await this.sessionRepo.findById(payload.jti); + + if (!session) { + throw new BaseException( + { + code: AuthErrorCodes.SESSION_NOT_FOUND, + message: AuthErrorMessages[AuthErrorCodes.SESSION_NOT_FOUND], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + if (session.isRevoked) { + throw new BaseException( + { + code: AuthErrorCodes.SESSION_REVOKED, + message: AuthErrorMessages[AuthErrorCodes.SESSION_REVOKED], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const entity = await this.findUserQ.execute({ id: session.userId }); + + if (!entity?.user) { + await this.sessionRepo.revoke(session.id); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Аккаунт пользователя не найден', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + try { + await this.sessionRepo.revoke(session.id); + + const sessionId = createId(); + const { access, refresh, expiresAt } = await this.tokenService.generateTokens( + entity.user, + sessionId, + ); + + await this.sessionRepo.create({ + id: sessionId, + userId: entity.user.id, + ...metadata, + expiresAt: expiresAt.toISOString(), + }); + + return { + success: true, + tokens: { access, refresh }, + expiresAt, + message: 'Токены успешно обновлены', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.REFRESH_FAILED, + message: AuthErrorMessages[AuthErrorCodes.REFRESH_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/auth/resend-code.use-case.ts b/src/auth/application/use-cases/auth/resend-code.use-case.ts new file mode 100644 index 00000000..f11d51e5 --- /dev/null +++ b/src/auth/application/use-cases/auth/resend-code.use-case.ts @@ -0,0 +1,136 @@ +import { ResendCodeDto } from '@core/auth/application/dtos'; +import { AuthQueues } from '@core/auth/domain/enums'; +import { + EMAIL_CODE_TTL_SECONDS, + MAX_ATTEMPTS, + RESEND_ATTEMPTS_KEY, + RESEND_COOLDOWN_KEY, + SECONDS_BETWEEN_ATTEMPTS, +} from '@core/auth/infrastructure/constants'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import { Queue } from 'bullmq'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { RESEND_CODE_STRATEGIES, ResendCodeStrategy } from '../../strategies'; + +@Injectable() +export class ResendCodeUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + ) {} + + async execute(dto: ResendCodeDto) { + const strategy = this.getStrategy(dto.context); + const cacheKey = strategy.getCacheKey(dto.email); + const cachedDataStr = await this.cacheService.getOne(cacheKey); + + if (!cachedDataStr) { + throw new BaseException( + { + code: strategy.cacheNotFoundCode, + message: strategy.cacheNotFoundMessage, + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.NOT_FOUND, + ); + } + + const cooldownKey = RESEND_COOLDOWN_KEY(dto.context, dto.email); + const { ttlSeconds: cooldownTtl } = await this.cacheService.getOneWithTtl(cooldownKey); + + if (cooldownTtl > 0) { + throw new BaseException( + { + code: AuthErrorCodes.RESEND_RATE_LIMIT, + message: `Повторная отправка доступна через ${this.formatWaitTime(cooldownTtl)}`, + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + const attemptsKey = RESEND_ATTEMPTS_KEY(dto.context, dto.email); + const attemptsStr = await this.cacheService.getOne(attemptsKey); + + let attemptsLeft = attemptsStr ? parseInt(attemptsStr, 10) : MAX_ATTEMPTS; + + if (attemptsLeft <= 0) { + throw new BaseException( + { + code: AuthErrorCodes.MAX_ATTEMPTS_REACHED, + message: AuthErrorMessages[AuthErrorCodes.MAX_ATTEMPTS_REACHED], + }, + HttpStatus.FORBIDDEN, + ); + } + + attemptsLeft -= 1; + + const cachedData = JSON.parse(cachedDataStr); + const { token, secret } = await strategy.generateOtp(); + const newCacheData = strategy.buildNewCacheData(cachedData, token, secret); + + await this.cacheService.setOne( + cacheKey, + JSON.stringify(newCacheData), + EMAIL_CODE_TTL_SECONDS, + ); + + await this.cacheService.setOne( + attemptsKey, + attemptsLeft.toString(), + EMAIL_CODE_TTL_SECONDS, + ); + + await this.cacheService.setOne(cooldownKey, 'locked', SECONDS_BETWEEN_ATTEMPTS); + + await strategy.dispatchEmail(this.mailQueue, dto.email, token, cachedData); + + return { + success: true, + message: strategy.successMessage, + retries: attemptsLeft, + ...this.buildResendTiming(SECONDS_BETWEEN_ATTEMPTS), + }; + } + + private getStrategy(context: ResendCodeDto['context']): ResendCodeStrategy { + const strategy = RESEND_CODE_STRATEGIES[context]; + + if (!strategy) { + throw new BaseException( + { + code: AuthErrorCodes.STRATEGY_NOT_FOUND, + message: AuthErrorMessages[AuthErrorCodes.STRATEGY_NOT_FOUND], + }, + HttpStatus.BAD_REQUEST, + ); + } + + return strategy; + } + + private buildResendTiming(retryAfterSeconds: number) { + return { + retryAfterSeconds, + nextResendAt: new Date(Date.now() + retryAfterSeconds * 1000).toISOString(), + }; + } + + private formatWaitTime(totalSeconds: number) { + const minutesLeft = Math.floor(totalSeconds / 60); + const secondsLeft = totalSeconds % 60; + + if (minutesLeft > 0) { + return `${minutesLeft} мин ${secondsLeft} сек`; + } + + return `${secondsLeft} сек`; + } +} diff --git a/src/auth/application/use-cases/auth/sign-in.use-case.ts b/src/auth/application/use-cases/auth/sign-in.use-case.ts new file mode 100644 index 00000000..f4d55a0d --- /dev/null +++ b/src/auth/application/use-cases/auth/sign-in.use-case.ts @@ -0,0 +1,95 @@ +import { FindUserQuery } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import { BaseException } from '@shared/error'; +import * as argon from 'argon2'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { ISessionRepository } from '../../../domain/repository'; +import { TokenService } from '../../../infrastructure/security'; +import { DeviceMetadata } from '../../../infrastructure/utils/get-device-meta'; +import { SignInDto } from '../../dtos'; + +@Injectable() +export class SignInUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly findUserQ: FindUserQuery, + ) {} + + async execute(dto: SignInDto, meta: DeviceMetadata) { + const entities = await this.findUserQ.execute({ email: dto.email }); + + if (!entities.security) { + throw new BaseException( + { + code: AuthErrorCodes.INVALID_CREDENTIALS, + message: AuthErrorMessages[AuthErrorCodes.INVALID_CREDENTIALS], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const { security, user } = entities; + + if (!security.passwordHash) { + throw new BaseException( + { + code: AuthErrorCodes.INVALID_CREDENTIALS, + message: AuthErrorMessages[AuthErrorCodes.INVALID_CREDENTIALS], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const isPasswordValid = await argon.verify(security.passwordHash, dto.password); + + if (!isPasswordValid) { + throw new BaseException( + { + code: AuthErrorCodes.INVALID_CREDENTIALS, + message: AuthErrorMessages[AuthErrorCodes.INVALID_CREDENTIALS], + }, + HttpStatus.UNAUTHORIZED, + ); + } + try { + const sessionId = createId(); + const { access, refresh, expiresAt } = await this.tokenService.generateTokens( + user, + sessionId, + ); + + await this.sessionRepo.create({ + id: sessionId, + userId: user.id, + expiresAt: expiresAt.toISOString(), + ...meta, + }); + + return { + success: true, + tokens: { + access, + refresh, + }, + expiresAt, + message: 'Вы успешно вошли в систему', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.SIGNIN_FAILED, + message: AuthErrorMessages[AuthErrorCodes.SIGNIN_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/auth/sign-out.use-case.ts b/src/auth/application/use-cases/auth/sign-out.use-case.ts new file mode 100644 index 00000000..83223e17 --- /dev/null +++ b/src/auth/application/use-cases/auth/sign-out.use-case.ts @@ -0,0 +1,72 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { ISessionRepository } from '../../../domain/repository'; +import { TokenService } from '../../../infrastructure/security'; + +@Injectable() +export class SignOutUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + ) {} + + async execute(token?: string) { + if (!token) { + throw new BaseException( + { + code: AuthErrorCodes.UNAUTHORIZED, + message: AuthErrorMessages[AuthErrorCodes.UNAUTHORIZED], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const payload = await this.tokenService.validateToken(token, 'refresh'); + + if (!payload?.jti) { + throw new BaseException( + { + code: AuthErrorCodes.SESSION_EXPIRED, + message: AuthErrorMessages[AuthErrorCodes.SESSION_EXPIRED], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + try { + const session = await this.sessionRepo.findById(payload.jti); + + if (!session) { + return { + success: true, + message: 'Сессия уже завершена', + }; + } + + const result = await this.sessionRepo.revoke(session.id); + + return { success: result, message: 'Успешно вышли из системы!' }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.SIGNOUT_FAILED, + message: AuthErrorMessages[AuthErrorCodes.SIGNOUT_FAILED], + details: [ + { + target: 'server', + message: error instanceof Error ? error.message : 'Unknown error', + }, + ], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/auth/sign-up-verify.use-case.ts b/src/auth/application/use-cases/auth/sign-up-verify.use-case.ts new file mode 100644 index 00000000..bd6860d7 --- /dev/null +++ b/src/auth/application/use-cases/auth/sign-up-verify.use-case.ts @@ -0,0 +1,139 @@ +import { SignUpCacheData } from '@core/auth/application/interfaces'; +import { AuthQueues } from '@core/auth/domain/enums'; +import { AuthUserJobs } from '@core/auth/domain/enums/auth-jobs.enum'; +import { CreateUserWorkspaceEvent } from '@core/auth/domain/events/create-user-workspace.event'; +import { SIGNUP_CACHE_KEY } from '@core/auth/infrastructure/constants'; +import { RegisterUserUseCase } from '@core/user'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import { Queue } from 'bullmq'; +import { verify as verifyOTP } from 'otplib'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { ISessionRepository } from '../../../domain/repository'; +import { TokenService } from '../../../infrastructure/security'; +import { DeviceMetadata } from '../../../infrastructure/utils/get-device-meta'; +import { VerifyDto } from '../../dtos'; + +@Injectable() +export class SignUpVerifyUseCase { + constructor( + @InjectQueue(AuthQueues.AUTH_USER) + private readonly queue: Queue, + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly registerUserUC: RegisterUserUseCase, + ) {} + + async execute(dto: VerifyDto, meta: DeviceMetadata) { + const cachedData = await this.cacheService.getOne(SIGNUP_CACHE_KEY(dto.email)); + + if (!cachedData) { + throw new BaseException( + { + code: AuthErrorCodes.REGISTRATION_EXPIRED, + message: AuthErrorMessages[AuthErrorCodes.REGISTRATION_EXPIRED], + }, + HttpStatus.GONE, + ); + } + + const userData: SignUpCacheData = JSON.parse(cachedData); + + if (!userData || !userData.otp || !userData.password) { + throw new BaseException( + { + code: AuthErrorCodes.DATA_CORRUPTION, + message: AuthErrorMessages[AuthErrorCodes.DATA_CORRUPTION], + details: [{ target: 'cache', message: 'Поврежденные данные регистрации' }], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + if (userData.otp.token !== dto.code) { + throw new BaseException( + { + code: AuthErrorCodes.INVALID_CODE, + message: AuthErrorMessages[AuthErrorCodes.INVALID_CODE], + details: [{ target: 'code', message: 'Неверный код подтверждения' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const verifyResult = await verifyOTP({ + token: dto.code, + secret: userData.otp.secret, + algorithm: 'sha256', + digits: 6, + period: 900, + strategy: 'totp', + afterTimeStep: 1, + }); + + if (!verifyResult.valid) { + throw new BaseException( + { + code: AuthErrorCodes.INVALID_CODE, + message: AuthErrorMessages[AuthErrorCodes.INVALID_CODE], + details: [{ target: 'code', message: 'Неверный код подтверждения' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + try { + const user = await this.registerUserUC.execute({ + ...userData.user, + emailVerified: true, + emailVerifiedAt: new Date().toISOString(), + password: userData.password, + }); + + const sessionId = createId(); + const { access, refresh, expiresAt } = await this.tokenService.generateTokens( + user, + sessionId, + ); + + await this.sessionRepo.create({ + id: sessionId, + userId: user.id, + ...meta, + expiresAt: expiresAt.toISOString(), + }); + + await this.cacheService.removeOne(SIGNUP_CACHE_KEY(dto.email)); + + const event = new CreateUserWorkspaceEvent(user.id, user.firstName); + await this.queue.add(AuthUserJobs.CREATE_WORKSPACE, event); + + return { + success: true, + tokens: { access, refresh }, + expiresAt, + message: 'Аккаунт успешно подтвержден', + }; + } catch (err) { + if (err instanceof BaseException) { + throw err; + } + + throw new BaseException( + { + code: AuthErrorCodes.SIGNUP_FAILED, + message: AuthErrorMessages[AuthErrorCodes.SIGNUP_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/auth/sign-up.use-case.ts b/src/auth/application/use-cases/auth/sign-up.use-case.ts new file mode 100644 index 00000000..9abd000e --- /dev/null +++ b/src/auth/application/use-cases/auth/sign-up.use-case.ts @@ -0,0 +1,95 @@ +import { SignUpCacheData } from '@core/auth/application/interfaces'; +import { EMAIL_CODE_TTL_SECONDS, SIGNUP_CACHE_KEY } from '@core/auth/infrastructure/constants'; +import { FindUserQuery } from '@core/user'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import * as argon from 'argon2'; +import { Queue } from 'bullmq'; +import { generate, generateSecret } from 'otplib'; + +import { AuthQueues, AuthMailJobs } from '../../../domain/enums'; +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { RegisterCodeEvent } from '../../../domain/events'; +import { SignUpDto } from '../../dtos'; + +@Injectable() +export class SignUpUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + private readonly findUserQ: FindUserQuery, + ) {} + + async execute(dto: SignUpDto) { + const cachedData = await this.cacheService.getOne(SIGNUP_CACHE_KEY(dto.email)); + + if (cachedData) { + throw new BaseException( + { + code: AuthErrorCodes.CODE_ALREADY_SENT, + message: AuthErrorMessages[AuthErrorCodes.CODE_ALREADY_SENT], + details: [{ target: 'email', message: 'Verification code already sent' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + try { + await this.findUserQ.execute({ email: dto.email }, { throwIfExists: true }); + + const hashPass = await argon.hash(dto.password); + + const secret = generateSecret(); + const token = await generate({ + secret, + algorithm: 'sha256', + digits: 6, + period: EMAIL_CODE_TTL_SECONDS, + strategy: 'totp', + }); + + const data: SignUpCacheData = { + user: dto, + password: hashPass, + otp: { token, secret }, + }; + + await this.cacheService.setOne( + SIGNUP_CACHE_KEY(dto.email), + JSON.stringify(data), + EMAIL_CODE_TTL_SECONDS, + ); + + const event = new RegisterCodeEvent(dto.email, dto.firstName, token); + await this.mailQueue.add(AuthMailJobs.SEND_REGISTER_CODE, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: 'Код подтверждения отправлен на вашу почту', + }; + } catch (err) { + if (err instanceof BaseException) { + throw err; + } + + throw new BaseException( + { + code: AuthErrorCodes.SIGNUP_FAILED, + message: AuthErrorMessages[AuthErrorCodes.SIGNUP_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/index.ts b/src/auth/application/use-cases/index.ts new file mode 100644 index 00000000..f1fca2ec --- /dev/null +++ b/src/auth/application/use-cases/index.ts @@ -0,0 +1,63 @@ +import { RefreshTokensUseCase } from './auth/refresh-tokens.use-case'; +import { ResendCodeUseCase } from './auth/resend-code.use-case'; +import { SignInUseCase } from './auth/sign-in.use-case'; +import { SignOutUseCase } from './auth/sign-out.use-case'; +import { SignUpVerifyUseCase } from './auth/sign-up-verify.use-case'; +import { SignUpUseCase } from './auth/sign-up.use-case'; +import { AuthenticateOAuthUseCase } from './oauth/authenticate-oauth.use-case'; +import { ConnectOAuthProviderUseCase } from './oauth/connect-oauth-provider.use-case'; +import { ConnectProviderUseCase } from './oauth/connect-provider.use-case'; +import { DisconnectProviderUseCase } from './oauth/disconnect-provider.use-case'; +import { ExchangeUseCase } from './oauth/exchange.use-case'; +import { GetConnectedProvidersQuery } from './oauth/get-connected-providers.query'; +import { GetEnabledProvidersQuery } from './oauth/get-enabled-providers.query'; +import { OAuthOrchestratorUseCase } from './oauth/oauth-orchestrator.use-case'; +import { ProcessOAuthLoginUseCase } from './oauth/process-oauth-login.use-case'; +import { ProcessOAuthRegistrationUseCase } from './oauth/process-oauth-registration.use-case'; +import { ConfirmResetPasswordUseCase } from './password/confirm-reset-password.use-case'; +import { ResetPasswordUseCase } from './password/reset-password.use-case'; +import { VerifyResetPasswordUseCase } from './password/verify-reset-password.use-case'; + +export const AuthUseCases = [ + ConfirmResetPasswordUseCase, + VerifyResetPasswordUseCase, + GetConnectedProvidersQuery, + DisconnectProviderUseCase, + GetEnabledProvidersQuery, + OAuthOrchestratorUseCase, + ProcessOAuthLoginUseCase, + ProcessOAuthRegistrationUseCase, + ConnectOAuthProviderUseCase, + AuthenticateOAuthUseCase, + ConnectProviderUseCase, + RefreshTokensUseCase, + ResetPasswordUseCase, + SignUpVerifyUseCase, + GetEnabledProvidersQuery, + SignInUseCase, + SignOutUseCase, + SignUpUseCase, + ResendCodeUseCase, + ExchangeUseCase, +]; + +export * from './password/confirm-reset-password.use-case'; +export * from './password/verify-reset-password.use-case'; +export * from './oauth/get-connected-providers.query'; +export * from './oauth/disconnect-provider.use-case'; +export * from './oauth/authenticate-oauth.use-case'; +export * from './oauth/connect-provider.use-case'; +export * from './auth/refresh-tokens.use-case'; +export * from './password/reset-password.use-case'; +export * from './auth/sign-up-verify.use-case'; +export * from './oauth/get-enabled-providers.query'; +export * from './oauth/exchange.use-case'; + +export * from './oauth/oauth-orchestrator.use-case'; +export * from './oauth/process-oauth-login.use-case'; +export * from './oauth/process-oauth-registration.use-case'; +export * from './oauth/connect-oauth-provider.use-case'; +export * from './auth/sign-in.use-case'; +export * from './auth/sign-out.use-case'; +export * from './auth/sign-up.use-case'; +export * from './auth/resend-code.use-case'; diff --git a/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts b/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts new file mode 100644 index 00000000..1346ff1b --- /dev/null +++ b/src/auth/application/use-cases/oauth/authenticate-oauth.use-case.ts @@ -0,0 +1,61 @@ +import crypto from 'node:crypto'; + +import { Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; + +import { EXCHANGE_TOKEN_NAME, EXCHANGE_TOKEN_TTL } from '../../../infrastructure/constants'; + +import { OAuthOrchestratorUseCase } from './oauth-orchestrator.use-case'; + +import type { OAuthResponse } from '../../dtos'; +import type { DeviceMetadata } from '@core/auth/infrastructure/utils'; + +@Injectable() +export class AuthenticateOAuthUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + private readonly orchestrator: OAuthOrchestratorUseCase, + ) {} + + async execute(dto: OAuthResponse, meta: DeviceMetadata, state?: string) { + const { user, isNewUser, isConnect } = await this.orchestrator.execute(dto, state); + + if (isConnect) { + const query = new URLSearchParams({ + success: 'true', + message: `Провайдер ${dto.provider} успешно привязан`, + }); + + return { + query, + isSign: false, + refresh: null, + expiresAt: null, + }; + } + const token = crypto.randomBytes(32).toString('hex'); + + const data = { + userId: user.id, + isNewUser, + email: user.email, + provider: dto.provider, + ip: meta.ip, + }; + + await this.cacheService.setOne( + EXCHANGE_TOKEN_NAME(token), + JSON.stringify(data), + EXCHANGE_TOKEN_TTL, + ); + + const query = new URLSearchParams({ + token, + success: 'true', + }); + + return { query, isSign: true }; + } +} diff --git a/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts b/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts new file mode 100644 index 00000000..20c6245c --- /dev/null +++ b/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts @@ -0,0 +1,115 @@ +import { IIdentityRepository } from '@core/auth/domain/repository'; +import { FindUserQuery } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; + +import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; +import { OAuthResponse } from '../../dtos'; + +@Injectable() +export class ConnectOAuthProviderUseCase { + constructor( + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + private readonly findUserQ: FindUserQuery, + ) {} + + async execute(dto: OAuthResponse, state: string) { + const stateData = await this.getStateData(state); + + this.validateProvider(stateData, dto); + + const { user } = await this.findUserQ.execute({ id: stateData.userId }); + + await this.validateProviderNotConnected(user.id, dto.provider, dto.id); + + await this.identityRepo.create({ + userId: user.id, + avatarUrl: dto.avatar_url, + provider: dto.provider as any, + providerUserId: dto.id, + email: dto.email, + }); + + await this.cacheService.removeMany([ + `oauth:user:active:${user.id}`, + `oauth:state:${state}`, + ]); + + return { user, isConnect: true, isNewUser: false }; + } + + private async getStateData(state: string) { + const rawData = await this.cacheService.getOne(`oauth:state:${state}`); + if (!rawData) { + throw new BaseException( + { + code: OAuthErrorCodes.INVALID_OR_EXPIRED_STATE, + message: OAuthErrorMessages[OAuthErrorCodes.INVALID_OR_EXPIRED_STATE], + }, + HttpStatus.BAD_REQUEST, + ); + } + return JSON.parse(rawData); + } + + private validateProvider(stateData: any, dto: OAuthResponse) { + if (stateData.action !== 'connect') { + throw new BaseException( + { + code: OAuthErrorCodes.INVALID_ACTION, + message: OAuthErrorMessages[OAuthErrorCodes.INVALID_ACTION], + }, + HttpStatus.BAD_REQUEST, + ); + } + + if (stateData.provider !== dto.provider) { + throw new BaseException( + { + code: OAuthErrorCodes.PROVIDER_MISMATCH, + message: OAuthErrorMessages[OAuthErrorCodes.PROVIDER_MISMATCH], + }, + HttpStatus.BAD_REQUEST, + ); + } + } + + private async validateProviderNotConnected( + userId: string, + provider: string, + providerUserId: string, + ) { + const existingIdentity = await this.identityRepo.findByProvider( + provider as any, + providerUserId, + ); + + if (existingIdentity && existingIdentity.userId !== userId) { + throw new BaseException( + { + code: OAuthErrorCodes.PROVIDER_ALREADY_USED, + message: OAuthErrorMessages[OAuthErrorCodes.PROVIDER_ALREADY_USED], + }, + HttpStatus.CONFLICT, + ); + } + + const userIdentities = await this.identityRepo.findAllByUserId(userId); + const alreadyConnected = userIdentities.some((i) => i.provider === provider); + + if (alreadyConnected) { + throw new BaseException( + { + code: OAuthErrorCodes.PROVIDER_ALREADY_CONNECTED, + message: OAuthErrorMessages[OAuthErrorCodes.PROVIDER_ALREADY_CONNECTED], + }, + HttpStatus.CONFLICT, + ); + } + } +} diff --git a/src/auth/application/use-cases/oauth/connect-provider.use-case.ts b/src/auth/application/use-cases/oauth/connect-provider.use-case.ts new file mode 100644 index 00000000..c60d3b8a --- /dev/null +++ b/src/auth/application/use-cases/oauth/connect-provider.use-case.ts @@ -0,0 +1,123 @@ +import { IIdentityRepository } from '@core/auth/domain/repository'; +import { FindUserQuery } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; + +import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; + +@Injectable() +export class ConnectProviderUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, + private readonly findUserQ: FindUserQuery, + ) {} + + private readonly STATE_TTL = 180; // 3 минуты + private readonly ACTIVE_SESSION_KEY = (userId: string) => `oauth:user:active:${userId}`; + private readonly STATE_KEY = (state: string) => `oauth:state:${state}`; + + async execute(provider: string, userId: string) { + await this.findUserQ.execute({ id: userId }); + await this.validateProviderNotConnected(userId, provider); + await this.validateNoActiveSession(userId, provider); + + const stateCode = createId(); + const stateData = { + code: stateCode, + provider, + userId, + action: 'connect', + createdAt: Date.now(), + }; + + await this.cacheService.setOne( + this.STATE_KEY(stateCode), + JSON.stringify(stateData), + this.STATE_TTL, + ); + + const activeSession = { + provider, + stateCode, + createdAt: Date.now(), + expiresAt: Date.now() + this.STATE_TTL * 1000, + }; + + await this.cacheService.setOne( + this.ACTIVE_SESSION_KEY(userId), + JSON.stringify(activeSession), + this.STATE_TTL, + ); + + return { success: true, url: `/v1/auth/oauth/${provider}?state=${stateCode}` }; + } + + private async validateProviderNotConnected(userId: string, provider: string) { + const identities = await this.identityRepo.findAllByUserId(userId); + const isConnected = identities.some((identity) => identity.provider === provider); + + if (isConnected) { + throw new BaseException( + { + code: OAuthErrorCodes.PROVIDER_ALREADY_CONNECTED, + message: OAuthErrorMessages[OAuthErrorCodes.PROVIDER_ALREADY_CONNECTED], + }, + HttpStatus.CONFLICT, + ); + } + } + + private async validateNoActiveSession(userId: string, newProvider: string) { + const activeSessionRaw = await this.cacheService.getOne(this.ACTIVE_SESSION_KEY(userId)); + + if (activeSessionRaw) { + const activeSession = JSON.parse(activeSessionRaw); + const timeLeft = Math.ceil((activeSession.expiresAt - Date.now()) / 1000); + const minutesLeft = Math.floor(timeLeft / 60); + const secondsLeft = timeLeft % 60; + + const timeMessage = + minutesLeft > 0 ? `${minutesLeft} мин ${secondsLeft} сек` : `${secondsLeft} сек`; + + const isSameProvider = activeSession.provider === newProvider; + const providerName = this.getProviderName(activeSession.provider); + + const message = isSameProvider + ? `У вас уже есть активный процесс авторизации через ${providerName}. Подождите ${timeMessage} или завершите его в другом окне.` + : `У вас уже есть активный процесс авторизации через ${providerName}. Дождитесь его завершения (${timeMessage}) или отмените, чтобы начать через ${this.getProviderName(newProvider)}.`; + + throw new BaseException( + { + code: OAuthErrorCodes.ACTIVE_OAUTH_SESSION_EXISTS, + message, + details: [ + { + activeProvider: activeSession.provider, + requestedProvider: newProvider, + isSameProvider, + timeLeftSeconds: timeLeft, + expiresAt: activeSession.expiresAt, + }, + ], + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } + + private getProviderName(provider: string): string { + const names: Record = { + google: 'Google', + github: 'GitHub', + yandex: 'Яндекс', + vkontakte: 'VK', + }; + return names[provider] || provider; + } +} diff --git a/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts b/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts new file mode 100644 index 00000000..472b1e60 --- /dev/null +++ b/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts @@ -0,0 +1,53 @@ +import { IIdentityRepository } from '@core/auth/domain/repository'; +import { FindUserQuery } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; + +@Injectable() +export class DisconnectProviderUseCase { + constructor( + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, + private readonly findUserQ: FindUserQuery, + ) {} + + async execute(provider: string, userId: string) { + const entity = await this.findUserQ.execute({ id: userId }); + + const providers = await this.identityRepo.findAllByUserId(entity.user.id); + const targetProvider = providers.find((p) => p.provider === provider); + + if (!targetProvider) { + throw new BaseException( + { + code: OAuthErrorCodes.PROVIDER_NOT_LINKED, + message: OAuthErrorMessages[OAuthErrorCodes.PROVIDER_NOT_LINKED], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const hasPassword = + entity.security.passwordHash !== null && entity.security.passwordHash !== ''; + const hasOtherProviders = providers.length > 1; + + if (!hasOtherProviders && !hasPassword) { + throw new BaseException( + { + code: OAuthErrorCodes.LAST_AUTH_METHOD_CANNOT_BE_REMOVED, + message: OAuthErrorMessages[OAuthErrorCodes.LAST_AUTH_METHOD_CANNOT_BE_REMOVED], + }, + HttpStatus.BAD_REQUEST, + ); + } + + await this.identityRepo.delete(targetProvider.id); + + return { + success: true, + message: `Провайдер ${provider} успешно отвязан`, + }; + } +} diff --git a/src/auth/application/use-cases/oauth/exchange.use-case.ts b/src/auth/application/use-cases/oauth/exchange.use-case.ts new file mode 100644 index 00000000..ab762bdd --- /dev/null +++ b/src/auth/application/use-cases/oauth/exchange.use-case.ts @@ -0,0 +1,100 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; + +import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; +import { ISessionRepository } from '../../../domain/repository'; +import { EXCHANGE_TOKEN_NAME } from '../../../infrastructure/constants'; +import { TokenService } from '../../../infrastructure/security'; +import { ExchangeDto, type IOAuthExchangeData } from '../../dtos'; + +import type { DeviceMetadata } from '../../../infrastructure/utils'; + +@Injectable() +export class ExchangeUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + private readonly tokenService: TokenService, + ) {} + + async execute(dto: ExchangeDto, meta: DeviceMetadata) { + const key = EXCHANGE_TOKEN_NAME(dto.token); + const rawData = await this.cacheService.getOne(key); + + if (!rawData) { + throw new BaseException( + { + code: OAuthErrorCodes.EXCHANGE_TOKEN_INVALID, + message: OAuthErrorMessages[OAuthErrorCodes.EXCHANGE_TOKEN_INVALID], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const data = JSON.parse(rawData) as IOAuthExchangeData; + await this.cacheService.removeOne(key); + + if (!data.userId || !data.email) { + await this.cacheService.removeOne(key); + throw new BaseException( + { + message: 'Неверный формат данных авторизации', + code: 'EXCHANGE_DATA_CORRUPTED', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + try { + const sessionId = createId(); + const { access, expiresAt, refresh } = await this.tokenService.generateTokens( + { id: data.userId, email: data.email }, + sessionId, + ); + + const result = await this.sessionRepo.create({ + id: sessionId, + ...meta, + expiresAt: expiresAt.toISOString(), + userId: data.userId, + }); + + if (!result?.id) { + throw new BaseException( + { + message: 'Не удалось создать сессию', + code: 'SESSION_CREATION_FAILED', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + success: true, + message: 'Вход выполнен успешно', + access, + isNewUser: data.isNewUser, + provider: data.provider, + refresh, + expiresAt, + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + message: 'Внутренняя ошибка сервера при создании сессии', + code: 'SESSION_CREATION_INTERNAL_ERROR', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/oauth/get-connected-providers.query.ts b/src/auth/application/use-cases/oauth/get-connected-providers.query.ts new file mode 100644 index 00000000..b4ac1e6f --- /dev/null +++ b/src/auth/application/use-cases/oauth/get-connected-providers.query.ts @@ -0,0 +1,21 @@ +import { IIdentityRepository } from '@core/auth/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetConnectedProvidersQuery { + constructor( + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, + ) {} + + async execute(userId: string) { + const providers = await this.identityRepo.findAllByUserId(userId); + + return providers.map((p) => ({ + connectedAt: p.connectedAt, + provider: p.provider, + avatarUrl: p.avatarUrl, + email: p.email, + })); + } +} diff --git a/src/auth/application/use-cases/oauth/get-enabled-providers.query.ts b/src/auth/application/use-cases/oauth/get-enabled-providers.query.ts new file mode 100644 index 00000000..111c6db9 --- /dev/null +++ b/src/auth/application/use-cases/oauth/get-enabled-providers.query.ts @@ -0,0 +1,25 @@ +import { OAuthAssets } from '@core/auth/infrastructure/constants'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class GetEnabledProvidersQuery { + constructor(private readonly cfg: ConfigService) {} + + async execute() { + return [ + ...(this.cfg.get('GOOGLE_CLIENT_ID') && this.cfg.get('GOOGLE_CLIENT_SECRET') + ? [OAuthAssets.google] + : []), + ...(this.cfg.get('GITHUB_CLIENT_ID') && this.cfg.get('GITHUB_CLIENT_SECRET') + ? [OAuthAssets.github] + : []), + ...(this.cfg.get('YANDEX_CLIENT_ID') && this.cfg.get('YANDEX_CLIENT_SECRET') + ? [OAuthAssets.yandex] + : []), + ...(this.cfg.get('VKONTAKTE_CLIENT_ID') && this.cfg.get('VKONTAKTE_CLIENT_SECRET') + ? [OAuthAssets.vkontakte] + : []), + ]; + } +} diff --git a/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts b/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts new file mode 100644 index 00000000..90726168 --- /dev/null +++ b/src/auth/application/use-cases/oauth/oauth-orchestrator.use-case.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { isBaseExceptionWithCode } from '@shared/error'; + +import { OAuthResponse } from '../../dtos'; + +import { ConnectOAuthProviderUseCase } from './connect-oauth-provider.use-case'; +import { ProcessOAuthLoginUseCase } from './process-oauth-login.use-case'; +import { ProcessOAuthRegistrationUseCase } from './process-oauth-registration.use-case'; + +@Injectable() +export class OAuthOrchestratorUseCase { + constructor( + private readonly processLogin: ProcessOAuthLoginUseCase, + private readonly connectProvider: ConnectOAuthProviderUseCase, + private readonly processRegistration: ProcessOAuthRegistrationUseCase, + ) {} + + async execute(dto: OAuthResponse, state?: string) { + if (state) { + try { + return this.connectProvider.execute(dto, state); + } catch (error) { + if (!isBaseExceptionWithCode(error, 'INVALID_ACTION')) { + throw error; + } + } + } + + const login = await this.processLogin.execute(dto).catch((err) => { + if (isBaseExceptionWithCode(err, 'OAUTH_LOGIN_NOT_FOUND')) { + return null; + } + + throw err; + }); + + if (login) { + return login; + } + + return this.processRegistration.execute(dto); + } +} diff --git a/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts b/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts new file mode 100644 index 00000000..5bd6a9e3 --- /dev/null +++ b/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts @@ -0,0 +1,38 @@ +import { IIdentityRepository } from '@core/auth/domain/repository'; +import { FindUserQuery } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; +import { OAuthResponse } from '../../dtos'; + +@Injectable() +export class ProcessOAuthLoginUseCase { + constructor( + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, + private readonly findUserQ: FindUserQuery, + ) {} + + async execute(dto: OAuthResponse) { + const identity = await this.identityRepo.findByProvider(dto.provider as any, dto.id); + + if (!identity) { + throw new BaseException( + { + code: OAuthErrorCodes.OAUTH_LOGIN_NOT_FOUND, + message: OAuthErrorMessages[OAuthErrorCodes.OAUTH_LOGIN_NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const result = await this.findUserQ.execute({ id: identity.userId }); + + return { + user: result.user, + isNewUser: false, + isConnect: false, + }; + } +} diff --git a/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts b/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts new file mode 100644 index 00000000..ead5adae --- /dev/null +++ b/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts @@ -0,0 +1,65 @@ +import { AuthQueues, AuthUserJobs } from '@core/auth/domain/enums'; +import { CreateUserWorkspaceEvent } from '@core/auth/domain/events'; +import { IIdentityRepository } from '@core/auth/domain/repository'; +import { FindUserQuery, RegisterUserUseCase } from '@core/user'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { Queue } from 'bullmq'; + +import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; +import { OAuthResponse } from '../../dtos'; + +@Injectable() +export class ProcessOAuthRegistrationUseCase { + constructor( + @InjectQueue(AuthQueues.AUTH_USER) + private readonly queue: Queue, + @Inject('IIdentityRepository') + private readonly identityRepo: IIdentityRepository, + private readonly findUserQ: FindUserQuery, + private readonly registerUserUC: RegisterUserUseCase, + ) {} + + async execute(dto: OAuthResponse) { + const existingUser = await this.findUserByEmail(dto.email); + + if (existingUser) { + throw new BaseException( + { + code: OAuthErrorCodes.EMAIL_ALREADY_EXISTS, + message: OAuthErrorMessages[OAuthErrorCodes.EMAIL_ALREADY_EXISTS], + }, + HttpStatus.CONFLICT, + ); + } + + const user = await this.registerUserUC.execute({ + email: dto.email, + firstName: dto.first_name || 'User', + lastName: dto.last_name ?? '', + password: null, + bio: dto.bio, + gender: dto.sex === 'male' ? 'male' : dto.sex === 'female' ? 'female' : 'none', + avatarUrl: dto.avatar_url, + }); + + await this.identityRepo.create({ + userId: user.id, + avatarUrl: dto.avatar_url, + provider: dto.provider as any, + providerUserId: dto.id, + email: dto.email, + }); + + const event = new CreateUserWorkspaceEvent(user.id, user.firstName); + await this.queue.add(AuthUserJobs.CREATE_WORKSPACE, event); + + return { user, isNewUser: true, isConnect: false }; + } + + private async findUserByEmail(email: string) { + const result = await this.findUserQ.execute({ email }); + return result?.user; + } +} diff --git a/src/auth/application/use-cases/password/confirm-reset-password.use-case.ts b/src/auth/application/use-cases/password/confirm-reset-password.use-case.ts new file mode 100644 index 00000000..4cba5968 --- /dev/null +++ b/src/auth/application/use-cases/password/confirm-reset-password.use-case.ts @@ -0,0 +1,83 @@ +import { UpdatePasswordUseCase } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import * as argon from 'argon2'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { PasswordResetConfirmDto } from '../../dtos'; + +@Injectable() +export class ConfirmResetPasswordUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + private readonly updatePasswordUserUC: UpdatePasswordUseCase, + ) {} + + async execute(dto: PasswordResetConfirmDto) { + const redisKey = `pass:reset:${dto.email}`; + const cachedData = await this.cacheService.getOne(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: AuthErrorCodes.RESET_SESSION_NOT_FOUND, + message: AuthErrorMessages[AuthErrorCodes.RESET_SESSION_NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const resetSession = JSON.parse(cachedData); + + if (!resetSession) { + await this.cacheService.removeOne(redisKey); + + throw new BaseException( + { + code: AuthErrorCodes.DATA_CORRUPTION, + message: AuthErrorMessages[AuthErrorCodes.DATA_CORRUPTION], + details: [{ target: 'cache', message: 'Поврежденные данные сброса пароля' }], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + if (!resetSession.isVerified) { + throw new BaseException( + { + code: AuthErrorCodes.CODE_NOT_VERIFIED, + message: AuthErrorMessages[AuthErrorCodes.CODE_NOT_VERIFIED], + details: [{ target: 'code', message: 'Код подтверждения не верифицирован' }], + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const hashed = await argon.hash(dto.password); + const result = await this.updatePasswordUserUC.execute(dto.email, hashed); + + await this.cacheService.removeOne(redisKey); + + return { + success: result, + message: 'Пароль успешно изменен. Теперь вы можете войти в аккаунт.', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.RESET_PASSWORD_FAILED, + message: AuthErrorMessages[AuthErrorCodes.RESET_PASSWORD_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/password/reset-password.use-case.ts b/src/auth/application/use-cases/password/reset-password.use-case.ts new file mode 100644 index 00000000..4b57749b --- /dev/null +++ b/src/auth/application/use-cases/password/reset-password.use-case.ts @@ -0,0 +1,97 @@ +import { ResetPasswordCacheData } from '@core/auth/application/interfaces'; +import { + EMAIL_CODE_TTL_SECONDS, + RESET_PASSWORD_CACHE_KEY, +} from '@core/auth/infrastructure/constants'; +import { FindUserQuery } from '@core/user'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import { Queue } from 'bullmq'; +import { generate, generateSecret } from 'otplib'; + +import { AuthMailJobs, AuthQueues } from '../../../domain/enums'; +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { ResetPasswordEvent } from '../../../domain/events'; +import { ResetPasswordDto } from '../../dtos'; + +@Injectable() +export class ResetPasswordUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + private readonly findUserQ: FindUserQuery, + ) {} + + async execute(dto: ResetPasswordDto) { + const isExistsAttempt = await this.cacheService.getOne(RESET_PASSWORD_CACHE_KEY(dto.email)); + + if (isExistsAttempt) { + throw new BaseException( + { + code: AuthErrorCodes.RESET_ATTEMPT_ACTIVE, + message: AuthErrorMessages[AuthErrorCodes.RESET_ATTEMPT_ACTIVE], + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.CONFLICT, + ); + } + + const entity = await this.findUserQ.execute( + { email: dto.email }, + { throwIfNotFound: true, throwIfExists: false }, + ); + + try { + const secret = generateSecret(); + const token = await generate({ + secret, + digits: 6, + period: EMAIL_CODE_TTL_SECONDS, + strategy: 'totp', + }); + + const resetPayload: ResetPasswordCacheData = { + email: entity.user.email, + otp: { secret, token }, + isVerified: false, + }; + + await this.cacheService.setOne( + RESET_PASSWORD_CACHE_KEY(dto.email), + JSON.stringify(resetPayload), + EMAIL_CODE_TTL_SECONDS, + ); + + const event = new ResetPasswordEvent(dto.email, token); + await this.mailQueue.add(AuthMailJobs.SEND_RESET_PASSWORD, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: 'Код для восстановления пароля отправлен на вашу почту', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.RESET_PASSWORD_FAILED, + message: AuthErrorMessages[AuthErrorCodes.RESET_PASSWORD_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/password/verify-reset-password.use-case.ts b/src/auth/application/use-cases/password/verify-reset-password.use-case.ts new file mode 100644 index 00000000..937e0ed0 --- /dev/null +++ b/src/auth/application/use-cases/password/verify-reset-password.use-case.ts @@ -0,0 +1,101 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import { verify as verifyOTP } from 'otplib'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { VerifyResetCodeDto } from '../../dtos'; + +@Injectable() +export class VerifyResetPasswordUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + ) {} + + async execute(dto: VerifyResetCodeDto) { + const redisKey = `pass:reset:${dto.email}`; + const cachedData = await this.cacheService.getOne(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: AuthErrorCodes.RESET_SESSION_NOT_FOUND, + message: AuthErrorMessages[AuthErrorCodes.RESET_SESSION_NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const resetSession = JSON.parse(cachedData); + + if (!resetSession) { + await this.cacheService.removeOne(redisKey); + + throw new BaseException( + { + code: AuthErrorCodes.DATA_CORRUPTION, + message: AuthErrorMessages[AuthErrorCodes.DATA_CORRUPTION], + details: [{ target: 'cache', message: 'Поврежденные данные сброса пароля' }], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + if (!resetSession.isVerified) { + throw new BaseException( + { + code: AuthErrorCodes.CODE_NOT_VERIFIED, + message: AuthErrorMessages[AuthErrorCodes.CODE_NOT_VERIFIED], + details: [{ target: 'code', message: 'Код подтверждения не верифицирован' }], + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const verifyResult = await verifyOTP({ + token: dto.code, + secret: resetSession.otp.secret, + digits: 6, + period: 900, + strategy: 'totp', + }); + + if (!verifyResult.valid) { + throw new BaseException( + { + code: AuthErrorCodes.INVALID_CODE, + message: AuthErrorMessages[AuthErrorCodes.INVALID_CODE], + details: [{ target: 'code', message: 'The provided OTP is incorrect' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + await this.cacheService.setOne( + redisKey, + JSON.stringify({ ...resetSession, isVerified: true }), + 600, + ); + + return { + success: true, + message: 'Код успешно подтвержден. Теперь вы можете установить новый пароль.', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.RESET_PASSWORD_FAILED, + message: AuthErrorMessages[AuthErrorCodes.RESET_PASSWORD_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 00000000..eb318923 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,65 @@ +import { ProjectModule } from '@core/project'; +import { TeamsModule } from '@core/teams'; +import { UserModule } from '@core/user'; +import { BullModule } from '@nestjs/bullmq'; +import { forwardRef, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { MailAdapter } from '@shared/adapters/mail'; + +import { AuthFacade } from './application/auth.facade'; +import { CONTROLLERS } from './application/controllers'; +import { AuthUseCases } from './application/use-cases'; +import { AuthQueues } from './domain/enums'; +import { REPOSITORIES } from './infrastructure/persistence/repositories'; +import { TokenService } from './infrastructure/security'; +import { STRATEGIES } from './infrastructure/strategies'; +import { MailProcessor, UserProcessor } from './infrastructure/workers'; + +const WORKERS = [MailProcessor, UserProcessor]; + +@Module({ + imports: [ + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (cfg: ConfigService) => ({ + secret: cfg.get('JWT_ACCESS_SECRET'), + signOptions: { + /** + * Использование 'any' здесь необходимо, так как Zod гарантирует + * формат строки (напр. '15m', '30d') через regex в ConfigSchema, но внутренний тип + * 'StringValue' из библиотеки 'ms' слишком строг для обычного string. + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + expiresIn: cfg.get('JWT_ACCESS_EXPIRES_IN'), + algorithm: 'HS256', + }, + verifyOptions: { + algorithms: ['HS256'], + ignoreExpiration: false, + clockTolerance: 10, + }, + }), + }), + BullModule.registerQueue({ name: AuthQueues.AUTH_MAIL }, { name: AuthQueues.AUTH_USER }), + forwardRef(() => UserModule), + TeamsModule, + ProjectModule, + ], + controllers: CONTROLLERS, + providers: [ + // TOOD: FIX PROVIDER + { + provide: 'IMailPort', + useClass: MailAdapter, + }, + ...WORKERS, + TokenService, + ...AuthUseCases, + ...STRATEGIES, + ...REPOSITORIES, + AuthFacade, + ], + exports: [], +}) +export class AuthModule {} diff --git a/src/auth/domain/domain/.gitkeep b/src/auth/domain/domain/.gitkeep new file mode 100644 index 00000000..42e6429b --- /dev/null +++ b/src/auth/domain/domain/.gitkeep @@ -0,0 +1 @@ +# feature added entity class \ No newline at end of file diff --git a/src/auth/domain/enums/auth-jobs.enum.ts b/src/auth/domain/enums/auth-jobs.enum.ts new file mode 100644 index 00000000..3135613c --- /dev/null +++ b/src/auth/domain/enums/auth-jobs.enum.ts @@ -0,0 +1,14 @@ +export const enum AuthQueues { + AUTH_MAIL = 'AUTH_MAIL_QUEUE', + AUTH_USER = 'AUTH_USER_QUEUE', +} + +export const enum AuthMailJobs { + SEND_REGISTER_CODE = 'AUTH_SEND_REGISTER_CODE', + SEND_RESET_PASSWORD = 'AUTH_SEND_RESET_PASSWORD', + SEND_CHANGE_EMAIL = 'AUTH_SEND_CHANGE_EMAIL', +} + +export const enum AuthUserJobs { + CREATE_WORKSPACE = 'AUTH_CREATE_WORKSPACE', +} diff --git a/src/auth/domain/enums/index.ts b/src/auth/domain/enums/index.ts new file mode 100644 index 00000000..49223996 --- /dev/null +++ b/src/auth/domain/enums/index.ts @@ -0,0 +1 @@ +export { AuthMailJobs, AuthUserJobs, AuthQueues } from './auth-jobs.enum'; diff --git a/src/auth/domain/errors/auth.error.ts b/src/auth/domain/errors/auth.error.ts new file mode 100644 index 00000000..91cf0c94 --- /dev/null +++ b/src/auth/domain/errors/auth.error.ts @@ -0,0 +1,54 @@ +export const AuthErrorCodes = { + INVALID_CREDENTIALS: 'AUTH.INVALID_CREDENTIALS', + INVALID_CODE: 'AUTH.INVALID_CODE', + CODE_ALREADY_SENT: 'AUTH.CODE_ALREADY_SENT', + SESSION_EXPIRED: 'AUTH.SESSION_EXPIRED', + SIGNOUT_FAILED: 'AUTH.SIGNOUT_FAILED', + STRATEGY_NOT_FOUND: 'AUTH.STRATEGY_NOT_FOUND', + SESSION_NOT_FOUND: 'AUTH.SESSION_NOT_FOUND', + SESSION_REVOKED: 'AUTH.SESSION_REVOKED', + TOKEN_INVALID: 'AUTH.TOKEN_INVALID', + REFRESH_FAILED: 'AUTH.REFRESH_FAILED', + CODE_NOT_VERIFIED: 'AUTH.CODE_NOT_VERIFIED', + SIGNUP_FAILED: 'AUTH.SIGNUP_FAILED', + UNAUTHORIZED: 'AUTH.UNAUTHORIZED', + RESET_SESSION_NOT_FOUND: 'AUTH.RESET_SESSION_NOT_FOUND', + RESET_ATTEMPT_ACTIVE: 'AUTH.RESET_ATTEMPT_ACTIVE', + REGISTRATION_EXPIRED: 'AUTH.REGISTRATION_EXPIRED', + RESET_SESSION_EXPIRED: 'AUTH.RESET_SESSION_EXPIRED', + RESEND_RATE_LIMIT: 'AUTH.RESEND_RATE_LIMIT', + MAX_ATTEMPTS_REACHED: 'AUTH.MAX_ATTEMPTS_REACHED', + SIGNIN_FAILED: 'AUTH.SIGNIN_FAILED', + SESSION_CREATION_FAILED: 'AUTH.SESSION_CREATION_FAILED', + RESET_PASSWORD_FAILED: 'AUTH.RESET_PASSWORD_FAILED', + DATA_CORRUPTION: 'DATA_CORRUPTION', +} as const; + +export type AuthErrorCode = (typeof AuthErrorCodes)[keyof typeof AuthErrorCodes]; + +export const AuthErrorMessages: Record = { + [AuthErrorCodes.DATA_CORRUPTION]: 'Ошибка целостности данных. Попробуйте начать заново', + [AuthErrorCodes.INVALID_CREDENTIALS]: 'Неверный email или пароль', + [AuthErrorCodes.INVALID_CODE]: 'Неверный или истекший код подтверждения', + [AuthErrorCodes.CODE_ALREADY_SENT]: 'Код уже был отправлен. Проверьте почту или подождите', + [AuthErrorCodes.SESSION_EXPIRED]: 'Сессия истекла. Выполните вход заново', + [AuthErrorCodes.SIGNOUT_FAILED]: 'Не удалось завершить сессию, попробуйте позже', + [AuthErrorCodes.TOKEN_INVALID]: 'Недействительный токен обновления', + [AuthErrorCodes.REFRESH_FAILED]: 'Не удалось обновить токены. Попробуйте позже', + [AuthErrorCodes.SESSION_NOT_FOUND]: 'Сессия не найдена', + [AuthErrorCodes.SESSION_REVOKED]: 'Сессия была отозвана. Выполните вход заново', + [AuthErrorCodes.STRATEGY_NOT_FOUND]: 'Неизвестный контекст операции', + [AuthErrorCodes.CODE_NOT_VERIFIED]: 'Код подтверждения еще не был верифицирован', + [AuthErrorCodes.UNAUTHORIZED]: 'Необходимо выполнить вход', + [AuthErrorCodes.RESET_SESSION_NOT_FOUND]: 'Запрос на сброс пароля не найден', + [AuthErrorCodes.REGISTRATION_EXPIRED]: 'Регистрация истекла. Зарегистрируйтесь заново', + [AuthErrorCodes.RESET_SESSION_EXPIRED]: 'Время подтверждения истекло. Запросите код снова', + [AuthErrorCodes.RESEND_RATE_LIMIT]: 'Слишком частые попытки. Повторная отправка доступна через', + [AuthErrorCodes.MAX_ATTEMPTS_REACHED]: + 'Превышено максимальное количество попыток. Начните процесс заново', + [AuthErrorCodes.SIGNIN_FAILED]: 'Не удалось выполнить вход. Попробуйте позже', + [AuthErrorCodes.SIGNUP_FAILED]: 'Не удалось зарегистрироваться. Попробуйте позже', + [AuthErrorCodes.SESSION_CREATION_FAILED]: 'Не удалось создать сессию', + [AuthErrorCodes.RESET_ATTEMPT_ACTIVE]: 'Запрос на сброс пароля уже активен. Проверьте почту', + [AuthErrorCodes.RESET_PASSWORD_FAILED]: 'Не удалось сбросить пароль. Попробуйте позже', +}; diff --git a/src/auth/domain/errors/index.ts b/src/auth/domain/errors/index.ts new file mode 100644 index 00000000..6539b960 --- /dev/null +++ b/src/auth/domain/errors/index.ts @@ -0,0 +1,2 @@ +export * from './auth.error'; +export * from './oauth.error'; diff --git a/src/auth/domain/errors/oauth.error.ts b/src/auth/domain/errors/oauth.error.ts new file mode 100644 index 00000000..ad3c55f8 --- /dev/null +++ b/src/auth/domain/errors/oauth.error.ts @@ -0,0 +1,45 @@ +export const OAuthErrorCodes = { + INVALID_ACTION: 'OAUTH.INVALID_ACTION', + PROVIDER_NOT_LINKED: 'OAUTH.PROVIDER_NOT_LINKED', + PROVIDER_ALREADY_CONNECTED: 'OAUTH.PROVIDER_ALREADY_CONNECTED', + PROVIDER_MISMATCH: 'OAUTH.PROVIDER_MISMATCH', + INVALID_OR_EXPIRED_STATE: 'OAUTH.INVALID_OR_EXPIRED_STATE', + LAST_AUTH_METHOD_CANNOT_BE_REMOVED: 'OAUTH.LAST_AUTH_METHOD_CANNOT_BE_REMOVED', + EXCHANGE_TOKEN_INVALID: 'OAUTH.EXCHANGE_TOKEN_INVALID', + EXCHANGE_DATA_CORRUPTED: 'OAUTH.EXCHANGE_DATA_CORRUPTED', + PROVIDER_ALREADY_USED: 'OAUTH.PROVIDER_ALREADY_USED', + EMAIL_ALREADY_EXISTS: 'OAUTH.EMAIL_ALREADY_EXISTS', + OAUTH_LOGIN_NOT_FOUND: 'OAUTH.LOGIN_NOT_FOUND', + ACTIVE_OAUTH_SESSION_EXISTS: 'OAUTH.ACTIVE_SESSION_EXISTS', + UNAUTHORIZED: 'OAUTH.UNAUTHORIZED', + DATA_CORRUPTION: 'OAUTH.DATA_CORRUPTION', + SESSION_CREATION_FAILED: 'OAUTH.SESSION_CREATION_FAILED', + SESSION_CREATION_INTERNAL_ERROR: 'OAUTH.SESSION_CREATION_INTERNAL_ERROR', + PROVIDER_CONNECT_FAILED: 'OAUTH.PROVIDER_CONNECT_FAILED', + PROVIDER_DISCONNECT_FAILED: 'OAUTH.PROVIDER_DISCONNECT_FAILED', +} as const; + +export type OAuthErrorCode = (typeof OAuthErrorCodes)[keyof typeof OAuthErrorCodes]; + +export const OAuthErrorMessages: Record = { + [OAuthErrorCodes.INVALID_ACTION]: 'Неверное действие для OAuth операции', + [OAuthErrorCodes.PROVIDER_NOT_LINKED]: 'Провайдер не привязан к пользователю', + [OAuthErrorCodes.PROVIDER_ALREADY_CONNECTED]: 'Провайдер уже подключен к аккаунту', + [OAuthErrorCodes.PROVIDER_MISMATCH]: 'Провайдер в запросе не совпадает с ожидаемым', + [OAuthErrorCodes.INVALID_OR_EXPIRED_STATE]: 'Сессия подключения недействительна или истекла', + [OAuthErrorCodes.LAST_AUTH_METHOD_CANNOT_BE_REMOVED]: + 'Нельзя удалить последний способ входа. Установите пароль или добавьте другой провайдер', + [OAuthErrorCodes.EXCHANGE_TOKEN_INVALID]: 'Токен обмена недействителен или истёк', + [OAuthErrorCodes.EXCHANGE_DATA_CORRUPTED]: 'Неверный формат данных авторизации', + [OAuthErrorCodes.PROVIDER_ALREADY_USED]: 'Провайдер уже привязан к другому пользователю', + [OAuthErrorCodes.EMAIL_ALREADY_EXISTS]: + 'Пользователь с таким email уже существует. Войдите через пароль', + [OAuthErrorCodes.OAUTH_LOGIN_NOT_FOUND]: 'Пользователь с таким OAuth аккаунтом не найден', + [OAuthErrorCodes.ACTIVE_OAUTH_SESSION_EXISTS]: 'Активный процесс авторизации уже существует', + [OAuthErrorCodes.UNAUTHORIZED]: 'Необходима авторизация', + [OAuthErrorCodes.DATA_CORRUPTION]: 'Ошибка целостности данных', + [OAuthErrorCodes.SESSION_CREATION_FAILED]: 'Не удалось создать сессию', + [OAuthErrorCodes.SESSION_CREATION_INTERNAL_ERROR]: 'Внутренняя ошибка при создании сессии', + [OAuthErrorCodes.PROVIDER_CONNECT_FAILED]: 'Не удалось привязать провайдера', + [OAuthErrorCodes.PROVIDER_DISCONNECT_FAILED]: 'Не удалось отвязать провайдера', +}; diff --git a/src/auth/domain/events/create-user-workspace.event.ts b/src/auth/domain/events/create-user-workspace.event.ts new file mode 100644 index 00000000..2b3b6bb7 --- /dev/null +++ b/src/auth/domain/events/create-user-workspace.event.ts @@ -0,0 +1,6 @@ +export class CreateUserWorkspaceEvent { + constructor( + public readonly userId: string, + public readonly username: string, + ) {} +} diff --git a/src/auth/domain/events/index.ts b/src/auth/domain/events/index.ts new file mode 100644 index 00000000..9d7e25fa --- /dev/null +++ b/src/auth/domain/events/index.ts @@ -0,0 +1,3 @@ +export * from './register-code.event'; +export * from './reset-password.event'; +export * from './create-user-workspace.event'; diff --git a/src/auth/domain/events/register-code.event.ts b/src/auth/domain/events/register-code.event.ts new file mode 100644 index 00000000..c0f9cfe9 --- /dev/null +++ b/src/auth/domain/events/register-code.event.ts @@ -0,0 +1,7 @@ +export class RegisterCodeEvent { + constructor( + public readonly email: string, + public readonly name: string, + public readonly otp: string, + ) {} +} diff --git a/src/auth/domain/events/reset-password.event.ts b/src/auth/domain/events/reset-password.event.ts new file mode 100644 index 00000000..992b232e --- /dev/null +++ b/src/auth/domain/events/reset-password.event.ts @@ -0,0 +1,6 @@ +export class ResetPasswordEvent { + constructor( + public readonly email: string, + public readonly otp: string, + ) {} +} diff --git a/src/auth/domain/repository/identity.repository.interface.ts b/src/auth/domain/repository/identity.repository.interface.ts new file mode 100644 index 00000000..5f4f1145 --- /dev/null +++ b/src/auth/domain/repository/identity.repository.interface.ts @@ -0,0 +1,14 @@ +import type { userIdentities } from '../../infrastructure/persistence/models/identity.model'; + +export type IdentitiyInsert = typeof userIdentities.$inferInsert; +export type IdentitiySelect = typeof userIdentities.$inferSelect; + +export interface IIdentityRepository { + create(data: IdentitiyInsert): Promise; + findByProvider( + provider: 'google' | 'yandex' | 'github', + providerUserId: string, + ): Promise; + findAllByUserId(userId: string): Promise; + delete(id: string): Promise; +} diff --git a/src/auth/domain/repository/index.ts b/src/auth/domain/repository/index.ts new file mode 100644 index 00000000..18f175e0 --- /dev/null +++ b/src/auth/domain/repository/index.ts @@ -0,0 +1,2 @@ +export * from './session.repository.interface'; +export * from './identity.repository.interface'; diff --git a/src/auth/domain/repository/session.repository.interface.ts b/src/auth/domain/repository/session.repository.interface.ts new file mode 100644 index 00000000..cd546290 --- /dev/null +++ b/src/auth/domain/repository/session.repository.interface.ts @@ -0,0 +1,13 @@ +import type { sessions } from '../../infrastructure/persistence/models/session.model'; + +export type SessionInsert = typeof sessions.$inferInsert; +export type SessionSelect = typeof sessions.$inferSelect; + +export interface ISessionRepository { + create(data: SessionInsert): Promise; + findById(id: string): Promise; + findAllByUserId(userId: string): Promise; + revoke(id: string): Promise; + revokeAllByUserId(userId: string, exceptSessionId?: string): Promise; + deleteExpired(): Promise; +} diff --git a/src/auth/infrastructure/constants/cache-keys.ts b/src/auth/infrastructure/constants/cache-keys.ts new file mode 100644 index 00000000..2a3a6bf1 --- /dev/null +++ b/src/auth/infrastructure/constants/cache-keys.ts @@ -0,0 +1,14 @@ +export const SIGNUP_CACHE_KEY = (email: string) => `reg:${email}`; +export const RESET_PASSWORD_CACHE_KEY = (email: string) => `pass:reset:${email}`; + +export const RESEND_COOLDOWN_KEY = (context: string, email: string) => + `resend:cooldown:${context}:${email}`; +export const RESEND_ATTEMPTS_KEY = (context: string, email: string) => + `resend:attempts:${context}:${email}`; + +export const EMAIL_CODE_TTL_SECONDS = 900; +export const MAX_ATTEMPTS = 5; +export const SECONDS_BETWEEN_ATTEMPTS = 60; + +export const EXCHANGE_TOKEN_TTL = 10 * 60; // 10 минут +export const EXCHANGE_TOKEN_NAME = (token: string) => `oauth:exchange:${token}`; diff --git a/src/auth/infrastructure/constants/index.ts b/src/auth/infrastructure/constants/index.ts new file mode 100644 index 00000000..124a9d92 --- /dev/null +++ b/src/auth/infrastructure/constants/index.ts @@ -0,0 +1,2 @@ +export * from './oauth'; +export * from './cache-keys'; diff --git a/src/auth/infrastructure/constants/oauth.ts b/src/auth/infrastructure/constants/oauth.ts new file mode 100644 index 00000000..c5c42e94 --- /dev/null +++ b/src/auth/infrastructure/constants/oauth.ts @@ -0,0 +1,22 @@ +export enum OAuthProvider { + GOOGLE = 'google', + GITHUB = 'github', + YANDEX = 'yandex', + VKONTAKTE = 'vkontakte', +} + +export const OAuthAssets = { + google: { + value: 'google', + label: 'Google', + }, + github: { + value: 'github', + label: 'GitHub', + }, + yandex: { value: 'yandex', label: 'Яндекс' }, + vkontakte: { + value: 'vkontakte', + label: 'Вконтакте', + }, +}; diff --git a/src/auth/infrastructure/persistence/models/identity.model.ts b/src/auth/infrastructure/persistence/models/identity.model.ts new file mode 100644 index 00000000..bdeeb174 --- /dev/null +++ b/src/auth/infrastructure/persistence/models/identity.model.ts @@ -0,0 +1,27 @@ +import { createId } from '@paralleldrive/cuid2'; +import { baseSchema, users } from '@shared/entities'; +import { text, timestamp, varchar, unique } from 'drizzle-orm/pg-core'; + +export const userIdentities = baseSchema.table( + 'user_identities', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + provider: varchar('provider', { length: 50 }) + .$type<'google' | 'yandex' | 'github'>() + .notNull(), + providerUserId: varchar('provider_user_id', { length: 255 }).notNull(), + email: varchar('email', { length: 255 }).notNull(), + avatarUrl: varchar('avatar_url', { length: 255 }), + connectedAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + (table) => ({ + providerUserIdIdx: unique('provider_user_id_idx').on(table.provider, table.providerUserId), + }), +); diff --git a/src/auth/infrastructure/persistence/models/index.ts b/src/auth/infrastructure/persistence/models/index.ts new file mode 100644 index 00000000..3be30728 --- /dev/null +++ b/src/auth/infrastructure/persistence/models/index.ts @@ -0,0 +1,2 @@ +export { sessions } from './session.model'; +export { userIdentities } from './identity.model'; diff --git a/src/auth/infrastructure/persistence/models/session.model.ts b/src/auth/infrastructure/persistence/models/session.model.ts new file mode 100644 index 00000000..98495f7f --- /dev/null +++ b/src/auth/infrastructure/persistence/models/session.model.ts @@ -0,0 +1,27 @@ +import { createId } from '@paralleldrive/cuid2'; +import { baseSchema, users } from '@shared/entities'; +import { text, timestamp, varchar, boolean } from 'drizzle-orm/pg-core'; + +export const sessions = baseSchema.table('sessions', { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + deviceType: varchar('device_type', { length: 20 }).$type<'mobile' | 'desktop' | 'tablet'>(), + browser: varchar('browser', { length: 50 }), + os: varchar('os', { length: 50 }), + userAgent: text('user_agent').notNull(), + ip: varchar('ip', { length: 45 }).notNull(), + city: varchar('city', { length: 100 }), + countryCode: varchar('country_code', { length: 5 }), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'string' }).notNull(), + isRevoked: boolean('is_revoked').default(false).notNull(), +}); diff --git a/src/auth/infrastructure/persistence/repositories/identity.repository.ts b/src/auth/infrastructure/persistence/repositories/identity.repository.ts new file mode 100644 index 00000000..311f44a7 --- /dev/null +++ b/src/auth/infrastructure/persistence/repositories/identity.repository.ts @@ -0,0 +1,55 @@ +import { IIdentityRepository } from '@core/auth/domain/repository'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Inject, Injectable } from '@nestjs/common'; +import { and, eq } from 'drizzle-orm'; + +import * as schema from '../models/identity.model'; + +@Injectable() +export class IdentitiyRepository implements IIdentityRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public readonly create = async (data: typeof schema.userIdentities.$inferInsert) => { + const [result] = await this.db.insert(schema.userIdentities).values(data).returning(); + + if (!result) { + throw new Error('Failed to create identity: no identity returned'); + } + + return result; + }; + + public readonly delete = async (id: string) => { + const result = await this.db + .delete(schema.userIdentities) + .where(eq(schema.userIdentities.id, id)); + + return result.count.valueOf() > 0; + }; + + public readonly findAllByUserId = async (userId: string) => + this.db + .select() + .from(schema.userIdentities) + .where(eq(schema.userIdentities.userId, userId)); + + public readonly findByProvider = async ( + provider: 'google' | 'yandex' | 'github', + providerUserId: string, + ) => { + const [result] = await this.db + .select() + .from(schema.userIdentities) + .where( + and( + eq(schema.userIdentities.provider, provider), + eq(schema.userIdentities.providerUserId, providerUserId), + ), + ); + + return result ?? null; + }; +} diff --git a/src/auth/infrastructure/persistence/repositories/index.ts b/src/auth/infrastructure/persistence/repositories/index.ts new file mode 100644 index 00000000..26d31eef --- /dev/null +++ b/src/auth/infrastructure/persistence/repositories/index.ts @@ -0,0 +1,7 @@ +import { IdentitiyRepository } from './identity.repository'; +import { SessionRepository } from './session.repository'; + +export const REPOSITORIES = [ + { provide: 'ISessionRepository', useClass: SessionRepository }, + { provide: 'IIdentityRepository', useClass: IdentitiyRepository }, +]; diff --git a/src/auth/infrastructure/persistence/repositories/session.repository.ts b/src/auth/infrastructure/persistence/repositories/session.repository.ts new file mode 100644 index 00000000..13c9996b --- /dev/null +++ b/src/auth/infrastructure/persistence/repositories/session.repository.ts @@ -0,0 +1,72 @@ +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Inject, Injectable } from '@nestjs/common'; +import { eq, and, ne, lt, desc } from 'drizzle-orm'; + +import { ISessionRepository, type SessionInsert } from '../../../domain/repository'; +import * as schema from '../models/session.model'; + +@Injectable() +export class SessionRepository implements ISessionRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + async create(data: SessionInsert) { + const [result] = await this.db.insert(schema.sessions).values(data).returning(); + + if (!result) { + throw new Error('Failed to create session: no session returned'); + } + + return result; + } + + async findById(id: string) { + const [result] = await this.db + .select() + .from(schema.sessions) + .where(and(eq(schema.sessions.id, id), eq(schema.sessions.isRevoked, false))) + .limit(1); + + return result || null; + } + + async findAllByUserId(userId: string) { + return this.db + .select() + .from(schema.sessions) + .where(and(eq(schema.sessions.userId, userId), eq(schema.sessions.isRevoked, false))) + .orderBy(desc(schema.sessions.createdAt)); + } + + async revoke(id: string) { + const result = await this.db + .update(schema.sessions) + .set({ isRevoked: true, updatedAt: new Date().toISOString() }) + .where(eq(schema.sessions.id, id)); + + return (result?.count ?? 0) > 0; + } + + async revokeAllByUserId(userId: string, exceptSessionId?: string) { + const filters = [eq(schema.sessions.userId, userId)]; + + if (exceptSessionId) { + filters.push(ne(schema.sessions.id, exceptSessionId)); + } + + await this.db + .update(schema.sessions) + .set({ isRevoked: true, updatedAt: new Date().toISOString() }) + .where(and(...filters)); + } + + async deleteExpired() { + const result = await this.db + .delete(schema.sessions) + .where(lt(schema.sessions.expiresAt, new Date().toISOString())); + + return result?.count ?? 0; + } +} diff --git a/src/auth/infrastructure/security/index.ts b/src/auth/infrastructure/security/index.ts new file mode 100644 index 00000000..0b27e01d --- /dev/null +++ b/src/auth/infrastructure/security/index.ts @@ -0,0 +1 @@ +export { TokenService } from './token.service'; diff --git a/src/auth/infrastructure/security/token.service.ts b/src/auth/infrastructure/security/token.service.ts new file mode 100644 index 00000000..159291b8 --- /dev/null +++ b/src/auth/infrastructure/security/token.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; + +import type { JwtPayload } from '@shared/types'; + +@Injectable() +export class TokenService { + constructor( + private readonly jwtService: JwtService, + private readonly cfg: ConfigService, + ) {} + + async generateTokens(user: { id: string; email: string }, sessionId: string) { + const iss = this.cfg.getOrThrow('JWT_ISSUER'); + const aud = this.cfg.getOrThrow('JWT_AUDIENCE'); + + const payload = { + jti: sessionId, + sub: user.id, + email: user.email, + iss, + aud, + }; + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const accessExp = this.cfg.get('JWT_ACCESS_EXPIRES_IN'); + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const refreshExp = this.cfg.get('JWT_REFRESH_EXPIRES_IN'); + + const [access, refresh] = await Promise.all([ + this.jwtService.signAsync(payload, { + secret: this.cfg.get('JWT_ACCESS_SECRET'), + expiresIn: accessExp, + }), + this.jwtService.signAsync(payload, { + secret: this.cfg.get('JWT_REFRESH_SECRET'), + expiresIn: refreshExp, + }), + ]); + + const refreshDecodedData = this.jwtService.decode(refresh); + + return { access, refresh, expiresAt: new Date(refreshDecodedData?.exp * 1000) }; + } + + async validateToken(token: string, type: 'access' | 'refresh'): Promise { + try { + const accessSecret = this.cfg.get('JWT_ACCESS_SECRET'); + const refreshSecret = this.cfg.get('JWT_REFRESH_SECRET'); + + const secret = type === 'access' ? accessSecret : refreshSecret; + + return this.jwtService.verifyAsync(token, { secret }); + } catch { + return null; + } + } +} diff --git a/src/auth/infrastructure/strategies/bearer.strategy.ts b/src/auth/infrastructure/strategies/bearer.strategy.ts new file mode 100644 index 00000000..91070222 --- /dev/null +++ b/src/auth/infrastructure/strategies/bearer.strategy.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, ExtractJwt } from 'passport-jwt'; + +import type { JwtPayload } from '@shared/types'; + +@Injectable() +export class BearerStrategy extends PassportStrategy(Strategy, 'bearer') { + constructor(cfg: ConfigService) { + const audience = cfg.getOrThrow('JWT_AUDIENCE'); + + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: cfg.getOrThrow('JWT_ACCESS_SECRET'), + issuer: cfg.getOrThrow('JWT_ISSUER'), + audience, + }); + } + + validate(payload: JwtPayload) { + return payload; + } +} diff --git a/src/auth/infrastructure/strategies/cookie.strategy.ts b/src/auth/infrastructure/strategies/cookie.strategy.ts new file mode 100644 index 00000000..96b89688 --- /dev/null +++ b/src/auth/infrastructure/strategies/cookie.strategy.ts @@ -0,0 +1,39 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { BaseException } from '@shared/error'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +import type { JwtPayload } from '@shared/types'; +import type { FastifyRequest } from 'fastify'; + +@Injectable() +export class CookieStrategy extends PassportStrategy(Strategy, 'cookie') { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([ + (request: FastifyRequest) => { + const token = request?.cookies?.['refresh']; + return token ?? null; + }, + ]), + secretOrKey: configService.getOrThrow('JWT_REFRESH_SECRET'), + passReqToCallback: true, + }); + } + + validate(_req: FastifyRequest, payload: JwtPayload) { + if (!payload || !payload.jti) { + throw new BaseException( + { + code: 'INVALID_REFRESH_TOKEN', + message: 'Refresh токен невалиден или протух', + details: [{ target: 'auth', reason: 'Payload is missing or jti is invalid' }], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + return payload; + } +} diff --git a/src/auth/infrastructure/strategies/github.strategy.ts b/src/auth/infrastructure/strategies/github.strategy.ts new file mode 100644 index 00000000..f1be86bf --- /dev/null +++ b/src/auth/infrastructure/strategies/github.strategy.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, type Profile } from 'passport-github'; + +import { ensureEmail } from '../utils'; + +interface GitHubJsonProfile { + readonly login: string; + readonly id: number; + readonly avatar_url: string; + readonly name: string | null; + readonly email: string | null; + readonly bio: string | null; +} + +@Injectable() +export class GithubStrategy extends PassportStrategy(Strategy, 'github-oauth') { + constructor(cfg: ConfigService) { + const isProduction = cfg.get('NODE_ENV') === 'production'; + const domain = cfg.get('DOMAIN'); + const port = cfg.get('PORT'); + const apiPath = 'v1/oauth/github/callback'; + + const callbackURL = domain + ? `${isProduction ? 'https' : 'http'}://api.${domain}/${apiPath}` + : `http://localhost:${port || 3000}/${apiPath}`; + + super({ + clientID: cfg.getOrThrow('GITHUB_CLIENT_ID'), + clientSecret: cfg.getOrThrow('GITHUB_CLIENT_SECRET'), + callbackURL, + scope: ['user:email', 'read:user'], + passReqToCallback: true, + }); + } + + validate( + _r: never, + _at: string, + _rt: string, + profile: Profile, + done: (...args: readonly unknown[]) => void, + ) { + const json = profile._json as unknown as GitHubJsonProfile; + + const user = { + id: json.id.toString(), + email: ensureEmail(json.email, 'github', json.id.toString(), json.login), + first_name: json.name || json.login, + last_name: null, + sex: null, + avatar_url: json.avatar_url || null, + bio: json.bio || null, + }; + + done(null, user); + } +} diff --git a/src/auth/infrastructure/strategies/google.strategy.ts b/src/auth/infrastructure/strategies/google.strategy.ts new file mode 100644 index 00000000..333779e8 --- /dev/null +++ b/src/auth/infrastructure/strategies/google.strategy.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, type VerifyCallback, type Profile } from 'passport-google-oauth20'; + +import { ensureEmail } from '../utils'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google-oauth') { + constructor(cfg: ConfigService) { + const isProduction = cfg.get('NODE_ENV') === 'production'; + const domain = cfg.get('DOMAIN'); + const port = cfg.get('PORT'); + const apiPath = 'v1/oauth/google/callback'; + + const callbackURL = domain + ? `${isProduction ? 'https' : 'http'}://api.${domain}/${apiPath}` + : `http://localhost:${port || 3000}/${apiPath}`; + + super({ + clientID: cfg.getOrThrow('GOOGLE_CLIENT_ID'), + clientSecret: cfg.getOrThrow('GOOGLE_CLIENT_SECRET'), + scope: ['email', 'profile'], + callbackURL, + passReqToCallback: true, + }); + } + + validate(_r: never, _at: string, _rt: string, profile: Profile, done: VerifyCallback) { + const json = profile._json; + + const user = { + id: profile.id, + email: ensureEmail(json.email, 'google', profile.id, json.given_name), + avatar_url: json.picture || null, + first_name: json.given_name, + last_name: json.family_name, + sex: null, + bio: null, + }; + + done(null, user); + } +} diff --git a/src/auth/infrastructure/strategies/index.ts b/src/auth/infrastructure/strategies/index.ts new file mode 100644 index 00000000..009ee925 --- /dev/null +++ b/src/auth/infrastructure/strategies/index.ts @@ -0,0 +1,13 @@ +import { BearerStrategy } from './bearer.strategy'; +import { CookieStrategy } from './cookie.strategy'; +import { GithubStrategy } from './github.strategy'; +import { GoogleStrategy } from './google.strategy'; +import { YandexStrategy } from './yandex.strategy'; + +export const STRATEGIES = [ + BearerStrategy, + CookieStrategy, + GoogleStrategy, + GithubStrategy, + YandexStrategy, +]; diff --git a/src/auth/infrastructure/strategies/vkontakte.strategy.ts b/src/auth/infrastructure/strategies/vkontakte.strategy.ts new file mode 100644 index 00000000..a994bc4d --- /dev/null +++ b/src/auth/infrastructure/strategies/vkontakte.strategy.ts @@ -0,0 +1,288 @@ +import { HttpService } from '@nestjs/axios'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { BaseException } from '@shared/error'; +import { Strategy } from 'passport-oauth2'; +import { firstValueFrom } from 'rxjs'; + +import { ensureEmail } from '../utils'; + +export interface IVKUserInfo { + readonly id: number; + readonly first_name: string; + readonly last_name: string; + readonly screen_name: string; + readonly sex: 0 | 1 | 2; + readonly photo_50?: string; + readonly photo_100?: string; + readonly photo_200?: string; + readonly photo_200_orig?: string; + readonly photo_400_orig?: string; + readonly photo_max?: string; + readonly photo_max_orig?: string; + readonly city?: { readonly id: number; readonly title: string }; + readonly country?: { readonly id: number; readonly title: string }; + readonly bdate?: string; + readonly about?: string; + readonly activities?: string; + readonly interests?: string; + readonly music?: string; + readonly movies?: string; + readonly tv?: string; + readonly books?: string; + readonly games?: string; + readonly status?: string; + readonly online?: number; + readonly domain?: string; + readonly has_mobile?: number; + readonly mobile_phone?: string; + readonly home_phone?: string; + readonly can_post?: number; + readonly can_see_all_posts?: number; + readonly can_see_audio?: number; + readonly contacts?: { + readonly mobile_phone?: string; + readonly home_phone?: string; + }; + readonly site?: string; + readonly education?: { + readonly university?: number; + readonly university_name?: string; + readonly faculty?: number; + readonly faculty_name?: string; + readonly graduation?: number; + }; + readonly universities?: ReadonlyArray<{ + readonly id: number; + readonly name: string; + readonly faculty: number; + readonly faculty_name: string; + readonly graduation: number; + }>; +} + +export interface IVKProfile { + readonly provider: 'vkontakte'; + readonly id: string; + readonly displayName: string; + readonly name: { + readonly familyName: string; + readonly givenName: string; + }; + readonly gender: 'male' | 'female' | undefined; + readonly emails?: ReadonlyArray<{ readonly value: string }>; + readonly photos: ReadonlyArray<{ readonly value: string }>; + readonly city?: string; + readonly country?: string; + readonly birthday?: string; + readonly about?: string; + readonly _raw: string; + readonly _json: IVKUserInfo; + readonly [key: string]: unknown; +} + +@Injectable() +export class VkontakteStrategy extends PassportStrategy(Strategy, 'vkontakte-oauth') { + private readonly apiVersion = '5.199'; + private readonly photoSize = 200; + private readonly lang = 'ru'; + + constructor( + cfg: ConfigService, + private readonly http: HttpService, + ) { + const isProduction = cfg.get('NODE_ENV') === 'production'; + const domain = cfg.get('DOMAIN'); + const port = cfg.get('PORT'); + const apiPath = 'v1/oauth/yandex/callback'; + + const callbackURL = domain + ? `${isProduction ? 'https' : 'http'}://api.${domain}/${apiPath}` + : `http://localhost:${port || 3000}/${apiPath}`; + + super({ + authorizationURL: 'https://oauth.vk.com/authorize', + tokenURL: 'https://oauth.vk.com/access_token', + clientID: cfg.getOrThrow('VKONTAKTE_CLIENT_ID'), + clientSecret: cfg.getOrThrow('VKONTAKTE_CLIENT_SECRET'), + callbackURL, + scope: ['email', 'photos', 'status', 'wall', 'groups'], + scopeSeparator: ',', + passReqToCallback: true, + }); + } + + validate( + _req: never, + _at: never, + _rt: never, + profile: IVKProfile, + done: (...args: readonly unknown[]) => void, + ) { + const user = { + id: profile.id, + email: ensureEmail( + profile.emails?.[0]?.value, + 'vkontakte', + profile.id, + profile.displayName, + ), + first_name: profile.name.givenName, + last_name: profile.name.familyName, + sex: profile.gender === 'male' ? 'male' : profile.gender === 'female' ? 'female' : null, + phone: null, + avatar_url: profile.photos[0]?.value || null, + bio: profile.about || null, + city: profile.city || null, + birthday: profile.birthday || null, + }; + + done(null, user); + } + + private async getUserProfile(accessToken: string) { + try { + const fields = [ + 'uid', + 'first_name', + 'last_name', + 'screen_name', + 'sex', + `photo_${this.photoSize}`, + 'city', + 'country', + 'bdate', + 'about', + 'activities', + 'interests', + 'music', + 'movies', + 'tv', + 'books', + 'games', + 'status', + 'contacts', + 'site', + 'education', + 'universities', + ]; + + const url = `https://api.vk.com/method/users.get`; + + const response = await firstValueFrom( + this.http.get(url, { + params: { + fields: fields.join(','), + v: this.apiVersion, + access_token: accessToken, + lang: this.lang, + https: 1, + }, + }), + ); + + const data = response.data; + + if (data.error) { + throw new BaseException( + { + code: 'VK_API_ERROR', + message: data.error.error_msg || 'Ошибка VK API', + details: [{ error_code: data.error.error_code }], + }, + HttpStatus.BAD_GATEWAY, + ); + } + + if (!data.response || !data.response[0]) { + throw new BaseException( + { + code: 'VK_USER_NOT_FOUND', + message: 'Пользователь VK не найден', + }, + HttpStatus.NOT_FOUND, + ); + } + + return this.parseProfile(data.response[0]); + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + console.error('Failed to get VK user info:', error); + + throw new BaseException( + { + code: 'VK_USER_INFO_FAILED', + message: 'Не удалось получить данные пользователя от VK', + details: [{ target: error instanceof Error ? error.message : String(error) }], + }, + HttpStatus.BAD_GATEWAY, + ); + } + } + + private parseProfile(json: IVKUserInfo): IVKProfile { + const gender: 'male' | 'female' | undefined = json.sex === 2 ? 'male' : 'female'; + + const photoSizes = ['photo_50', 'photo_100', 'photo_200', 'photo_400_orig', 'photo_max']; + + const photos = photoSizes.reduce<{ value: string }[]>((acc, size) => { + const photoUrl = json[size as keyof IVKUserInfo]; + if (photoUrl && typeof photoUrl === 'string') { + return [...acc, { value: photoUrl }]; + } + return acc; + }, []); + + const finalPhotos = + photos.length === 0 && json.photo_max ? [...photos, { value: json.photo_max }] : photos; + + const email = json.contacts?.mobile_phone + ? `${json.contacts.mobile_phone}@vk.phone.internal` + : undefined; + + const profile: IVKProfile = { + provider: 'vkontakte', + id: String(json.id), + displayName: `${json.first_name} ${json.last_name}`, + name: { + familyName: json.last_name || '', + givenName: json.first_name || '', + }, + gender, + emails: email ? [{ value: email }] : [], + photos: finalPhotos, + _raw: JSON.stringify(json), + _json: json, + ...(json.city?.title && { city: json.city.title }), + ...(json.country?.title && { country: json.country.title }), + ...(json.bdate && { birthday: json.bdate }), + ...(json.about && { about: json.about }), + }; + + return profile; + } + + override userProfile( + accessToken: string, + done: (err?: Error | null, profile?: unknown) => void, + ): void { + this.getUserProfile(accessToken) + .then((profile) => done(null, profile)) + .catch((err) => done(err, null)); + } + + override authorizationParams( + options: { display?: 'page' | 'popup' | 'mobile' } = {}, + ): Record { + const params: Record = {}; + + return { + ...params, + ...(options.display && { display: options.display }), + }; + } +} diff --git a/src/auth/infrastructure/strategies/yandex.strategy.ts b/src/auth/infrastructure/strategies/yandex.strategy.ts new file mode 100644 index 00000000..33fad7e6 --- /dev/null +++ b/src/auth/infrastructure/strategies/yandex.strategy.ts @@ -0,0 +1,153 @@ +import { HttpService } from '@nestjs/axios'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { BaseException } from '@shared/error'; +import { Strategy } from 'passport-oauth2'; +import { firstValueFrom } from 'rxjs'; + +import { ensureEmail } from '../utils'; + +export interface IUserInfo { + readonly id: string; + readonly login: string; + readonly client_id: string; + readonly display_name: string; + readonly real_name: string; + readonly first_name: string; + readonly last_name: string; + readonly sex: 'male' | 'female'; + readonly default_email: string; + readonly emails: readonly string[]; + readonly birthday: string; + readonly default_avatar_id: string; + readonly is_avatar_empty: false; + readonly default_phone: { readonly id: number; readonly number: string }; + readonly psuid: string; +} + +export interface IYandexProfile { + readonly provider: 'yandex'; + readonly id: string; + readonly displayName: string; + readonly username: string; + readonly emails: readonly [{ readonly value: string }]; + readonly name: { + readonly familyName: string; + readonly givenName: string; + }; + readonly gender: 'female' | 'male' | undefined; + readonly photos: readonly [{ readonly value: string }]; + readonly _raw: string; + readonly _json: IUserInfo; + readonly [key: string]: unknown; +} + +@Injectable() +export class YandexStrategy extends PassportStrategy(Strategy, 'yandex-oauth') { + constructor( + cfg: ConfigService, + private readonly http: HttpService, + ) { + const isProduction = cfg.get('NODE_ENV') === 'production'; + const domain = cfg.get('DOMAIN'); + const port = cfg.get('PORT'); + const apiPath = 'v1/oauth/yandex/callback'; + + const callbackURL = domain + ? `${isProduction ? 'https' : 'http'}://api.${domain}/${apiPath}` + : `http://localhost:${port || 3000}/${apiPath}`; + + super({ + authorizationURL: 'https://oauth.yandex.ru/authorize', + tokenURL: 'https://oauth.yandex.ru/token', + clientID: cfg.getOrThrow('YANDEX_CLIENT_ID'), + clientSecret: cfg.getOrThrow('YANDEX_CLIENT_SECRET'), + callbackURL, + scope: ['login:email', 'login:info'], + passReqToCallback: true, + }); + } + + validate( + _req: never, + _at: string, + _rt: string, + profile: IYandexProfile, + done: (...args: readonly unknown[]) => void, + ) { + const json = profile._json; + + const user = { + id: json.id, + email: json.default_email, + first_name: json.first_name, + last_name: json.last_name, + sex: json.sex || null, + phone: json.default_phone.number, + avatar_url: profile.photos?.[0]?.value || null, + bio: null, + }; + + done(null, user); + } + + private async getUserProfile(accessToken: string) { + try { + const response = await firstValueFrom( + this.http.get('https://login.yandex.ru/info', { + headers: { + Authorization: `OAuth ${accessToken}`, + }, + params: { + format: 'json', + }, + }), + ); + + const data = response.data; + + return { + provider: 'yandex', + id: String(data.id), + displayName: data.display_name || data.real_name || data.login, + username: data.login, + email: ensureEmail(data.default_email, 'yandex', data.id.toString(), data.login), + name: { + familyName: data.last_name || '', + givenName: data.first_name || '', + }, + gender: data.sex === 'male' ? 'male' : data.sex === 'female' ? 'female' : undefined, + photos: data.default_avatar_id + ? [ + { + value: `https://avatars.yandex.net/get-yapic/${data.default_avatar_id}/islands-200`, + }, + ] + : [], + _raw: JSON.stringify(data), + _json: data, + }; + } catch (error) { + console.error('Failed to get Yandex user info:', error); + + throw new BaseException( + { + code: 'YANDEX_USER_INFO_FAILED', + message: 'Не удалось получить данные пользователя от Яндекса', + details: [{ target: error instanceof Error ? error.message : String(error) }], + }, + HttpStatus.BAD_GATEWAY, + ); + } + } + + override userProfile( + accessToken: string, + done: (err?: Error | null, profile?: unknown) => void, + ): void { + this.getUserProfile(accessToken) + .then((profile) => done(null, profile)) + .catch((err) => done(err, null)); + } +} diff --git a/src/auth/infrastructure/utils/ensure-email.util.ts b/src/auth/infrastructure/utils/ensure-email.util.ts new file mode 100644 index 00000000..6a80cb64 --- /dev/null +++ b/src/auth/infrastructure/utils/ensure-email.util.ts @@ -0,0 +1,22 @@ +export function ensureEmail( + email: string | null | undefined, + provider: string, + id: string, + login?: string, +): string { + if (email?.trim() && email.includes('@')) { + return email; + } + + const providers: Record = { + github: 'github', + vkontakte: 'vk', + yandex: 'yandex', + google: 'google', + }; + + const domain = providers[provider] ?? provider; + const username = login || id; + + return `${username}@${domain}.placeholder.internal`; +} diff --git a/src/auth/infrastructure/utils/get-device-meta.ts b/src/auth/infrastructure/utils/get-device-meta.ts new file mode 100644 index 00000000..34e259ec --- /dev/null +++ b/src/auth/infrastructure/utils/get-device-meta.ts @@ -0,0 +1,35 @@ +import { UAParser } from 'ua-parser-js'; + +import type { FastifyRequest } from 'fastify'; + +export interface DeviceMetadata { + readonly ip: string; + readonly userAgent: string; + readonly browser: string; + readonly os: string; + readonly deviceType: 'mobile' | 'desktop' | 'tablet'; +} + +export function getDeviceMeta(req: FastifyRequest): DeviceMetadata { + const uaString = req.headers['user-agent'] || ''; + const parser = new UAParser(uaString); + const res = parser.getResult(); + + const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0] || req.ip || '0.0.0.0'; + + let deviceType: 'mobile' | 'desktop' | 'tablet' = 'desktop'; + if (res.device.type === 'mobile') { + deviceType = 'mobile'; + } + if (res.device.type === 'tablet') { + deviceType = 'tablet'; + } + + return { + ip, + userAgent: uaString, + browser: `${res.browser.name || 'Unknown'} ${res.browser.version || ''}`.trim(), + os: `${res.os.name || 'Unknown'} ${res.os.version || ''}`.trim(), + deviceType, + }; +} diff --git a/src/auth/infrastructure/utils/index.ts b/src/auth/infrastructure/utils/index.ts new file mode 100644 index 00000000..b84c0e37 --- /dev/null +++ b/src/auth/infrastructure/utils/index.ts @@ -0,0 +1,2 @@ +export { getDeviceMeta, type DeviceMetadata } from './get-device-meta'; +export * from './ensure-email.util'; diff --git a/src/auth/infrastructure/workers/index.ts b/src/auth/infrastructure/workers/index.ts new file mode 100644 index 00000000..03791b1f --- /dev/null +++ b/src/auth/infrastructure/workers/index.ts @@ -0,0 +1,2 @@ +export { MailProcessor } from './mail.processor'; +export { UserProcessor } from './user.processor'; diff --git a/src/auth/infrastructure/workers/mail.processor.ts b/src/auth/infrastructure/workers/mail.processor.ts new file mode 100644 index 00000000..ff3f3a5f --- /dev/null +++ b/src/auth/infrastructure/workers/mail.processor.ts @@ -0,0 +1,74 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Inject } from '@nestjs/common'; +import { IMailPort } from '@shared/adapters/mail'; + +import { AuthMailJobs, AuthQueues } from '../../domain/enums'; +import { RegisterCodeEvent, ResetPasswordEvent } from '../../domain/events'; + +import type { Job } from 'bullmq'; + +@Processor(AuthQueues.AUTH_MAIL) +export class MailProcessor extends WorkerHost { + constructor( + @Inject('IMailPort') + private readonly mailAdapter: IMailPort, + ) { + super(); + } + + async process(job: Job): Promise; + async process(job: Job): Promise; + async process(job: Job): Promise { + await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); + + try { + switch (job.name) { + case AuthMailJobs.SEND_REGISTER_CODE: + await this.sendRegisterCode(job); + break; + case AuthMailJobs.SEND_RESET_PASSWORD: + await this.sendResetPassCode(job); + break; + default: + await job.log(`[WRN] No handler for job: ${job.name}`); + await job.updateProgress(100); + } + + await job.log(`[DONE] Job ${job.id} processed`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : ''; + + await job.log(`[FAIL] ${errorMessage}`); + if (errorStack) { + await job.log(errorStack); + } + + throw error; + } + } + + private readonly sendRegisterCode = async (job: Job) => { + const { email, name, otp } = job.data; + + await job.log(`Sending registration code to: ${email}`); + await job.updateProgress(20); + + await this.mailAdapter.sendRegistrationCode(email, name, otp); + + await job.log(`Successfully sent to ${email}`); + await job.updateProgress(100); + }; + + private readonly sendResetPassCode = async (job: Job) => { + const { email, otp } = job.data; + + await job.log(`Sending password reset to: ${email}`); + await job.updateProgress(30); + + await this.mailAdapter.sendResetPasswordCode(email, otp); + + await job.log(`Reset link delivered to ${email}`); + await job.updateProgress(100); + }; +} diff --git a/src/auth/infrastructure/workers/user.processor.ts b/src/auth/infrastructure/workers/user.processor.ts new file mode 100644 index 00000000..fb61c30c --- /dev/null +++ b/src/auth/infrastructure/workers/user.processor.ts @@ -0,0 +1,66 @@ +import { AuthQueues } from '@core/auth/domain/enums'; +import { AuthUserJobs } from '@core/auth/domain/enums/auth-jobs.enum'; +import { CreateUserWorkspaceEvent } from '@core/auth/domain/events'; +import { CreateProjectUseCase } from '@core/project/application/use-cases'; +import { CreateTeamUseCase } from '@core/teams/application/use-cases'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import slugify from 'slugify'; + +@Processor(AuthQueues.AUTH_USER) +export class UserProcessor extends WorkerHost { + constructor( + private readonly createTeamUseCase: CreateTeamUseCase, + private readonly createProjectUseCase: CreateProjectUseCase, + ) { + super(); + } + + async process(job: Job): Promise { + await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); + + try { + switch (job.name) { + case AuthUserJobs.CREATE_WORKSPACE: + await this.createWorkspace(job); + break; + + default: + await job.log(`[WRN] No handler for job: ${job.name}`); + await job.updateProgress(100); + } + + await job.log(`[DONE] Job ${job.id} processed`); + } catch (error) { + await job.log(String(error)); + + throw error; + } + } + + private readonly createWorkspace = async (job: Job) => { + const { userId, username } = job.data; + + await job.log(`Start creating a workspace for ${username}`); + await job.updateProgress(20); + + const team = await this.createTeamUseCase.execute(userId, { + name: username, + description: `Personal team for ${username}`, + }); + + await this.createProjectUseCase.execute(userId, team.teamId, { + name: `${username}'s Project`, + description: `Personal project for ${username}`, + slug: slugify(username.slice(0, 10), { + lower: true, + strict: true, + }), + status: 'active', + visibility: 'private', + }); + + await job.log(`Successfully created a workspace for ${username}`); + await job.updateProgress(100); + }; +} diff --git a/src/main.ts b/src/main.ts index 13cad38c..bcd8ca0a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,29 @@ -import { NestFactory } from '@nestjs/core'; +import { bootstrapApp } from '@libs/bootstrap'; + import { AppModule } from './app.module'; -async function bootstrap() { - const app = await NestFactory.create(AppModule); - await app.listen(3000); -} -bootstrap(); +bootstrapApp({ + serviceName: 'Tracker Monolit', + appModule: AppModule, + version: 'v1', + defaultPort: 2000, + portEnvKey: 'PORT', + swaggerOptions: { + title: 'Task Tracker API', + description: ` +### Описание +RESTful API сервиса управления задачами (Task Tracker). + +### Поддержка +Для доступа к закрытым методам используйте заголовок Authorization: Bearer token. +По вопросам интеграции обращаться к команде разработки. + `.trim(), + version: '0.1.0', + path: 'docs', + }, + useCors: true, + useCookieParser: true, +}).catch((error) => { + console.error('Failed to bootstrap app:', error); + process.exit(1); +}); diff --git a/src/media/application/controllers/index.ts b/src/media/application/controllers/index.ts new file mode 100644 index 00000000..d80f3980 --- /dev/null +++ b/src/media/application/controllers/index.ts @@ -0,0 +1,19 @@ +import { Post } from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; + +import { ExtractMediaReq } from '../../infrastructure/decorators'; +import { UploadMediaDto } from '../dtos'; +import { MediaService } from '../media.service'; + +import { UploadMediaSwagger } from './swagger'; + +@ApiBaseController('upload', 'Upload Media', true) +export class MediaController { + constructor(private readonly service: MediaService) {} + + @Post() + @UploadMediaSwagger() + upload(@ExtractMediaReq() dto: UploadMediaDto, @GetUserId() userId: string) { + return this.service.upload(dto, userId); + } +} diff --git a/src/media/application/controllers/swagger.ts b/src/media/application/controllers/swagger.ts new file mode 100644 index 00000000..20a96981 --- /dev/null +++ b/src/media/application/controllers/swagger.ts @@ -0,0 +1,30 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiConsumes, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { UploadMediaDto } from '../dtos'; + +export const UploadMediaSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Загрузить медиа-файл', + description: + 'Загружает файл в S3 и инициирует фоновую задачу по обновлению ссылки в БД.', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + description: 'Файл для загрузки и метаданные', + type: UploadMediaDto.Output, + }), + ApiResponse({ + status: 201, + description: 'Файл успешно загружен и принят в обработку', + type: ActionResponse.Output, + }), + ApiValidationError('Неверный формат файла или отсутствуют обязательные поля'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/media/application/dtos/index.ts b/src/media/application/dtos/index.ts new file mode 100644 index 00000000..f49c3fe2 --- /dev/null +++ b/src/media/application/dtos/index.ts @@ -0,0 +1 @@ +export * from './upload-file.dto'; diff --git a/src/media/application/dtos/upload-file.dto.ts b/src/media/application/dtos/upload-file.dto.ts new file mode 100644 index 00000000..cd9b4799 --- /dev/null +++ b/src/media/application/dtos/upload-file.dto.ts @@ -0,0 +1,31 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +const FileSchema = z + .object({ + buffer: z.any().describe('Бинарные данные файла'), + filename: z + .string() + .regex(/\.(jpg|jpeg|png|webp)$/i, 'Допустимы только изображения') + .describe('Имя файла с расширением'), + mimetype: z.enum(['image/jpeg', 'image/png', 'image/webp']).describe('MIME-тип файла'), + }) + .describe('Объект загруженного файла'); + +export const UploadMediaSchema = z.object({ + context: z + .enum(['user.avatar', 'team.avatar', 'team.banner'], { + error: 'Выберите корректный контекст: user.avatar, team.avatar или team.banner', + }) + .describe('Контекст загрузки (тип сущности и тип медиа)'), + file: FileSchema, + teamId: z + .string({ + error: 'Team ID должен быть строкой', + }) + .min(1, 'Team ID не может быть пустым') + .optional() + .describe('Уникальный идентификатор команды. Обязателен для контекстов team.*'), +}); + +export class UploadMediaDto extends createZodDto(UploadMediaSchema) {} diff --git a/src/media/application/media.service.ts b/src/media/application/media.service.ts new file mode 100644 index 00000000..605a78f4 --- /dev/null +++ b/src/media/application/media.service.ts @@ -0,0 +1,119 @@ +import { extname } from 'node:path'; + +import { S3Service } from '@libs/s3'; +import { InjectFlowProducer } from '@nestjs/bullmq'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { FlowProducer } from 'bullmq'; + +import { MEDIA_FLOW, MEDIA_JOBS, MEDIA_QUEUES } from '../domain/enums'; +import { MEDIA_STRATEGIES, MediaStrategyKey } from '../infrastructure/strategies'; + +import { UploadMediaDto } from './dtos'; + +import type { MediaDispatchStrategy } from '../infrastructure/strategies/media.strategy'; + +@Injectable() +export class MediaService { + constructor( + @InjectFlowProducer(MEDIA_FLOW) + private readonly flow: FlowProducer, + private readonly s3: S3Service, + ) {} + + public readonly upload = async (dto: UploadMediaDto, userId: string) => { + const { context, file } = dto; + + const strategy = this.getStrategy(context); + const { folder, fileName } = this.generateStoragePath(context, userId, file.filename); + + try { + const originalUrl = await this.s3.upload(file.buffer, { + mimetype: file.mimetype, + original: file.filename, + path: { folder, key: fileName }, + }); + + await this.enqueueMediaFlow(strategy, dto, userId, originalUrl); + + return { + success: true, + message: 'Изменения вступят в силу после завершения фоновой обработки', + }; + } catch (error) { + this.handleError(error); + } + }; + + private generateStoragePath(context: string, userId: string, originalName: string) { + const contextPath = context.replace(/\./g, '/'); + const extension = extname(originalName); + + return { + folder: `${contextPath}/${Date.now()}-${userId}`, + fileName: `original${extension}`, + }; + } + + private async enqueueMediaFlow( + strategy: MediaDispatchStrategy, + dto: UploadMediaDto, + userId: string, + url: string, + ) { + const payload = strategy.createPayload(dto, userId, url); + + return this.flow.add({ + name: MEDIA_JOBS.RESIZE_IMAGES, + queueName: MEDIA_QUEUES.RESIZE, + data: { userId, original: url, context: dto.context }, + opts: this.getJobOptions(5, 'fixed'), + children: [ + { + name: strategy.jobName, + queueName: MEDIA_QUEUES.SAVE_ENTITY, + data: payload, + opts: { ...this.getJobOptions(3, 'exponential'), failParentOnFailure: true }, + }, + ], + }); + } + + private getJobOptions(attempts: number, backoffType: 'fixed' | 'exponential') { + return { + attempts, + backoff: { type: backoffType, delay: backoffType === 'fixed' ? 2000 : 1000 }, + removeOnComplete: true, + removeOnFail: false, + }; + } + + private getStrategy(context: string) { + if (!this.isValidStrategyKey(context)) { + throw new BaseException( + { code: 'STRATEGY_NOT_FOUND', message: `No strategy for ${context}` }, + HttpStatus.BAD_REQUEST, + ); + } + return MEDIA_STRATEGIES[context]; + } + + private isValidStrategyKey(key: string): key is MediaStrategyKey { + return key in MEDIA_STRATEGIES; + } + + private handleError(error: unknown): never { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: 'MEDIA_SAVE_FAILED', + message: 'Ошибка при сохранении медиа-данных', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.BAD_REQUEST, + ); + } +} diff --git a/src/media/domain/enums/flow.enum.ts b/src/media/domain/enums/flow.enum.ts new file mode 100644 index 00000000..2cd782f4 --- /dev/null +++ b/src/media/domain/enums/flow.enum.ts @@ -0,0 +1 @@ +export const MEDIA_FLOW = 'MEDIA_FLOW'; diff --git a/src/media/domain/enums/index.ts b/src/media/domain/enums/index.ts new file mode 100644 index 00000000..a73b34ca --- /dev/null +++ b/src/media/domain/enums/index.ts @@ -0,0 +1,3 @@ +export * from './flow.enum'; +export * from './jobs.enum'; +export * from './queues'; diff --git a/src/media/domain/enums/jobs.enum.ts b/src/media/domain/enums/jobs.enum.ts new file mode 100644 index 00000000..2b83a2a9 --- /dev/null +++ b/src/media/domain/enums/jobs.enum.ts @@ -0,0 +1,5 @@ +export const enum MEDIA_JOBS { + RESIZE_IMAGES = 'RESIZE_IMAGES', + UPDATE_USER_AVATAR = 'UPDATE_USER_AVATAR', + UPDATE_TEAM_MEDIA = 'UPDATE_TEAM_MEDIA', +} diff --git a/src/media/domain/enums/queues.ts b/src/media/domain/enums/queues.ts new file mode 100644 index 00000000..0ddac691 --- /dev/null +++ b/src/media/domain/enums/queues.ts @@ -0,0 +1,4 @@ +export const enum MEDIA_QUEUES { + RESIZE = 'RESIZE', + SAVE_ENTITY = 'SAVE_ENTITY', +} diff --git a/src/media/index.ts b/src/media/index.ts new file mode 100644 index 00000000..f5f63d22 --- /dev/null +++ b/src/media/index.ts @@ -0,0 +1,4 @@ +export { MediaModule } from './media.module'; +export * from './infrastructure/constants'; +export * from './infrastructure/interfaces'; +export * from './domain/enums'; diff --git a/src/media/infrastructure/constants/index.ts b/src/media/infrastructure/constants/index.ts new file mode 100644 index 00000000..ad5cafba --- /dev/null +++ b/src/media/infrastructure/constants/index.ts @@ -0,0 +1,14 @@ +export const MEDIA_SPECS = { + avatar: [ + { name: 'sm', width: 64, height: 64, quality: 80 }, + { name: 'md', width: 256, height: 256, quality: 85 }, + { name: 'lg', width: 512, height: 512, quality: 90 }, + ], + banner: [ + { name: 'sm', width: 640, height: 360, fit: 'fit-in' }, + { name: 'md', width: 1280, height: 720, fit: 'fit-in' }, + { name: 'lg', width: 1920, height: 1080, fit: 'fit-in' }, + ], +} as const; + +export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']; diff --git a/src/media/infrastructure/decorators/extract-media-req.decorator.ts b/src/media/infrastructure/decorators/extract-media-req.decorator.ts new file mode 100644 index 00000000..7103ca9c --- /dev/null +++ b/src/media/infrastructure/decorators/extract-media-req.decorator.ts @@ -0,0 +1,97 @@ +import { createParamDecorator, type ExecutionContext, HttpStatus } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { formatBytes } from '@shared/utils/format-bytes.util'; + +import { IMAGE_MIME_TYPES } from '../constants'; + +import type { FastifyRequest } from 'fastify'; + +export const ExtractMediaReq = createParamDecorator( + async ( + { + allowedMimetypes = IMAGE_MIME_TYPES, + }: { readonly allowedMimetypes?: readonly string[] } = {}, + ctx: ExecutionContext, + ) => { + const maxFileSize = 5 * 1024 * 1024; + const req = ctx.switchToHttp().getRequest(); + + if (!req.isMultipart()) { + throw new BaseException( + { + code: 'INVALID_CONTENT_TYPE', + message: 'Ожидался multipart/form-data запрос', + details: [ + { target: 'header', message: 'Content-Type must be multipart/form-data' }, + ], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + try { + const file = await req.file(); + if (!file) { + throw new BaseException( + { + code: 'FILE_NOT_FOUND', + message: 'Файл не был передан в запросе', + }, + HttpStatus.BAD_REQUEST, + ); + } + + const buffer = await file.toBuffer(); + + if (allowedMimetypes?.length && !allowedMimetypes.includes(file.mimetype)) { + throw new BaseException( + { code: 'INVALID_FILE_TYPE', message: 'Недопустимый формат файла' }, + HttpStatus.UNSUPPORTED_MEDIA_TYPE, + ); + } + + const fields: Record = {}; + + for (const key of Object.keys(file.fields)) { + if (key === 'file') { + continue; + } + + const field = file.fields[key]; + if (field && !Array.isArray(field) && 'value' in field) { + fields[key] = String(field.value); + } + } + + return { + file: { + filename: file.filename, + mimetype: file.mimetype, + buffer, + }, + ...fields, + }; + } catch (e) { + const hasCode = (err: unknown): err is { readonly code: string } => + err !== null && typeof err === 'object' && 'code' in err; + + if (hasCode(e) && e?.code === 'FST_REQ_FILE_TOO_LARGE') { + throw new BaseException( + { + code: 'FILE_TOO_LARGE', + message: `Размер файла слишком большой. Максимальный размер: ${formatBytes(maxFileSize)}`, + details: [ + { + target: 'file', + message: `Размер файла превышает лимит в ${formatBytes(maxFileSize)}`, + }, + ], + }, + HttpStatus.PAYLOAD_TOO_LARGE, + ); + } + + throw e; + } + }, +); diff --git a/src/media/infrastructure/decorators/index.ts b/src/media/infrastructure/decorators/index.ts new file mode 100644 index 00000000..2ed4b483 --- /dev/null +++ b/src/media/infrastructure/decorators/index.ts @@ -0,0 +1 @@ +export { ExtractMediaReq } from './extract-media-req.decorator'; diff --git a/src/media/infrastructure/interfaces/index.ts b/src/media/infrastructure/interfaces/index.ts new file mode 100644 index 00000000..524ee804 --- /dev/null +++ b/src/media/infrastructure/interfaces/index.ts @@ -0,0 +1 @@ +export * from './media.interface'; diff --git a/src/media/infrastructure/interfaces/media.interface.ts b/src/media/infrastructure/interfaces/media.interface.ts new file mode 100644 index 00000000..9c29a7c8 --- /dev/null +++ b/src/media/infrastructure/interfaces/media.interface.ts @@ -0,0 +1,19 @@ +export type MediaEntityType = 'team' | 'user'; + +export interface UpdateMediaUser { + entity: { + type: 'user'; + id: string; + }; + path: string; +} + +export interface UpdateMediaTeam { + entity: { + type: 'team'; + id: string; + }; + path: string; + type: 'avatar' | 'banner'; + initiatorId: string; +} diff --git a/src/media/infrastructure/strategies/index.ts b/src/media/infrastructure/strategies/index.ts new file mode 100644 index 00000000..42eef8c9 --- /dev/null +++ b/src/media/infrastructure/strategies/index.ts @@ -0,0 +1,10 @@ +import { TeamMediaStrategy } from './team-media.strategy'; +import { UserAvatarStrategy } from './user-avatar.strategy'; + +export const MEDIA_STRATEGIES = { + 'user.avatar': new UserAvatarStrategy(), + 'team.avatar': new TeamMediaStrategy(), + 'team.banner': new TeamMediaStrategy(), +} as const; + +export type MediaStrategyKey = keyof typeof MEDIA_STRATEGIES; diff --git a/src/media/infrastructure/strategies/media.strategy.ts b/src/media/infrastructure/strategies/media.strategy.ts new file mode 100644 index 00000000..88690e41 --- /dev/null +++ b/src/media/infrastructure/strategies/media.strategy.ts @@ -0,0 +1,7 @@ +// eslint-disable-next-line no-restricted-syntax +import type { UploadMediaDto } from '../../application/dtos'; + +export abstract class MediaDispatchStrategy { + abstract readonly jobName: string; + abstract createPayload(dto: UploadMediaDto, userId: string, path: string): unknown; +} diff --git a/src/media/infrastructure/strategies/team-media.strategy.ts b/src/media/infrastructure/strategies/team-media.strategy.ts new file mode 100644 index 00000000..ee271c83 --- /dev/null +++ b/src/media/infrastructure/strategies/team-media.strategy.ts @@ -0,0 +1,24 @@ +import { MEDIA_JOBS } from '../../domain/enums/jobs.enum'; + +// eslint-disable-next-line no-restricted-syntax +import type { UploadMediaDto } from '../../application/dtos'; +import type { UpdateMediaTeam } from '../interfaces'; +import type { MediaDispatchStrategy } from './media.strategy'; + +export class TeamMediaStrategy implements MediaDispatchStrategy { + readonly jobName: string = MEDIA_JOBS.UPDATE_TEAM_MEDIA; + + createPayload(dto: UploadMediaDto, userId: string, path: string): UpdateMediaTeam { + const type = dto.context.split('.').pop(); + if (type !== 'avatar' && type !== 'banner') { + throw new Error(`Invalid media type: ${type}`); + } + + return { + entity: { type: 'team', id: dto.teamId! }, + initiatorId: userId, + type, + path, + }; + } +} diff --git a/src/media/infrastructure/strategies/user-avatar.strategy.ts b/src/media/infrastructure/strategies/user-avatar.strategy.ts new file mode 100644 index 00000000..ad3c2292 --- /dev/null +++ b/src/media/infrastructure/strategies/user-avatar.strategy.ts @@ -0,0 +1,17 @@ +import { MEDIA_JOBS } from '../../domain/enums/jobs.enum'; + +// eslint-disable-next-line no-restricted-syntax +import type { UploadMediaDto } from '../../application/dtos'; +import type { UpdateMediaUser } from '../interfaces'; +import type { MediaDispatchStrategy } from './media.strategy'; + +export class UserAvatarStrategy implements MediaDispatchStrategy { + readonly jobName: string = MEDIA_JOBS.UPDATE_USER_AVATAR; + + createPayload(_d: UploadMediaDto, userId: string, path: string): UpdateMediaUser { + return { + entity: { type: 'user', id: userId }, + path, + }; + } +} diff --git a/src/media/infrastructure/workers/index.ts b/src/media/infrastructure/workers/index.ts new file mode 100644 index 00000000..4a622439 --- /dev/null +++ b/src/media/infrastructure/workers/index.ts @@ -0,0 +1 @@ +export { MediaProcessor } from './media.worker'; diff --git a/src/media/infrastructure/workers/media.worker.ts b/src/media/infrastructure/workers/media.worker.ts new file mode 100644 index 00000000..6ab8e7a5 --- /dev/null +++ b/src/media/infrastructure/workers/media.worker.ts @@ -0,0 +1,75 @@ +import { dirname } from 'node:path'; + +import { ImagorService } from '@libs/imagor'; +import { S3Service } from '@libs/s3'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; + +import { MEDIA_JOBS, MEDIA_QUEUES } from '../../domain/enums'; +import { MEDIA_SPECS } from '../constants'; + +@Processor(MEDIA_QUEUES.RESIZE) +export class MediaProcessor extends WorkerHost { + constructor( + private readonly imagor: ImagorService, + private readonly s3: S3Service, + ) { + super(); + } + + async process( + job: Job<{ readonly original: string; readonly context: string; readonly userId: string }>, + ) { + if (job.name !== MEDIA_JOBS.RESIZE_IMAGES) { + return; + } + + const { original: originalFilePath, context } = job.data; + + try { + await job.updateProgress(5); + + const type = context.includes('banner') ? 'banner' : 'avatar'; + const resizeSpecs = MEDIA_SPECS[type]; + const targetFolder = dirname(originalFilePath); + + const progressStep = Math.floor(90 / resizeSpecs.length); + + for (let i = 0; i < resizeSpecs.length; i++) { + const spec = resizeSpecs[i]; + if (!spec) { + continue; + } + + const { name, ...dimensions } = spec; + const targetFileName = `${name}.webp`; + + const processedImage = await this.imagor.get(`/${originalFilePath}`, dimensions); + + const uploadedPath = await this.s3.upload(processedImage, { + original: targetFileName, + mimetype: 'image/webp', + path: { + folder: targetFolder, + key: targetFileName, + }, + }); + + await job.log(`[Variant:${name}] Saved to: ${uploadedPath}`); + await job.updateProgress(5 + progressStep * (i + 1)); + } + + await job.updateProgress(100); + + return { + original: originalFilePath, + folder: targetFolder, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + await job.log(`Error during resizing: ${errorMessage}`); + + throw error; + } + } +} diff --git a/src/media/media.module.ts b/src/media/media.module.ts new file mode 100644 index 00000000..be31e73d --- /dev/null +++ b/src/media/media.module.ts @@ -0,0 +1,52 @@ +import { ImagorModule } from '@libs/imagor'; +import { S3Module } from '@libs/s3'; +import { BullModule } from '@nestjs/bullmq'; +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { MediaController } from './application/controllers'; +import { MediaService } from './application/media.service'; +import { MEDIA_FLOW, MEDIA_QUEUES } from './domain/enums'; +import { MediaProcessor } from './infrastructure/workers'; + +@Module({ + imports: [ + S3Module.registerAsync({ + inject: [ConfigService], + global: true, + useFactory: (cfg: ConfigService) => ({ + bucket: cfg.getOrThrow('S3_BUCKET_NAME'), + connection: { + endpoint: cfg.getOrThrow('S3_ENDPOINT'), + region: cfg.getOrThrow('S3_REGION'), + credentials: { + accessKeyId: cfg.getOrThrow('S3_ACCESS_KEY'), + secretAccessKey: cfg.getOrThrow('S3_SECRET_KEY'), + }, + }, + config: { + forcePathStyle: true, + connectTimeout: 2000, + requestTimeout: 5000, + maxAttempts: 3, + }, + }), + }), + ImagorModule.forRootAsync({ + inject: [ConfigService], + useFactory: (cfg: ConfigService) => ({ + url: cfg.getOrThrow('IMAGOR_URL'), + secret: cfg.getOrThrow('IMAGOR_SECRET'), + debug: true, + filters: { format: 'webp', smart: true, strip_icc: true }, + }), + }), + BullModule.registerQueue({ name: MEDIA_QUEUES.RESIZE }, { name: MEDIA_QUEUES.SAVE_ENTITY }), + BullModule.registerFlowProducer({ + name: MEDIA_FLOW, + }), + ], + controllers: [MediaController], + providers: [MediaProcessor, MediaService], +}) +export class MediaModule {} diff --git a/src/project/application/controllers/index.ts b/src/project/application/controllers/index.ts new file mode 100644 index 00000000..a8d00bab --- /dev/null +++ b/src/project/application/controllers/index.ts @@ -0,0 +1,4 @@ +import { ProjectMembersController } from './members/controller'; +import { ProjectsController } from './projects/controller'; + +export const CONTROLLERS = [ProjectsController, ProjectMembersController]; diff --git a/src/project/application/controllers/members/controller.ts b/src/project/application/controllers/members/controller.ts new file mode 100644 index 00000000..070524bf --- /dev/null +++ b/src/project/application/controllers/members/controller.ts @@ -0,0 +1,66 @@ +import { Body, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiBaseController, GetUserId, SkipContract } from '@shared/decorators'; + +import { AddProjectMemberDto, UpdateProjectMemberDto } from '../../dtos'; +import { ProjectFacade } from '../../project.facade'; + +import { + AddMemberSwagger, + FindAllMembersSwagger, + FindAvailableUsersSwagger, + RemoveMemberSwagger, + UpdateMemberSwagger, +} from './swagger'; + +@ApiBaseController('projects/:slug/members', 'Project Members', true) +export class ProjectMembersController { + constructor(private readonly facade: ProjectFacade) {} + + @Get() + @FindAllMembersSwagger() + async findAll(@Param('slug') slug: string, @GetUserId() userId: string) { + return this.facade.getMembers(slug, userId); + } + + @Post() + @AddMemberSwagger() + async addMember( + @Param('slug') slug: string, + @GetUserId() userId: string, + @Body() dto: AddProjectMemberDto, + ) { + return this.facade.addMember(slug, userId, dto); + } + + @Put(':memberId') + @UpdateMemberSwagger() + async updateMember( + @Param('slug') slug: string, + @Param('memberId') memberId: string, + @GetUserId() userId: string, + @Body() dto: UpdateProjectMemberDto, + ) { + return this.facade.updateMemberRole(slug, memberId, userId, dto); + } + + @Delete(':memberId') + @RemoveMemberSwagger() + async removeMember( + @Param('slug') slug: string, + @Param('memberId') memberId: string, + @GetUserId() userId: string, + ) { + return this.facade.removeMember(slug, memberId, userId); + } + + @Get('available') + @SkipContract() + @FindAvailableUsersSwagger() + async getAvailableUsers( + @Param('slug') slug: string, + @GetUserId() userId: string, + @Query('search') search?: string, + ) { + return this.facade.getAvailableTeamMembers(slug, userId, search); + } +} diff --git a/src/project/application/controllers/members/swagger.ts b/src/project/application/controllers/members/swagger.ts new file mode 100644 index 00000000..490a44a4 --- /dev/null +++ b/src/project/application/controllers/members/swagger.ts @@ -0,0 +1,181 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { AddProjectMemberDto, ListMembersResponse, UpdateProjectMemberDto } from '../../dtos'; + +export const FindAllMembersSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список участников проекта', + description: [ + 'Возвращает всех активных участников проекта с их ролями.', + 'Доступно участникам с любой ролью, включая viewer.', + ].join('\n\n'), + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiResponse({ + status: 200, + description: 'Список участников получен', + type: ListMembersResponse.Output, + }), + ApiNotFound('Проект не найден'), + ApiForbidden('У вас нет доступа к этому проекту'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ListMembersResponse), + ); + +export const FindAvailableUsersSwagger = () => + applyDecorators( + ApiOperation({ + deprecated: true, + summary: 'Получить список пользователей, доступных для добавления', + description: [ + 'Возвращает членов команды, которых еще нет в проекте.', + 'Полезно для поиска при добавлении новых участников.', + 'Поддерживает поиск по email и имени.', + 'Требуется роль owner или admin.', + ].join('\n\n'), + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiQuery({ + name: 'search', + description: 'Поиск по email или имени пользователя', + type: 'string', + required: false, + example: 'ivan', + }), + ApiResponse({ + status: 200, + description: 'Список доступных пользователей', + type: class B {}, + // type: AvailableUsersResponse.Output, + }), + ApiNotFound('Проект не найден'), + ApiForbidden('Недостаточно прав (требуется owner или admin)'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, null), + ); + +export const AddMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Добавить участника в проект', + description: [ + 'Добавляет пользователя из команды в проект с указанной ролью.', + 'Нельзя добавить пользователя, который уже является участником.', + 'Нельзя назначить роль owner через этот метод.', + 'Требуется роль owner или admin.', + ].join('\n\n'), + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiBody({ + type: AddProjectMemberDto.Output, + description: 'Данные для добавления участника', + }), + ApiResponse({ + status: 201, + description: 'Участник успешно добавлен', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректные данные (несуществующий userId или роль)'), + ApiNotFound('Проект не найден'), + ApiForbidden('Недостаточно прав (требуется owner или admin)'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const UpdateMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Изменить роль участника', + description: [ + 'Изменяет роль существующего участника проекта.', + 'Нельзя изменить роль владельца (owner).', + 'Нельзя назначить роль owner через этот метод.', + 'Требуется роль owner или admin.', + ].join('\n\n'), + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiParam({ + name: 'memberId', + description: 'ID записи участника (не userId!)', + type: 'string', + example: 'clv123456', + }), + ApiBody({ + type: UpdateProjectMemberDto.Output, + description: 'Новая роль участника', + }), + ApiResponse({ + status: 200, + description: 'Роль успешно обновлена', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректная роль'), + ApiNotFound('Участник не найден в проекте'), + ApiForbidden('Недостаточно прав или попытка изменить владельца'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const RemoveMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить участника из проекта', + description: [ + 'Удаляет участника из проекта.', + 'Нельзя удалить владельца (owner).', + 'Участник может удалить себя сам (покинуть проект), даже если у него нет роли admin.', + 'Требуется роль owner/admin, либо это действие над собой.', + ].join('\n\n'), + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiParam({ + name: 'memberId', + description: 'ID записи участника (не userId!)', + type: 'string', + example: 'clv123456', + }), + ApiResponse({ + status: 200, + description: 'Участник удален из проекта', + type: ActionResponse.Output, + }), + ApiNotFound('Участник не найден в проекте'), + ApiForbidden('Недостаточно прав или попытка удалить владельца'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/project/application/controllers/projects/controller.ts b/src/project/application/controllers/projects/controller.ts new file mode 100644 index 00000000..e48dacd4 --- /dev/null +++ b/src/project/application/controllers/projects/controller.ts @@ -0,0 +1,97 @@ +import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiBaseController, GetUserId, Public } from '@shared/decorators'; + +import { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../../dtos'; +import { ProjectFacade } from '../../project.facade'; + +import { + ArchiveProjectSwagger, + CheckSlugSwagger, + CreateProjectSwagger, + CreateShareTokenSwagger, + FindAllProjectsSwagger, + FindOneProjectSwagger, + RemoveProjectSwagger, + UpdateProjectSwagger, +} from './swagger'; + +@ApiBaseController('teams/:teamId/projects', 'Projects', true) +export class ProjectsController { + constructor(private readonly facade: ProjectFacade) {} + + @Get() + @FindAllProjectsSwagger() + async findAll(@Param('teamId') teamId: string, @GetUserId() userId: string) { + return this.facade.getTeamProjects(teamId, userId); + } + + @Get(':slug') + @Public() + @FindOneProjectSwagger() + async getOne( + @Param('slug') slug: string, + @Param('teamId') teamId: string, + @GetUserId() userId?: string, + @Query('token') token?: string, + ) { + return this.facade.getDetail(slug, teamId, userId, token); + } + + @Post(':slug/share') + @CreateShareTokenSwagger() + async generateShareToken( + @Param('slug') slug: string, + @Param('teamId') teamId: string, + @GetUserId() userId: string, + @Body() dto: CreateShareTokenDto, + ) { + return this.facade.generateShareToken(slug, teamId, userId, dto); + } + + @Get('check-slug') + @CheckSlugSwagger() + async checkSlug(@Param('teamId') teamId: string, @Query('q') slug: string) { + return this.facade.checkSlugAvailability(teamId, slug); + } + + @Post(':slug/archive') + @ArchiveProjectSwagger() + async archive( + @Param('slug') slug: string, + @Param('teamId') teamId: string, + @GetUserId() userId: string, + ) { + return this.facade.setStatus(slug, teamId, userId, 'archived'); + } + + @Post() + @CreateProjectSwagger() + async create( + @Param('teamId') teamId: string, + @GetUserId() userId: string, + @Body() dto: CreateProjectDto, + ) { + return this.facade.create(userId, teamId, dto); + } + + @Patch(':slug') + @UpdateProjectSwagger() + async update( + @Param('slug') slug: string, + @Param('teamId') teamId: string, + @GetUserId() userId: string, + @Body() dto: UpdateProjectDto, + ) { + return this.facade.update(slug, teamId, userId, dto); + } + + @Delete(':slug') + @RemoveProjectSwagger() + async remove( + @Param('slug') slug: string, + @Param('teamId') teamId: string, + @GetUserId() userId: string, + ) { + return this.facade.delete(slug, teamId, userId); + } +} diff --git a/src/project/application/controllers/projects/swagger.ts b/src/project/application/controllers/projects/swagger.ts new file mode 100644 index 00000000..76d3f662 --- /dev/null +++ b/src/project/application/controllers/projects/swagger.ts @@ -0,0 +1,387 @@ +import { + CheckSlugResponse, + CreateShareTokenResponse, +} from '@core/project/application/dtos/project.dto'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiOperation, ApiBody, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { + ApiValidationError, + ApiUnauthorized, + ApiForbidden, + ApiNotFound, + ApiConflict, +} from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { + CreateProjectDto, + CreateProjectResponse, + CreateShareTokenDto, + UpdateProjectDto, + ProjectListResponse, + ProjectDetailResponse, +} from '../../dtos'; + +export const CreateProjectSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Создать новый проект в команде', + description: [ + 'Создает проект с указанным названием и настройками.', + 'Slug генерируется автоматически из названия, если не передан явно.', + 'Создатель автоматически становится владельцем (owner) проекта.', + 'Настройки (settings) необязательны — если не переданы, создаются дефолтные.', + 'Требуется роль admin или owner в команде.', + ].join('\n\n'), + }), + ApiParam({ + name: 'teamId', + description: 'ID команды, в которой создается проект', + type: 'string', + example: 'clv123456', + }), + ApiBody({ + type: CreateProjectDto.Output, + description: 'Данные проекта. Slug опционален — если не указан, генерируется из name.', + }), + ApiResponse({ + status: 201, + description: 'Проект успешно создан', + type: CreateProjectResponse.Output, + }), + ApiConflict('Проект с таким slug уже существует в команде'), + ApiValidationError('Некорректные данные (slug, цвет, название)'), + ApiForbidden('Недостаточно прав или достигнут лимит проектов'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, CreateProjectResponse), + ); + +export const FindAllProjectsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список проектов команды', + description: [ + 'Возвращает все проекты, доступные пользователю в рамках команды.', + 'Включает как публичные проекты, так и те, где пользователь участник.', + 'Архивированные и удаленные проекты не возвращаются.', + 'Сортировка по полю sequence (по возрастанию).', + ].join('\n\n'), + }), + ApiParam({ + name: 'teamId', + description: 'ID команды', + type: 'string', + example: 'clv123456', + }), + ApiQuery({ + name: 'search', + description: 'Поиск по названию проекта', + type: 'string', + required: false, + example: 'маркетинг', + }), + ApiQuery({ + name: 'status', + description: 'Фильтр по статусу проекта', + type: 'string', + required: false, + enum: ['active', 'archived'], + example: 'active', + }), + ApiResponse({ + status: 200, + description: 'Список проектов получен', + type: ProjectListResponse.Output, + }), + ApiForbidden('У вас нет доступа к этой команде'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ProjectListResponse), + ); + +export const FindOneProjectSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить детальную информацию о проекте', + description: [ + 'Возвращает полную информацию о проекте, включая:', + '- Основные поля (название, описание, статус)', + '- Визуальные настройки (цвет, иконка)', + '- Мета-информацию (счетчики, даты)', + '- Права доступа текущего пользователя', + '- Настройки проекта', + '- Информацию о команде и владельце', + '', + 'Проект должен принадлежать указанной команде.', + 'Пользователь должен иметь доступ к проекту (быть участником или проект публичный).', + ].join('\n'), + }), + ApiParam({ + name: 'teamId', + description: 'ID команды', + type: 'string', + example: 'clv123456', + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта (URL-идентификатор)', + type: 'string', + example: 'my-project', + }), + ApiResponse({ + status: 200, + description: 'Детальная информация о проекте', + type: ProjectDetailResponse.Output, + }), + ApiNotFound('Проект не найден в этой команде'), + ApiForbidden('У вас нет доступа к этому проекту'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ProjectDetailResponse), + ); +export const UpdateProjectSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить информацию о проекте', + description: [ + 'Частичное обновление проекта — можно передать только те поля, которые нужно изменить.', + 'Если поле не передано — оно остается без изменений.', + 'Для сброса опциональных полей (description, icon, color) передайте null.', + '', + 'Особенности обновления slug:', + '- Slug должен быть уникальным в рамках команды', + '- При смене slug старые ссылки на проект становятся невалидными', + '', + 'Обновление настроек (settings):', + '- Если settings передан — обновляются только указанные поля', + '- Не переданные поля настроек остаются без изменений', + '', + 'Требуется роль owner или admin в проекте.', + ].join('\n'), + }), + ApiParam({ + name: 'teamId', + description: 'ID команды', + type: 'string', + example: 'clv123456', + }), + ApiParam({ + name: 'slug', + description: 'Текущий slug проекта', + type: 'string', + example: 'my-project', + }), + ApiBody({ + type: UpdateProjectDto.Output, + description: 'Поля для обновления. Все поля опциональны.', + }), + ApiResponse({ + status: 200, + description: 'Проект успешно обновлен', + type: ActionResponse.Output, + }), + ApiConflict('Проект с таким slug уже существует'), + ApiValidationError('Некорректные данные'), + ApiNotFound('Проект не найден'), + ApiForbidden('Недостаточно прав'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const ArchiveProjectSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Архивировать проект', + description: [ + 'Переводит проект в статус "archived".', + 'Архивный проект:', + '- Не отображается в общем списке проектов', + '- Нельзя создавать новые задачи', + '- Нельзя изменять существующие задачи', + '- Можно только просматривать', + '', + 'Перед архивацией проверяется отсутствие активных задач.', + 'Если в проекте есть незавершенные задачи — архивация будет отклонена.', + '', + 'Требуется роль owner или admin в проекте.', + ].join('\n'), + }), + ApiParam({ + name: 'teamId', + description: 'ID команды', + type: 'string', + example: 'clv123456', + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiResponse({ + status: 200, + description: 'Проект архивирован', + type: ActionResponse.Output, + }), + ApiConflict('Проект уже в архиве или есть активные задачи'), + ApiNotFound('Проект не найден'), + ApiForbidden('Недостаточно прав'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const RemoveProjectSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить проект (в корзину)', + description: [ + 'Мягкое удаление — проект перемещается в корзину (статус "deleted").', + 'Проект в корзине:', + '- Не отображается нигде, кроме корзины', + '- Недоступен для любых операций', + '- Может быть восстановлен в течение 30 дней', + '- Через 30 дней удаляется безвозвратно', + '', + 'Перед удалением проект должен быть архивирован.', + 'Нельзя удалить активный проект напрямую.', + '', + 'Требуется роль owner в проекте.', + ].join('\n'), + }), + ApiParam({ + name: 'teamId', + description: 'ID команды', + type: 'string', + example: 'clv123456', + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiResponse({ + status: 200, + description: 'Проект перемещен в корзину', + type: ActionResponse.Output, + }), + ApiConflict('Проект не архивирован или уже в корзине'), + ApiNotFound('Проект не найден'), + ApiForbidden('Недостаточно прав (требуется owner)'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const GetProjectByTokenSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить проект по публичной ссылке', + description: [ + 'Позволяет получить доступ к проекту без авторизации — по share-токену.', + 'Токен имеет ограниченный срок действия.', + 'Возвращает ту же структуру что и FindOne, но с ограниченными правами.', + '', + 'Если токен истек — вернется 404.', + 'По share-ссылке доступен только просмотр, нельзя редактировать.', + ].join('\n'), + }), + ApiParam({ + name: 'token', + description: 'Публичный токен доступа к проекту', + type: 'string', + example: 'st_a1b2c3d4e5f6...', + }), + ApiResponse({ + status: 200, + description: 'Проект получен по токену', + type: ProjectDetailResponse.Output, + }), + ApiNotFound('Токен недействителен или истек'), + ApiForbidden('Доступ по токену запрещен (проект приватный)'), + + SetMetadata(ZOD_RESPONSE_TOKEN, ProjectDetailResponse), + ); + +export const CreateShareTokenSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Создать публичную ссылку на проект', + description: [ + 'Генерирует уникальный токен для публичного доступа к проекту.', + 'Токен можно передавать кому угодно — по нему открывается проект в режиме чтения.', + '', + 'Срок действия:', + '- Если ttl не указан — токен действует 3 месяца (по умолчанию)', + '- Если ttl указан — токен действует до указанной даты', + '- ttl не может быть в прошлом', + '', + 'Безопасность:', + '- Токен хешируется в БД (SHA-256), сырой токен показывается только при создании', + '- Сохраните сырой токен сразу — потом его нельзя восстановить', + '- Можно отозвать токен, удалив его из проекта', + '', + 'Требуется роль owner или admin в проекте.', + ].join('\n'), + }), + ApiParam({ + name: 'teamId', + description: 'ID команды', + type: 'string', + example: 'clv123456', + }), + ApiParam({ + name: 'slug', + description: 'Slug проекта', + type: 'string', + example: 'my-project', + }), + ApiBody({ + type: CreateShareTokenDto.Output, + description: 'Настройки срока действия. ttl опционален.', + }), + ApiResponse({ + status: 201, + description: 'Токен создан', + type: CreateShareTokenResponse.Output, + }), + ApiValidationError('Некорректная дата (ttl в прошлом или невалидный формат)'), + ApiNotFound('Проект не найден'), + ApiForbidden('Недостаточно прав'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, CreateShareTokenResponse), + ); + +export const CheckSlugSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Проверить доступность slug', + description: [ + 'Проверяет, свободен ли slug для создания нового проекта.', + 'Slug глобален — проверка по всем проектам, а не только внутри команды.', + '', + 'Формат slug: строчные латинские буквы, цифры и дефисы (kebab-case).', + 'Пример: `my-project`, `marketing-2024`, `backend`', + ].join('\n'), + }), + ApiQuery({ + name: 'q', + description: 'Slug для проверки', + type: 'string', + required: true, + example: 'my-project', + }), + ApiResponse({ + status: 200, + description: 'Результат проверки', + type: CheckSlugResponse.Output, + }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, CheckSlugResponse), + ); diff --git a/src/project/application/dtos/index.ts b/src/project/application/dtos/index.ts new file mode 100644 index 00000000..3ecf3c6d --- /dev/null +++ b/src/project/application/dtos/index.ts @@ -0,0 +1,3 @@ +export * from './project.dto'; +export * from './settings.dto'; +export * from './member.dto'; diff --git a/src/project/application/dtos/member.dto.ts b/src/project/application/dtos/member.dto.ts new file mode 100644 index 00000000..d0b89141 --- /dev/null +++ b/src/project/application/dtos/member.dto.ts @@ -0,0 +1,66 @@ +import { createPaginationSchema } from '@shared/schemas'; +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const ProjectMemberRoleSchema = z.enum(['owner', 'admin', 'member', 'viewer']); +export type ProjectMemberRole = z.infer; + +export const ProjectMemberSchema = z + .object({ + id: z + .string() + .min(1, 'ID не может быть пустым') + .describe('Уникальный идентификатор записи'), + projectId: z.string().min(1, 'ID проекта обязателен').describe('ID проекта'), + userId: z + .string() + .min(1, 'ID пользователя обязателен') + .describe('ID пользователя — участника проекта'), + role: ProjectMemberRoleSchema.default('member').describe('Роль участника в проекте'), + addedBy: z + .string() + .nullable() + .optional() + .describe('ID пользователя, который добавил участника'), + createdAt: z.string().datetime({ offset: true }).describe('Дата добавления в проект'), + }) + .describe('Участник проекта'); + +export const MemberUserSchema = z.object({ + id: z.string().describe('ID пользователя'), + email: z.string().email().describe('Email'), + firstName: z.string().nullable().optional().describe('Имя'), + lastName: z.string().nullable().optional().describe('Фамилия'), + avatarUrl: z.string().nullable().optional().describe('URL аватара'), +}); + +const MemberResponseSchema = ProjectMemberSchema.omit({ + userId: true, + projectId: true, +}).extend({ + user: MemberUserSchema, +}); + +export const ProjectMemberListResponseSchema = createPaginationSchema(MemberResponseSchema); + +export const AddProjectMemberSchema = z + .object({ + userId: z + .string() + .min(1, 'ID пользователя обязателен') + .describe('ID пользователя для добавления в проект'), + role: ProjectMemberRoleSchema.exclude(['owner']) + .default('member') + .describe('Роль нового участника'), + }) + .describe('Схема для добавления участника'); + +export const UpdateProjectMemberSchema = z + .object({ + role: ProjectMemberRoleSchema.exclude(['owner']).describe('Новая роль участника'), + }) + .describe('Схема для изменения роли участника'); + +export class AddProjectMemberDto extends createZodDto(AddProjectMemberSchema) {} +export class UpdateProjectMemberDto extends createZodDto(UpdateProjectMemberSchema) {} +export class ListMembersResponse extends createZodDto(ProjectMemberListResponseSchema) {} diff --git a/src/project/application/dtos/project.dto.ts b/src/project/application/dtos/project.dto.ts new file mode 100644 index 00000000..79d2d2ca --- /dev/null +++ b/src/project/application/dtos/project.dto.ts @@ -0,0 +1,224 @@ +import { PROJECT_STATUSES, PROJECT_VISIBILITIES } from '@core/project/domain/entities'; +import { createPaginationSchema, ActionResponseSchema } from '@shared/schemas'; +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +import { ProjectMemberRoleSchema } from './member.dto'; +import { CreateProjectSettingsSchema, ProjectSettingsSchema } from './settings.dto'; + +export const ProjectStatusSchema = z.enum(PROJECT_STATUSES); +export const ProjectVisibilitySchema = z.enum(PROJECT_VISIBILITIES); +export const ProjectTypeSchema = z.enum(['team', 'personal']); + +export const ProjectSchema = z.object({ + id: z.string().min(1, 'ID не может быть пустым').describe('Уникальный идентификатор проекта'), + teamId: z.string().nullish().describe('ID команды (null для личных проектов)'), + slug: z + .string() + .min(1, 'Slug обязателен') + .max(100, 'Slug не должен превышать 100 символов') + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug должен быть в формате kebab-case') + .describe('URL-дружественный идентификатор проекта'), + name: z + .string() + .min(1, 'Название проекта обязательно') + .max(100, 'Название не должно превышать 100 символов') + .describe('Отображаемое название проекта'), + description: z.string().nullish().describe('Markdown-описание проекта, его целей и правил'), + descriptionHtml: z.string().nullish().describe('Сгенерированный HTML из Markdown описания'), + icon: z + .string() + .max(255, 'Иконка должна быть не длиннее 255 символов') + .nullish() + .describe('Emoji или иконка проекта (например: "🚀", "💼", "🎯")'), + color: z + .string() + .regex( + /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + 'Цвет должен быть в HEX формате (#RRGGBB или #RGB)', + ) + .nullish() + .describe('HEX-код цвета для визуального выделения проекта'), + status: ProjectStatusSchema.default('active').describe('Текущий статус проекта'), + // type: ProjectTypeSchema.default('team').describe('Тип проекта: командный или личный'), + sequence: z + .number() + .int('Порядковый номер должен быть целым числом') + .min(0, 'Порядковый номер не может быть отрицательным') + .default(0) + .describe('Порядок отображения проекта в списке (меньше число — выше)'), + ownerId: z.string().nullish().describe('ID создателя/владельца проекта'), + visibility: ProjectVisibilitySchema.default('public').describe( + 'Видимость проекта для участников команды', + ), + createdAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время создания проекта (ISO 8601)'), + updatedAt: z + .string() + .datetime({ offset: true }) + .describe('Дата и время последнего обновления проекта'), + deletedAt: z + .string() + .datetime({ offset: true }) + .nullish() + .describe('Дата мягкого удаления (null — не удалено)'), +}); + +export const CreateProjectSchema = ProjectSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deletedAt: true, + ownerId: true, +}) + .partial({ + description: true, + descriptionHtml: true, + icon: true, + color: true, + sequence: true, + visibility: true, + slug: true, + }) + .extend({ + settings: CreateProjectSettingsSchema.optional().describe('Настройки проекта'), + }) + .describe('Схема для создания нового проекта'); + +const CreateProjectsResponseSchema = ActionResponseSchema.extend({ + slug: z.string().describe('Уникальный идентификатор проекта в системе'), +}); + +export const CheckSlugResponseSchema = z + .object({ + available: z + .boolean() + .describe('Доступен ли slug. true — свободен, false — занят или невалидный'), + reason: z + .string() + .nullish() + .describe( + 'Причина недоступности. null если slug свободен. ' + + 'Возможные значения: "Этот slug уже занят", ' + + '"Недопустимый формат. Только строчные латинские буквы, цифры и дефисы"', + ), + }) + .describe('Результат проверки доступности slug'); + +export const UpdateProjectSchema = CreateProjectSchema.partial() + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) + .describe('Схема для обновления существующего проекта'); + +export const TransferProjectSchema = z + .object({ + teamId: z.string().min(1, 'ID команды обязателен').describe('ID новой команды для проекта'), + }) + .describe('Схема для переноса проекта в другую команду'); + +export const ProjectFilterSchema = z + .object({ + status: ProjectStatusSchema.optional(), + type: ProjectTypeSchema.optional(), + visibility: ProjectVisibilitySchema.optional(), + search: z.string().min(1).max(100).optional(), + teamId: z.string().optional(), + }) + .partial() + .describe('Фильтры для списка проектов'); + +export const CreateShareTokenSchema = z.object({ + ttl: z + .string() + .datetime() + .nullish() + .describe('Дата истечения ссылки. Если не указана — ставится дефолт 3 месяца'), +}); + +export const CreateShareTokenResponseSchema = ActionResponseSchema.extend({ + payload: z.object({ + token: z.string().describe('Токен'), + expiresAt: z + .string() + .datetime({ offset: true }) + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe("'Дата истечения ссылки. Если не была указана — ставится дефолт 3 месяца'"), + }), +}); + +export const ProjectListItemSchema = z.object({ + id: z.string().describe('ID проекта'), + slug: z.string().describe('Slug проекта (URL-идентификатор)'), + name: z.string().describe('Название проекта'), + status: ProjectStatusSchema.default('active').describe('Текущий статус проекта'), + color: z.string().describe('Цвет проекта'), + icon: z.string().nullish().describe('Иконка проекта'), + createdAt: z.string().datetime({ offset: true }).describe('Дата создания проекта'), + role: ProjectMemberRoleSchema.describe('Роль текущего пользователя в проекте'), +}); + +export const ProjectListResponseSchema = createPaginationSchema(ProjectListItemSchema); + +export const ProjectDetailResponseSchema = z.object({ + id: z.string().describe('ID проекта'), + slug: z.string().describe('URL-идентификатор проекта'), + name: z.string().describe('Название проекта'), + status: ProjectStatusSchema.default('active').describe('Текущий статус проекта'), + description: z.string().nullable().describe('Markdown-описание проекта'), + descriptionHtml: z.string().nullish().describe('HTML из Markdown описания'), + visuals: z + .object({ + color: z.string().nullish().describe('Цвет проекта'), + icon: z.string().nullish().optional().describe('Иконка проекта'), + }) + .describe('Визуальные настройки'), + meta: z.object({ + sequence: z.number().int().nonnegative().describe('Счётчик задач'), + createdAt: z + .string() + .datetime({ offset: true }) + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата создания'), + updatedAt: z + .string() + .datetime({ offset: true }) + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата обновления'), + }), + access: z + .object({ + visibility: ProjectVisibilitySchema.default('public').describe( + 'Видимость проекта для участников команды', + ), + currentUserRole: z + .enum(['owner', 'admin', 'member', 'viewer']) + .describe('Роль текущего пользователя'), + shareUrl: z.string().nullable().describe('Публичная ссылка для шаринга'), + }) + .describe('Права доступа'), + settings: ProjectSettingsSchema.omit({ + id: true, + projectId: true, + createdAt: true, + updatedAt: true, + }).describe('Настройки проекта'), +}); + +export class CreateProjectDto extends createZodDto(CreateProjectSchema) {} +export class UpdateProjectDto extends createZodDto(UpdateProjectSchema) {} +export class CreateProjectResponse extends createZodDto(CreateProjectsResponseSchema) {} +export class CreateShareTokenDto extends createZodDto(CreateShareTokenSchema) {} +export class CreateShareTokenResponse extends createZodDto(CreateShareTokenResponseSchema) {} +export class ProjectListResponse extends createZodDto(ProjectListResponseSchema) {} +export class ProjectDetailResponse extends createZodDto(ProjectDetailResponseSchema) {} +export class CheckSlugResponse extends createZodDto(CheckSlugResponseSchema) {} diff --git a/src/project/application/dtos/settings.dto.ts b/src/project/application/dtos/settings.dto.ts new file mode 100644 index 00000000..961ab492 --- /dev/null +++ b/src/project/application/dtos/settings.dto.ts @@ -0,0 +1,89 @@ +import { z } from 'zod/v4'; + +export const ProjectSettingsSchema = z + .object({ + id: z + .string() + .min(1, 'ID не может быть пустым') + .describe('Уникальный идентификатор настроек'), + projectId: z + .string() + .min(1, 'ID проекта обязателен') + .describe('ID проекта, к которому относятся настройки'), + defaultView: z + .enum(['kanban', 'list', 'calendar', 'gantt']) + .default('kanban') + .describe('Представление по умолчанию'), + taskPrefix: z + .string() + .max(10, 'Префикс не должен превышать 10 символов') + .nullable() + .optional() + .describe('Префикс для номеров задач'), + autoCloseDays: z + .number() + .int('Должно быть целым числом') + .positive('Должно быть положительным числом') + .nullable() + .optional() + .describe('Автозакрытие неактивных задач через N дней'), + maxTasksPerArea: z + .number() + .int('Должно быть целым числом') + .positive('Должно быть положительным числом') + .nullable() + .optional() + .describe('Максимум задач в одной области'), + maxMembers: z + .number() + .int('Должно быть целым числом') + .positive('Должно быть положительным числом') + .nullable() + .optional() + .describe('Максимум участников проекта'), + maxAreas: z + .number() + .int('Должно быть целым числом') + .positive('Должно быть положительным числом') + .nullable() + .optional() + .describe('Максимум областей в проекте'), + allowGuests: z.boolean().default(false).describe('Разрешить гостевой доступ по ссылке'), + timeTracking: z.boolean().default(false).describe('Включить учет времени по задачам'), + timeTrackingMode: z + .enum(['optional', 'required', 'disabled']) + .default('optional') + .describe('Режим учета времени'), + defaultAssigneeId: z + .string() + .nullable() + .optional() + .describe('ID исполнителя по умолчанию для новых задач'), + createdAt: z.string().datetime({ offset: true }).describe('Дата создания настроек'), + updatedAt: z + .string() + .datetime({ offset: true }) + .describe('Дата последнего обновления настроек'), + }) + .describe('Полная схема настроек проекта'); + +export const CreateProjectSettingsSchema = ProjectSettingsSchema.omit({ + id: true, + projectId: true, + createdAt: true, + updatedAt: true, +}) + .partial({ + defaultView: true, + timeTrackingMode: true, + }) + .describe('Схема настроек при создании проекта'); + +export const UpdateProjectSettingsSchema = ProjectSettingsSchema.omit({ + id: true, + projectId: true, + createdAt: true, + updatedAt: true, +}) + .partial() + .describe('Схема для обновления настроек проекта'); diff --git a/src/project/application/mappers/index.ts b/src/project/application/mappers/index.ts new file mode 100644 index 00000000..c832100e --- /dev/null +++ b/src/project/application/mappers/index.ts @@ -0,0 +1 @@ +export { ProjectMapper } from './project.mapper'; diff --git a/src/project/application/mappers/member.mapper.ts b/src/project/application/mappers/member.mapper.ts new file mode 100644 index 00000000..c1a8411a --- /dev/null +++ b/src/project/application/mappers/member.mapper.ts @@ -0,0 +1,34 @@ +import type { MemberWithUser } from '@core/project/domain/entities'; + +export class MemberMapper { + public static toMemberResponse(member: MemberWithUser) { + return { + id: member.id, + role: member.role, + createdAt: new Date(member.createdAt).toISOString(), + user: { + id: member.user.id, + email: member.user.email, + firstName: member.user.firstName, + lastName: member.user.lastName, + avatarUrl: member.user.avatarUrl, + }, + }; + } + + public static toMemberListResponse(members: readonly MemberWithUser[]) { + const items = members.map(MemberMapper.toMemberResponse); + + return { + items, + meta: { + hasNextPage: false, + hasPrevPage: false, + total: items.length, + totalPages: 1, + page: 1, + limit: items.length, + }, + }; + } +} diff --git a/src/project/application/mappers/project.mapper.ts b/src/project/application/mappers/project.mapper.ts new file mode 100644 index 00000000..df3466fc --- /dev/null +++ b/src/project/application/mappers/project.mapper.ts @@ -0,0 +1,58 @@ +import type { Project } from '@core/project/domain/entities'; +import type { RawMemberRow } from '@core/teams/domain/repository'; + +export class ProjectMapper { + public static toDetailResponse(project: Project, member?: RawMemberRow | null, token?: string) { + const { + id, + slug, + name, + status, + description, + color, + icon, + sequence, + createdAt, + updatedAt, + visibility, + } = project; + + return { + id, + slug, + name, + status, + description, + visuals: { + color: color || '#3b82f6', + icon, + }, + meta: { + sequence, + createdAt: new Date(createdAt).toISOString(), + updatedAt: new Date(updatedAt).toISOString(), + }, + access: { + visibility, + currentUserRole: member?.role || 'viewer', + shareUrl: visibility === 'public' && token ? `/share/${token}` : null, + }, + settings: {}, + }; + } + + public static toListResponse(project: Project, member: RawMemberRow) { + const { id, slug, name, status, color, icon, createdAt } = project; + + return { + id, + slug, + name, + status, + color: color || '#3b82f6', + icon, + role: member?.role || 'viewer', + createdAt: new Date(createdAt).toISOString(), + }; + } +} diff --git a/src/project/application/project.facade.ts b/src/project/application/project.facade.ts new file mode 100644 index 00000000..9fbbe3db --- /dev/null +++ b/src/project/application/project.facade.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@nestjs/common'; + +import { + AddProjectMemberDto, + CreateProjectDto, + CreateShareTokenDto, + UpdateProjectDto, + UpdateProjectMemberDto, +} from './dtos'; +import { + AddProjectMemberUseCase, + DeleteProjectMemberUseCase, + FindAllProjectMembersQuery, + GetAvailableTeamMemberQuery, + UpdateProjectMemberUseCase, +} from './use-cases'; +import { + CreateProjectUseCase, + DeleteProjectUseCase, + GenerateShareTokenUseCase, + SetProjectStatusUseCase, + UpdateProjectUseCase, + FindProjectsByTeamQuery, + GetProjectDetailQuery, +} from './use-cases/project'; +import { CheckSlugAvailabilityQuery } from './use-cases/project/check-slug.use-case'; + +import type { ProjectStatus } from '../domain/entities'; + +@Injectable() +export class ProjectFacade { + constructor( + private readonly checkSlugAvailabilityQ: CheckSlugAvailabilityQuery, + private readonly generateTokenUC: GenerateShareTokenUseCase, + private readonly createProjectUC: CreateProjectUseCase, + private readonly updateProjectUC: UpdateProjectUseCase, + private readonly deleteProjectUC: DeleteProjectUseCase, + private readonly setStatusUC: SetProjectStatusUseCase, + private readonly findByTeamQ: FindProjectsByTeamQuery, + private readonly getDetailQ: GetProjectDetailQuery, + + private readonly getMembersQ: FindAllProjectMembersQuery, + private readonly addMemberUC: AddProjectMemberUseCase, + private readonly removeMemberUC: DeleteProjectMemberUseCase, + private readonly updateMemberRoleUC: UpdateProjectMemberUseCase, + private readonly getAvailableTeamMembersQ: GetAvailableTeamMemberQuery, + ) {} + + public async create(userId: string, teamId: string, dto: CreateProjectDto) { + return this.createProjectUC.execute(userId, teamId, dto); + } + + public async update(slug: string, teamId: string, userId: string, dto: UpdateProjectDto) { + return this.updateProjectUC.execute(slug, teamId, userId, dto); + } + + public async delete(slug: string, teamId: string, userId: string) { + return this.deleteProjectUC.execute(slug, teamId, userId); + } + + public async setStatus(slug: string, teamId: string, userId: string, status: ProjectStatus) { + return this.setStatusUC.execute(slug, teamId, userId, status); + } + + public async generateShareToken( + slug: string, + teamId: string, + userId: string, + dto: CreateShareTokenDto, + ) { + return this.generateTokenUC.execute(slug, teamId, userId, dto); + } + + public async getDetail(slug: string, teamId: string, userId?: string, token?: string) { + return this.getDetailQ.execute(slug, teamId, userId, token); + } + + public async getTeamProjects(teamId: string, userId: string) { + return this.findByTeamQ.execute(teamId, userId); + } + + public async getMembers(teamId: string, userId: string) { + return this.getMembersQ.execute(teamId, userId); + } + + public async addMember(slug: string, userId: string, dto: AddProjectMemberDto) { + return this.addMemberUC.execute(slug, userId, dto); + } + + public async updateMemberRole( + slug: string, + memberId: string, + userId: string, + dto: UpdateProjectMemberDto, + ) { + return this.updateMemberRoleUC.execute(slug, memberId, userId, dto); + } + + public async removeMember(slug: string, memberId: string, userId: string) { + return this.removeMemberUC.execute(slug, memberId, userId); + } + + public async getAvailableTeamMembers(slug: string, userId: string, search?: string) { + return this.getAvailableTeamMembersQ.execute(slug, userId, search); + } + + public async checkSlugAvailability(teamId: string, slug: string) { + return this.checkSlugAvailabilityQ.execute(teamId, slug); + } +} diff --git a/src/project/application/use-cases/index.ts b/src/project/application/use-cases/index.ts new file mode 100644 index 00000000..8f892d7c --- /dev/null +++ b/src/project/application/use-cases/index.ts @@ -0,0 +1,12 @@ +import { MemberQueries, MemberUseCases } from './member'; +import { ProjectQueries, ProjectUseCases } from './project'; + +export * from './project'; +export * from './member'; + +export const USE_CASES = [ + ...MemberQueries, + ...ProjectQueries, + ...MemberUseCases, + ...ProjectUseCases, +]; diff --git a/src/project/application/use-cases/member/add.use-case.ts b/src/project/application/use-cases/member/add.use-case.ts new file mode 100644 index 00000000..f528e863 --- /dev/null +++ b/src/project/application/use-cases/member/add.use-case.ts @@ -0,0 +1,88 @@ +import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IMemberRepository } from '@core/project/domain/repository'; +import { MAX_MEMBERS_PER_PROJECT } from '@core/project/infrastructure/constants'; +import { FindTeamMemberQuery } from '@core/teams'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { AddProjectMemberDto } from '../../dtos'; + +@Injectable() +export class AddProjectMemberUseCase { + constructor( + @Inject('IMemberRepository') private readonly memberRepo: IMemberRepository, + private readonly findTeamMemberQ: FindTeamMemberQuery, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, userId: string, dto: AddProjectMemberDto) { + const { project } = await this.policy.ensureProjectAccess(slug, userId, ['owner', 'admin']); + + if (dto.userId === userId) { + throw new BaseException( + { + code: MemberErrorCodes.SELF_ADD, + message: MemberErrorMessages[MemberErrorCodes.SELF_ADD], + }, + HttpStatus.BAD_REQUEST, + ); + } + + if (dto.userId === userId) { + throw new BaseException( + { + code: MemberErrorCodes.SELF_ADD, + message: MemberErrorMessages[MemberErrorCodes.SELF_ADD], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const teamMember = await this.findTeamMemberQ.execute(project.teamId, dto.userId); + if (!teamMember) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_IN_TEAM, + message: MemberErrorMessages[MemberErrorCodes.NOT_IN_TEAM], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const existing = await this.memberRepo.findByProjectAndUser(project.id, dto.userId); + if (existing) { + throw new BaseException( + { + code: MemberErrorCodes.ALREADY_EXISTS, + message: MemberErrorMessages[MemberErrorCodes.ALREADY_EXISTS], + }, + HttpStatus.CONFLICT, + ); + } + + const currentCount = await this.memberRepo.countByProject(project.id); + if (currentCount >= MAX_MEMBERS_PER_PROJECT) { + throw new BaseException( + { + code: MemberErrorCodes.LIMIT_REACHED, + message: MemberErrorMessages[MemberErrorCodes.LIMIT_REACHED], + }, + HttpStatus.FORBIDDEN, + ); + } + + const { id } = await this.memberRepo.create({ + projectId: project.id, + userId: dto.userId, + role: dto.role, + addedBy: userId, + }); + + return { + success: true, + memberId: id, + message: `Пользователь добавлен в проект «${project.name}» с ролью ${dto.role}`, + }; + } +} diff --git a/src/project/application/use-cases/member/delete.use-case.ts b/src/project/application/use-cases/member/delete.use-case.ts new file mode 100644 index 00000000..22a29fb7 --- /dev/null +++ b/src/project/application/use-cases/member/delete.use-case.ts @@ -0,0 +1,87 @@ +import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IMemberRepository } from '@core/project/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteProjectMemberUseCase { + constructor( + @Inject('IMemberRepository') private readonly memberRepo: IMemberRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, memberId: string, userId: string) { + const { project, member: currentMember } = await this.policy.ensureProjectAccess( + slug, + userId, + ); + + const targetMember = await this.memberRepo.findById(memberId); + if (!targetMember || targetMember.projectId !== project.id) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const isSelfRemove = targetMember.userId === userId; + + if (targetMember.role === 'owner') { + throw new BaseException( + { + code: isSelfRemove + ? MemberErrorCodes.SELF_REMOVE_OWNER + : MemberErrorCodes.CANNOT_REMOVE_OWNER, + message: isSelfRemove + ? MemberErrorMessages[MemberErrorCodes.SELF_REMOVE_OWNER] + : MemberErrorMessages[MemberErrorCodes.CANNOT_REMOVE_OWNER], + }, + HttpStatus.FORBIDDEN, + ); + } + + if (!isSelfRemove) { + if (currentMember.role !== 'owner' && currentMember.role !== 'admin') { + throw new BaseException( + { + code: MemberErrorCodes.ACCESS_DENIED, + message: MemberErrorMessages[MemberErrorCodes.ACCESS_DENIED], + }, + HttpStatus.FORBIDDEN, + ); + } + + if (targetMember.role === 'admin' && currentMember.role !== 'owner') { + throw new BaseException( + { + code: MemberErrorCodes.ADMIN_REMOVE_FORBIDDEN, + message: MemberErrorMessages[MemberErrorCodes.ADMIN_REMOVE_FORBIDDEN], + }, + HttpStatus.FORBIDDEN, + ); + } + } + + const deleted = await this.memberRepo.delete(memberId); + if (!deleted) { + throw new BaseException( + { + code: MemberErrorCodes.DELETE_FAILED, + message: MemberErrorMessages[MemberErrorCodes.DELETE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const action = isSelfRemove ? 'покинул' : 'удален из'; + + return { + success: true, + message: `Пользователь ${action} проект «${project.name}»`, + }; + } +} diff --git a/src/project/application/use-cases/member/find-all.query.ts b/src/project/application/use-cases/member/find-all.query.ts new file mode 100644 index 00000000..1b38743d --- /dev/null +++ b/src/project/application/use-cases/member/find-all.query.ts @@ -0,0 +1,36 @@ +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IMemberRepository } from '@core/project/domain/repository'; +import { FindByIdsQuery } from '@core/user/application/use-cases'; +import { Inject, Injectable } from '@nestjs/common'; + +import { MemberMapper } from '../../mappers/member.mapper'; + +@Injectable() +export class FindAllProjectMembersQuery { + constructor( + @Inject('IMemberRepository') private readonly memberRepo: IMemberRepository, + private readonly policy: ProjectAccessPolicy, + private readonly findUsersQ: FindByIdsQuery, + ) {} + + async execute(slug: string, userId: string) { + const { project } = await this.policy.ensureProjectAccess(slug, userId); + const members = await this.memberRepo.findByProject(project.id); + + const userIds = members.map((m) => m.userId); + const users = await this.findUsersQ.execute(userIds); + + const map = new Map(users.map((u) => [u.id, u])); + const result = members + .map((m) => ({ + ...m, + user: map.get(m.userId), + })) + .filter( + (item): item is typeof item & { readonly user: NonNullable } => + item.user !== undefined, + ); + + return MemberMapper.toMemberListResponse(result); + } +} diff --git a/src/project/application/use-cases/member/get-available.query.ts b/src/project/application/use-cases/member/get-available.query.ts new file mode 100644 index 00000000..7056c30d --- /dev/null +++ b/src/project/application/use-cases/member/get-available.query.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class GetAvailableTeamMemberQuery { + constructor() {} + + async execute(slug: string, userId: string, search?: string) { + return { + success: true, + message: '', + slug, + userId, + search, + }; + } +} diff --git a/src/project/application/use-cases/member/index.ts b/src/project/application/use-cases/member/index.ts new file mode 100644 index 00000000..b25b53c9 --- /dev/null +++ b/src/project/application/use-cases/member/index.ts @@ -0,0 +1,19 @@ +import { AddProjectMemberUseCase } from './add.use-case'; +import { DeleteProjectMemberUseCase } from './delete.use-case'; +import { FindAllProjectMembersQuery } from './find-all.query'; +import { GetAvailableTeamMemberQuery } from './get-available.query'; +import { UpdateProjectMemberUseCase } from './update.use-case'; + +export * from './add.use-case'; +export * from './delete.use-case'; +export * from './find-all.query'; +export * from './get-available.query'; +export * from './update.use-case'; + +export const MemberQueries = [FindAllProjectMembersQuery, GetAvailableTeamMemberQuery]; + +export const MemberUseCases = [ + AddProjectMemberUseCase, + DeleteProjectMemberUseCase, + UpdateProjectMemberUseCase, +]; diff --git a/src/project/application/use-cases/member/update.use-case.ts b/src/project/application/use-cases/member/update.use-case.ts new file mode 100644 index 00000000..b2d00677 --- /dev/null +++ b/src/project/application/use-cases/member/update.use-case.ts @@ -0,0 +1,80 @@ +import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IMemberRepository } from '@core/project/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { UpdateProjectMemberDto } from '../../dtos'; + +@Injectable() +export class UpdateProjectMemberUseCase { + constructor( + @Inject('IMemberRepository') private readonly memberRepo: IMemberRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, memberId: string, userId: string, dto: UpdateProjectMemberDto) { + const { project, member: currentMember } = await this.policy.ensureProjectAccess( + slug, + userId, + ['owner', 'admin'], + ); + + const targetMember = await this.memberRepo.findById(memberId); + if (!targetMember || targetMember.projectId !== project.id) { + throw new BaseException( + { + code: MemberErrorCodes.NOT_FOUND, + message: MemberErrorMessages[MemberErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (targetMember.role === 'owner') { + throw new BaseException( + { + code: MemberErrorCodes.CANNOT_CHANGE_OWNER, + message: MemberErrorMessages[MemberErrorCodes.CANNOT_CHANGE_OWNER], + }, + HttpStatus.FORBIDDEN, + ); + } + + if ( + (targetMember.role === 'admin' || dto.role === 'admin') && + currentMember.role !== 'owner' + ) { + throw new BaseException( + { + code: MemberErrorCodes.ADMIN_CHANGE_FORBIDDEN, + message: MemberErrorMessages[MemberErrorCodes.ADMIN_CHANGE_FORBIDDEN], + }, + HttpStatus.FORBIDDEN, + ); + } + + if (targetMember.role === dto.role) { + return { + success: true, + message: `Пользователь уже имеет роль «${dto.role}»`, + }; + } + + const updated = await this.memberRepo.updateRole(memberId, dto.role); + if (!updated) { + throw new BaseException( + { + code: MemberErrorCodes.UPDATE_FAILED, + message: MemberErrorMessages[MemberErrorCodes.UPDATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + success: true, + message: `Роль пользователя изменена с «${targetMember.role}» на «${dto.role}» в проекте «${project.name}»`, + }; + } +} diff --git a/src/project/application/use-cases/project/check-slug.use-case.ts b/src/project/application/use-cases/project/check-slug.use-case.ts new file mode 100644 index 00000000..c5a119b3 --- /dev/null +++ b/src/project/application/use-cases/project/check-slug.use-case.ts @@ -0,0 +1,19 @@ +import { IProjectRepository } from '@core/project/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class CheckSlugAvailabilityQuery { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + ) {} + + async execute(teamId: string, slug: string) { + const project = await this.projectsRepo.findBySlug(slug.toLowerCase(), teamId); + + return { + available: !project, + reason: project ? 'Этот slug уже занят' : null, + }; + } +} diff --git a/src/project/application/use-cases/project/create.use-case.ts b/src/project/application/use-cases/project/create.use-case.ts new file mode 100644 index 00000000..10028c82 --- /dev/null +++ b/src/project/application/use-cases/project/create.use-case.ts @@ -0,0 +1,77 @@ +import { PROJECT_STATUSES } from '@core/project/domain/entities'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IProjectRepository } from '@core/project/domain/repository'; +import { MAX_PROJECTS_PER_TEAM } from '@core/project/infrastructure/constants'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import slugify from 'slugify'; + +import { CreateProjectDto } from '../../dtos'; + +@Injectable() +export class CreateProjectUseCase { + constructor( + @Inject('IProjectRepository') private readonly projectsRepo: IProjectRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(userId: string, teamId: string, dto: CreateProjectDto) { + const { settings: _s, ...project } = dto; + const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); + + const currentSlug = slugify(project?.slug ? project.slug : project.name, { + lower: true, + strict: true, + }); + + const slugExists = await this.projectsRepo.findBySlug(currentSlug, team.id); + + if (slugExists) { + throw new BaseException( + { + code: ProjectErrorCodes.SLUG_DUPLICATE, + message: ProjectErrorMessages[ProjectErrorCodes.SLUG_DUPLICATE], + }, + HttpStatus.CONFLICT, + ); + } + + const projectCount = await this.projectsRepo.countByTeam(team.id); + if (projectCount >= MAX_PROJECTS_PER_TEAM) { + throw new BaseException( + { + code: ProjectErrorCodes.MAX_PROJECTS_REACHED, + message: ProjectErrorMessages[ProjectErrorCodes.MAX_PROJECTS_REACHED], + }, + HttpStatus.FORBIDDEN, + ); + } + + const data = { + ...project, + teamId: team.id, + ownerId: userId, + slug: currentSlug, + status: PROJECT_STATUSES[0], + }; + + const { result, slug } = await this.projectsRepo.create(userId, data); + + if (!result) { + throw new BaseException( + { + code: ProjectErrorCodes.CREATE_FAILED, + message: ProjectErrorMessages[ProjectErrorCodes.CREATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + success: result, + message: `Проект ${dto.name} успешно создан`, + slug, + }; + } +} diff --git a/src/project/application/use-cases/project/delete.use-case.ts b/src/project/application/use-cases/project/delete.use-case.ts new file mode 100644 index 00000000..6c90ea96 --- /dev/null +++ b/src/project/application/use-cases/project/delete.use-case.ts @@ -0,0 +1,48 @@ +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IProjectRepository } from '@core/project/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteProjectUseCase { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(slug: string, teamId: string, userId: string) { + const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); + + const project = await this.projectsRepo.findBySlug(slug, team.id); + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const result = await this.projectsRepo.delete(team.id, project.id); + + if (!result) { + throw new BaseException( + { + code: ProjectErrorCodes.DELETE_FAILED, + message: ProjectErrorMessages[ProjectErrorCodes.DELETE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { + success: true, + message: result + ? `Проект ${project.name} успешно перемещен в корзину` + : 'Не удалось удалить проект, попробуйте позже', + }; + } +} diff --git a/src/project/application/use-cases/project/find-by-team.query.ts b/src/project/application/use-cases/project/find-by-team.query.ts new file mode 100644 index 00000000..2b0e04ce --- /dev/null +++ b/src/project/application/use-cases/project/find-by-team.query.ts @@ -0,0 +1,33 @@ +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IProjectRepository } from '@core/project/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +import { ProjectMapper } from '../../mappers'; + +@Injectable() +export class FindProjectsByTeamQuery { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(teamId: string, userId: string) { + const { team, member } = await this.policy.ensureTeamAccess(teamId, userId, 'viewer'); + const projects = await this.projectsRepo.findByTeam(team.id); + const items = projects.map((p) => ProjectMapper.toListResponse(p, member)); + + return { + // TODO: реализовать полноценную пагинацию для проектов команды. + items, + meta: { + total: items.length + 1, + totalPages: items.length ? items.length + 1 : 1, + page: 1, + limit: 10, + hasPrevPage: false, + hasNextPage: false, + }, + }; + } +} diff --git a/src/project/application/use-cases/project/find-one.query.ts b/src/project/application/use-cases/project/find-one.query.ts new file mode 100644 index 00000000..3a4d67b8 --- /dev/null +++ b/src/project/application/use-cases/project/find-one.query.ts @@ -0,0 +1,116 @@ +import { createHash } from 'node:crypto'; + +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { IProjectRepository } from '@core/project/domain/repository'; +import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { isTeamRole, ROLE_PRIORITY } from '@shared/constants'; +import { BaseException } from '@shared/error'; + +import type { Project } from '@core/project/domain/entities'; + +@Injectable() +export class FindProjectQuery { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + private readonly findTeamQ: FindTeamQuery, + private readonly findTeamMemberQ: FindTeamMemberQuery, + ) {} + + public async execute( + slug: string, + teamId: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + shareToken?: string, + userId?: string, + ) { + const project = await this.projectsRepo.findBySlug(slug, teamId); + + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + details: [{ target: 'slug', value: slug }], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (shareToken) { + return this.findPublic(project, shareToken); + } + + return this.findPrivate(project, teamId, userId, minRole); + } + + private readonly findPrivate = async ( + project: Project, + teamId: string, + userId?: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) => { + if (!userId) { + throw new BaseException( + { + code: 'AUTH_REQUIRED', + message: 'Требуется авторизация для доступа к приватному проекту', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const team = await this.findTeamQ.execute(teamId); + if (!team || team.id !== project.teamId) { + throw new BaseException( + { + code: 'PROJECT_TEAM_MISMATCH', + message: 'Проект не принадлежит указанной команде', + }, + HttpStatus.BAD_REQUEST, + ); + } + + const member = await this.findTeamMemberQ.execute(team.id, userId); + if (!member) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Вы не являетесь участником этой команды' }, + HttpStatus.FORBIDDEN, + ); + } + + if (isTeamRole(member.role) && ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Для этого действия необходимы права: ${minRole}`, + }, + HttpStatus.FORBIDDEN, + ); + } + + return { project, member, team }; + }; + + private readonly findPublic = async (project: Project, token: string) => { + if (project.visibility !== 'public') { + throw new BaseException( + { code: 'PROJECT_NOT_PUBLIC', message: 'Публичный доступ к проекту ограничен' }, + HttpStatus.FORBIDDEN, + ); + } + + const hashedToken = createHash('sha256').update(token).digest('hex'); + const isValidToken = await this.projectsRepo.hasValidShareToken(project.id, hashedToken); + + if (!isValidToken) { + throw new BaseException( + { code: 'SHARE_LINK_INVALID', message: 'Ссылка недействительна или истекла' }, + HttpStatus.GONE, + ); + } + + return { project, member: null, team: null }; + }; +} diff --git a/src/project/application/use-cases/project/generate-share-token.use-case.ts b/src/project/application/use-cases/project/generate-share-token.use-case.ts new file mode 100644 index 00000000..dc2a3816 --- /dev/null +++ b/src/project/application/use-cases/project/generate-share-token.use-case.ts @@ -0,0 +1,117 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IProjectRepository } from '@core/project/domain/repository'; +import { + SHARE_LINK_LENGTH, + SHARE_LINK_PREFIX, + SHARE_LINK_TTL_MONTHS, +} from '@core/project/infrastructure/constants'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { CreateShareTokenDto } from '../../dtos'; + +@Injectable() +export class GenerateShareTokenUseCase { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(slug: string, teamId: string, userId: string, dto: CreateShareTokenDto) { + const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); + + const project = await this.projectsRepo.findBySlug(slug, team.id); + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const expiresAt = this.resolveExpiration(dto?.ttl); + + const rawToken = this.generateToken(); + const hashedToken = this.hashToken(rawToken); + + const result = await this.projectsRepo.createShare({ + projectId: project.id, + token: hashedToken, + expiresAt: expiresAt.toISOString(), + createdBy: userId, + }); + + if (!result) { + throw new BaseException( + { + code: ProjectErrorCodes.UPDATE_FAILED, + message: 'Не удалось создать ссылку доступа', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const durationMsg = dto?.ttl + ? `закроется ${expiresAt.toLocaleDateString('ru-RU')}` + : 'бессрочна (на 3 месяца по умолчанию)'; + + return { + success: true, + message: `Ссылка для проекта «${project.name}» создана и ${durationMsg}`, + payload: { + token: rawToken, + expiresAt: expiresAt.toISOString(), + }, + }; + } + + /** + * Вычисляет дату истечения токена. + * Если ttl передан — использует его, иначе +3 месяца от текущей даты. + */ + private resolveExpiration(ttl?: string | null): Date { + if (ttl) { + const date = new Date(ttl); + + if (isNaN(date.getTime())) { + throw new BaseException( + { + code: ProjectErrorCodes.INVALID_STATUS, + message: 'Невалидная дата истечения', + }, + HttpStatus.BAD_REQUEST, + ); + } + + if (date <= new Date()) { + throw new BaseException( + { + code: ProjectErrorCodes.INVALID_STATUS, + message: 'Дата истечения не может быть в прошлом', + }, + HttpStatus.BAD_REQUEST, + ); + } + + return date; + } + + const date = new Date(); + date.setMonth(date.getMonth() + SHARE_LINK_TTL_MONTHS); + return date; + } + + private generateToken(): string { + return `${SHARE_LINK_PREFIX}${randomBytes(SHARE_LINK_LENGTH).toString('hex')}`; + } + + private hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } +} diff --git a/src/project/application/use-cases/project/get-detail.query.ts b/src/project/application/use-cases/project/get-detail.query.ts new file mode 100644 index 00000000..b69915db --- /dev/null +++ b/src/project/application/use-cases/project/get-detail.query.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; + +import { ProjectMapper } from '../../mappers'; + +import { FindProjectQuery } from './find-one.query'; + +@Injectable() +export class GetProjectDetailQuery { + constructor(private readonly findProjectQuery: FindProjectQuery) {} + + public async execute(slug: string, teamId: string, userId?: string, token?: string) { + const { project, member } = await this.findProjectQuery.execute( + slug, + teamId, + 'viewer', + token, + userId, + ); + + return ProjectMapper.toDetailResponse(project, member); + } +} diff --git a/src/project/application/use-cases/project/index.ts b/src/project/application/use-cases/project/index.ts new file mode 100644 index 00000000..3039bda0 --- /dev/null +++ b/src/project/application/use-cases/project/index.ts @@ -0,0 +1,34 @@ +import { CheckSlugAvailabilityQuery } from './check-slug.use-case'; +import { CreateProjectUseCase } from './create.use-case'; +import { DeleteProjectUseCase } from './delete.use-case'; +import { FindProjectsByTeamQuery } from './find-by-team.query'; +import { FindProjectQuery } from './find-one.query'; +import { GenerateShareTokenUseCase } from './generate-share-token.use-case'; +import { GetProjectDetailQuery } from './get-detail.query'; +import { SetProjectStatusUseCase } from './set-status.use-case'; +import { UpdateProjectUseCase } from './update.use-case'; + +export * from './find-by-team.query'; +export * from './find-one.query'; +export * from './create.use-case'; +export * from './delete.use-case'; +export * from './generate-share-token.use-case'; +export * from './get-detail.query'; +export * from './set-status.use-case'; +export * from './update.use-case'; +export * from './check-slug.use-case'; + +export const ProjectUseCases = [ + CreateProjectUseCase, + DeleteProjectUseCase, + GenerateShareTokenUseCase, + SetProjectStatusUseCase, + UpdateProjectUseCase, +]; + +export const ProjectQueries = [ + CheckSlugAvailabilityQuery, + FindProjectsByTeamQuery, + GetProjectDetailQuery, + FindProjectQuery, +]; diff --git a/src/project/application/use-cases/project/set-status.use-case.ts b/src/project/application/use-cases/project/set-status.use-case.ts new file mode 100644 index 00000000..48a97d61 --- /dev/null +++ b/src/project/application/use-cases/project/set-status.use-case.ts @@ -0,0 +1,88 @@ +import { PROJECT_STATUSES, type ProjectStatus } from '@core/project/domain/entities'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IProjectRepository } from '@core/project/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class SetProjectStatusUseCase { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(slug: string, teamId: string, userId: string, status: ProjectStatus) { + const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); + + const project = await this.projectsRepo.findBySlug(slug, team.id); + + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (project.status === status) { + throw new BaseException( + { + code: + status === PROJECT_STATUSES[1] + ? ProjectErrorCodes.ALREADY_ARCHIVED + : ProjectErrorCodes.ALREADY_ACTIVE, + message: + status === PROJECT_STATUSES[1] + ? ProjectErrorMessages[ProjectErrorCodes.ALREADY_ARCHIVED] + : ProjectErrorMessages[ProjectErrorCodes.ALREADY_ACTIVE], + }, + HttpStatus.CONFLICT, + ); + } + + // if (status === ProjectStatus.Archived) { + // const activeTasksCount = await this.projectsRepo.countActiveTasks(project.id); + // if (activeTasksCount > 0) { + // throw new BaseException( + // { + // code: ProjectErrorCodes.CANNOT_ARCHIVE_WITH_ACTIVE_TASKS, + // message: + // ProjectErrorMessages[ + // ProjectErrorCodes.CANNOT_ARCHIVE_WITH_ACTIVE_TASKS + // ], + // }, + // HttpStatus.CONFLICT, + // ); + // } + // } + + const result = await this.projectsRepo.update(team.id, project.id, { status }); + + if (!result) { + throw new BaseException( + { + code: 'STATUS_UPDATE_FAILED', + message: 'Не удалось обновить статус проекта', + details: [{ target: 'status', value: status }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const messages: Record = { + [PROJECT_STATUSES[0]]: `Проект «${project.name}» восстановлен`, + [PROJECT_STATUSES[1]]: `Проект «${project.name}» архивирован`, + [PROJECT_STATUSES[2]]: `Проект «${project.name}» сохранен как шаблон`, + [PROJECT_STATUSES[3]]: `Проект «${project.name}» удален`, + }; + + return { + success: result, + message: messages[status] || `Статус проекта «${project.name}» изменен`, + }; + } +} diff --git a/src/project/application/use-cases/project/update.use-case.ts b/src/project/application/use-cases/project/update.use-case.ts new file mode 100644 index 00000000..37230753 --- /dev/null +++ b/src/project/application/use-cases/project/update.use-case.ts @@ -0,0 +1,86 @@ +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IProjectRepository } from '@core/project/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import slugify from 'slugify'; + +import { UpdateProjectDto } from '../../dtos'; + +@Injectable() +export class UpdateProjectUseCase { + constructor( + @Inject('IProjectRepository') + private readonly projectsRepo: IProjectRepository, + private readonly policy: ProjectAccessPolicy, + ) {} + + public async execute(slug: string, teamId: string, userId: string, dto: UpdateProjectDto) { + const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); + + const project = await this.projectsRepo.findBySlug(slug, team.id); + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (dto.slug && dto.slug !== project.slug) { + const slugExists = await this.projectsRepo.findBySlug(dto.slug, team.id); + if (slugExists) { + throw new BaseException( + { + code: ProjectErrorCodes.SLUG_DUPLICATE, + message: ProjectErrorMessages[ProjectErrorCodes.SLUG_DUPLICATE], + }, + HttpStatus.CONFLICT, + ); + } + } + + const data = { + ...(dto.slug && { slug: slugify(dto.slug, { lower: true, strict: true }) }), + ...(dto.name && { name: dto.name.trim() }), + ...(dto.description !== undefined && { description: dto.description?.trim() || null }), + ...(dto.descriptionHtml !== undefined && { + descriptionHtml: dto.descriptionHtml?.trim() || null, + }), + ...(dto.icon !== undefined && { icon: dto.icon || null }), + ...(dto.color !== undefined && { color: dto.color || null }), + ...(dto.sequence !== undefined && { sequence: dto.sequence }), + ...(dto.visibility && { visibility: dto.visibility }), + }; + + if (Object.keys(data).length === 0 && !dto.settings) { + return { + success: true, + message: 'Нет данных для обновления', + }; + } + + const result = await this.projectsRepo.update(team.id, project.id, data); + + if (!result) { + throw new BaseException( + { + code: ProjectErrorCodes.UPDATE_FAILED, + message: ProjectErrorMessages[ProjectErrorCodes.UPDATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + // if (dto.settings) { + // await this.settingsRepo.update(project.id, dto.settings); + // } + + return { + success: result, + message: result ? 'Настройки проекта успешно обновлены' : 'Изменения не были применены', + }; + } +} diff --git a/src/project/domain/entities/enum.ts b/src/project/domain/entities/enum.ts new file mode 100644 index 00000000..215d6962 --- /dev/null +++ b/src/project/domain/entities/enum.ts @@ -0,0 +1,15 @@ +export const PROJECT_STATUSES = ['active', 'archived', 'template', 'deleted'] as const; +export const PROJECT_VISIBILITIES = ['public', 'private'] as const; +export const LAYOUTS = ['kanban', 'list', 'calendar', 'gantt'] as const; + +export const MEMBER_ROLES = [ + 'owner', // Владелец — создатель проекта, может всё, включая удаление + 'admin', // Админ — управляет участниками, настройками, может всё кроме удаления + 'editor', // Редактор — создает и редактирует задачи, меняет статусы, но не управляет людьми + 'member', // Участник — работает со своими задачами, комментирует + 'viewer', // Наблюдатель — только смотрит, комментирует +] as const; +export type MemberRole = (typeof MEMBER_ROLES)[number]; +export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; +export type ProjectVisibility = (typeof PROJECT_VISIBILITIES)[number]; +export type Layout = (typeof LAYOUTS)[number]; diff --git a/src/project/domain/entities/index.ts b/src/project/domain/entities/index.ts new file mode 100644 index 00000000..84816716 --- /dev/null +++ b/src/project/domain/entities/index.ts @@ -0,0 +1,3 @@ +export * from './project.domain'; +export * from './member.domain'; +export * from './enum'; diff --git a/src/project/domain/entities/member.domain.ts b/src/project/domain/entities/member.domain.ts new file mode 100644 index 00000000..8d01cde5 --- /dev/null +++ b/src/project/domain/entities/member.domain.ts @@ -0,0 +1,15 @@ +import type { projectMembers } from '@shared/entities'; +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; + +export type Member = InferSelectModel; +export type NewMember = InferInsertModel; + +export interface MemberWithUser extends Member { + user: { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + avatarUrl: string | null; + }; +} diff --git a/src/project/domain/entities/project.domain.ts b/src/project/domain/entities/project.domain.ts new file mode 100644 index 00000000..e39b8da6 --- /dev/null +++ b/src/project/domain/entities/project.domain.ts @@ -0,0 +1,21 @@ +import type { + projects, + projectShares, +} from '../../infrastructure/persistence/models/project.model'; +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; + +export type Project = InferSelectModel; +export type NewProject = InferInsertModel; + +export interface ProjectSettings { + allowGuestComments?: boolean; + defaultAssigneeId?: string; + showTaskNumbers?: boolean; +} + +export type ProjectWithTypedSettings = Omit & { + settings: ProjectSettings; +}; + +export type ProjectShare = InferSelectModel; +export type NewProjectShare = InferInsertModel; diff --git a/src/project/domain/errors/index.ts b/src/project/domain/errors/index.ts new file mode 100644 index 00000000..0e10ef2d --- /dev/null +++ b/src/project/domain/errors/index.ts @@ -0,0 +1,2 @@ +export * from './project.errors'; +export * from './member.errors'; diff --git a/src/project/domain/errors/member.errors.ts b/src/project/domain/errors/member.errors.ts new file mode 100644 index 00000000..e78052f6 --- /dev/null +++ b/src/project/domain/errors/member.errors.ts @@ -0,0 +1,54 @@ +export const MemberErrorCodes = { + // 404 + NOT_FOUND: 'MEMBER.NOT_FOUND', + + // 409 — Conflict + ALREADY_EXISTS: 'MEMBER.ALREADY_EXISTS', + + // 400 — Bad Request + SELF_ADD: 'MEMBER.SELF_ADD', + SELF_REMOVE_OWNER: 'MEMBER.SELF_REMOVE_OWNER', + NOT_IN_TEAM: 'MEMBER.NOT_IN_TEAM', + INVALID_ROLE: 'MEMBER.INVALID_ROLE', + + // 403 — Forbidden + CANNOT_REMOVE_OWNER: 'MEMBER.CANNOT_REMOVE_OWNER', + CANNOT_CHANGE_OWNER: 'MEMBER.CANNOT_CHANGE_OWNER', + CANNOT_ASSIGN_OWNER: 'MEMBER.CANNOT_ASSIGN_OWNER', + ADMIN_REMOVE_FORBIDDEN: 'MEMBER.ADMIN_REMOVE_FORBIDDEN', + ADMIN_CHANGE_FORBIDDEN: 'MEMBER.ADMIN_CHANGE_FORBIDDEN', + ACCESS_DENIED: 'MEMBER.ACCESS_DENIED', + LIMIT_REACHED: 'MEMBER.LIMIT_REACHED', + + INSUFFICIENT_PERMISSIONS: 'MEMBER.INSUFFICIENT_PERMISSIONS', + + // 500 — Internal + CREATE_FAILED: 'MEMBER.CREATE_FAILED', + UPDATE_FAILED: 'MEMBER.UPDATE_FAILED', + DELETE_FAILED: 'MEMBER.DELETE_FAILED', +} as const; + +export type MemberErrorCode = (typeof MemberErrorCodes)[keyof typeof MemberErrorCodes]; + +export const MemberErrorMessages: Record = { + [MemberErrorCodes.NOT_FOUND]: 'Участник не найден в проекте', + [MemberErrorCodes.ALREADY_EXISTS]: 'Пользователь уже является участником проекта', + [MemberErrorCodes.SELF_ADD]: 'Нельзя добавить самого себя в участники', + [MemberErrorCodes.SELF_REMOVE_OWNER]: + 'Владелец не может покинуть проект. Сначала передайте права другому участнику', + [MemberErrorCodes.NOT_IN_TEAM]: 'Пользователь не является участником команды', + [MemberErrorCodes.INVALID_ROLE]: 'Недопустимая роль. Доступные роли: admin, member, viewer', + [MemberErrorCodes.CANNOT_REMOVE_OWNER]: 'Невозможно удалить владельца проекта', + [MemberErrorCodes.CANNOT_CHANGE_OWNER]: 'Невозможно изменить роль владельца проекта', + [MemberErrorCodes.CANNOT_ASSIGN_OWNER]: + 'Нельзя назначить роль владельца через этот метод. Используйте трансфер прав', + [MemberErrorCodes.ADMIN_REMOVE_FORBIDDEN]: 'Только владелец может удалить администратора', + [MemberErrorCodes.ADMIN_CHANGE_FORBIDDEN]: + 'Только владелец может назначать или снимать роль администратора', + [MemberErrorCodes.ACCESS_DENIED]: 'У вас нет доступа к участникам этого проекта', + [MemberErrorCodes.LIMIT_REACHED]: 'Достигнут лимит участников проекта', + [MemberErrorCodes.CREATE_FAILED]: 'Не удалось добавить участника', + [MemberErrorCodes.UPDATE_FAILED]: 'Не удалось обновить роль участника', + [MemberErrorCodes.DELETE_FAILED]: 'Не удалось удалить участника', + [MemberErrorCodes.INSUFFICIENT_PERMISSIONS]: 'Требуется одна из ролей', +} as const; diff --git a/src/project/domain/errors/project.errors.ts b/src/project/domain/errors/project.errors.ts new file mode 100644 index 00000000..59ce3917 --- /dev/null +++ b/src/project/domain/errors/project.errors.ts @@ -0,0 +1,67 @@ +export const ProjectErrorCodes = { + // 404 + NOT_FOUND: 'PROJECT.NOT_FOUND', + + // 409 — Conflict + SLUG_DUPLICATE: 'PROJECT.SLUG_DUPLICATE', + ALREADY_ARCHIVED: 'PROJECT.ALREADY_ARCHIVED', + ALREADY_ACTIVE: 'PROJECT.ALREADY_ACTIVE', + + // 400 — Bad Request + SLUG_INVALID: 'PROJECT.SLUG_INVALID', + NAME_INVALID: 'PROJECT.NAME_INVALID', + COLOR_INVALID: 'PROJECT.COLOR_INVALID', + ICON_INVALID: 'PROJECT.ICON_INVALID', + DESCRIPTION_TOO_LONG: 'PROJECT.DESCRIPTION_TOO_LONG', + INVALID_VISIBILITY: 'PROJECT.INVALID_VISIBILITY', + INVALID_STATUS: 'PROJECT.INVALID_STATUS', + TEAM_REQUIRED: 'PROJECT.TEAM_REQUIRED', + + // 403 — Forbidden + MAX_PROJECTS_REACHED: 'PROJECT.MAX_PROJECTS_REACHED', + OWNER_NOT_IN_TEAM: 'PROJECT.OWNER_NOT_IN_TEAM', + + // 422 — Unprocessable + CANNOT_ARCHIVE_WITH_ACTIVE_TASKS: 'PROJECT.CANNOT_ARCHIVE_WITH_ACTIVE_TASKS', + CANNOT_DELETE_NOT_ARCHIVED: 'PROJECT.CANNOT_DELETE_NOT_ARCHIVED', + + // 500 — Internal + CREATE_FAILED: 'PROJECT.CREATE_FAILED', + UPDATE_FAILED: 'PROJECT.UPDATE_FAILED', + DELETE_FAILED: 'PROJECT.DELETE_FAILED', + RESTORE_FAILED: 'PROJECT.RESTORE_FAILED', +} as const; + +export type ProjectErrorCode = (typeof ProjectErrorCodes)[keyof typeof ProjectErrorCodes]; + +export const ProjectErrorMessages: Record = { + [ProjectErrorCodes.NOT_FOUND]: 'Проект не найден', + + [ProjectErrorCodes.SLUG_DUPLICATE]: 'Проект с таким ключом уже существует в команде', + [ProjectErrorCodes.ALREADY_ARCHIVED]: 'Проект уже находится в архиве', + [ProjectErrorCodes.ALREADY_ACTIVE]: 'Проект уже активен', + + [ProjectErrorCodes.SLUG_INVALID]: + 'Ключ проекта должен содержать только строчные латинские буквы и цифры (2-10 символов)', + [ProjectErrorCodes.NAME_INVALID]: + 'Название проекта не может быть пустым и должно быть не длиннее 100 символов', + [ProjectErrorCodes.COLOR_INVALID]: 'Цвет должен быть в формате HEX (например, #FFFFFF)', + [ProjectErrorCodes.ICON_INVALID]: 'URL иконки слишком длинный (максимум 255 символов)', + [ProjectErrorCodes.DESCRIPTION_TOO_LONG]: 'Описание слишком длинное (максимум 2000 символов)', + [ProjectErrorCodes.INVALID_VISIBILITY]: 'Недопустимый тип видимости проекта', + [ProjectErrorCodes.INVALID_STATUS]: 'Недопустимый статус проекта', + [ProjectErrorCodes.TEAM_REQUIRED]: 'ID команды обязателен', + + [ProjectErrorCodes.MAX_PROJECTS_REACHED]: 'Достигнут лимит проектов в команде', + [ProjectErrorCodes.OWNER_NOT_IN_TEAM]: 'Владелец проекта должен быть участником команды', + + [ProjectErrorCodes.CANNOT_ARCHIVE_WITH_ACTIVE_TASKS]: + 'Нельзя архивировать проект с активными задачами', + [ProjectErrorCodes.CANNOT_DELETE_NOT_ARCHIVED]: + 'Перед удалением проект необходимо архивировать', + + [ProjectErrorCodes.CREATE_FAILED]: 'Не удалось создать проект', + [ProjectErrorCodes.UPDATE_FAILED]: 'Не удалось обновить проект', + [ProjectErrorCodes.DELETE_FAILED]: 'Не удалось удалить проект', + [ProjectErrorCodes.RESTORE_FAILED]: 'Не удалось восстановить проект', +} as const; diff --git a/src/project/domain/policy/index.ts b/src/project/domain/policy/index.ts new file mode 100644 index 00000000..cc90b6c6 --- /dev/null +++ b/src/project/domain/policy/index.ts @@ -0,0 +1,5 @@ +import { ProjectAccessPolicy } from './project-access.policy'; + +export * from './project-access.policy'; + +export const POLICIES = [ProjectAccessPolicy]; diff --git a/src/project/domain/policy/project-access.policy.ts b/src/project/domain/policy/project-access.policy.ts new file mode 100644 index 00000000..d11a4234 --- /dev/null +++ b/src/project/domain/policy/project-access.policy.ts @@ -0,0 +1,163 @@ +import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { ROLE_PRIORITY, PROJECT_ROLE_PRIORITY } from '@shared/constants'; +import { BaseException } from '@shared/error'; + +import { isTeamRole } from '../../../shared/constants/roles.constant'; +import { ProjectErrorCodes, ProjectErrorMessages } from '../errors'; +import { MemberErrorCodes, MemberErrorMessages } from '../errors/member.errors'; +import { IMemberRepository, IProjectRepository } from '../repository'; + +import type { MemberRole } from '../entities'; + +@Injectable() +export class ProjectAccessPolicy { + constructor( + @Inject('IProjectRepository') + private readonly projectRepo: IProjectRepository, + @Inject('IMemberRepository') + private readonly memberRepo: IMemberRepository, + private readonly findTeamQ: FindTeamQuery, + private readonly findTeamMemberQ: FindTeamMemberQuery, + ) {} + + /** + * Проверка доступа к команде (используется, например, при создании проекта) + */ + public async ensureTeamAccess( + teamId: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) { + const team = await this.findTeamQ.execute(teamId); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.findTeamMemberQ.execute(team.id, userId); + if (!member) { + throw new BaseException( + { code: 'NOT_TEAM_MEMBER', message: 'Вы не участник команды' }, + HttpStatus.FORBIDDEN, + ); + } + + if (isTeamRole(member.role) && ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Требуется роль ${minRole} или выше`, + details: [{ target: 'role', current: member.role, required: minRole }], + }, + HttpStatus.FORBIDDEN, + ); + } + + return { team, member }; + } + + /** + * Проверка доступа к проекту. + * Проверяет роль пользователя именно в проекте, а не в команде. + */ + public async ensureProjectAccess( + slug: string, + userId: string, + minRoles: readonly MemberRole[] = ['viewer'], + ) { + const project = await this.projectRepo.findBySlug(slug); + if (!project) { + throw new BaseException( + { + code: ProjectErrorCodes.NOT_FOUND, + message: ProjectErrorMessages[ProjectErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.memberRepo.findByProjectAndUser(project.id, userId); + if (!member) { + throw new BaseException( + { + code: MemberErrorCodes.ACCESS_DENIED, + message: MemberErrorMessages[MemberErrorCodes.ACCESS_DENIED], + }, + HttpStatus.FORBIDDEN, + ); + } + + const hasRole = minRoles.some((role) => { + if (!isTeamRole(member.role) || !isTeamRole(role)) { + return false; + } + + const memberPriority = PROJECT_ROLE_PRIORITY[member.role] ?? -1; + const rolePriority = PROJECT_ROLE_PRIORITY[role] ?? -1; + + return memberPriority >= rolePriority; + }); + + if (!hasRole) { + throw new BaseException( + { + code: MemberErrorCodes.INSUFFICIENT_PERMISSIONS, + message: `Требуется одна из ролей: ${minRoles.join(', ')}. Ваша роль: ${member.role}`, + }, + HttpStatus.FORBIDDEN, + ); + } + + return { project, member }; + } + + /** + * Проверка доступа к проекту по slug + */ + public async validateProjectAccessById( + slug: string, + userId: string, + minRole: keyof typeof ROLE_PRIORITY = 'viewer', + ) { + const project = await this.projectRepo.findOne(slug); + if (!project) { + throw new BaseException( + { code: 'PROJECT_NOT_FOUND', message: 'Проект не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.findTeamMemberQ.execute(project.teamId, userId); + if (!member) { + throw new BaseException( + { code: 'NOT_TEAM_MEMBER', message: 'Вы не участник команды' }, + HttpStatus.FORBIDDEN, + ); + } + + // TODO: replace with project members query + const isProjectMember = true; + if (!isProjectMember) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Вы не являетесь участником этого проекта' }, + HttpStatus.FORBIDDEN, + ); + } + + if (isTeamRole(member.role) && ROLE_PRIORITY[member.role] < ROLE_PRIORITY[minRole]) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: `Требуется роль ${minRole} или выше`, + details: [{ target: 'role', current: member.role, required: minRole }], + }, + HttpStatus.FORBIDDEN, + ); + } + + return { project, member }; + } +} diff --git a/src/project/domain/repository/index.ts b/src/project/domain/repository/index.ts new file mode 100644 index 00000000..d4fcc44f --- /dev/null +++ b/src/project/domain/repository/index.ts @@ -0,0 +1,2 @@ +export * from './project.repository.interface'; +export * from './member.repository.interface'; diff --git a/src/project/domain/repository/member.repository.interface.ts b/src/project/domain/repository/member.repository.interface.ts new file mode 100644 index 00000000..bd3aae95 --- /dev/null +++ b/src/project/domain/repository/member.repository.interface.ts @@ -0,0 +1,16 @@ +import type { Member, MemberRole, NewMember } from '../entities'; + +export interface IMemberRepository { + create(data: NewMember): Promise<{ readonly id: string }>; + updateRole(memberId: string, role: MemberRole): Promise; + delete(memberId: string): Promise; + + findById(memberId: string): Promise; + findByProjectAndUser(projectId: string, userId: string): Promise; + findByProject(projectId: string): Promise; + + isMember(projectId: string, userId: string): Promise; + getUserRole(projectId: string, userId: string): Promise; + + countByProject(projectId: string): Promise; +} diff --git a/src/project/domain/repository/project.repository.interface.ts b/src/project/domain/repository/project.repository.interface.ts new file mode 100644 index 00000000..319fa6b6 --- /dev/null +++ b/src/project/domain/repository/project.repository.interface.ts @@ -0,0 +1,20 @@ +import type { NewProject, NewProjectShare, Project } from '../entities'; + +export interface IProjectRepository { + create( + userId: string, + data: NewProject, + ): Promise<{ readonly result: boolean; readonly slug: string }>; + update(teamId: string, projectId: string, data: Partial): Promise; + delete(teamId: string, projectId: string): Promise; + findOne(projectId: string, teamId?: string): Promise; + findByTeam(teamId: string): Promise; + createShare(data: NewProjectShare): Promise; + + findBySlug(slug: string, teamId?: string): Promise; + + hasValidShareToken(slug: string, token: string): Promise; + revokeAllShares(projectId: string): Promise; + + countByTeam(teamId: string): Promise; +} diff --git a/src/project/index.ts b/src/project/index.ts new file mode 100644 index 00000000..592dbc69 --- /dev/null +++ b/src/project/index.ts @@ -0,0 +1,2 @@ +export { ProjectModule } from './project.module'; +export { FindProjectQuery } from './application/use-cases/project'; diff --git a/src/project/infrastructure/constants/index.ts b/src/project/infrastructure/constants/index.ts new file mode 100644 index 00000000..555e1cb4 --- /dev/null +++ b/src/project/infrastructure/constants/index.ts @@ -0,0 +1,6 @@ +export const MAX_PROJECTS_PER_TEAM = 20; +export const MAX_MEMBERS_PER_PROJECT = 10; + +export const SHARE_LINK_TTL_MONTHS = 3; +export const SHARE_LINK_PREFIX = 'st_'; +export const SHARE_LINK_LENGTH = 16; diff --git a/src/project/infrastructure/persistence/models/enum.ts b/src/project/infrastructure/persistence/models/enum.ts new file mode 100644 index 00000000..88167dea --- /dev/null +++ b/src/project/infrastructure/persistence/models/enum.ts @@ -0,0 +1,6 @@ +import { LAYOUTS, PROJECT_STATUSES, PROJECT_VISIBILITIES } from '@core/project/domain/entities'; +import { baseSchema } from '@shared/entities'; + +export const projectStatusEnum = baseSchema.enum('project_status', PROJECT_STATUSES); +export const projectVisibilityEnum = baseSchema.enum('project_visibility', PROJECT_VISIBILITIES); +export const layoutEnum = baseSchema.enum('layout_type', LAYOUTS); diff --git a/src/project/infrastructure/persistence/models/index.ts b/src/project/infrastructure/persistence/models/index.ts new file mode 100644 index 00000000..8809d89a --- /dev/null +++ b/src/project/infrastructure/persistence/models/index.ts @@ -0,0 +1,3 @@ +export { projectStatusEnum, projectVisibilityEnum, layoutEnum } from './enum'; +export { projectShares, projectSettings, projects } from './project.model'; +export { projectMembers } from './member.model'; diff --git a/src/project/infrastructure/persistence/models/member.model.ts b/src/project/infrastructure/persistence/models/member.model.ts new file mode 100644 index 00000000..095c67b6 --- /dev/null +++ b/src/project/infrastructure/persistence/models/member.model.ts @@ -0,0 +1,28 @@ +import { createId } from '@paralleldrive/cuid2'; +import { baseSchema, projects, users } from '@shared/entities'; +import { index, uniqueIndex, timestamp, varchar, text } from 'drizzle-orm/pg-core'; + +export const projectMembers = baseSchema.table( + 'project_members', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + projectId: text('project_id') + .references(() => projects.id, { onDelete: 'cascade' }) + .notNull(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + role: varchar('role', { length: 20 }).notNull().default('member'), + addedBy: text('added_by').references(() => users.id), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + (t) => ({ + uniqueMember: uniqueIndex('project_member_unique_idx').on(t.projectId, t.userId), + userIdx: index('project_member_user_idx').on(t.userId), + projectIdx: index('project_member_project_idx').on(t.projectId), + }), +); diff --git a/src/project/infrastructure/persistence/models/project.model.ts b/src/project/infrastructure/persistence/models/project.model.ts new file mode 100644 index 00000000..bd5c9adf --- /dev/null +++ b/src/project/infrastructure/persistence/models/project.model.ts @@ -0,0 +1,112 @@ +import { createId } from '@paralleldrive/cuid2'; +import { baseSchema, teams, users } from '@shared/entities'; +import { isNull } from 'drizzle-orm'; +import { + text, + varchar, + timestamp, + integer, + boolean, + uniqueIndex, + index, +} from 'drizzle-orm/pg-core'; + +import { layoutEnum, projectStatusEnum, projectVisibilityEnum } from './enum'; + +export const projects = baseSchema.table( + 'projects', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + teamId: text('team_id') + .references(() => teams.id, { onDelete: 'cascade' }) + .notNull(), + slug: varchar('slug', { length: 100 }).notNull().unique(), + name: varchar('name', { length: 100 }).notNull(), + description: text('description'), + descriptionHtml: text('descriptionHtml'), + icon: varchar('icon', { length: 255 }), + color: varchar('color', { length: 7 }), + status: projectStatusEnum('status').default('active').notNull(), + sequence: integer('sequence').default(0), + ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), + visibility: projectVisibilityEnum('visibility').default('public').notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), + }, + (t) => ({ + uniqueTeamSlug: uniqueIndex('project_team_slug_idx') + .on(t.teamId, t.slug) + .where(isNull(t.deletedAt)), + ownerIdx: index('project_owner_id_idx').on(t.ownerId), + teamIdx: index('project_team_id_idx').on(t.teamId), + }), +); + +export const projectSettings = baseSchema.table( + 'project_settings', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + projectId: text('project_id') + .references(() => projects.id, { onDelete: 'cascade' }) + .notNull() + .unique(), + defaultView: layoutEnum('default_view').default('kanban').notNull(), + taskPrefix: varchar('task_prefix', { length: 10 }), + autoCloseDays: integer('auto_close_days'), + maxTasksPerArea: integer('max_tasks_per_area'), + maxMembers: integer('max_members'), + maxAreas: integer('max_areas'), + allowGuests: boolean('allow_guests').default(false), + timeTracking: boolean('time_tracking').default(false), + timeTrackingMode: varchar('time_tracking_mode', { length: 20 }).default('optional'), + defaultAssigneeId: text('default_assignee_id').references(() => users.id, { + onDelete: 'set null', + }), + createdAt: timestamp('created_at', { + withTimezone: true, + mode: 'string', + }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { + withTimezone: true, + mode: 'string', + }) + .defaultNow() + .notNull(), + }, + (t) => ({ + projectIdx: uniqueIndex('project_settings_project_idx').on(t.projectId), + }), +); + +export const projectShares = baseSchema.table( + 'project_shares', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + token: text('token').notNull().unique(), + expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'string' }), + createdBy: text('created_by').references(() => users.id), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + (t) => ({ + tokenIdx: index('token_idx').on(t.token), + projectIdx: index('project_share_project_id_idx').on(t.projectId), + }), +); diff --git a/src/project/infrastructure/persistence/repositories/index.ts b/src/project/infrastructure/persistence/repositories/index.ts new file mode 100644 index 00000000..431500ba --- /dev/null +++ b/src/project/infrastructure/persistence/repositories/index.ts @@ -0,0 +1,13 @@ +import { MemberRepository } from './member.repository'; +import { ProjectRepository } from './project.repository'; + +export const REPOSITORIES = [ + { + provide: 'IProjectRepository', + useClass: ProjectRepository, + }, + { + provide: 'IMemberRepository', + useClass: MemberRepository, + }, +]; diff --git a/src/project/infrastructure/persistence/repositories/member.repository.ts b/src/project/infrastructure/persistence/repositories/member.repository.ts new file mode 100644 index 00000000..bb5740ce --- /dev/null +++ b/src/project/infrastructure/persistence/repositories/member.repository.ts @@ -0,0 +1,118 @@ +import { IMemberRepository } from '@core/project/domain/repository'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Inject, Injectable } from '@nestjs/common'; +import { and, eq, sql } from 'drizzle-orm'; + +import * as schema from '../models'; + +import type { MemberRole } from '@core/project/domain/entities'; + +@Injectable() +export class MemberRepository implements IMemberRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public readonly create = async (data: typeof schema.projectMembers.$inferInsert) => { + const [result] = await this.db + .insert(schema.projectMembers) + .values(data) + .returning({ id: schema.projectMembers.id }); + + if (!result) { + throw new Error('Failed to create member: no member returned'); + } + + return { id: result?.id }; + }; + + public readonly findById = async (memberId: string) => { + const [result] = await this.db + .select() + .from(schema.projectMembers) + .where(eq(schema.projectMembers.id, memberId)) + .limit(1); + + return result ?? null; + }; + + public readonly findByProject = async (projectId: string) => + this.db + .select() + .from(schema.projectMembers) + .where(eq(schema.projectMembers.projectId, projectId)) + .orderBy(schema.projectMembers.createdAt); + + async isMember(projectId: string, userId: string) { + const [result] = await this.db + .select({ one: sql`1` }) + .from(schema.projectMembers) + .where( + and( + eq(schema.projectMembers.projectId, projectId), + eq(schema.projectMembers.userId, userId), + ), + ) + .limit(1); + + return result !== undefined; + } + + public readonly findByProjectAndUser = async (projectId: string, userId: string) => { + const [result] = await this.db + .select() + .from(schema.projectMembers) + .where( + and( + eq(schema.projectMembers.projectId, projectId), + eq(schema.projectMembers.userId, userId), + ), + ); + + return result || null; + }; + + public readonly getUserRole = async (projectId: string, userId: string) => { + const [result] = await this.db + .select({ role: schema.projectMembers.role }) + .from(schema.projectMembers) + .where( + and( + eq(schema.projectMembers.projectId, projectId), + eq(schema.projectMembers.userId, userId), + ), + ) + .limit(1); + + return (result?.role as MemberRole) ?? null; + }; + + public readonly countByProject = async (projectId: string) => { + const [result] = await this.db + .select({ count: sql`count(*)` }) + .from(schema.projectMembers) + .where(eq(schema.projectMembers.projectId, projectId)); + + return result?.count ?? 0; + }; + + public readonly updateRole = async (memberId: string, role: MemberRole) => { + const [result] = await this.db + .update(schema.projectMembers) + .set({ role }) + .where(eq(schema.projectMembers.id, memberId)) + .returning(); + + return result ?? null; + }; + + public readonly delete = async (memberId: string) => { + const [result] = await this.db + .delete(schema.projectMembers) + .where(eq(schema.projectMembers.id, memberId)) + .returning({ id: schema.projectMembers.id }); + + return result !== undefined; + }; +} diff --git a/src/project/infrastructure/persistence/repositories/project.repository.ts b/src/project/infrastructure/persistence/repositories/project.repository.ts new file mode 100644 index 00000000..6d84c141 --- /dev/null +++ b/src/project/infrastructure/persistence/repositories/project.repository.ts @@ -0,0 +1,177 @@ +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Injectable, Inject } from '@nestjs/common'; +import { and, count, eq, gt, isNull, or } from 'drizzle-orm'; + +import { IProjectRepository } from '../../../domain/repository'; +import * as schema from '../models'; + +import type { NewProject, NewProjectShare } from '@core/project/domain/entities'; + +@Injectable() +export class ProjectRepository implements IProjectRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public readonly create = async (userId: string, data: NewProject) => { + const result = await this.db.transaction(async (tx) => { + const project = await tx + .insert(schema.projects) + .values(data) + .returning({ slug: schema.projects.slug, id: schema.projects.id }); + + if (!project[0]) { + throw new Error('Failed to create project: no project returned'); + } + + const member = await tx + .insert(schema.projectMembers) + .values({ + projectId: project[0].id, + userId, + role: 'owner', + }) + .returning({ id: schema.projectMembers.id }); + + return { slug: project[0].slug, result: project.length > 0 && member.length > 0 }; + }); + + return result; + }; + + public readonly update = async ( + teamId: string, + projectId: string, + data: Partial, + ) => { + const result = await this.db + .update(schema.projects) + .set({ ...data, updatedAt: new Date().toISOString() }) + .where( + and( + eq(schema.projects.id, projectId), + eq(schema.projects.teamId, teamId), + isNull(schema.projects.deletedAt), + ), + ) + .returning({ id: schema.projects.id }); + + return result.length > 0; + }; + + public readonly delete = async (teamId: string, projectId: string) => { + const result = await this.db + .update(schema.projects) + .set({ + status: 'deleted', + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + .where( + and( + eq(schema.projects.id, projectId), + eq(schema.projects.teamId, teamId), + isNull(schema.projects.deletedAt), + ), + ) + .returning({ id: schema.projects.id }); + + return result.length > 0; + }; + + public readonly findOne = async (id: string, teamId?: string) => { + const [project] = await this.db + .select() + .from(schema.projects) + .where( + and( + eq(schema.projects.id, id), + isNull(schema.projects.deletedAt), + teamId ? eq(schema.projects.teamId, teamId) : undefined, + ), + ); + + return project || null; + }; + + public readonly findBySlug = async (slug: string, teamId?: string) => { + const [project] = await this.db + .select() + .from(schema.projects) + .where( + and( + eq(schema.projects.slug, slug), + isNull(schema.projects.deletedAt), + teamId ? eq(schema.projects.teamId, teamId) : undefined, + ), + ); + + return project || null; + }; + + public readonly findByTeam = async (teamId: string) => + this.db + .select() + .from(schema.projects) + .where(and(eq(schema.projects.teamId, teamId), isNull(schema.projects.deletedAt))); + + public readonly createShare = async (data: NewProjectShare) => { + const [result] = await this.db + .insert(schema.projectShares) + .values(data) + .onConflictDoUpdate({ + target: schema.projectShares.token, + set: { + expiresAt: data.expiresAt, + token: data.token, + }, + }) + .returning({ id: schema.projectShares.id }); + + return !!result; + }; + + public readonly hasValidShareToken = async (id: string, token: string) => { + const [result] = await this.db + .select() + .from(schema.projectShares) + .where( + and( + eq(schema.projectShares.projectId, id), + eq(schema.projectShares.token, token), + or( + isNull(schema.projectShares.expiresAt), + gt(schema.projectShares.expiresAt, new Date().toISOString()), + ), + ), + ) + .limit(1); + + return !!result; + }; + + public readonly revokeAllShares = async (projectId: string) => { + const result = await this.db + .delete(schema.projectShares) + .where(eq(schema.projectShares.projectId, projectId)) + .returning({ id: schema.projectShares.id }); + + return result.length > 0; + }; + + public readonly countByTeam = async (teamId: string) => { + const [result] = await this.db + .select({ count: count() }) + .from(schema.projects) + .where( + and( + eq(schema.projects.teamId, teamId), + isNull(schema.projects.deletedAt), + eq(schema.projects.status, 'active'), + ), + ); + + return result?.count ?? 0; + }; +} diff --git a/src/project/project.module.ts b/src/project/project.module.ts new file mode 100644 index 00000000..6fbd2864 --- /dev/null +++ b/src/project/project.module.ts @@ -0,0 +1,17 @@ +import { TeamsModule } from '@core/teams'; +import { UserModule } from '@core/user'; +import { forwardRef, Module } from '@nestjs/common'; + +import { CONTROLLERS } from './application/controllers'; +import { ProjectFacade } from './application/project.facade'; +import { CreateProjectUseCase, FindProjectQuery, USE_CASES } from './application/use-cases'; +import { POLICIES, ProjectAccessPolicy } from './domain/policy'; +import { REPOSITORIES } from './infrastructure/persistence/repositories'; + +@Module({ + imports: [UserModule, forwardRef(() => TeamsModule)], + controllers: CONTROLLERS, + providers: [...REPOSITORIES, ...POLICIES, ...USE_CASES, ProjectFacade], + exports: [FindProjectQuery, ProjectAccessPolicy, CreateProjectUseCase], +}) +export class ProjectModule {} diff --git a/src/shared/adapters/cache/adapters/index.ts b/src/shared/adapters/cache/adapters/index.ts new file mode 100644 index 00000000..d75652f3 --- /dev/null +++ b/src/shared/adapters/cache/adapters/index.ts @@ -0,0 +1 @@ +export * from './redis-cache.adapter'; diff --git a/src/shared/adapters/cache/adapters/redis-cache.adapter.ts b/src/shared/adapters/cache/adapters/redis-cache.adapter.ts new file mode 100644 index 00000000..41eeb1e2 --- /dev/null +++ b/src/shared/adapters/cache/adapters/redis-cache.adapter.ts @@ -0,0 +1,181 @@ +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { Injectable } from '@nestjs/common'; +import Redis, { ChainableCommander } from 'ioredis'; + +import { ICacheService, ICacheTransaction } from '../ports'; + +@Injectable() +export class RedisCacheAdapter implements ICacheService { + private readonly defaultTtl = 259200; // 3 days in seconds + + constructor(@InjectRedis() private readonly redis: Redis) {} + + async getOne(key: string) { + return this.redis.get(key); + } + + async getMany(keys: string[]): Promise<(string | null)[]> { + if (keys.length === 0) { + return []; + } + return this.redis.mget(keys); + } + + async getCollection(key: string): Promise { + return this.redis.smembers(key); + } + + async setOne(key: string, value: string, ttlSeconds: number = this.defaultTtl) { + await this.redis.set(key, value, 'EX', ttlSeconds); + } + + async setMany( + items: readonly { readonly key: string; readonly value: string }[], + ttlSeconds: number = this.defaultTtl, + ) { + if (!items.length) { + return; + } + + const pipeline = this.redis.pipeline(); + + for (const item of items) { + pipeline.set(item.key, item.value, 'EX', ttlSeconds); + } + + await pipeline.exec(); + } + + async addOneToCollection(key: string, value: string, ttlSeconds: number = this.defaultTtl) { + await this.redis.pipeline().sadd(key, value).expire(key, ttlSeconds).exec(); + } + + async addManyToCollection(key: string, values: string[], ttlSeconds: number = this.defaultTtl) { + if (!values.length) { + return; + } + + await this.redis + .pipeline() + .sadd(key, ...values) + .expire(key, ttlSeconds) + .exec(); + } + + async removeOne(key: string) { + await this.redis.del(key); + } + + async removeMany(keys: string[]) { + if (!keys.length) { + return; + } + await this.redis.del(keys); + } + + async removeOneFromCollection(key: string, value: string) { + await this.redis.srem(key, value); + } + + async removeManyFromCollection(key: string, values: string[]) { + if (!values.length) { + return; + } + await this.redis.srem(key, ...values); + } + + async getTtl(key: string): Promise { + const ttl = await this.redis.ttl(key); + return ttl > 0 ? ttl : 0; + } + + async getOneWithTtl(key: string) { + const [[, value], [, ttl]] = (await this.redis.pipeline().get(key).ttl(key).exec()) as [ + [Error | null, string | null], + [Error | null, number], + ]; + + return { + value, + ttlSeconds: ttl && ttl > 0 ? ttl : 0, + }; + } + + transaction(): ICacheTransaction { + return new RedisTransaction(this.redis.multi()); + } + + async isAlive() { + try { + return (await this.redis.ping()) === 'PONG'; + } catch { + return false; + } + } +} + +class RedisTransaction implements ICacheTransaction { + private readonly defaultTtl = 259200; // 3 days in seconds + + constructor(private readonly multi: ChainableCommander) {} + + setOne(key: string, value: string, ttlSeconds: number = this.defaultTtl): this { + this.multi.set(key, value, 'EX', ttlSeconds); + return this; + } + + setMany( + items: readonly { readonly key: string; readonly value: string }[], + ttlSeconds: number = this.defaultTtl, + ): this { + for (const item of items) { + this.multi.set(item.key, item.value, 'EX', ttlSeconds); + } + return this; + } + + addOneToCollection(key: string, value: string, ttlSeconds: number = this.defaultTtl): this { + this.multi.sadd(key, value); + this.multi.expire(key, ttlSeconds); + return this; + } + + addManyToCollection(key: string, values: string[], ttlSeconds: number = this.defaultTtl): this { + if (!values.length) { + return this; + } + this.multi.sadd(key, ...values); + this.multi.expire(key, ttlSeconds); + return this; + } + + removeOne(key: string): this { + this.multi.del(key); + return this; + } + + removeMany(keys: string[]): this { + if (!keys.length) { + return this; + } + this.multi.del(keys); + return this; + } + + removeOneFromCollection(collectionKey: string, value: string): this { + this.multi.srem(collectionKey, value); + return this; + } + + removeManyFromCollection(collectionKey: string, values: string[]): this { + if (!values.length) { + return this; + } + this.multi.srem(collectionKey, ...values); + return this; + } + + async execute(): Promise { + await this.multi.exec(); + } +} diff --git a/src/shared/adapters/cache/constants.ts b/src/shared/adapters/cache/constants.ts new file mode 100644 index 00000000..b6f0485f --- /dev/null +++ b/src/shared/adapters/cache/constants.ts @@ -0,0 +1 @@ +export const CACHE_SERVICE = 'ICacheService'; diff --git a/src/shared/adapters/cache/module.ts b/src/shared/adapters/cache/module.ts new file mode 100644 index 00000000..2e476557 --- /dev/null +++ b/src/shared/adapters/cache/module.ts @@ -0,0 +1,41 @@ +import { RedisModule } from '@nestjs-modules/ioredis'; +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { RedisCacheAdapter } from './adapters'; +import { CACHE_SERVICE } from './constants'; + +@Global() +@Module({ + imports: [ + RedisModule.forRootAsync({ + inject: [ConfigService], + useFactory: (cfg: ConfigService) => { + const host = cfg.getOrThrow('REDIS_HOST'); + const port = cfg.get('REDIS_PORT'); + const password = cfg.get('REDIS_PASSWORD'); + const url = `redis://${host}${port ? `:${port}` : ''}`; + + return { + type: 'single', + url, + options: { + password, + retryStrategy(times) { + return Math.min(times * 50, 2000); + }, + commandTimeout: 3000, + }, + }; + }, + }), + ], + providers: [ + { + provide: CACHE_SERVICE, + useClass: RedisCacheAdapter, + }, + ], + exports: [CACHE_SERVICE], +}) +export class CacheModule {} diff --git a/src/shared/adapters/cache/ports/index.ts b/src/shared/adapters/cache/ports/index.ts new file mode 100644 index 00000000..454366ba --- /dev/null +++ b/src/shared/adapters/cache/ports/index.ts @@ -0,0 +1 @@ +export { ICacheService, ICacheTransaction } from './static-cache.port'; diff --git a/src/shared/adapters/cache/ports/static-cache.port.ts b/src/shared/adapters/cache/ports/static-cache.port.ts new file mode 100644 index 00000000..b4da0840 --- /dev/null +++ b/src/shared/adapters/cache/ports/static-cache.port.ts @@ -0,0 +1,48 @@ +export interface ICacheService { + getOne(key: string): Promise; + getMany(keys: readonly string[]): Promise; + getCollection(collectionKey: string): Promise; + + setOne(key: string, value: string, ttlSeconds?: number): Promise; + setMany( + items: readonly { readonly key: string; readonly value: string }[], + ttlSeconds?: number, + ): Promise; + + addOneToCollection(key: string, value: string, ttlSeconds?: number): Promise; + addManyToCollection(key: string, values: readonly string[], ttlSeconds?: number): Promise; + + removeOne(key: string): Promise; + removeMany(keys: readonly string[]): Promise; + + removeOneFromCollection(key: string, value: string): Promise; + removeManyFromCollection(key: string, values: readonly string[]): Promise; + + getTtl(key: string): Promise; + getOneWithTtl( + key: string, + ): Promise<{ readonly value: string | null; readonly ttlSeconds: number }>; + + transaction(): ICacheTransaction; + + isAlive(): Promise; +} + +export interface ICacheTransaction { + setOne(key: string, value: string, ttlSeconds?: number): this; + setMany( + items: readonly { readonly key: string; readonly value: string }[], + ttlSeconds?: number, + ): this; + + addOneToCollection(key: string, value: string, ttlSeconds?: number): this; + addManyToCollection(key: string, values: readonly string[], ttlSeconds?: number): this; + + removeOne(key: string): this; + removeMany(keys: readonly string[]): this; + + removeOneFromCollection(key: string, value: string): this; + removeManyFromCollection(key: string, values: readonly string[]): this; + + execute(): Promise; +} diff --git a/src/shared/adapters/mail/adapter.ts b/src/shared/adapters/mail/adapter.ts new file mode 100644 index 00000000..e62e050e --- /dev/null +++ b/src/shared/adapters/mail/adapter.ts @@ -0,0 +1,79 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as hbs from 'handlebars'; +import * as nodemailer from 'nodemailer'; + +import { IMailPort } from './port'; + +@Injectable() +export class MailAdapter implements IMailPort { + private readonly transporter: nodemailer.Transporter; + + constructor(private readonly cfg: ConfigService) { + const port = this.cfg.get('MAIL_PORT'); + const mode = this.cfg.get('NODE_ENV'); + + this.transporter = nodemailer.createTransport({ + host: this.cfg.get('MAIL_HOST'), + port: +port, + secure: port === 465, + auth: { + user: this.cfg.get('MAIL_USER'), + pass: this.cfg.get('MAIL_PASSWORD'), + }, + pool: true, + connectionTimeout: 10000, + tls: { + rejectUnauthorized: mode === 'production', + servername: 'smtp.gmail.com', + }, + }); + } + + private sendMail(to: string, subject: string, templateName: string, context: any) { + const templatePath = path.join(process.cwd(), 'templates', `${templateName}.hbs`); + const templateSource = fs.readFileSync(templatePath, 'utf8'); + + const contextWithYear = { + ...context, + year: new Date().getFullYear(), + }; + + const template = hbs.compile(templateSource); + const html = template(contextWithYear); + + return this.transporter.sendMail({ + from: `"${this.cfg.get('MAIL_FROM_NAME')}" <${this.cfg.get('MAIL_FROM_EMAIL')}>`, + to, + subject, + html, + }); + } + + async sendRegistrationCode(email: string, name: string, code: string) { + const codeArray = code.toString().split(''); + + return this.sendMail(email, 'Код подтверждения регистрации', 'confirmation', { + name, + codeArray, + }); + } + + async sendResetPasswordCode(email: string, code: string) { + const codeArray = code.toString().split(''); + + return this.sendMail(email, 'Восстановление пароля', 'reset-password', { + codeArray, + }); + } + + async sendTeamInvitation(email: string, teamName: string, inviteUrl: string) { + return this.sendMail(email, `Приглашение в команду ${teamName}`, 'team-invitation', { + teamName, + inviteUrl, + }); + } +} diff --git a/src/shared/adapters/mail/index.ts b/src/shared/adapters/mail/index.ts new file mode 100644 index 00000000..e1326524 --- /dev/null +++ b/src/shared/adapters/mail/index.ts @@ -0,0 +1,3 @@ +export { MailAdapter } from './adapter'; +export { IMailPort } from './port'; +export { MailModule } from './module'; diff --git a/src/shared/adapters/mail/module.ts b/src/shared/adapters/mail/module.ts new file mode 100644 index 00000000..1271ed39 --- /dev/null +++ b/src/shared/adapters/mail/module.ts @@ -0,0 +1,15 @@ +import { Global, Module } from '@nestjs/common'; + +import { MailAdapter } from './adapter'; + +@Global() +@Module({ + providers: [ + { + provide: 'IMailPort', + useClass: MailAdapter, + }, + ], + exports: ['IMailPort'], +}) +export class MailModule {} diff --git a/src/shared/adapters/mail/port.ts b/src/shared/adapters/mail/port.ts new file mode 100644 index 00000000..0ae1a574 --- /dev/null +++ b/src/shared/adapters/mail/port.ts @@ -0,0 +1,5 @@ +export interface IMailPort { + sendRegistrationCode(email: string, name: string, code: string): Promise; + sendResetPasswordCode(email: string, code: string): Promise; + sendTeamInvitation(email: string, teamName: string, inviteUrl: string): Promise; +} diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts new file mode 100644 index 00000000..27c88449 --- /dev/null +++ b/src/shared/constants/index.ts @@ -0,0 +1 @@ +export * from './roles.constant'; diff --git a/src/shared/constants/roles.constant.ts b/src/shared/constants/roles.constant.ts new file mode 100644 index 00000000..b50e0b4f --- /dev/null +++ b/src/shared/constants/roles.constant.ts @@ -0,0 +1,21 @@ +export const TEAM_ROLES = ['owner', 'admin', 'moderator', 'lead', 'member', 'viewer'] as const; +export type TeamRole = (typeof TEAM_ROLES)[number]; + +export const ROLE_PRIORITY: Record = { + owner: 4, + admin: 3, + lead: 2, + moderator: 2, + member: 1, + viewer: 0, +}; + +export const isTeamRole = (role: string): role is TeamRole => TEAM_ROLES.includes(role as TeamRole); + +export const PROJECT_ROLE_PRIORITY: Record = { + owner: 4, + admin: 3, + editor: 2, + member: 1, + viewer: 0, +}; diff --git a/src/shared/decorators/api-controller.decorator.ts b/src/shared/decorators/api-controller.decorator.ts new file mode 100644 index 00000000..4899cfcc --- /dev/null +++ b/src/shared/decorators/api-controller.decorator.ts @@ -0,0 +1,20 @@ +import { Controller, UseGuards, applyDecorators } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiErrorResponse } from '@shared/error'; + +import { BearerAuthGuard } from '../guards'; + +export const ApiBaseController = (path: string, tag: string, hasJWTGuard?: boolean) => { + const decorators = [ + ApiTags(tag), + Controller(path), + hasJWTGuard ? UseGuards(BearerAuthGuard) : null, + ApiErrorResponse( + 500, + 'INTERNAL_SERVER_ERROR', + 'Произошла критическая ошибка на стороне сервера', + ), + ].filter((decorator): decorator is Exclude => decorator !== null); + + return applyDecorators(...decorators); +}; diff --git a/src/shared/decorators/index.ts b/src/shared/decorators/index.ts new file mode 100644 index 00000000..28d4e731 --- /dev/null +++ b/src/shared/decorators/index.ts @@ -0,0 +1,5 @@ +export * from './api-controller.decorator'; +export * from './public.decorator'; +export * from './user.decorator'; +export * from './skip-zod-validation.decorator'; +export * from './query-list.decorator'; diff --git a/src/shared/decorators/public.decorator.ts b/src/shared/decorators/public.decorator.ts new file mode 100644 index 00000000..b3845e12 --- /dev/null +++ b/src/shared/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/shared/decorators/query-list.decorator.ts b/src/shared/decorators/query-list.decorator.ts new file mode 100644 index 00000000..4bec7b56 --- /dev/null +++ b/src/shared/decorators/query-list.decorator.ts @@ -0,0 +1,109 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiQuery } from '@nestjs/swagger'; + +export interface SortableFields { + fields: string[]; + defaultField?: string; + defaultOrder?: 'asc' | 'desc'; +} + +export interface ListQueryOptions { + sortableFields: string[]; + defaultSortField?: string; + defaultSortOrder?: 'asc' | 'desc'; + withSearch?: boolean; + withDateRange?: boolean; +} + +export const ApiPagination = () => + applyDecorators( + ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Номер страницы (начиная с 1)', + example: 1, + }), + ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Количество записей на странице (макс. 100)', + example: 20, + }), + ApiQuery({ + name: 'offset', + required: false, + type: Number, + description: 'Смещение для пагинации (альтернатива page)', + example: 0, + }), + ); + +export const ApiSorting = (options: SortableFields) => + applyDecorators( + ApiQuery({ + name: 'sortBy', + required: false, + enum: options.fields, + description: `Поле для сортировки. Доступные поля: ${options.fields.join(', ')}`, + example: options.defaultField || options.fields[0], + }), + ApiQuery({ + name: 'sortOrder', + required: false, + enum: ['asc', 'desc'], + description: 'Направление сортировки', + example: options.defaultOrder || 'asc', + }), + ); + +export const ApiDateRangeFilter = () => + applyDecorators( + ApiQuery({ + name: 'fromDate', + required: false, + type: String, + description: 'Начальная дата (ISO 8601)', + example: '2024-01-01T00:00:00Z', + }), + ApiQuery({ + name: 'toDate', + required: false, + type: String, + description: 'Конечная дата (ISO 8601)', + example: '2024-12-31T23:59:59Z', + }), + ); + +export const ApiSearchFilter = () => + applyDecorators( + ApiQuery({ + name: 'search', + required: false, + type: String, + description: 'Поиск по тексту', + example: 'keyword', + }), + ); + +export const ApiListQuery = (options: ListQueryOptions) => { + const decorators = [ + ApiPagination(), + ApiSorting({ + fields: options.sortableFields, + defaultField: options.defaultSortField, + defaultOrder: options.defaultSortOrder, + }), + ]; + + if (options.withSearch) { + decorators.push(ApiSearchFilter()); + } + + if (options.withDateRange) { + decorators.push(ApiDateRangeFilter()); + } + + return applyDecorators(...decorators); +}; diff --git a/src/shared/decorators/skip-zod-validation.decorator.ts b/src/shared/decorators/skip-zod-validation.decorator.ts new file mode 100644 index 00000000..189fc73e --- /dev/null +++ b/src/shared/decorators/skip-zod-validation.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const SKIP_CONTRACT = 'SKIP_CONTRACT'; +export const SkipContract = () => SetMetadata(SKIP_CONTRACT, true); diff --git a/src/shared/decorators/user.decorator.ts b/src/shared/decorators/user.decorator.ts new file mode 100644 index 00000000..61cc71d5 --- /dev/null +++ b/src/shared/decorators/user.decorator.ts @@ -0,0 +1,23 @@ +import { createParamDecorator, type ExecutionContext } from '@nestjs/common'; + +import type { JwtPayload } from '@shared/types'; +import type { FastifyRequest } from 'fastify'; + +export const GetUser = createParamDecorator( + (data: keyof JwtPayload | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + if (!user) { + return null; + } + return data ? user[data] : user; + }, +); + +export const GetUserId = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): string | undefined => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + return user?.sub; + }, +); diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts new file mode 100644 index 00000000..b357898a --- /dev/null +++ b/src/shared/entities/index.ts @@ -0,0 +1,6 @@ +export { baseSchema } from './schema'; +export * from '../../user/infrastructure/persistence/models'; +export * from '../../auth/infrastructure/persistence/models'; +export * from '../../teams/infrastructure/persistence/models'; +export * from '../../project/infrastructure/persistence/models'; +export * from '../../area/infrastructure/persistence/models'; diff --git a/src/shared/entities/schema.ts b/src/shared/entities/schema.ts new file mode 100644 index 00000000..8a9bcc94 --- /dev/null +++ b/src/shared/entities/schema.ts @@ -0,0 +1,3 @@ +import { pgSchema, type PgSchema } from 'drizzle-orm/pg-core'; + +export const baseSchema: PgSchema = pgSchema('base'); diff --git a/src/shared/error/exception.ts b/src/shared/error/exception.ts new file mode 100644 index 00000000..c484f617 --- /dev/null +++ b/src/shared/error/exception.ts @@ -0,0 +1,27 @@ +import { HttpException, type HttpStatus } from '@nestjs/common'; + +interface IDetailsOptions { + readonly target?: string; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + readonly [key: string]: any; +} + +export interface IErrorOptions { + code: string; + message: string; + details?: IDetailsOptions[]; +} + +export class BaseException extends HttpException { + constructor(options: IErrorOptions, status: HttpStatus) { + super(options, status); + } +} + +export function isBaseException(error: unknown): error is BaseException { + return error instanceof BaseException; +} + +export function isBaseExceptionWithCode(error: unknown, code: string): error is BaseException { + return isBaseException(error) && (error.getResponse() as IErrorOptions).code === code; +} diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts new file mode 100644 index 00000000..0450fea5 --- /dev/null +++ b/src/shared/error/filter.ts @@ -0,0 +1,263 @@ +import { + type ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { DrizzleQueryError } from 'drizzle-orm'; +import { ZodValidationException } from 'nestjs-zod'; +import { PostgresError } from 'postgres'; + +import { BaseException, type IErrorOptions } from './exception'; +import { DATABASE_ERRORS } from './swagger'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { ZodError, ZodIssue } from 'zod/v4'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(GlobalExceptionFilter.name); + private readonly isDev = process.env['NODE_ENV'] === 'development'; + + catch(exception: unknown, host: ArgumentsHost) { + if (exception instanceof ZodValidationException) { + return this.parseZodValidation(exception, host); + } + + if (exception instanceof BaseException) { + return this.parseHttp(exception, host); + } + + if (exception instanceof HttpException) { + return this.parseNestHttp(exception, host); + } + + if (exception instanceof DrizzleQueryError) { + return this.parseDatabase(exception, host); + } + + return this.handleUnknownError(exception, host); + } + + private parseZodValidation = (exception: ZodValidationException, host: ArgumentsHost) => { + const { request, response } = this.getCtxBase(host); + const status = exception.getStatus(); + + const zodError = exception.getZodError() as ZodError; + const issues: ZodIssue[] = zodError.issues || []; + + this.log(exception, host, status, { + validationIssues: issues, + body: request.body, + }); + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code: 'VALIDATION_FAILED', + message: 'Переданные данные не прошли валидацию', + details: issues, + stack: exception.stack, + }), + ); + }; + + private parseDatabase = (exception: DrizzleQueryError, host: ArgumentsHost) => { + const { request, response } = this.getCtxBase(host); + + const error = + exception.cause instanceof PostgresError + ? exception.cause + : exception instanceof PostgresError + ? exception + : null; + + let status = 500; + let message = exception.message || 'Database operation failed'; + const errorCode = 'DATABASE_ERROR'; + + if (error) { + const mapping = DATABASE_ERRORS[error.code]; + if (mapping) { + status = mapping.code; + message = mapping.msg; + } + } + + this.log(exception, host, status, { + dbCode: error?.code, + dbTable: error?.table_name, + dbDetail: error?.detail, + query: this.isDev ? exception.query : undefined, + }); + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code: errorCode, + message, + details: error?.detail ? [{ target: error.detail }] : [], + stack: exception.stack, + service: 'postgres', + }), + ); + }; + + private parseHttp = (exception: BaseException, host: ArgumentsHost) => { + const { request, response } = this.getCtxBase(host); + const status = exception.getStatus(); + + const error = exception.getResponse() as IErrorOptions; + + this.log(exception, host, status, { + errorCode: error.code, + details: error.details, + type: 'BUSINESS_EXCEPTION', + }); + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code: error.code, + message: error.message || exception.message, + details: error.details || [], + stack: exception.stack, + }), + ); + }; + + private parseNestHttp = (exception: HttpException, host: ArgumentsHost) => { + const { request, response } = this.getCtxBase(host); + const status = exception.getStatus(); + const res = exception.getResponse(); + + const message = + typeof res === 'object' && res !== null && 'message' in res + ? String(res.message) + : exception.message; + + const errorCode = + typeof res === 'object' && res !== null && 'error' in res && res.error + ? String(res.error) + : null; + const code = errorCode ? errorCode.toUpperCase().replace(/\s+/g, '_') : 'HTTP_EXCEPTION'; + + this.log(exception, host, status, { + httpCode: code, + nestResponse: res, + type: 'NEST_HTTP_EXCEPTION', + }); + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code, + message, + stack: exception.stack, + details: [], + }), + ); + }; + + private handleUnknownError(exception: unknown, host: ArgumentsHost) { + const { request, response } = this.getCtxBase(host); + const status = HttpStatus.INTERNAL_SERVER_ERROR; + + this.log(exception, host, status, { type: 'UNKNOWN_SERVER_ERROR' }); + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code: 'INTERNAL_SERVER_ERROR', + message: 'Произошла непредвиденная ошибка на сервере', + details: [], + }), + ); + } + + private formatErrorResponse( + request: FastifyRequest, + status: number, + data: { + code: string; + message: string; + details: Record[] | undefined | null; + stack?: string; + service?: string; + }, + ) { + const requestId = request.id ?? request.headers['x-request-id']; + + return { + success: false, + error: { + code: data.code, + message: data.message, + retryable: status >= 500, + }, + details: data.details, + meta: { + service: data.service ?? 'gateway', + request: { + requestId, + path: request.url, + method: request.method, + ip: request.ip, + }, + timestamp: new Date().toISOString(), + ...(this.isDev && { + debug: { + stack: data.stack, + }, + }), + }, + }; + } + + private getCtxBase(host: ArgumentsHost) { + const ctx = host.switchToHttp(); + return { + response: ctx.getResponse(), + request: ctx.getRequest(), + }; + } + + private log( + exception: unknown, + host: ArgumentsHost, + status: number, + extraData: Record = {}, + ) { + const { request } = this.getCtxBase(host); + + const hasMessage = (err: unknown): err is { message: string } => + typeof err === 'object' && + err !== null && + 'message' in err && + typeof (err as { message: unknown }).message === 'string'; + + const stack = exception instanceof Error ? exception.stack : undefined; + const errMessage = hasMessage(exception) ? exception.message : 'Unknown Error'; + + const logData = { + request_id: request.id || request.headers['x-request-id'] || 'unknown', + triggered_by: 'filter_exception', + type: 'error', + method: request.method ?? 'Unknown', + url: request.url, + path: request.url.split('?')[0], + status_code: status, + ip: request.ip, + user_agent: request.headers['user-agent'] || 'unknown', + controller: 'Unknown', + handler: 'Unknown', + stack, + error_details: extraData, + }; + + const message = `Exception Filter: ${logData.method} ${logData.path} | ${status} | ${errMessage}`; + + if (status >= 500) { + this.logger.error(message, logData); + } else { + this.logger.warn(message, logData); + } + } +} diff --git a/src/shared/error/index.ts b/src/shared/error/index.ts new file mode 100644 index 00000000..9ddc9224 --- /dev/null +++ b/src/shared/error/index.ts @@ -0,0 +1,3 @@ +export * from './swagger'; +export * from './filter'; +export * from './exception'; diff --git a/src/shared/error/schema.ts b/src/shared/error/schema.ts new file mode 100644 index 00000000..e9ace4bf --- /dev/null +++ b/src/shared/error/schema.ts @@ -0,0 +1,42 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +const ErrorDetailSchema = z.object({ + field: z.string().describe('Путь к полю (например, "user.email")'), + message: z.string().describe('Сообщение об ошибке'), + code: z.string().describe('Машиночитаемый код (например, "too_short")'), +}); + +const ErrorMetaSchema = z.object({ + service: z.string().default('gateway').describe('Имя микросервиса'), + request: z.object({ + requestId: z.string().describe('Trace ID для логов'), + path: z.string().describe('URL эндпоинта'), + method: z.string().describe('HTTP метод'), + ip: z.string().optional().describe('IP клиента'), + }), + timestamp: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Время ошибки ISO 8601'), + debug: z + .object({ + stack: z.string().optional().describe('Стек вызовов (только в Dev)'), + }) + .optional(), +}); + +export const GlobalErrorSchema = z.object({ + success: z.literal(false).default(false), + error: z.object({ + code: z.string().describe('Бизнес-код ошибки'), + message: z.string().describe('Описание для пользователя'), + retryable: z.boolean().describe('Флаг возможности повтора'), + }), + details: z.array(ErrorDetailSchema).optional(), + meta: ErrorMetaSchema, +}); + +export class GlobalErrorResponse extends createZodDto(GlobalErrorSchema) {} diff --git a/src/shared/error/swagger.ts b/src/shared/error/swagger.ts new file mode 100644 index 00000000..71be8462 --- /dev/null +++ b/src/shared/error/swagger.ts @@ -0,0 +1,72 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiResponse, getSchemaPath } from '@nestjs/swagger'; + +import { GlobalErrorResponse } from './schema'; + +type TDetails = { field: string; message: string; code: string }[]; + +export const ApiErrorResponse = ( + status: number, + bizCode: string, + description: string, + details: TDetails = [], +) => + ApiResponse({ + status, + description, + schema: { + allOf: [{ $ref: getSchemaPath(GlobalErrorResponse.Output) }], + example: { + success: false, + error: { + code: bizCode, + message: description, + retryable: status >= 500, + }, + details: details || [], + meta: { + request: { + requestId: 'req-clj1abc230000jk78', + path: '/api/v1/...', + method: 'POST', + ip: '127.0.0.1', + }, + timestamp: new Date().toISOString(), + service: 'main-backend', + }, + }, + }, + }); + +export const ApiBadRequest = (description: string = 'Некорректный запрос') => + applyDecorators(ApiErrorResponse(400, 'BAD_REQUEST', description)); + +export const ApiUnauthorized = (description: string = 'Сессия истекла или токен не валиден') => + applyDecorators(ApiErrorResponse(401, 'AUTH_REQUIRED', description)); + +export const ApiForbidden = (description: string = 'У вас недостаточно прав для этого действия') => + applyDecorators(ApiErrorResponse(403, 'ACCESS_DENIED', description)); + +export const ApiNotFound = (description: string = 'Ресурс не найден') => + applyDecorators(ApiErrorResponse(404, 'NOT_FOUND', description)); + +export const ApiValidationError = ( + description: string = 'Ошибка валидации входных данных', + fields: TDetails = [], +) => applyDecorators(ApiErrorResponse(400, 'VALIDATION_FAILED', description, fields)); + +export const ApiConflict = (description: string = 'Ресурс уже существует') => + applyDecorators(ApiErrorResponse(409, 'CONFLICT', description)); + +export const ApiTooManyRequests = (description: string = 'Слишком много попыток') => + applyDecorators(ApiErrorResponse(429, 'TOO_MANY_REQUESTS', description)); + +export const DATABASE_ERRORS: Record = { + '23505': { code: 409, msg: 'Запись с таким значением уже существует (дубликат).' }, + '23503': { code: 409, msg: 'Ошибка внешнего ключа: связанная запись не найдена.' }, + '22P02': { code: 400, msg: 'Неверный формат данных (например, некорректный UUID).' }, + '23514': { code: 400, msg: 'Нарушено ограничение проверки (check constraint).' }, + '23502': { code: 400, msg: 'Отсутствует обязательное поле.' }, + '08006': { code: 500, msg: 'Ошибка соединения с базой данных.' }, + '40001': { code: 500, msg: 'Конфликт транзакции. Пожалуйста, повторите попытку.' }, +}; diff --git a/src/shared/guards/bearer.guard.ts b/src/shared/guards/bearer.guard.ts new file mode 100644 index 00000000..1542ce8f --- /dev/null +++ b/src/shared/guards/bearer.guard.ts @@ -0,0 +1,73 @@ +import { type ExecutionContext, HttpStatus, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { IS_PUBLIC_KEY } from '@shared/decorators'; +import { BaseException } from '@shared/error'; + +import type { JwtPayload } from '@shared/types'; +import type { FastifyRequest } from 'fastify'; +import type { Observable } from 'rxjs'; + +@Injectable() +export class BearerAuthGuard extends AuthGuard('bearer') { + constructor(private readonly reflector: Reflector) { + super(); + } + + override canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + try { + return super.canActivate(context); + } catch (e) { + if (this.isPublicOrHasToken(context)) { + return true; + } + + throw e; + } + } + + override handleRequest( + err: unknown, + user: TUser, + info: unknown, + context: ExecutionContext, + ): TUser { + if (user) { + return user; + } + + if (this.isPublicOrHasToken(context)) { + return null as TUser; + } + + throw new BaseException( + { + code: 'AUTH_FAILED', + message: 'Доступ запрещен: требуется валидный токен авторизации', + details: this.getAuthDetails(err, info), + }, + HttpStatus.UNAUTHORIZED, + ); + } + + private isPublicOrHasToken(context: ExecutionContext): boolean { + const { query } = context + .switchToHttp() + .getRequest>(); + + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + return !!(isPublic || query.token); + } + + private getAuthDetails(err: unknown, info: any) { + const message = info?.message || (err instanceof Error ? err.message : null); + + return message ? [{ target: 'auth', reason: message }] : []; + } +} diff --git a/src/shared/guards/cookie.guard.ts b/src/shared/guards/cookie.guard.ts new file mode 100644 index 00000000..50d98e4a --- /dev/null +++ b/src/shared/guards/cookie.guard.ts @@ -0,0 +1,28 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { BaseException } from '@shared/error'; + +import type { JwtPayload } from '@shared/types'; + +@Injectable() +export class CookieAuthGuard extends AuthGuard('cookie') { + override handleRequest(err: unknown, user: TUser, info: any): TUser { + if (err || !user) { + throw new BaseException( + { + code: 'INVALID_REFRESH_TOKEN', + message: 'Refresh токен невалиден или отсутствует', + details: [ + { + target: 'auth', + reason: info?.message || 'Token verification failed', + }, + ], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + return user; + } +} diff --git a/src/shared/guards/index.ts b/src/shared/guards/index.ts new file mode 100644 index 00000000..a2888599 --- /dev/null +++ b/src/shared/guards/index.ts @@ -0,0 +1,3 @@ +export { BearerAuthGuard } from './bearer.guard'; +export { CookieAuthGuard } from './cookie.guard'; +export { OAuthGuard } from './oauth.guard'; diff --git a/src/shared/guards/oauth.guard.ts b/src/shared/guards/oauth.guard.ts new file mode 100644 index 00000000..bfef6ac7 --- /dev/null +++ b/src/shared/guards/oauth.guard.ts @@ -0,0 +1,66 @@ +import { HttpStatus, Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class OAuthGuard implements CanActivate { + private readonly guardClasses: Record<'google' | 'github' | 'yandex' | 'vkontakte', any> = { + google: AuthGuard('google-oauth'), + github: AuthGuard('github-oauth'), + yandex: AuthGuard('yandex-oauth'), + vkontakte: AuthGuard('vkontakte-oauth'), + }; + + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const provider = request.params.provider; + const query = request.query.state; + + if (!this.isSupportedProvider(provider)) { + throw new BaseException( + { + code: 'INVALID_OAUTH_PROVIDER', + message: `OAuth провайдер "${provider}" не поддерживается`, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + const GuardClass = this.guardClasses[provider]; + + const passportOptions: Record = { + session: false, + ...(query && { state: query }), + ...(provider === 'google' && { + accessType: 'offline', + prompt: 'consent', + }), + }; + + const targetGuard = new GuardClass(passportOptions); + + try { + const result = await targetGuard.canActivate(context); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + throw new BaseException( + { + code: 'OAUTH_AUTHENTICATION_FAILED', + message: `Ошибка авторизации через ${provider}: ${message || 'Неизвестная ошибка'}`, + }, + HttpStatus.UNAUTHORIZED, + ); + } + } + + private isSupportedProvider(provider: string): provider is keyof OAuthGuard['guardClasses'] { + return ( + provider === 'google' || + provider === 'github' || + provider === 'yandex' || + provider === 'vkontakte' + ); + } +} diff --git a/src/shared/interceptors/http-metrics.interceptor.ts b/src/shared/interceptors/http-metrics.interceptor.ts new file mode 100644 index 00000000..ea1af637 --- /dev/null +++ b/src/shared/interceptors/http-metrics.interceptor.ts @@ -0,0 +1,59 @@ +import { + type CallHandler, + type ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { InjectMetric } from '@willsoto/nestjs-prometheus'; +import { Histogram } from 'prom-client'; +import { throwError } from 'rxjs'; +import { tap, catchError } from 'rxjs/operators'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; + +@Injectable() +export class HttpMetricsInterceptor implements NestInterceptor { + constructor( + @InjectMetric('http_request_duration_seconds') + private readonly histogram: Histogram, + ) {} + + intercept(context: ExecutionContext, next: CallHandler) { + const ctx = context.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + const end = this.histogram.startTimer(); + + return next.handle().pipe( + tap(() => { + this.recordMetrics(request, response, end); + }), + catchError((err) => { + this.recordMetrics(request, response, end, err); + return throwError(() => err); + }), + ); + } + + private recordMetrics( + req: FastifyRequest, + res: FastifyReply, + end: (labels?: any) => number, + err?: any, + ) { + const route = req.routeOptions?.url || req.url; + + if (route === '/metrics') { + return; + } + + const statusCode = err ? err.status || err.statusCode || 500 : res.statusCode; + + end({ + method: req.method, + route, + status: statusCode.toString(), + }); + } +} diff --git a/src/shared/interceptors/index.ts b/src/shared/interceptors/index.ts new file mode 100644 index 00000000..e73d0be0 --- /dev/null +++ b/src/shared/interceptors/index.ts @@ -0,0 +1,2 @@ +export * from './zod-validation.interceptor'; +export * from './http-metrics.interceptor'; diff --git a/src/shared/interceptors/zod-validation.interceptor.ts b/src/shared/interceptors/zod-validation.interceptor.ts new file mode 100644 index 00000000..f6f20d3b --- /dev/null +++ b/src/shared/interceptors/zod-validation.interceptor.ts @@ -0,0 +1,64 @@ +import { + CallHandler, + ExecutionContext, + HttpStatus, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { SKIP_CONTRACT } from '@shared/decorators'; +import { BaseException } from '@shared/error'; +import { map, Observable } from 'rxjs'; +import { z } from 'zod/v4'; + +export const ZOD_RESPONSE_TOKEN = 'ZOD_RESPONSE_TOKEN'; + +@Injectable() +export class ZodValidationInterceptor implements NestInterceptor { + constructor(private readonly reflector: Reflector) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const handler = context.getHandler(); + const metadata = this.reflector.get<{ schema: z.ZodTypeAny } | undefined>( + ZOD_RESPONSE_TOKEN, + handler, + ); + + const skipValidation = this.reflector.get(SKIP_CONTRACT, handler); + + if (skipValidation) { + return next.handle(); + } + + const schema = metadata ? metadata.schema : undefined; + + return next.handle().pipe( + map((data) => { + if (!schema) { + throw new BaseException( + { + code: 'MISSING_VALIDATION_SCHEMA', + message: 'Данные ответа не соответствуют ожидаемому формату', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const res = schema.safeParse(data); + + if (!res.success) { + throw new BaseException( + { + code: 'RESPONSE_VALIDATION_FAILED', + message: 'Данные ответа не соответствуют ожидаемому формату', + details: res.error.issues, + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return res.data; + }), + ); + } +} diff --git a/src/shared/schemas/avatar-response.schema.ts b/src/shared/schemas/avatar-response.schema.ts new file mode 100644 index 00000000..09d71bb5 --- /dev/null +++ b/src/shared/schemas/avatar-response.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod/v4'; + +export const AvatarResponseSchema = z + .object({ + small: z.string().url().describe('width: 64'), + medium: z.string().url().describe('width: 256'), + large: z.string().url().describe('width: 512'), + original: z.string().url().describe('width: original'), + }) + .nullable() + .describe('Объект с размерами (sm, md, lg, original) или null, если изображение отсутствует'); diff --git a/src/shared/schemas/datetime.schema.ts b/src/shared/schemas/datetime.schema.ts new file mode 100644 index 00000000..f3c84e58 --- /dev/null +++ b/src/shared/schemas/datetime.schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod/v4'; + +export const DateRangeFilterSchema = z + .object({ + fromDate: z + .string() + .datetime({ offset: true }) + .optional() + .describe('Начальная дата (ISO 8601)'), + + toDate: z + .string() + .datetime({ offset: true }) + .optional() + .describe('Конечная дата (ISO 8601)'), + }) + .refine( + (data) => { + if (data.fromDate && data.toDate) { + return new Date(data.fromDate) <= new Date(data.toDate); + } + return true; + }, + { + message: 'Дата начала не может быть позже даты окончания', + path: ['fromDate'], + }, + ); diff --git a/src/shared/schemas/index.ts b/src/shared/schemas/index.ts new file mode 100644 index 00000000..054c8246 --- /dev/null +++ b/src/shared/schemas/index.ts @@ -0,0 +1,6 @@ +export * from './pagination.schema'; +export * from './avatar-response.schema'; +export * from './datetime.schema'; +export * from './search.schema'; +export * from './sorting.schema'; +export * from './response.schema'; diff --git a/src/shared/schemas/pagination.schema.ts b/src/shared/schemas/pagination.schema.ts new file mode 100644 index 00000000..53006cbc --- /dev/null +++ b/src/shared/schemas/pagination.schema.ts @@ -0,0 +1,68 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const PaginationBaseSchema = z.object({ + page: z.coerce + .number() + .int() + .positive('Страница должна быть положительным числом') + .optional() + .default(1) + .describe('Номер страницы (начиная с 1)'), + + offset: z.coerce + .number() + .int() + .min(0, 'Смещение не может быть отрицательным') + .optional() + .default(0) + .describe('Смещение для пагинации (альтернатива page)'), + + limit: z.coerce + .number() + .int() + .min(1, 'Лимит должен быть не менее 1') + .max(100, 'Лимит не может превышать 100') + .optional() + .default(20) + .describe('Количество записей на странице'), +}); + +export const PaginationSchema = PaginationBaseSchema.transform((data) => { + if (data.page > 1 && data.offset === 0) { + return { + ...data, + offset: (data.page - 1) * (data.limit || 20), + }; + } + return data; +}); + +export const paginationResponseSchema = z.object({ + hasNextPage: z + .boolean() + .describe('Флаг наличия следующей страницы. True, если текущая страница не последняя.'), + hasPrevPage: z + .boolean() + .describe('Флаг наличия предыдущей страницы. True, если текущая страница больше первой.'), + total: z + .number() + .int() + .nonnegative() + .describe('Общее количество записей, соответствующих поисковому запросу/фильтрам.'), + totalPages: z + .number() + .int() + .nonnegative() + .describe('Общее количество страниц, рассчитанное на основе limit.'), + page: z.number().int().positive().describe('Номер текущей страницы (начиная с 1).'), + limit: z.number().int().positive().describe('Количество элементов на одну страницу.'), +}); + +export const createPaginationSchema = (itemSchema: T) => + z.object({ + items: z.array(itemSchema), + meta: paginationResponseSchema, + }); + +export class PaginationQuery extends createZodDto(PaginationSchema) {} diff --git a/src/shared/schemas/response.schema.ts b/src/shared/schemas/response.schema.ts new file mode 100644 index 00000000..08b0786f --- /dev/null +++ b/src/shared/schemas/response.schema.ts @@ -0,0 +1,9 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const ActionResponseSchema = z.object({ + success: z.boolean().describe('Статус операции'), + message: z.string().optional().describe('Сообщение для пользователя'), +}); + +export class ActionResponse extends createZodDto(ActionResponseSchema) {} diff --git a/src/shared/schemas/search.schema.ts b/src/shared/schemas/search.schema.ts new file mode 100644 index 00000000..8ca2f52c --- /dev/null +++ b/src/shared/schemas/search.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod/v4'; + +export const SearchFilterSchema = z.object({ + search: z + .string() + .trim() + .min(1, 'Поисковый запрос не может быть пустым') + .max(100, 'Поисковый запрос слишком длинный') + .optional() + .describe('Поиск по тексту'), +}); diff --git a/src/shared/schemas/sorting.schema.ts b/src/shared/schemas/sorting.schema.ts new file mode 100644 index 00000000..6737fe39 --- /dev/null +++ b/src/shared/schemas/sorting.schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod/v4'; + +export const createSortingSchema = ( + fields: T, + defaultField?: T[number], + defaultOrder: 'asc' | 'desc' = 'asc', +) => + z.object({ + sortBy: z + .enum(fields) + .optional() + /** + * Приведение as any обусловлено ограничением системы типов TypeScript: + * тип fields[0 выводится как string, а Zod ожидает конкретный литеральный тип + * из объединения T[number]. В рантайме значение гарантированно валидно, + * так как массив fields используется для создания enum. + * as any безопасно подавляет ошибку, не расширяя тип за пределы этой строки. + */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + .default(defaultField ?? (fields[0] as any)) + .describe(`Поле для сортировки. Доступно: ${fields.join(', ')}`), + + sortOrder: z + .enum(['asc', 'desc']) + .optional() + .default(() => defaultOrder) + .describe('Направление сортировки: asc - по возрастанию, desc - по убыванию'), + }); diff --git a/src/shared/types/fastify.d.ts b/src/shared/types/fastify.d.ts new file mode 100644 index 00000000..9c77358f --- /dev/null +++ b/src/shared/types/fastify.d.ts @@ -0,0 +1,7 @@ +import type { JwtPayload } from './jwt-payload'; + +declare module 'fastify' { + interface FastifyRequest { + user?: JwtPayload; + } +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 00000000..9a3c79a2 --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1 @@ +export type { JwtPayload } from './jwt-payload'; diff --git a/src/shared/types/jwt-payload.ts b/src/shared/types/jwt-payload.ts new file mode 100644 index 00000000..7cb5ddcb --- /dev/null +++ b/src/shared/types/jwt-payload.ts @@ -0,0 +1,8 @@ +export interface JwtPayload { + readonly sub: string; + readonly email: string; + readonly role: string; + readonly iss: string; + readonly aud: string; + readonly jti: string; +} diff --git a/src/shared/utils/format-bytes.util.ts b/src/shared/utils/format-bytes.util.ts new file mode 100644 index 00000000..34320a31 --- /dev/null +++ b/src/shared/utils/format-bytes.util.ts @@ -0,0 +1,9 @@ +export const formatBytes = (bytes: number): string => { + if (bytes === 0) { + return '0 Bytes'; + } + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +}; diff --git a/src/shared/utils/image-builder.util.ts b/src/shared/utils/image-builder.util.ts new file mode 100644 index 00000000..328b112a --- /dev/null +++ b/src/shared/utils/image-builder.util.ts @@ -0,0 +1,19 @@ +import { dirname } from 'node:path'; + +export class ImageHelper { + public static buildResponsiveUrls(cdn: string, path?: string | null) { + if (!path) { + return null; + } + + const folder = dirname(path); + const base = `${cdn}/${folder}`; + + return { + small: `${base}/sm.webp`, + medium: `${base}/md.webp`, + large: `${base}/lg.webp`, + original: `${cdn}/${path}`, + }; + } +} diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts new file mode 100644 index 00000000..24b54a6a --- /dev/null +++ b/src/shared/utils/index.ts @@ -0,0 +1,2 @@ +export { ImageHelper } from './image-builder.util'; +export * from './remove-undefined.util'; diff --git a/src/shared/utils/remove-undefined.util.ts b/src/shared/utils/remove-undefined.util.ts new file mode 100644 index 00000000..0c226856 --- /dev/null +++ b/src/shared/utils/remove-undefined.util.ts @@ -0,0 +1,5 @@ +export function removeUndefined>(obj: T): Partial { + return Object.fromEntries( + Object.entries(obj).filter(([_, value]) => value !== undefined), + ) as Partial; +} diff --git a/src/teams/application/controller/index.ts b/src/teams/application/controller/index.ts new file mode 100644 index 00000000..cb32def7 --- /dev/null +++ b/src/teams/application/controller/index.ts @@ -0,0 +1,4 @@ +export { MeController } from './me/controller'; +export { TeamsController } from './teams/controller'; +export { TeamsMembersController } from './members/controller'; +export { TeamsInvitationsController } from './invitations/controller'; diff --git a/src/teams/application/controller/invitations/controller.ts b/src/teams/application/controller/invitations/controller.ts new file mode 100644 index 00000000..5cedc7e7 --- /dev/null +++ b/src/teams/application/controller/invitations/controller.ts @@ -0,0 +1,74 @@ +import { Body, Get, Param, Delete, Patch, Post } from '@nestjs/common'; +import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; + +import { InviteMemberDto, UpdateInvitationDto } from '../../dtos'; +import { TeamsFacade } from '../../team.facade'; + +import { + AcceptInviteSwagger, + DeleteTeamInvitationSwagger, + GetTeamInvitationSwagger, + GetTeamInvitationsSwagger, + InviteMemberSwagger, + UpdateTeamInvitationSwagger, +} from './swagger'; + +import type { JwtPayload } from '@shared/types'; + +@ApiBaseController('teams/:teamId/invitations', 'Teams Invitations', true) +export class TeamsInvitationsController { + constructor(private readonly facade: TeamsFacade) {} + + @Get() + @GetTeamInvitationsSwagger() + async getAll(@Param('teamId') teamId: string, @GetUserId() userId: string) { + return this.facade.getInvitations(teamId, userId); + } + + @Get(':code') + @GetTeamInvitationSwagger() + async getOne( + @Param('teamId') teamId: string, + @Param('code') code: string, + @GetUser() user: JwtPayload, + ) { + return this.facade.getInvitation(teamId, code, user.sub, user.email); + } + + @Post() + @InviteMemberSwagger() + async invite( + @Param('teamId') teamId: string, + @GetUserId() inviterId: string, + @Body() dto: InviteMemberDto, + ) { + return this.facade.invite(teamId, inviterId, dto); + } + + @Post(':code/accept') + @AcceptInviteSwagger() + async accept(@Param('code') code: string, @GetUser() user: JwtPayload) { + return this.facade.acceptInvite(code, user.sub, user.email); + } + + @Patch(':code') + @UpdateTeamInvitationSwagger() + async update( + @Param('teamId') teamId: string, + @Param('code') code: string, + @GetUserId() userId: string, + @Body() dto: UpdateInvitationDto, + ) { + return this.facade.updateInvitation(teamId, code, userId, dto); + } + + @Delete(':code') + @DeleteTeamInvitationSwagger() + async decline( + @Param('teamId') teamId: string, + @Param('code') code: string, + @GetUser() user: JwtPayload, + ) { + return this.facade.declineInvitation(teamId, code, user.sub, user.email); + } +} diff --git a/src/teams/application/controller/invitations/swagger.ts b/src/teams/application/controller/invitations/swagger.ts new file mode 100644 index 00000000..746d83fe --- /dev/null +++ b/src/teams/application/controller/invitations/swagger.ts @@ -0,0 +1,173 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiBadRequest, + ApiConflict, + ApiForbidden, + ApiNotFound, + ApiUnauthorized, + ApiValidationError, +} from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { + InviteMemberDto, + TeamInvitationResponse, + UpdateInvitationDto, + TeamInvitationsResponse, + UserInvitesResponse, +} from '../../dtos'; + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: UserInvitesResponse.Output, + }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserInvitesResponse), + ); + +export const InviteMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Пригласить пользователя в команду по Email', + description: + 'Создает запись об участнике со статусом "pending".' + + ' Если пользователь уже зарегистрирован — он увидит приглашение в разделе "my/invites".' + + ' Если нет — ему уйдет письмо на указанный Email.', + }), + ApiBody({ type: InviteMemberDto.Output }), + ApiParam({ + name: 'teamId', + description: 'Уникальный идентификатор команды, в которую приглашаем', + }), + ApiResponse({ + status: 201, + description: 'Инвайт создан и отправлен', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат Email или роль не поддерживается'), + ApiUnauthorized(), + ApiForbidden(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const AcceptInviteSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Принять приглашение в команду', + description: + 'Активирует участие пользователя в команде по уникальному коду приглашения.' + + ' После успешного принятия статус участника меняется с "pending" на "active".' + + ' Система автоматически связывает текущего авторизованного пользователя с инвайтом через Email.', + }), + ApiParam({ + name: 'code', + description: 'Уникальный код/токен приглашения (из ссылки или письма)', + example: '7df1-4a2b-9e8c', + }), + ApiResponse({ + status: 200, + description: 'Приглашение успешно принято. Пользователь теперь участник команды.', + type: ActionResponse.Output, + }), + ApiBadRequest('Невалидный код, срок действия приглашения истек или оно уже использовано'), + ApiNotFound('Приглашение с таким кодом не найдено'), + ApiConflict('Пользователь уже является участником этой команды'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const GetTeamInvitationsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список всех приглашений в команду', + description: 'Возвращает все активные инвайты команды. Доступно только owner/admin.', + }), + ApiParam({ name: 'teamId', description: 'Уникальный идентификатор команды' }), + ApiResponse({ + status: 200, + description: 'Список приглашений команды', + type: TeamInvitationsResponse.Output, + }), + ApiNotFound('Команда не найдена'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, TeamInvitationsResponse), + ); + +export const GetTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить приглашение по коду', + description: + 'Возвращает данные инвайта по коду в рамках команды. Доступно только owner/admin.', + }), + ApiParam({ name: 'teamId', description: 'Уникальный идентификатор команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiResponse({ + status: 200, + description: 'Инвайт найден', + type: TeamInvitationResponse.Output, + }), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, TeamInvitationResponse), + ); + +export const UpdateTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить приглашение (только роль)', + description: + 'Позволяет изменить только поле role у существующего инвайта. TTL сохраняется.', + }), + ApiParam({ name: 'teamId', description: 'Уникальный идентификатор команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiBody({ type: UpdateInvitationDto.Output }), + ApiResponse({ + status: 200, + description: 'Инвайт обновлён', + type: ActionResponse.Output, + }), + ApiValidationError(), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const DeleteTeamInvitationSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Удалить приглашение', + description: + 'Удаляет инвайт и чистит индексы в Redis (team:invites и user:invites). Доступно только owner/admin.', + }), + ApiParam({ name: 'teamId', description: 'Уникальный идентификатор команды' }), + ApiParam({ name: 'code', description: 'Код инвайта' }), + ApiResponse({ + status: 200, + description: 'Инвайт удалён', + type: ActionResponse.Output, + }), + ApiNotFound('Инвайт или команда не найдены'), + ApiForbidden('Недостаточно прав (только owner/admin)'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/teams/application/controller/me/controller.ts b/src/teams/application/controller/me/controller.ts new file mode 100644 index 00000000..877808fd --- /dev/null +++ b/src/teams/application/controller/me/controller.ts @@ -0,0 +1,25 @@ +import { Get } from '@nestjs/common'; +import { ApiBaseController, GetUser, GetUserId } from '@shared/decorators'; + +import { TeamsFacade } from '../../team.facade'; + +import { FindInvitesSwagger, FindTeamsSwagger } from './swagger'; + +import type { JwtPayload } from '@shared/types'; + +@ApiBaseController('users/me', 'Account Teams', true) +export class MeController { + constructor(private readonly facade: TeamsFacade) {} + + @Get('teams') + @FindTeamsSwagger() + async findMyTeams(@GetUserId() userId: string) { + return this.facade.getMyTeams(userId); + } + + @Get('invites') + @FindInvitesSwagger() + async findMyInvites(@GetUser() user: JwtPayload) { + return this.facade.getMyInvites(user.email); + } +} diff --git a/src/teams/application/controller/me/swagger.ts b/src/teams/application/controller/me/swagger.ts new file mode 100644 index 00000000..fe316ad4 --- /dev/null +++ b/src/teams/application/controller/me/swagger.ts @@ -0,0 +1,40 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiUnauthorized } from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; + +import { UserTeamsResponse, UserInvitesResponse } from '../../dtos'; + +export const FindTeamsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список команд пользователя', + description: + 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', + }), + ApiResponse({ + status: 200, + description: 'Список команд получен', + type: UserTeamsResponse.Output, + }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserTeamsResponse), + ); + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: UserInvitesResponse.Output, + }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserInvitesResponse), + ); diff --git a/src/teams/application/controller/members/controller.ts b/src/teams/application/controller/members/controller.ts new file mode 100644 index 00000000..a558bd8a --- /dev/null +++ b/src/teams/application/controller/members/controller.ts @@ -0,0 +1,39 @@ +import { Body, Delete, Get, Param, Patch } from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; + +import { UpdateMemberDto } from '../../dtos'; +import { TeamsFacade } from '../../team.facade'; + +import { GetMembersSwagger, RemoveMemberSwagger, UpdateMemberSwagger } from './swagger'; + +@ApiBaseController('teams/:teamId', 'Teams Members', true) +export class TeamsMembersController { + constructor(private readonly facade: TeamsFacade) {} + + @Get('members') + @GetMembersSwagger() + async getMembers(@Param('teamId') teamId: string) { + return this.facade.getMembers(teamId); + } + + @Patch('members/:userId') + @UpdateMemberSwagger() + async updateMember( + @Param('teamId') teamId: string, + @Param('userId') targetUserId: string, + @GetUserId() currentUserId: string, + @Body() dto: UpdateMemberDto, + ) { + return this.facade.updateMember(teamId, currentUserId, targetUserId, dto); + } + + @Delete('members/:userId') + @RemoveMemberSwagger() + async removeMember( + @Param('teamId') teamId: string, + @Param('userId') targetUserId: string, + @GetUserId() currentUserId: string, + ) { + return this.facade.removeMember(teamId, currentUserId, targetUserId); + } +} diff --git a/src/teams/application/controller/members/swagger.ts b/src/teams/application/controller/members/swagger.ts new file mode 100644 index 00000000..0fda3d29 --- /dev/null +++ b/src/teams/application/controller/members/swagger.ts @@ -0,0 +1,101 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { + UpdateMemberDto, + TeamMembersResponse, + UserTeamsResponse, + UserInvitesResponse, +} from '../../dtos'; + +export const FindTeamsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список команд пользователя', + description: + 'Возвращает все команды, в которых текущий пользователь является участником или владельцем.', + }), + ApiResponse({ + status: 200, + description: 'Список команд получен', + type: UserTeamsResponse.Output, + }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserTeamsResponse), + ); + +export const FindInvitesSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить список входящих приглашений', + description: + 'Возвращает все активные приглашения в команды, отправленные на email текущего пользователя.', + }), + ApiResponse({ + status: 200, + description: 'Список приглашений успешно получен', + type: UserInvitesResponse.Output, + }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserInvitesResponse), + ); + +export const GetMembersSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить список всех участников команды' }), + ApiParam({ name: 'teamId', description: 'Уникальный идентификатор команды' }), + ApiResponse({ + status: 200, + description: 'Список участников получен', + type: TeamMembersResponse.Output, + }), + ApiUnauthorized(), + ApiForbidden(), + + SetMetadata(ZOD_RESPONSE_TOKEN, TeamMembersResponse), + ); + +export const UpdateMemberSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Изменить роль или статус участника', + description: + 'Позволяет изменить роль участника (member -> admin) или вручную изменить его статус.' + + ' Владелец команды (Owner) не может понизить свою роль через этот эндпоинт.', + }), + ApiBody({ type: UpdateMemberDto.Output }), + ApiParam({ name: 'teamId', description: 'Уникальный идентификатор команды' }), + ApiParam({ name: 'userId', description: 'ID пользователя, чьи права редактируются' }), + ApiResponse({ + status: 200, + description: 'Данные участника обновлены', + type: ActionResponse.Output, + }), + ApiNotFound('Участник или команда не найдены'), + ApiUnauthorized(), + ApiForbidden(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const RemoveMemberSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить участника из команды' }), + ApiParam({ name: 'teamId', description: 'Уникальный идентификатор команды' }), + ApiParam({ name: 'userId', description: 'ID пользователя' }), + ApiResponse({ + status: 200, + type: ActionResponse.Output, + description: 'Участник успешно удален', + }), + ApiNotFound(), + ApiUnauthorized(), + ApiForbidden(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/teams/application/controller/teams/controller.ts b/src/teams/application/controller/teams/controller.ts new file mode 100644 index 00000000..b3e872d4 --- /dev/null +++ b/src/teams/application/controller/teams/controller.ts @@ -0,0 +1,42 @@ +import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; + +import { CreateTeamDto, UpdateTeamDto } from '../../dtos'; +import { TeamsFacade } from '../../team.facade'; + +import { + CreateTeamSwagger, + FindOneTeamSwagger, + RemoveTeamSwagger, + UpdateTeamSwagger, +} from './swagger'; + +@ApiBaseController('teams', 'Teams', true) +export class TeamsController { + constructor(private readonly facade: TeamsFacade) {} + + @Post() + @CreateTeamSwagger() + async create(@GetUserId() userId: string, @Body() dto: CreateTeamDto) { + return this.facade.createTeam(userId, dto); + } + + @Get(':id') + @FindOneTeamSwagger() + async findOne(@Param('id') id: string) { + return this.facade.getTeamById(id); + } + + @Patch(':id') + @UpdateTeamSwagger() + async update(@Param('id') id: string, @GetUserId() userId: string, @Body() dto: UpdateTeamDto) { + return this.facade.updateTeam(id, userId, dto); + } + + @Delete(':id') + @RemoveTeamSwagger() + @HttpCode(HttpStatus.OK) + async remove(@Param('id') id: string, @GetUserId() userId: string) { + return this.facade.deleteTeam(id, userId); + } +} diff --git a/src/teams/application/controller/teams/swagger.ts b/src/teams/application/controller/teams/swagger.ts new file mode 100644 index 00000000..ea1dec4a --- /dev/null +++ b/src/teams/application/controller/teams/swagger.ts @@ -0,0 +1,75 @@ +import { CreateTeamResponse } from '@core/teams/application/dtos/team.dto'; +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { CreateTeamDto, UpdateTeamDto, TeamResponse } from '../../dtos'; + +export const CreateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Создать новую команду' }), + ApiBody({ type: CreateTeamDto.Output }), + ApiResponse({ + status: 201, + description: 'Команда успешно создана', + type: CreateTeamResponse.Output, + }), + ApiValidationError(), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, CreateTeamResponse), + ); + +export const FindOneTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Получить детальную информацию о команде' }), + ApiParam({ name: 'id', description: 'Уникальный идентификатор команды' }), + ApiResponse({ + status: 200, + description: 'Данные команды получены', + type: TeamResponse.Output, + }), + ApiNotFound('Команда не найдена'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, TeamResponse), + ); + +export const UpdateTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Обновить данные команды' }), + ApiBody({ type: UpdateTeamDto.Output }), + ApiParam({ + name: 'id', + description: 'Уникальный идентификатор команды для редактирования', + }), + ApiResponse({ + status: 200, + description: 'Команда успешно обновлена', + type: ActionResponse.Output, + }), + ApiForbidden(), + ApiNotFound(), + ApiValidationError(), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const RemoveTeamSwagger = () => + applyDecorators( + ApiOperation({ summary: 'Удалить команду' }), + ApiParam({ name: 'id', description: 'Уникальный идентификатор команды для удаления' }), + ApiResponse({ + status: 200, + description: 'Команда успешно удалена', + type: ActionResponse.Output, + }), + ApiForbidden(), + ApiNotFound(), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/teams/application/dtos/index.ts b/src/teams/application/dtos/index.ts new file mode 100644 index 00000000..cd1a98a3 --- /dev/null +++ b/src/teams/application/dtos/index.ts @@ -0,0 +1,3 @@ +export * from './member.dto'; +export * from './invitation.dto'; +export * from './team.dto'; diff --git a/src/teams/application/dtos/invitation.dto.ts b/src/teams/application/dtos/invitation.dto.ts new file mode 100644 index 00000000..23b93af7 --- /dev/null +++ b/src/teams/application/dtos/invitation.dto.ts @@ -0,0 +1,54 @@ +import { createPaginationSchema } from '@shared/schemas'; +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +import { roleEnum, type TeamRole } from '../../infrastructure/persistence/models/enums'; + +export const UpdateInvitationSchema = z.object({ + role: z + .enum(roleEnum.enumValues) + .describe('Новая роль, которая будет назначена пользователю после принятия инвайта'), +}); + +export class UpdateInvitationDto extends createZodDto(UpdateInvitationSchema) {} + +export const TeamInvitationSchema = z.object({ + code: z.string().describe('Код инвайта'), + teamId: z.string().describe('ID команды'), + teamName: z.string().describe('Название команды'), + teamAvatar: z.string().nullable().describe('Аватар команды'), + email: z.string().email().describe('Email приглашённого пользователя'), + role: z.string().describe('Роль, которая будет назначена после принятия инвайта'), + inviterId: z.string().describe('ID пользователя, отправившего приглашение'), + inviterName: z.string().describe('Имя пригласившего'), + createdAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата создания инвайта (ISO 8601)'), + expiresAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата истечения инвайта (ISO 8601)'), +}); + +export class TeamInvitationResponse extends createZodDto(TeamInvitationSchema) {} + +export class TeamInvitationsResponse extends createZodDto( + createPaginationSchema(TeamInvitationSchema), +) {} + +export interface TeamInvite { + readonly teamId: string; + readonly teamName: string; + readonly teamAvatar: string | null; + readonly email: string; + readonly role: TeamRole; + readonly inviterId: string; + readonly inviterName: string; + readonly createdAt: string; + readonly expiresAt: string; +} diff --git a/src/teams/application/dtos/member.dto.ts b/src/teams/application/dtos/member.dto.ts new file mode 100644 index 00000000..9c2e6a0d --- /dev/null +++ b/src/teams/application/dtos/member.dto.ts @@ -0,0 +1,76 @@ +import { roleEnum } from '@core/teams/infrastructure/persistence/models'; +import { AvatarResponseSchema, createPaginationSchema } from '@shared/schemas'; +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const InviteMemberSchema = z.object({ + email: z.string().email().describe('Email пользователя, которого нужно пригласить'), + role: z + .enum(roleEnum.enumValues) + .default('member') + .describe('Роль, которая будет назначена пользователю после принятия инвайта'), +}); + +export class InviteMemberDto extends createZodDto(InviteMemberSchema) {} + +const UpdateMemberDtoSchema = z + .object({ + role: z.enum(roleEnum.enumValues).optional().describe('Новая роль участника'), + status: z.string().optional().describe('Новый статус (active, blocked и т.д.)'), + }) + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }); + +export class UpdateMemberDto extends createZodDto(UpdateMemberDtoSchema) {} + +export const TeamMemberResponseSchema = z.object({ + id: z.string().describe('Уникальный ID пользователя (UUID или ULID)'), + role: z + .enum(['owner', 'admin', 'member']) + .describe('Роль участника в рамках конкретной команды'), + status: z + .enum(['active', 'pending', 'blocked']) + .describe('Текущий статус членства (активен, ожидает приглашения, заблокирован)'), + fullName: z.string().describe('Полное имя для отображения (Фамилия Имя Отчество)'), + firstName: z.string().describe('Имя пользователя'), + lastName: z.string().describe('Фамилия пользователя'), + avatar: AvatarResponseSchema, + + initials: z.string().max(2).describe('Две буквы для аватара-заглушки (например, "ИИ")'), + joinedAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата и время вступления в команду в формате ISO 8601'), +}); + +export class TeamMemberResponse extends createZodDto(TeamMemberResponseSchema) {} + +export class TeamMembersResponse extends createZodDto( + createPaginationSchema(TeamMemberResponseSchema), +) {} + +export const UserInviteSchema = z.object({ + code: z.string().describe('Код инвайта'), + teamName: z.string().describe('Название команды'), + teamAvatar: z + .string() + .url() + .nullable() + .describe('URL аватара команды (может быть null, если аватар не установлен)'), + role: z.string().describe('Роль'), + inviterName: z.string().describe('Имя пригласившего'), + expiresAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата истечения'), +}); + +export class UserInviteResponse extends createZodDto(UserInviteSchema) {} + +export class UserInvitesResponse extends createZodDto(createPaginationSchema(UserInviteSchema)) {} diff --git a/src/teams/application/dtos/team.dto.ts b/src/teams/application/dtos/team.dto.ts new file mode 100644 index 00000000..45c92086 --- /dev/null +++ b/src/teams/application/dtos/team.dto.ts @@ -0,0 +1,89 @@ +import { ActionResponseSchema, AvatarResponseSchema } from '@shared/schemas'; +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +export const CreateTeamSchema = z.object({ + name: z.string().min(2).max(100).describe('Название команды, отображаемое в интерфейсе'), + description: z + .string() + .min(10) + .max(500) + .describe('Краткое описание деятельности или целей команды'), +}); + +export class CreateTeamDto extends createZodDto(CreateTeamSchema) {} + +const CreateTeamResponseSchema = ActionResponseSchema.extend({ + teamId: z.string().describe('Уникальный идентификатор команды в системе'), +}); + +export class CreateTeamResponse extends createZodDto(CreateTeamResponseSchema) {} + +export class UpdateTeamDto extends createZodDto( + CreateTeamSchema.partial().refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }), +) {} + +export const TeamPermissionsSchema = z.object({ + canEdit: z.boolean().describe('Разрешено ли редактировать настройки и профиль команды'), + canDelete: z + .boolean() + .describe('Разрешено ли полностью удалить команду (только для владельца)'), + canManageMembers: z.boolean().describe('Разрешено ли менять роли и исключать участников'), + canInvite: z.boolean().describe('Разрешено ли приглашать новых участников'), + isOwner: z.boolean().describe('Является ли текущий пользователь владельцем (Owner)'), +}); + +export const UserTeamSchema = z.object({ + id: z.string().describe('Уникальный ID команды'), + name: z.string().describe('Название команды'), + description: z.string().nullable().describe('Краткое описание команды'), + avatar: AvatarResponseSchema, + role: z.string().describe('Системное название роли пользователя'), + joinedAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата, когда пользователь вступил в команду'), + permissions: TeamPermissionsSchema.describe('Объект прав доступа текущего пользователя'), +}); + +export const UserTeamsSchema = z.array(UserTeamSchema); +export class UserTeamsResponse extends createZodDto(UserTeamsSchema) {} + +export const TeamResponseSchema = z.object({ + id: z.string().describe('Уникальный ID команды'), + name: z.string().describe('Название команды'), + description: z.string().nullable().describe('Описание команды'), + avatarUrl: z + .string() + .url() + .nullable() + .describe('URL аватара команды или null, если аватар отсутствует'), + coverUrl: z.string().nullable().describe('URL обложки команды'), + ownerId: z.string().nullable().describe('ID владельца команды'), + createdAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата создания команды'), + updatedAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата обновления команды'), + deletedAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .nullable() + .describe('Дата удаления (если удалена)'), +}); + +export class TeamResponse extends createZodDto(TeamResponseSchema) {} diff --git a/src/teams/application/mappers/index.ts b/src/teams/application/mappers/index.ts new file mode 100644 index 00000000..f09718ac --- /dev/null +++ b/src/teams/application/mappers/index.ts @@ -0,0 +1 @@ +export { TeamMemberMapper } from './member.mapper'; diff --git a/src/teams/application/mappers/member.mapper.ts b/src/teams/application/mappers/member.mapper.ts new file mode 100644 index 00000000..97bcfc0e --- /dev/null +++ b/src/teams/application/mappers/member.mapper.ts @@ -0,0 +1,76 @@ +import { ImageHelper } from '@shared/utils'; + +import type { RawMemberRow, RawMemberTeams } from '../../domain/repository'; + +export class TeamMemberMapper { + public static toDetail(row: RawMemberRow, cdn: string) { + const { firstName, lastName, middleName, avatarUrl, userId, ...rest } = row; + + const fullName = + [lastName, firstName, middleName].filter(Boolean).join(' ') || 'Unknown User'; + + const avatar = ImageHelper.buildResponsiveUrls(cdn, avatarUrl); + + return { + id: userId, + ...rest, + firstName, + lastName, + middleName, + fullName, + avatar, + initials: this.getInitials(firstName, lastName), + }; + } + + public static toList(rows: readonly RawMemberRow[], cdn: string) { + return rows.map((row) => this.toDetail(row, cdn)); + } + + public static toUserTeam(data: RawMemberTeams, cdn: string) { + const { role, avatarUrl, ...row } = data; + + const avatar = ImageHelper.buildResponsiveUrls(cdn, avatarUrl); + + return { + id: row.id, + name: row.name, + description: row.description, + avatar, + role, + joinedAt: row.joinedAt, + permissions: { + canEdit: ['owner', 'admin'].includes(role), + canDelete: role === 'owner', + canManageMembers: ['owner', 'admin'].includes(role), + canInvite: ['owner', 'admin'].includes(role), + isOwner: role === 'owner', + }, + }; + } + + public static toPublicInvite(raw: string | null, code: string) { + if (!raw) { + return null; + } + try { + const p = JSON.parse(raw); + return { + code, + teamName: p.teamName, + teamAvatar: p.teamAvatar ?? null, + inviterName: p.inviterName, + role: p.role, + expiresAt: p.expiresAt, + }; + } catch { + return null; + } + } + + private static getInitials(fName: string | null, lName: string | null): string { + const first = fName?.[0] ?? ''; + const last = lName?.[0] ?? ''; + return (first + last).toUpperCase() || '?'; + } +} diff --git a/src/teams/application/team.facade.ts b/src/teams/application/team.facade.ts new file mode 100644 index 00000000..c979f27c --- /dev/null +++ b/src/teams/application/team.facade.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; + +import { + CreateTeamDto, + InviteMemberDto, + UpdateInvitationDto, + UpdateMemberDto, + UpdateTeamDto, +} from './dtos'; +import * as UC from './use-cases'; + +@Injectable() +export class TeamsFacade { + constructor( + private readonly findTeamQ: UC.FindTeamQuery, + private readonly getInvitationQ: UC.GetInvitationQuery, + private readonly getInvitationsQ: UC.GetInvitationsQuery, + private readonly getTeamMembersQ: UC.GetTeamMembersQuery, + + private readonly createTeamUc: UC.CreateTeamUseCase, + private readonly deleteTeamUc: UC.DeleteTeamUseCase, + private readonly updateTeamUc: UC.UpdateTeamUseCase, + + private readonly updateMemberUc: UC.UpdateTeamMemberUseCase, + private readonly removeMemberUc: UC.RemoveTeamMemberUseCase, + private readonly sendInviteUc: UC.SendInvitationUseCase, + private readonly acceptInviteUc: UC.AcceptInvitationUseCase, + private readonly updateInvitationUc: UC.UpdateInvitationUseCase, + private readonly declineInvitationUc: UC.DeclineInvitationUseCase, + + private readonly getMyTeamsUc: UC.GetMyTeamsUseCase, + private readonly getMyInvitesUc: UC.GetMyInvitesUseCase, + ) {} + + public getTeamById = (teamId: string) => this.findTeamQ.execute(teamId); + + public getInvitation = (teamId: string, code: string, userId: string, userEmail: string) => + this.getInvitationQ.execute(teamId, code, userId, userEmail); + + public createTeam = (ownerId: string, dto: CreateTeamDto) => + this.createTeamUc.execute(ownerId, dto); + + public updateTeam = (teamId: string, userId: string, dto: UpdateTeamDto) => + this.updateTeamUc.execute(teamId, userId, dto); + + public deleteTeam = (teamId: string, userId: string) => + this.deleteTeamUc.execute(teamId, userId); + + public getMembers = (teamId: string) => this.getTeamMembersQ.execute(teamId); + + public updateMember = (teamId: string, curr: string, target: string, dto: UpdateMemberDto) => + this.updateMemberUc.execute(teamId, curr, target, dto); + + public removeMember = (teamId: string, curr: string, target: string) => + this.removeMemberUc.execute(teamId, curr, target); + + public getInvitations = (teamId: string, userId: string) => + this.getInvitationsQ.execute(teamId, userId); + + public invite = (teamId: string, inviterId: string, dto: InviteMemberDto) => + this.sendInviteUc.execute(teamId, inviterId, dto); + + public acceptInvite = (code: string, userId: string, email: string) => + this.acceptInviteUc.execute(code, userId, email); + + public declineInvitation = (teamId: string, code: string, userId: string, userEmail: string) => + this.declineInvitationUc.execute(teamId, code, userId, userEmail); + + public updateInvitation = ( + teamId: string, + code: string, + userId: string, + dto: UpdateInvitationDto, + ) => this.updateInvitationUc.execute(teamId, code, userId, dto); + + public getMyTeams = (userId: string) => this.getMyTeamsUc.execute(userId); + + public getMyInvites = (email: string) => this.getMyInvitesUc.execute(email); +} diff --git a/src/teams/application/use-cases/base/create-team.use-case.ts b/src/teams/application/use-cases/base/create-team.use-case.ts new file mode 100644 index 00000000..4fb0b55d --- /dev/null +++ b/src/teams/application/use-cases/base/create-team.use-case.ts @@ -0,0 +1,37 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { CreateTeamDto } from '../../dtos'; + +@Injectable() +export class CreateTeamUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(userId: string, dto: CreateTeamDto) { + try { + const result = await this.teamsRepo.create(userId, dto); + + return { + ...result, + message: 'Команда успешно создана', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: 'TEAM_CREATE_FAILED', + message: 'Не удалось создать команду', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/application/use-cases/base/delete-team.use-case.ts b/src/teams/application/use-cases/base/delete-team.use-case.ts new file mode 100644 index 00000000..f0cb9a02 --- /dev/null +++ b/src/teams/application/use-cases/base/delete-team.use-case.ts @@ -0,0 +1,60 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { Inject, Injectable, HttpStatus } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class DeleteTeamUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(teamId: string, userId: string) { + const team = await this.teamsRepo.findById(teamId); + + if (!team) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${teamId} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + const isOwner = team.ownerId === userId || member?.role === 'owner'; + + if (!isOwner) { + throw new BaseException( + { + code: 'ONLY_OWNER_CAN_DELETE', + message: 'Только владелец может удалить команду', + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const result = await this.teamsRepo.remove(team.id, userId); + + return { + success: result, + message: 'Команда успешно удалена', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: 'TEAM_DELETE_FAILED', + message: 'Не удалось удалить команду', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/application/use-cases/base/find-team.query.ts b/src/teams/application/use-cases/base/find-team.query.ts new file mode 100644 index 00000000..8b025d7f --- /dev/null +++ b/src/teams/application/use-cases/base/find-team.query.ts @@ -0,0 +1,16 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { ITeamsRepository } from '../../../domain/repository'; + +@Injectable() +export class FindTeamQuery { + constructor( + @Inject('ITeamsRepository') + private readonly repository: ITeamsRepository, + ) {} + + async execute(teamId: string) { + //TODO: add avatarURL handling + return this.repository.findById(teamId); + } +} diff --git a/src/teams/application/use-cases/base/get-my-teams.use-case.ts b/src/teams/application/use-cases/base/get-my-teams.use-case.ts new file mode 100644 index 00000000..96d1b498 --- /dev/null +++ b/src/teams/application/use-cases/base/get-my-teams.use-case.ts @@ -0,0 +1,28 @@ +import { TeamMemberMapper } from '@core/teams/application/mappers'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class GetMyTeamsUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + private readonly cfg: ConfigService, + ) {} + + async execute(userId: string) { + const teams = await this.teamsRepo.findByUser(userId); + const cdn = this.getCdnBaseUrl(); + + return teams.map((t) => TeamMemberMapper.toUserTeam(t, cdn)); + } + + private getCdnBaseUrl(): string { + const domain = this.cfg.get('DOMAIN'); + const bucket = this.cfg.get('S3_BUCKET_NAME'); + const endpoint = this.cfg.get('S3_ENDPOINT'); + + return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`; + } +} diff --git a/src/teams/application/use-cases/base/update-team.use-case.ts b/src/teams/application/use-cases/base/update-team.use-case.ts new file mode 100644 index 00000000..2cdd00bd --- /dev/null +++ b/src/teams/application/use-cases/base/update-team.use-case.ts @@ -0,0 +1,63 @@ +import { Inject, Injectable, HttpStatus } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { ITeamsRepository } from '../../../domain/repository'; +import { UpdateTeamDto } from '../../dtos'; + +@Injectable() +export class UpdateTeamUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + ) {} + + async execute(teamId: string, userId: string, dto: UpdateTeamDto) { + const team = await this.teamsRepo.findById(teamId); + + if (!team?.id) { + throw new BaseException( + { + code: 'TEAM_NOT_FOUND', + message: `Команда ${teamId} не найдена`, + }, + HttpStatus.NOT_FOUND, + ); + } + + const member = await this.teamsRepo.findMember(team.id, userId); + const canEdit = member?.role === 'admin' || member?.role === 'owner'; + + if (!canEdit) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для редактирования этой команды', + details: [{ target: 'role', value: member?.role }], + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const result = await this.teamsRepo.update(team.id, dto); + + return { + ...result, + message: 'Данные команды успешно обновлены', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: 'TEAM_UPDATE_FAILED', + message: 'Ошибка при обновлении данных команды', + details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/application/use-cases/index.ts b/src/teams/application/use-cases/index.ts new file mode 100644 index 00000000..ac91205d --- /dev/null +++ b/src/teams/application/use-cases/index.ts @@ -0,0 +1,59 @@ +import { CreateTeamUseCase } from './base/create-team.use-case'; +import { DeleteTeamUseCase } from './base/delete-team.use-case'; +import { FindTeamQuery } from './base/find-team.query'; +import { GetMyTeamsUseCase } from './base/get-my-teams.use-case'; +import { UpdateTeamUseCase } from './base/update-team.use-case'; +import { AcceptInvitationUseCase } from './invitions/accept-invitation.use-case'; +import { DeclineInvitationUseCase } from './invitions/decline-invitation.use-case'; +import { GetInvitationQuery } from './invitions/get-invitation.query'; +import { GetInvitationsQuery } from './invitions/get-invitations.query'; +import { GetMyInvitesUseCase } from './invitions/get-my-invites.use-case'; +import { SendInvitationUseCase } from './invitions/send-invitation.use-case'; +import { UpdateInvitationUseCase } from './invitions/update-invitation.use-case'; +import { FindTeamMemberQuery } from './members/find-team-member.query'; +import { GetTeamMembersQuery } from './members/get-team-members.query'; +import { RemoveTeamMemberUseCase } from './members/remove-team-member.use-case'; +import { UpdateTeamMemberUseCase } from './members/update-team-member.use-case'; + +export const TeamQueries = [ + FindTeamQuery, + FindTeamMemberQuery, + GetInvitationQuery, + GetInvitationsQuery, + GetTeamMembersQuery, + GetMyInvitesUseCase, + GetMyTeamsUseCase, +]; + +export const TeamUseCases = [ + AcceptInvitationUseCase, + CreateTeamUseCase, + DeleteTeamUseCase, + RemoveTeamMemberUseCase, + SendInvitationUseCase, + UpdateTeamUseCase, + UpdateTeamMemberUseCase, + UpdateInvitationUseCase, + DeclineInvitationUseCase, +]; + +export const TEAM_EXTERNAL_QUERIES = [FindTeamQuery, FindTeamMemberQuery]; +export const TEAM_EXTERNAL_COMMANDS = [CreateTeamUseCase]; + +export { FindTeamQuery } from './base/find-team.query'; +export { FindTeamMemberQuery } from './members/find-team-member.query'; +export { GetInvitationQuery } from './invitions/get-invitation.query'; +export { GetInvitationsQuery } from './invitions/get-invitations.query'; +export { GetTeamMembersQuery } from './members/get-team-members.query'; +export { GetMyInvitesUseCase } from './invitions/get-my-invites.use-case'; +export { GetMyTeamsUseCase } from './base/get-my-teams.use-case'; +export { AcceptInvitationUseCase } from './invitions/accept-invitation.use-case'; +export { CreateTeamUseCase } from './base/create-team.use-case'; +export { DeleteTeamUseCase } from './base/delete-team.use-case'; + +export { RemoveTeamMemberUseCase } from './members/remove-team-member.use-case'; +export { SendInvitationUseCase } from './invitions/send-invitation.use-case'; +export { UpdateTeamUseCase } from './base/update-team.use-case'; +export { UpdateTeamMemberUseCase } from './members/update-team-member.use-case'; +export { UpdateInvitationUseCase } from './invitions/update-invitation.use-case'; +export { DeclineInvitationUseCase } from './invitions/decline-invitation.use-case'; diff --git a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts new file mode 100644 index 00000000..94e9a308 --- /dev/null +++ b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts @@ -0,0 +1,86 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; + +import type { TeamInvite } from '../../dtos/invitation.dto'; + +@Injectable() +export class AcceptInvitationUseCase { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, + ) {} + + async execute(code: string, userId: string, email: string) { + const inviteRaw = await this.cacheService.getOne(this.INVITES_KEY(code)); + if (!inviteRaw) { + throw new BaseException( + { + code: 'INVITE_EXPIRED_OR_INVALID', + message: 'The invitation link has expired or is no longer valid.', + }, + HttpStatus.GONE, + ); + } + + const invite = JSON.parse(inviteRaw) as TeamInvite; + if (invite?.email?.toLowerCase() !== email.toLowerCase()) { + throw new BaseException( + { + code: 'INVITE_EMAIL_MISMATCH', + message: 'This invitation was sent to a different email address.', + }, + HttpStatus.FORBIDDEN, + ); + } + + const member = await this.teamsRepo.findMember(invite.teamId, userId); + if (member) { + if (member.status === 'banned') { + throw new BaseException( + { code: 'MEMBER_BANNED', message: 'You are banned from this team.' }, + HttpStatus.FORBIDDEN, + ); + } + if (member.status === 'active') { + await this.cleanupInvite(code, invite.teamId, email); + throw new BaseException( + { code: 'ALREADY_MEMBER', message: 'You are already a member of this team.' }, + HttpStatus.BAD_REQUEST, + ); + } + } + + await this.teamsRepo.addMember({ + teamId: invite.teamId, + userId, + role: invite.role, + status: 'active', + joinedAt: new Date().toISOString(), + }); + + await this.cacheService + .transaction() + .removeOne(this.INVITES_KEY(code)) + .removeOneFromCollection(this.TEAM_INVITES_KEY(invite.teamId), code) + .removeOneFromCollection(this.USER_INVITES_KEY(email.toLowerCase()), code) + .execute(); + + return { success: true, message: 'Вы успешно присоединились к команде' }; + } + + private async cleanupInvite(code: string, teamId: string, email: string) { + await this.cacheService + .transaction() + .removeOne(this.INVITES_KEY(code)) + .removeOneFromCollection(this.TEAM_INVITES_KEY(teamId), code) + .removeOneFromCollection(this.USER_INVITES_KEY(email.toLowerCase()), code) + .execute(); + } +} diff --git a/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts b/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts new file mode 100644 index 00000000..4ded7102 --- /dev/null +++ b/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts @@ -0,0 +1,99 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; + +import type { TeamInvite } from '../../dtos/invitation.dto'; + +@Injectable() +export class DeclineInvitationUseCase { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, + ) {} + + async execute(teamId: string, code: string, userId: string, userEmail: string) { + const team = await this.getTeamOrThrow(teamId); + const invite = await this.getInviteOrThrow(code); + + this.validateInviteOwnership(invite, team.id); + + await this.validateAccess(team.id, userId, userEmail, invite.email); + + await this.cleanupInvite(code, team.id, invite.email); + + return { + success: true, + message: 'Приглашение успешно удалено', + }; + } + + private async validateAccess( + teamId: string, + userId: string, + currentUserEmail: string, + inviteEmail: string, + ) { + if (currentUserEmail.toLowerCase() === inviteEmail.toLowerCase()) { + return; + } + + const member = await this.teamsRepo.findMember(teamId, userId); + if (member && (member.role === 'owner' || member.role === 'admin')) { + return; + } + + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас нет прав для отмены этого приглашения', + }, + HttpStatus.FORBIDDEN, + ); + } + + private async getTeamOrThrow(teamId: string) { + const team = await this.teamsRepo.findById(teamId); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + return team; + } + + private async getInviteOrThrow(code: string) { + const rawInvite = await this.cacheService.getOne(this.INVITES_KEY(code)); + if (!rawInvite) { + throw new BaseException( + { code: 'INVITE_NOT_FOUND', message: 'Приглашение не найдено' }, + HttpStatus.NOT_FOUND, + ); + } + return JSON.parse(rawInvite) as TeamInvite; + } + + private validateInviteOwnership(invite: TeamInvite, teamId: string) { + if (invite.teamId !== teamId) { + throw new BaseException( + { code: 'ACCESS_DENIED', message: 'Ошибка доступа' }, + HttpStatus.FORBIDDEN, + ); + } + } + + private async cleanupInvite(code: string, teamId: string, email: string) { + await this.cacheService + .transaction() + .removeOne(this.INVITES_KEY(code)) + .removeOneFromCollection(this.TEAM_INVITES_KEY(teamId), code) + .removeOneFromCollection(this.USER_INVITES_KEY(email), code) + .execute(); + } +} diff --git a/src/teams/application/use-cases/invitions/get-invitation.query.ts b/src/teams/application/use-cases/invitions/get-invitation.query.ts new file mode 100644 index 00000000..43a8c528 --- /dev/null +++ b/src/teams/application/use-cases/invitions/get-invitation.query.ts @@ -0,0 +1,79 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; + +import type { TeamInvite } from '../../dtos/invitation.dto'; + +@Injectable() +export class GetInvitationQuery { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, + ) {} + + async execute(teamId: string, code: string, userId: string, userEmail: string) { + const team = await this.getTeamOrThrow(teamId); + const invite = await this.getInviteOrThrow(code); + + this.validateInviteOwnership(invite, team.id); + await this.validateAccess(team.id, userId, userEmail, invite.email); + + return { code, ...invite }; + } + + private async getTeamOrThrow(teamId: string) { + const team = await this.teamsRepo.findById(teamId); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + return team; + } + + private async getInviteOrThrow(code: string) { + const raw = await this.cacheService.getOne(this.INVITES_KEY(code)); + if (!raw) { + throw new BaseException( + { code: 'INVITE_EXPIRED', message: 'Срок действия приглашения истек' }, + HttpStatus.NOT_FOUND, + ); + } + return JSON.parse(raw) as TeamInvite; + } + + private validateInviteOwnership(invite: TeamInvite, teamId: string) { + if (invite.teamId !== teamId) { + throw new BaseException( + { code: 'INVITE_NOT_FOUND', message: 'Приглашение не найдено' }, + HttpStatus.NOT_FOUND, + ); + } + } + + private async validateAccess( + teamId: string, + userId: string, + currentUserEmail: string, + inviteEmail: string, + ) { + if (currentUserEmail.toLowerCase() === inviteEmail.toLowerCase()) { + return; + } + + const member = await this.teamsRepo.findMember(teamId, userId); + if (member && (member.role === 'owner' || member.role === 'admin')) { + return; + } + + throw new BaseException( + { code: 'INSUFFICIENT_PERMISSIONS', message: 'У вас нет прав просмотра' }, + HttpStatus.FORBIDDEN, + ); + } +} diff --git a/src/teams/application/use-cases/invitions/get-invitations.query.ts b/src/teams/application/use-cases/invitions/get-invitations.query.ts new file mode 100644 index 00000000..66b9b7e3 --- /dev/null +++ b/src/teams/application/use-cases/invitions/get-invitations.query.ts @@ -0,0 +1,97 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class GetInvitationsQuery { + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, + ) {} + + async execute(teamId: string, userId: string) { + const team = await this.getTeamOrThrow(teamId); + await this.ensureAdminPermissions(team.id, userId); + + const teamKey = this.TEAM_INVITES_KEY(team.id); + const codes = await this.cacheService.getCollection(teamKey); + if (!codes.length) { + return { + // TODO: реализовать полноценную пагинацию для инвайтов команды. + items: [], + meta: { + total: 0, + totalPages: 0, + page: 1, + limit: 0, + hasPrevPage: false, + hasNextPage: false, + }, + }; + } + + const results = await this.cacheService.getMany(codes.map(this.INVITES_KEY)); + + const { active, expired } = results.reduce( + (acc: { active: any[]; expired: string[] }, raw, i) => { + const code = codes[i]; + if (!code) { + return acc; + } + + if (raw) { + acc.active.push({ code, ...JSON.parse(raw) }); + } else { + acc.expired.push(code); + } + return acc; + }, + { active: [], expired: [] }, + ); + + if (expired.length > 0) { + this.cacheService + .removeManyFromCollection(teamKey, expired) + .catch((e) => console.error('Cleanup error:', e)); + } + + return { + // TODO: реализовать полноценную пагинацию для инвайтов команды. + items: active, + meta: { + total: active.length, + totalPages: active.length ? 1 : 0, + page: 1, + limit: active.length, + hasPrevPage: false, + hasNextPage: false, + }, + }; + } + + private async getTeamOrThrow(teamId: string) { + const team = await this.teamsRepo.findById(teamId); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + return team; + } + + private async ensureAdminPermissions(teamId: string, userId: string) { + const member = await this.teamsRepo.findMember(teamId, userId); + if (!member || (member.role !== 'owner' && member.role !== 'admin')) { + throw new BaseException( + { code: 'INSUFFICIENT_PERMISSIONS', message: 'У вас нет прав' }, + HttpStatus.FORBIDDEN, + ); + } + } +} diff --git a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts new file mode 100644 index 00000000..6c176699 --- /dev/null +++ b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts @@ -0,0 +1,69 @@ +import { TeamMemberMapper } from '@core/teams/application/mappers'; +import { Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; + +@Injectable() +export class GetMyInvitesUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + ) {} + + async execute(email: string) { + const userKey = `user:invites:${email.toLowerCase()}`; + const codes = await this.cacheService.getCollection(userKey); + + if (!codes.length) { + return { + // TODO: реализовать полноценную пагинацию для инвайтов пользователя. + items: [], + meta: { + total: 0, + totalPages: 0, + page: 1, + limit: 10, + hasPrevPage: false, + hasNextPage: false, + }, + }; + } + + const inviteKeys = codes.map((c) => `inv:code:${c}`); + const results = await this.cacheService.getMany(inviteKeys); + const { active, expired } = results.reduce( + (acc: { active: any[]; expired: string[] }, raw, i) => { + const code = codes[i]; + if (!code) { + return acc; + } + + if (raw) { + acc.active.push(TeamMemberMapper.toPublicInvite(raw, code)); + } else { + acc.expired.push(code); + } + return acc; + }, + { active: [], expired: [] }, + ); + + if (expired.length > 0) { + this.cacheService.removeManyFromCollection(userKey, expired).catch((err) => { + console.error('Failed to cleanup expired invites:', err); + }); + } + + return { + items: active, + meta: { + total: active.length, + totalPages: active.length ? 1 : 0, + page: 1, + limit: active.length, + hasPrevPage: false, + hasNextPage: false, + }, + }; + } +} diff --git a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts new file mode 100644 index 00000000..875f3191 --- /dev/null +++ b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts @@ -0,0 +1,160 @@ +import { TeamMailJobs, TeamQueues } from '@core/teams/domain/enums'; +import { TeamInvitationEvent } from '@core/teams/domain/events'; +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import { ImageHelper } from '@shared/utils'; +import { Queue } from 'bullmq'; +import { generateSecret } from 'otplib'; + +import { InviteMemberDto, type TeamInvite } from '../../dtos'; + +import type { TeamRole } from '@shared/entities'; + +@Injectable() +export class SendInvitationUseCase { + private readonly INVITE_TTL = 86400; + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + private readonly TEAM_INVITES_KEY = (teamId: string) => `team:invites:${teamId}`; + private readonly USER_INVITES_KEY = (email: string) => `user:invites:${email.toLowerCase()}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, + @InjectQueue(TeamQueues.TEAM_MAIL) private readonly mailQueue: Queue, + private readonly cfg: ConfigService, + private readonly policy: TeamMemberPolicy, + ) {} + + async execute(teamId: string, inviterId: string, dto: InviteMemberDto) { + const team = await this.getTeamOrThrow(teamId); + const inviter = await this.getInviterOrThrow(team.id, inviterId); + + this.validatePermissions(inviter.role as TeamRole, dto.role as TeamRole); + await this.ensureNotAlreadyMember(team.id, dto.email); + await this.ensureNoPendingInvite(team.id, dto.email); + + const code = generateSecret({ length: 8 }); + const inviteData = this.buildInviteData(team, inviter, dto); + + await this.saveInviteToCache(code, inviteData); + + await this.sendEmailNotification(code, team.name, dto.email); + + return { success: true, message: `Приглашение отправлено на ${dto.email.toLowerCase()}` }; + } + + private async getTeamOrThrow(teamId: string) { + const team = await this.teamsRepo.findById(teamId); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + return team; + } + + private async getInviterOrThrow(teamId: string, userId: string) { + const inviter = await this.teamsRepo.findMember(teamId, userId); + if (!inviter) { + throw new BaseException( + { code: 'NOT_A_MEMBER', message: 'Вы не член команды' }, + HttpStatus.FORBIDDEN, + ); + } + return inviter; + } + + private validatePermissions(inviterRole: TeamRole, targetRole: TeamRole) { + if (!this.policy.canInvite(inviterRole, targetRole || 'member')) { + throw new BaseException( + { code: 'INSUFFICIENT_PERMISSIONS', message: 'Недостаточно прав' }, + HttpStatus.FORBIDDEN, + ); + } + } + + private async ensureNotAlreadyMember(teamId: string, email: string) { + const member = await this.teamsRepo.findMember(teamId, email); // Тут лучше искать по email в репо + if (member) { + throw new BaseException( + { code: 'ALREADY_MEMBER', message: 'Уже в команде' }, + HttpStatus.BAD_REQUEST, + ); + } + } + + private async ensureNoPendingInvite(teamId: string, email: string) { + const activeCodes = await this.cacheService.getCollection(this.USER_INVITES_KEY(email)); + if (activeCodes.length === 0) { + return; + } + + const invitesData = await this.cacheService.getMany(activeCodes.map(this.INVITES_KEY)); + const hasDuplicate = invitesData + .filter((d): d is string => !!d) + .map((d) => JSON.parse(d) as TeamInvite) + .some((i) => i.teamId === teamId); + + if (hasDuplicate) { + throw new BaseException( + { code: 'INVITATION_ALREADY_SENT', message: 'Приглашение уже в пути' }, + HttpStatus.BAD_REQUEST, + ); + } + } + + private buildInviteData(team: any, inviter: any, dto: InviteMemberDto): TeamInvite { + const expiresAt = new Date(Date.now() + this.INVITE_TTL * 1000); + + const cdn = this.getCdnBaseUrl(); + const images = ImageHelper.buildResponsiveUrls(cdn, team.avatarUrl); + + return { + teamId: team.id, + teamName: team.name, + teamAvatar: images?.small ?? null, + email: dto.email.toLowerCase(), + role: (dto.role || 'member') as TeamRole, + inviterId: inviter.userId, + inviterName: inviter.firstName, + createdAt: new Date().toISOString(), + expiresAt: expiresAt.toISOString(), + }; + } + + private async saveInviteToCache(code: string, data: TeamInvite) { + await this.cacheService + .transaction() + .setOne(this.INVITES_KEY(code), JSON.stringify(data), this.INVITE_TTL) + .addOneToCollection(this.TEAM_INVITES_KEY(data.teamId), code) + .addOneToCollection(this.USER_INVITES_KEY(data.email), code) + .execute(); + } + + private async sendEmailNotification(code: string, teamName: string, email: string) { + const origins = this.cfg.get('CORS_ALLOWED_ORIGINS') || []; + const url = `${origins[0]}/invites/accept?code=${code}`; + const event = new TeamInvitationEvent(email, teamName, url); + + await this.mailQueue.add(TeamMailJobs.SEND_TEAM_INVITATION, event, { + attempts: 3, + backoff: { type: 'exponential', delay: 5000 }, + removeOnComplete: true, + }); + } + + private getCdnBaseUrl(): string { + const domain = this.cfg.get('DOMAIN'); + const bucket = this.cfg.get('S3_BUCKET_NAME'); + const endpoint = this.cfg.get('S3_ENDPOINT'); + + return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`; + } +} diff --git a/src/teams/application/use-cases/invitions/update-invitation.use-case.ts b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts new file mode 100644 index 00000000..aeaebdda --- /dev/null +++ b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts @@ -0,0 +1,106 @@ +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; + +import { UpdateInvitationDto } from '../../dtos'; +import { TeamInvite } from '../../dtos/invitation.dto'; + +import type { TeamRole } from '../../../infrastructure/persistence/models'; + +@Injectable() +export class UpdateInvitationUseCase { + private readonly INVITES_KEY = (code: string) => `inv:code:${code}`; + + constructor( + @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, + @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, + private readonly policy: TeamMemberPolicy, + ) {} + + async execute(teamId: string, code: string, userId: string, dto: UpdateInvitationDto) { + const team = await this.getTeamOrThrow(teamId); + const member = await this.getMemberOrThrow(team.id, userId); + + const key = this.INVITES_KEY(code); + const { invite, ttlSeconds } = await this.getInviteContextOrThrow(key); + + this.validateInviteOwnership(invite, team.id); + this.validatePolicy(member.role as TeamRole, invite.role as TeamRole, dto.role as TeamRole); + + const updatedInvite = { + ...invite, + role: dto.role as TeamRole, + }; + + await this.cacheService.setOne(key, JSON.stringify(updatedInvite), ttlSeconds); + + return { + success: true, + message: 'Роль в приглашении успешно обновлена', + }; + } + + private async getTeamOrThrow(teamId: string) { + const team = await this.teamsRepo.findById(teamId); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: 'Команда не найдена' }, + HttpStatus.NOT_FOUND, + ); + } + return team; + } + + private async getMemberOrThrow(teamId: string, userId: string) { + const member = await this.teamsRepo.findMember(teamId, userId); + if (!member) { + throw new BaseException( + { code: 'NOT_A_MEMBER', message: 'Вы не член команды' }, + HttpStatus.FORBIDDEN, + ); + } + return member; + } + + private async getInviteContextOrThrow(key: string) { + const { value, ttlSeconds } = await this.cacheService.getOneWithTtl(key); + + if (!value || ttlSeconds <= 0) { + throw new BaseException( + { + code: 'INVITE_NOT_FOUND_OR_EXPIRED', + message: 'Приглашение не найдено или истекло', + }, + HttpStatus.NOT_FOUND, + ); + } + + return { invite: JSON.parse(value) as TeamInvite, ttlSeconds }; + } + + private validateInviteOwnership(invite: TeamInvite, teamId: string) { + if (invite.teamId !== teamId) { + throw new BaseException( + { code: 'INVITE_TEAM_MISMATCH', message: 'Приглашение принадлежит другой команде' }, + HttpStatus.BAD_REQUEST, + ); + } + } + + private validatePolicy(issuerRole: TeamRole, currentTargetRole: TeamRole, newRole: TeamRole) { + const canUpdate = this.policy.canAssignRole(issuerRole, currentTargetRole, newRole); + + if (!canUpdate) { + throw new BaseException( + { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'У вас недостаточно прав для назначения этой роли', + }, + HttpStatus.FORBIDDEN, + ); + } + } +} diff --git a/src/teams/application/use-cases/members/find-team-member.query.ts b/src/teams/application/use-cases/members/find-team-member.query.ts new file mode 100644 index 00000000..5f82597e --- /dev/null +++ b/src/teams/application/use-cases/members/find-team-member.query.ts @@ -0,0 +1,15 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { ITeamsRepository } from '../../../domain/repository'; + +@Injectable() +export class FindTeamMemberQuery { + constructor( + @Inject('ITeamsRepository') + private readonly repository: ITeamsRepository, + ) {} + + async execute(teamId: string, userId: string) { + return this.repository.findMember(teamId, userId); + } +} diff --git a/src/teams/application/use-cases/members/get-team-members.query.ts b/src/teams/application/use-cases/members/get-team-members.query.ts new file mode 100644 index 00000000..ccf02a81 --- /dev/null +++ b/src/teams/application/use-cases/members/get-team-members.query.ts @@ -0,0 +1,49 @@ +import { TeamMemberMapper } from '@core/teams/application/mappers'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { BaseException } from '@shared/error'; + +@Injectable() +export class GetTeamMembersQuery { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + private readonly cfg: ConfigService, + ) {} + + async execute(teamId: string) { + const team = await this.teamsRepo.findById(teamId); + + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: `Команда ${teamId} не найдена` }, + HttpStatus.NOT_FOUND, + ); + } + const cdn = this.getCdnBaseUrl(); + const members = await this.teamsRepo.findMembers(team.id); + const data = TeamMemberMapper.toList(members, cdn); + + return { + // TODO: реализовать полноценную пагинацию для участников команды. + items: data, + meta: { + total: data.length, + totalPages: data.length ? 1 : 0, + page: 1, + limit: data.length, + hasPrevPage: false, + hasNextPage: false, + }, + }; + } + + private getCdnBaseUrl(): string { + const domain = this.cfg.get('DOMAIN'); + const bucket = this.cfg.get('S3_BUCKET_NAME'); + const endpoint = this.cfg.get('S3_ENDPOINT'); + + return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`; + } +} diff --git a/src/teams/application/use-cases/members/remove-team-member.use-case.ts b/src/teams/application/use-cases/members/remove-team-member.use-case.ts new file mode 100644 index 00000000..d5a875a9 --- /dev/null +++ b/src/teams/application/use-cases/members/remove-team-member.use-case.ts @@ -0,0 +1,83 @@ +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import type { TeamRole } from '@shared/entities'; + +@Injectable() +export class RemoveTeamMemberUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + private readonly policy: TeamMemberPolicy, + ) {} + + async execute(teamId: string, currentUserId: string, targetUserId: string) { + const team = await this.teamsRepo.findById(teamId); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: `Команда ${teamId} не найдена` }, + HttpStatus.NOT_FOUND, + ); + } + + const [currentUser, targetUser] = await Promise.all([ + this.teamsRepo.findMember(team.id, currentUserId), + this.teamsRepo.findMember(team.id, targetUserId), + ]); + + if (!targetUser) { + throw new BaseException( + { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + if (!currentUser) { + throw new BaseException( + { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, + HttpStatus.FORBIDDEN, + ); + } + + const isSelfRemoval = currentUserId === targetUserId; + + const canRemove = this.policy.canRemove( + currentUser.role as TeamRole, + targetUser.role as TeamRole, + isSelfRemoval, + ); + + if (!canRemove) { + const errorCode = isSelfRemoval ? 'OWNER_CANNOT_LEAVE' : 'KICK_FORBIDDEN'; + const errorMessage = isSelfRemoval + ? 'Владелец не может покинуть команду без передачи прав' + : 'У вас недостаточно прав, чтобы исключить этого участника'; + + throw new BaseException( + { code: errorCode, message: errorMessage }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const result = await this.teamsRepo.removeMember(team.id, targetUserId); + return { + success: result, + message: isSelfRemoval + ? `Вы успешно покинули команду ${team.name}` + : `Участник успешно исключен из команды ${team.name}`, + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { code: 'MEMBER_REMOVAL_FAILED', message: 'Ошибка при удалении участника' }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/application/use-cases/members/update-team-member.use-case.ts b/src/teams/application/use-cases/members/update-team-member.use-case.ts new file mode 100644 index 00000000..9b8eb5c8 --- /dev/null +++ b/src/teams/application/use-cases/members/update-team-member.use-case.ts @@ -0,0 +1,107 @@ +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { TeamRole } from '@shared/entities'; +import { BaseException } from '@shared/error'; + +import { UpdateMemberDto } from '../../dtos'; + +@Injectable() +export class UpdateTeamMemberUseCase { + constructor( + @Inject('ITeamsRepository') + private readonly teamsRepo: ITeamsRepository, + private readonly teamMemberPolicy: TeamMemberPolicy, + ) {} + + async execute( + teamId: string, + currentUserId: string, + targetUserId: string, + dto: UpdateMemberDto, + ) { + if (currentUserId === targetUserId) { + throw new BaseException( + { code: 'SELF_EDIT_RESTRICTED', message: 'Вы не можете редактировать свои данные' }, + HttpStatus.BAD_REQUEST, + ); + } + + const team = await this.teamsRepo.findById(teamId); + if (!team) { + throw new BaseException( + { code: 'TEAM_NOT_FOUND', message: `Команда ${teamId} не найдена` }, + HttpStatus.NOT_FOUND, + ); + } + + const [currentUser, targetUser] = await Promise.all([ + this.teamsRepo.findMember(team.id, currentUserId), + this.teamsRepo.findMember(team.id, targetUserId), + ]); + + if (!targetUser) { + throw new BaseException( + { code: 'MEMBER_NOT_FOUND', message: 'Участник не найден' }, + HttpStatus.NOT_FOUND, + ); + } + + if (!currentUser) { + throw new BaseException( + { code: 'NOT_A_TEAM_MEMBER', message: 'Вы не состоите в этой команде' }, + HttpStatus.FORBIDDEN, + ); + } + + const issuerRole = currentUser.role as TeamRole; + const targetRole = targetUser.role as TeamRole; + + if (!this.teamMemberPolicy.canManage(issuerRole, targetRole)) { + throw new BaseException( + { code: 'INSUFFICIENT_RANK', message: 'Ваш ранг должен быть выше ранга цели' }, + HttpStatus.FORBIDDEN, + ); + } + + if (dto.role && !this.teamMemberPolicy.canAssignRole(issuerRole, targetRole, dto.role)) { + throw new BaseException( + { + code: 'INVALID_ROLE_ASSIGNMENT', + message: 'У вас нет прав назначить выбранную роль', + }, + HttpStatus.FORBIDDEN, + ); + } + + if (dto.status && !this.teamMemberPolicy.canChangeStatus(issuerRole, targetRole)) { + throw new BaseException( + { + code: 'INVALID_STATUS_CHANGE', + message: 'Вы не можете менять статус этого участника', + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const result = await this.teamsRepo.updateMember(team.id, targetUserId, dto); + return { + success: result, + message: `Данные участника команды ${team.name} успешно обновлены`, + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: 'MEMBER_UPDATE_FAILED', + message: 'Ошибка при обновлении данных участника', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/teams/domain/entities/index.ts b/src/teams/domain/entities/index.ts new file mode 100644 index 00000000..40d100b7 --- /dev/null +++ b/src/teams/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './teams.domain'; diff --git a/src/teams/domain/entities/teams.domain.ts b/src/teams/domain/entities/teams.domain.ts new file mode 100644 index 00000000..4ee5e26b --- /dev/null +++ b/src/teams/domain/entities/teams.domain.ts @@ -0,0 +1,12 @@ +import type { teams, teamMembers } from '../../infrastructure/persistence/models'; +import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'; + +export type Team = InferSelectModel; +export type NewTeam = InferInsertModel; + +export type TeamMember = InferSelectModel; +export type NewTeamMember = InferInsertModel; + +export type TeamWithMembers = Team & { + readonly members: readonly TeamMember[]; +}; diff --git a/src/teams/domain/enums/index.ts b/src/teams/domain/enums/index.ts new file mode 100644 index 00000000..4a727802 --- /dev/null +++ b/src/teams/domain/enums/index.ts @@ -0,0 +1 @@ +export { TeamMailJobs, TeamQueues } from './mail-jobs.enum'; diff --git a/src/teams/domain/enums/mail-jobs.enum.ts b/src/teams/domain/enums/mail-jobs.enum.ts new file mode 100644 index 00000000..da9a52a8 --- /dev/null +++ b/src/teams/domain/enums/mail-jobs.enum.ts @@ -0,0 +1,7 @@ +export const enum TeamQueues { + TEAM_MAIL = 'TEAM_MAIL_QUEUE', +} + +export const enum TeamMailJobs { + SEND_TEAM_INVITATION = 'TEAM_SEND_TEAM_INVITATION', +} diff --git a/src/teams/domain/events/index.ts b/src/teams/domain/events/index.ts new file mode 100644 index 00000000..f0cfd4e6 --- /dev/null +++ b/src/teams/domain/events/index.ts @@ -0,0 +1 @@ +export { TeamInvitationEvent } from './team-invitation.event'; diff --git a/src/teams/domain/events/team-invitation.event.ts b/src/teams/domain/events/team-invitation.event.ts new file mode 100644 index 00000000..aeac2d83 --- /dev/null +++ b/src/teams/domain/events/team-invitation.event.ts @@ -0,0 +1,7 @@ +export class TeamInvitationEvent { + constructor( + public readonly email: string, + public readonly teamName: string, + public readonly inviteUrl: string, + ) {} +} diff --git a/src/teams/domain/policy/index.ts b/src/teams/domain/policy/index.ts new file mode 100644 index 00000000..f076bcee --- /dev/null +++ b/src/teams/domain/policy/index.ts @@ -0,0 +1 @@ +export { TeamMemberPolicy } from './team-member.policy'; diff --git a/src/teams/domain/policy/team-member.policy.ts b/src/teams/domain/policy/team-member.policy.ts new file mode 100644 index 00000000..2a726e24 --- /dev/null +++ b/src/teams/domain/policy/team-member.policy.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@nestjs/common'; +import { ROLE_PRIORITY } from '@shared/constants'; + +import type { TeamRole } from '@shared/entities'; + +@Injectable() +export class TeamMemberPolicy { + constructor() {} + + private getPriority(role: TeamRole): number { + return ROLE_PRIORITY[role] ?? 0; + } + + /** + * Может ли Инициатор вообще редактировать Цель? + */ + public canManage(issuerRole: TeamRole, targetRole: TeamRole): boolean { + // Минимальный порог для управления — администратор + if (this.getPriority(issuerRole) < (ROLE_PRIORITY['admin'] ?? 3)) { + return false; + } + + // Нельзя редактировать того, кто равен или выше по рангу + return this.getPriority(issuerRole) > this.getPriority(targetRole); + } + + /** + * Может ли Инициатор назначить Цели новую роль? + */ + public canAssignRole( + issuerRole: TeamRole, + targetCurrentRole: TeamRole, + newRole: TeamRole, + ): boolean { + // 1. Проверка прав на управление целью + if (!this.canManage(issuerRole, targetCurrentRole)) { + return false; + } + + // 2. Роль Owner неприкосновенна (нельзя снять и нельзя назначить через обычный Update) + if (targetCurrentRole === 'owner' || newRole === 'owner') { + return false; + } + + // 3. Нельзя назначить роль выше своей или равную своей (если ты не владелец) + if (issuerRole !== 'owner' && this.getPriority(newRole) >= this.getPriority(issuerRole)) { + return false; + } + + return true; + } + + /** + * Может ли Инициатор менять статус (ban/block/active) Цели? + */ + public canChangeStatus(issuerRole: TeamRole, targetRole: TeamRole): boolean { + // Владельца нельзя забанить или деактивировать + if (targetRole === 'owner') { + return false; + } + + // В остальном работают стандартные правила иерархии + return this.canManage(issuerRole, targetRole); + } + + /** + * Может ли Инициатор удалить Цель (или самого себя)? + */ + public canRemove(issuerRole: TeamRole, targetRole: TeamRole, isSelf: boolean): boolean { + if (isSelf) { + return issuerRole !== 'owner'; + } + + const issuerPrio = this.getPriority(issuerRole); + const targetPrio = this.getPriority(targetRole); + + return issuerPrio >= (ROLE_PRIORITY['admin'] ?? 3) && issuerPrio > targetPrio; + } + + /** + * Может ли Инициатор приглашать новых участников с определенной ролью? + */ + public canInvite(issuerRole: TeamRole, newMemberRole: TeamRole): boolean { + const issuerPrio = this.getPriority(issuerRole); + const newRolePrio = this.getPriority(newMemberRole); + + // Только админы и выше могут приглашать + if (issuerPrio < (ROLE_PRIORITY['admin'] ?? 3)) { + return false; + } + + // Нельзя пригласить кого-то на роль выше или равную своей (кроме owner) + if (issuerRole !== 'owner' && newRolePrio >= issuerPrio) { + return false; + } + + // Нельзя пригласить на роль owner через обычный инвайт + if (newMemberRole === 'owner') { + return false; + } + + return true; + } + + /** + * Проверяет, имеет ли участник право на обновление медиа-ресурсов (аватар, баннер) команды. + * + * @remarks + * Логика базируется на приоритете ролей. Минимально допустимая роль — Модератор. + * + * @param {TeamRole} issuerRole - Роль участника, инициирующего обновление. + * @returns {boolean} `true`, если приоритет роли равен или выше приоритета модератора, иначе `false`. + * + * @example + * const canUpdate = policy.canUpdateMedia('admin'); // true + */ + public canUpdateMedia(issuerRole: TeamRole): boolean { + return this.getPriority(issuerRole) >= (ROLE_PRIORITY['moderator'] ?? 2); + } +} diff --git a/src/teams/domain/repository/index.ts b/src/teams/domain/repository/index.ts new file mode 100644 index 00000000..0d97b361 --- /dev/null +++ b/src/teams/domain/repository/index.ts @@ -0,0 +1,5 @@ +export { + ITeamsRepository, + type RawMemberRow, + type RawMemberTeams, +} from './teams.repository.interface'; diff --git a/src/teams/domain/repository/teams.repository.interface.ts b/src/teams/domain/repository/teams.repository.interface.ts new file mode 100644 index 00000000..aa958cf6 --- /dev/null +++ b/src/teams/domain/repository/teams.repository.interface.ts @@ -0,0 +1,46 @@ +import type { Team, NewTeam, NewTeamMember } from '../entities'; + +type TResponse = { readonly success: boolean; readonly teamId: string }; + +export type RawMemberRow = { + readonly userId: string; + readonly role: string; + readonly status: string; + readonly joinedAt: string | null; + readonly firstName: string | null; + readonly lastName: string | null; + readonly middleName: string | null; + readonly avatarUrl: string | null; + readonly email?: string; +}; + +export type RawMemberTeams = { + readonly id: string; + readonly name: string; + readonly description: string | null; + readonly avatarUrl: string | null; + readonly role: string; + readonly joinedAt: string | null; +}; + +export interface ITeamsRepository { + create(ownerId: string, dto: NewTeam): Promise; + update(id: string, dto: Partial): Promise; + remove(id: string, userId: string): Promise; + + findMember(teamId: string, userId: string): Promise; + findMembers(teamId: string): Promise; + findById(teamId: string): Promise; + findByUser(userId: string): Promise; + + updateTeamAvatar(teamId: string, url: string): Promise; + updateTeamBanner(teamId: string, url: string): Promise; + + addMember(dto: NewTeamMember): Promise; + updateMember( + teamId: string, + userId: string, + dto: { role?: string; status?: string }, + ): Promise; + removeMember(teamId: string, userId: string): Promise; +} diff --git a/src/teams/index.ts b/src/teams/index.ts new file mode 100644 index 00000000..f4d6e9c9 --- /dev/null +++ b/src/teams/index.ts @@ -0,0 +1,2 @@ +export { TeamsModule } from './teams.module'; +export { FindTeamQuery, FindTeamMemberQuery } from './application/use-cases'; diff --git a/src/teams/infrastructure/listeners/index.ts b/src/teams/infrastructure/listeners/index.ts new file mode 100644 index 00000000..9c374baa --- /dev/null +++ b/src/teams/infrastructure/listeners/index.ts @@ -0,0 +1,3 @@ +import { UpdateTeamMediaListener } from './update-media.listener'; + +export const LISTENERS = [UpdateTeamMediaListener]; diff --git a/src/teams/infrastructure/listeners/update-media.listener.ts b/src/teams/infrastructure/listeners/update-media.listener.ts new file mode 100644 index 00000000..b3d92471 --- /dev/null +++ b/src/teams/infrastructure/listeners/update-media.listener.ts @@ -0,0 +1,80 @@ +import { MEDIA_JOBS, MEDIA_QUEUES, UpdateMediaTeam } from '@core/media'; +import { TeamMemberPolicy } from '@core/teams/domain/policy'; +import { ITeamsRepository } from '@core/teams/domain/repository'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Inject } from '@nestjs/common'; +import { type Job, UnrecoverableError } from 'bullmq'; + +import type { TeamRole } from '@shared/entities'; + +@Processor(MEDIA_QUEUES.SAVE_ENTITY) +export class UpdateTeamMediaListener extends WorkerHost { + constructor( + @Inject('ITeamsRepository') + private readonly repository: ITeamsRepository, + private readonly policy: TeamMemberPolicy, + ) { + super(); + } + + async process(job: Job): Promise { + if (job.name !== MEDIA_JOBS.UPDATE_TEAM_MEDIA) { + return; + } + + const { initiatorId, entity, type, path } = job.data; + + try { + const teamId = await this.validatePermissionsAndGetTeamId(entity.id, initiatorId); + + await this.executeMediaUpdate(teamId, type, path); + + await job.log(`Successfully updated ${type} for team ${entity.id}`); + } catch (error) { + await job.log( + `Failed to update ${type} for team ${entity.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } + } + + private async validatePermissionsAndGetTeamId(teamId: string, userId: string): Promise { + const team = await this.repository.findById(teamId); + if (!team) { + throw new UnrecoverableError('Команда не найдена'); + } + + const member = await this.repository.findMember(team.id, userId); + + if (!member) { + throw new UnrecoverableError('Не состоит в этой команде'); + } + + const hasAccess = this.policy.canUpdateMedia(member.role as TeamRole); + + if (!hasAccess) { + throw new UnrecoverableError('Недостаточно прав для обновления медиа'); + } + + return team.id; + } + + private async executeMediaUpdate( + teamId: string, + type: 'banner' | 'avatar', + url: string, + ): Promise { + const updateActions: Record Promise> = { + banner: (id, path) => this.repository.updateTeamBanner(id, path), + avatar: (id, path) => this.repository.updateTeamAvatar(id, path), + }; + + const action = updateActions[type]; + + if (!action) { + throw new UnrecoverableError(`Unsupported media type: ${type}`); + } + + await action(teamId, url); + } +} diff --git a/src/teams/infrastructure/persistence/models/enums.ts b/src/teams/infrastructure/persistence/models/enums.ts new file mode 100644 index 00000000..2dba2b2a --- /dev/null +++ b/src/teams/infrastructure/persistence/models/enums.ts @@ -0,0 +1,17 @@ +import { baseSchema } from '@shared/entities'; + +export const roleEnum = baseSchema.enum('team_role', [ + 'owner', + 'admin', // управление юзерами, настройками + 'lead', // управление проектами + 'moderator', // чистка контента/сообщений + 'member', // обычный работяга + 'viewer', // просто смотрит +]); +export type TeamRole = (typeof roleEnum.enumValues)[number]; + +export const statusEnum = baseSchema.enum('member_status', [ + 'active', // Полноценный участник + 'banned', // Заблокирован не может вернуться по инвайту + 'inactive', // Доступ закрыт, но запись сохранена +]); diff --git a/src/teams/infrastructure/persistence/models/index.ts b/src/teams/infrastructure/persistence/models/index.ts new file mode 100644 index 00000000..2a40eb06 --- /dev/null +++ b/src/teams/infrastructure/persistence/models/index.ts @@ -0,0 +1,2 @@ +export { teamMembers, teams } from './teams.model'; +export { type TeamRole, roleEnum, statusEnum } from './enums'; diff --git a/src/teams/infrastructure/persistence/models/teams.model.ts b/src/teams/infrastructure/persistence/models/teams.model.ts new file mode 100644 index 00000000..9a78f6f1 --- /dev/null +++ b/src/teams/infrastructure/persistence/models/teams.model.ts @@ -0,0 +1,53 @@ +import { createId } from '@paralleldrive/cuid2'; +import { baseSchema, users } from '@shared/entities'; +import { primaryKey, timestamp, text, varchar, index } from 'drizzle-orm/pg-core'; + +import { roleEnum, statusEnum } from './enums'; + +export const teams = baseSchema.table( + 'teams', + { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + name: varchar('name', { length: 100 }).notNull(), + description: text('description'), + avatarUrl: text('avatar_url'), + coverUrl: text('cover_url'), + ownerId: text('owner_id').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), + }, + (t) => ({ + ownerIdx: index('team_owner_idx').on(t.ownerId), + softDeleteIdx: index('team_deleted_at_idx').on(t.deletedAt), + }), +); + +export const teamMembers = baseSchema.table( + 'team_members', + { + teamId: text('team_id') + .references(() => teams.id, { onDelete: 'cascade' }) + .notNull(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + role: roleEnum('role').default('member').notNull(), + status: statusEnum('status').default('inactive').notNull(), + joinedAt: timestamp('joined_at', { withTimezone: true, mode: 'string' }), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.teamId, t.userId] }), + statusIdx: index('member_status_idx').on(t.status), + userRoleIdx: index('member_role_idx').on(t.userId, t.role), + }), +); diff --git a/src/teams/infrastructure/persistence/repositories/index.ts b/src/teams/infrastructure/persistence/repositories/index.ts new file mode 100644 index 00000000..259ca0a5 --- /dev/null +++ b/src/teams/infrastructure/persistence/repositories/index.ts @@ -0,0 +1 @@ +export { TeamsRepository } from './teams.repository'; diff --git a/src/teams/infrastructure/persistence/repositories/teams.repository.ts b/src/teams/infrastructure/persistence/repositories/teams.repository.ts new file mode 100644 index 00000000..cc119359 --- /dev/null +++ b/src/teams/infrastructure/persistence/repositories/teams.repository.ts @@ -0,0 +1,191 @@ +import { ITeamsRepository } from '@core/teams/domain/repository'; +import * as scUsers from '@core/user/infrastructure/persistence/models'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Inject } from '@nestjs/common'; +import { and, desc, eq, isNull } from 'drizzle-orm'; + +import * as schema from '../models'; + +import type { NewTeam, NewTeamMember, Team, TeamMember } from '@core/teams/domain/entities'; + +export class TeamsRepository implements ITeamsRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + public addMember = async (dto: NewTeamMember) => { + const result = await this.db + .insert(schema.teamMembers) + .values(dto) + .onConflictDoNothing({ + target: [schema.teamMembers.teamId, schema.teamMembers.userId], + }); + + return (result?.count ?? 0) > 0; + }; + + public create = async (ownerId: string, dto: NewTeam) => + this.db.transaction(async (tx) => { + const [team] = await tx + .insert(schema.teams) + .values({ ...dto, ownerId }) + .returning({ teamId: schema.teams.id }); + + if (!team?.teamId) { + throw new Error('Failed to create team: no team returned'); + } + + await tx.insert(schema.teamMembers).values({ + teamId: team.teamId, + userId: ownerId, + role: 'owner', + status: 'active', + joinedAt: new Date().toISOString(), + }); + + return { + success: true, + teamId: team.teamId, + }; + }); + + public update = async (id: string, dto: Partial) => + this.db.transaction(async (tx) => { + const [team] = await tx + .update(schema.teams) + .set(dto) + .where(eq(schema.teams.id, id)) + .returning({ teamId: schema.teams.id }); + + if (!team?.teamId) { + throw new Error('Failed to create team: no team returned'); + } + + return { + success: true, + teamId: team.teamId, + }; + }); + + public remove = async (teamId: string, userId: string) => { + const result = await this.db + .update(schema.teams) + .set({ + deletedAt: new Date().toISOString(), + }) + .where(and(eq(schema.teams.id, teamId), eq(schema.teams.ownerId, userId))); + + return (result?.count ?? 0) > 0; + }; + + public findMember = async (teamId: string, userId: string) => { + const [member] = await this.membersQuery.where( + and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), + ); + + return member || null; + }; + + public findMembers = async (teamId: string) => + this.membersQuery + .where(eq(schema.teamMembers.teamId, teamId)) + .orderBy(desc(schema.teamMembers.joinedAt)); + + public findByUser = async (userId: string) => { + const filters = [ + eq(schema.teamMembers.userId, userId), + eq(schema.teamMembers.status, 'active'), + isNull(schema.teams.deletedAt), + ]; + + const query = this.db + .select({ + id: schema.teams.id, + name: schema.teams.name, + description: schema.teams.description, + avatarUrl: schema.teams.avatarUrl, + role: schema.teamMembers.role, + joinedAt: schema.teamMembers.joinedAt, + }) + .from(schema.teamMembers) + .innerJoin(schema.teams, eq(schema.teams.id, schema.teamMembers.teamId)) + .where(and(...filters)) + .orderBy(desc(schema.teamMembers.joinedAt)); + + return query; + }; + + public findById = async (teamId: string) => { + const [team] = await this.db.select().from(schema.teams).where(eq(schema.teams.id, teamId)); + if (!team) { + return null; + } + return team; + }; + + public removeMember = async (teamId: string, userId: string) => { + const result = await this.db + .delete(schema.teamMembers) + .where( + and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), + ); + + return (result?.count ?? 0) > 0; + }; + + public updateMember = async (teamId: string, userId: string, dto: Partial) => { + const { role, status } = dto; + + const data = { + role, + ...(status === 'active' ? { joinedAt: new Date().toISOString() } : {}), + }; + + const result = await this.db + .update(schema.teamMembers) + .set(data) + .where( + and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), + ); + + return (result?.count ?? 0) > 0; + }; + + public async updateTeamAvatar(teamId: string, url: string): Promise { + const result = await this.db + .update(schema.teams) + .set({ avatarUrl: url, updatedAt: new Date().toISOString() }) + .where(eq(schema.teams.id, teamId)); + return (result?.count ?? 0) > 0; + } + + public async updateTeamBanner(teamId: string, url: string): Promise { + const result = await this.db + .update(schema.teams) + .set({ coverUrl: url, updatedAt: new Date().toISOString() }) + .where(eq(schema.teams.id, teamId)); + return (result?.count ?? 0) > 0; + } + + private get memberSelection() { + return { + userId: schema.teamMembers.userId, + role: schema.teamMembers.role, + status: schema.teamMembers.status, + joinedAt: schema.teamMembers.joinedAt, + firstName: scUsers.users.firstName, + lastName: scUsers.users.lastName, + middleName: scUsers.users.middleName, + avatarUrl: scUsers.users.avatarUrl, + email: scUsers.users.email, + }; + } + + private get membersQuery() { + return this.db + .select(this.memberSelection) + .from(schema.teamMembers) + .innerJoin(scUsers.users, eq(schema.teamMembers.userId, scUsers.users.id)); + } +} diff --git a/src/teams/infrastructure/workers/index.ts b/src/teams/infrastructure/workers/index.ts new file mode 100644 index 00000000..d20e25dd --- /dev/null +++ b/src/teams/infrastructure/workers/index.ts @@ -0,0 +1 @@ +export { MailProcessor } from './mail.processor'; diff --git a/src/teams/infrastructure/workers/mail.processor.ts b/src/teams/infrastructure/workers/mail.processor.ts new file mode 100644 index 00000000..ec6c0655 --- /dev/null +++ b/src/teams/infrastructure/workers/mail.processor.ts @@ -0,0 +1,50 @@ +import { TeamQueues } from '@core/teams/domain/enums'; +import { TeamInvitationEvent } from '@core/teams/domain/events'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Inject } from '@nestjs/common'; +import { IMailPort } from '@shared/adapters/mail'; + +import type { Job } from 'bullmq'; + +@Processor(TeamQueues.TEAM_MAIL) +export class MailProcessor extends WorkerHost { + constructor( + @Inject('IMailPort') + private readonly mailAdapter: IMailPort, + ) { + super(); + } + + async process(job: Job): Promise { + await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); + + try { + await this.sendTeamInvitation(job); + + await job.log(`[DONE] Job ${job.id} processed`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : ''; + + await job.log(`[FAIL] ${errorMessage}`); + + if (errorStack) { + await job.log(errorStack); + } + + throw error; + } + } + + private readonly sendTeamInvitation = async (job: Job) => { + const { email, teamName, inviteUrl } = job.data; + + await job.log(`Sending team(${teamName}) invitation link to: ${email}`); + await job.updateProgress(30); + + await this.mailAdapter.sendTeamInvitation(email, teamName, inviteUrl); + + await job.log(`Team invitation link delivered to ${email}`); + await job.updateProgress(100); + }; +} diff --git a/src/teams/teams.module.ts b/src/teams/teams.module.ts new file mode 100644 index 00000000..92b39574 --- /dev/null +++ b/src/teams/teams.module.ts @@ -0,0 +1,48 @@ +import { MailProcessor } from '@core/teams/infrastructure/workers'; +import { BullModule } from '@nestjs/bullmq'; +import { Module } from '@nestjs/common'; + +import { + TeamsInvitationsController, + TeamsMembersController, + TeamsController, + MeController, +} from './application/controller'; +import { TeamsFacade } from './application/team.facade'; +import { + TeamQueries, + TeamUseCases, + TEAM_EXTERNAL_QUERIES, + TEAM_EXTERNAL_COMMANDS, +} from './application/use-cases'; +import { TeamQueues } from './domain/enums'; +import { TeamMemberPolicy } from './domain/policy'; +import { LISTENERS } from './infrastructure/listeners'; +import { TeamsRepository } from './infrastructure/persistence/repositories'; + +const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; + +@Module({ + imports: [ + BullModule.registerQueue({ + name: TeamQueues.TEAM_MAIL, + }), + ], + controllers: [ + TeamsInvitationsController, + TeamsMembersController, + TeamsController, + MeController, + ], + providers: [ + TeamMemberPolicy, + REPOSITORY, + ...LISTENERS, + ...TeamUseCases, + ...TeamQueries, + TeamsFacade, + MailProcessor, + ], + exports: [...TEAM_EXTERNAL_QUERIES, ...TEAM_EXTERNAL_COMMANDS], +}) +export class TeamsModule {} diff --git a/src/user/application/controllers/index.ts b/src/user/application/controllers/index.ts new file mode 100644 index 00000000..0ea2e274 --- /dev/null +++ b/src/user/application/controllers/index.ts @@ -0,0 +1,2 @@ +export { UserController } from './user/controller'; +export { UserSettingsController } from './settings/controller'; diff --git a/src/user/application/controllers/settings/controller.ts b/src/user/application/controllers/settings/controller.ts new file mode 100644 index 00000000..5263602f --- /dev/null +++ b/src/user/application/controllers/settings/controller.ts @@ -0,0 +1,18 @@ +import { Body, Patch } from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; + +import { UpdateNotificationsDto } from '../../dtos'; +import { UserFacade } from '../../user.facade'; + +import { PatchMeNotificationsSwagger } from './swagger'; + +@ApiBaseController('users/me', 'Account Settings', true) +export class UserSettingsController { + constructor(private readonly facade: UserFacade) {} + + @Patch('notifications') + @PatchMeNotificationsSwagger() + async updateNotifications(@Body() settings: UpdateNotificationsDto, @GetUserId() id: string) { + return this.facade.updateNotifications(id, settings); + } +} diff --git a/src/user/application/controllers/settings/swagger.ts b/src/user/application/controllers/settings/swagger.ts new file mode 100644 index 00000000..642b0d50 --- /dev/null +++ b/src/user/application/controllers/settings/swagger.ts @@ -0,0 +1,27 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { UpdateNotificationsDto } from '../../dtos'; + +export const PatchMeNotificationsSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить настройки уведомлений', + description: 'Частичное обновление настроек email и push уведомлений.', + }), + ApiBody({ + type: UpdateNotificationsDto.Output, + }), + ApiResponse({ + status: 200, + description: 'Настройки успешно сохранены.', + type: ActionResponse.Output, + }), + ApiValidationError('Некорректный формат настроек'), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); diff --git a/src/user/application/controllers/user/controller.ts b/src/user/application/controllers/user/controller.ts new file mode 100644 index 00000000..a99f0616 --- /dev/null +++ b/src/user/application/controllers/user/controller.ts @@ -0,0 +1,31 @@ +import { Body, Get, Patch, Query } from '@nestjs/common'; +import { ApiBaseController, GetUserId } from '@shared/decorators'; +import { PaginationQuery } from '@shared/schemas'; + +import { UpdateProfileDto } from '../../dtos'; +import { UserFacade } from '../../user.facade'; + +import { GetMeActivitySwagger, GetMeSwagger, PatchMeSwagger } from './swagger'; + +@ApiBaseController('users/me', 'Account Profile', true) +export class UserController { + constructor(private readonly facade: UserFacade) {} + + @Get() + @GetMeSwagger() + async getProfile(@GetUserId() id: string) { + return this.facade.getProfile(id); + } + + @Patch() + @PatchMeSwagger() + async updateProfile(@Body() dto: UpdateProfileDto, @GetUserId() id: string) { + return this.facade.updateProfile(id, dto); + } + + @Get('activity') + @GetMeActivitySwagger() + async getActivity(@Query() query: PaginationQuery, @GetUserId() id: string) { + return this.facade.getActivity(id, query); + } +} diff --git a/src/user/application/controllers/user/swagger.ts b/src/user/application/controllers/user/swagger.ts new file mode 100644 index 00000000..eb97b692 --- /dev/null +++ b/src/user/application/controllers/user/swagger.ts @@ -0,0 +1,72 @@ +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiBody, ApiExtraModels, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { ApiUnauthorized, ApiValidationError } from '@shared/error'; +import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; + +import { UpdateProfileDto, UserActivityResponse, UserResponse } from '../../dtos'; + +export const GetMeSwagger = () => + applyDecorators( + ApiExtraModels(UserResponse.Output), + ApiOperation({ + summary: 'Получить профиль текущего пользователя', + description: + 'Возвращает полную структуру профиля, включая вложенные объекты безопасности и настроек.', + }), + ApiResponse({ + status: 200, + description: 'Данные профиля успешно получены.', + type: UserResponse.Output, + }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserResponse), + ); + +export const PatchMeSwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Обновить данные профиля', + description: 'Позволяет точечно обновить имя, bio, часовой пояс и язык интерфейса.', + }), + ApiBody({ type: UpdateProfileDto.Output }), + ApiResponse({ + status: 200, + description: 'Профиль успешно обновлен.', + type: ActionResponse.Output, + }), + ApiValidationError('Ошибка валидации (например, слишком короткое имя)', [ + { + field: 'fullName', + message: 'Строка должна содержать минимум 2 символа', + code: 'too_small', + }, + ]), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), + ); + +export const GetMeActivitySwagger = () => + applyDecorators( + ApiOperation({ + summary: 'Получить ленту активности пользователя', + description: 'Возвращает список последних действий пользователя (логи).', + }), + ApiQuery({ + name: 'limit', + required: false, + type: String, + description: 'Количество записей для вывода (по умолчанию 10)', + example: '15', + }), + ApiResponse({ + status: 200, + description: 'Список активностей успешно получен.', + type: UserActivityResponse.Output, + }), + ApiUnauthorized(), + + SetMetadata(ZOD_RESPONSE_TOKEN, UserActivityResponse), + ); diff --git a/src/user/application/dtos/index.ts b/src/user/application/dtos/index.ts new file mode 100644 index 00000000..162915b4 --- /dev/null +++ b/src/user/application/dtos/index.ts @@ -0,0 +1,6 @@ +export { + UpdateProfileDto, + UpdateNotificationsDto, + UserResponse, + UserActivityResponse, +} from './user.dto'; diff --git a/src/user/application/dtos/user.dto.ts b/src/user/application/dtos/user.dto.ts new file mode 100644 index 00000000..5046d290 --- /dev/null +++ b/src/user/application/dtos/user.dto.ts @@ -0,0 +1,192 @@ +import { AvatarResponseSchema, createPaginationSchema } from '@shared/schemas'; +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod/v4'; + +const NotificationsSchema = z + .object({ + email: z.object({ + task_assigned: z.boolean().describe('Уведомление на почту при назначении задачи'), + mentions: z.boolean().describe('Уведомление на почту при упоминании в комментариях'), + daily_summary: z.boolean().describe('Ежедневная сводка задач на почту'), + }), + push: z.object({ + task_assigned: z.boolean().describe('Push-уведомление при назначении задачи'), + reminders: z.boolean().describe('Push-уведомления о дедлайнах'), + }), + }) + .describe('Настройки уведомлений пользователя'); + +export const UpdateNotificationsSchema = NotificationsSchema.partial() + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) + .describe('Схема для частичного обновления настроек уведомлений'); + +export class UpdateNotificationsDto extends createZodDto(UpdateNotificationsSchema) {} + +const SecuritySchema = z + .object({ + is2faEnabled: z.boolean().describe('Статус двухфакторной аутентификации'), + lastPasswordChange: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата последнего изменения пароля'), + }) + .describe('Данные безопасности аккаунта'); + +const ProfileSchema = z.object({ + firstName: z.string().describe('Имя пользователя'), + lastName: z.string().describe('Фамилия'), + middleName: z.string().nullable().describe('Отчество'), + bio: z.string().nullable().describe('О себе'), + avatar: AvatarResponseSchema, + headline: z + .string() + .nullable() + .describe('Краткий заголовок или должность (например: "Senior Developer @ Company")'), + location: z.string().nullable().describe('Город или страна проживания'), + phone: z.string().nullable().describe('Номер телефона (для связи)'), + gender: z + .enum(['none', 'male', 'female', 'non_binary', 'other', 'prefer_not_to_say']) + .default('none') + .describe( + 'Пол пользователя: none - не указан, male - мужской, female - женский, non_binary - небинарный, other - другой, prefer_not_to_say - предпочитаю не указывать', + ), + vacationStart: z.string().nullable().describe('Дата начала отпуска (ISO 8601)'), + vacationEnd: z.string().nullable().describe('Дата окончания отпуска (ISO 8601)'), + vacationMessage: z.string().nullable().describe('Сообщение автоответчика на время отпуска'), + pronouns: z + .enum(['he_him', 'she_her', 'they_them', 'other', 'none']) + .default('none') + .describe( + 'Предпочитаемые местоимения: he_him - он/его, she_her - она/ее, they_them - они/их, other - другие, none - не указаны', + ), + pronounsCustom: z + .string() + .max(50, 'Максимальная длина 50 символов') + .nullable() + .optional() + .describe('Пользовательские местоимения (заполняется, если pronouns = "other")'), + createdAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата регистрации'), + updatedAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата последнего обновления профиля'), +}); + +const PreferencesSchema = z.object({ + timezone: z + .string() + .describe('Временная зона пользователя (например: "Europe/Moscow", "UTC+3")'), + language: z.string().describe('Язык интерфейса (ISO 639-1: "ru", "en", "de" и т.д.)'), + theme: z + .enum(['light', 'dark', 'system']) + .optional() + .describe('Тема оформления: light - светлая, dark - темная, system - как в системе'), +}); + +export const UserSchema = z.object({ + id: z.string().describe('Уникальный идентификатор (CUID/UUID)'), + email: z.string().email().describe('Электронная почта'), + profile: ProfileSchema, + security: SecuritySchema, + notifications: NotificationsSchema, + preferences: PreferencesSchema, +}); + +export class UserResponse extends createZodDto(UserSchema) {} + +export const UpdateProfileSchema = z + .object({ + firstName: z + .string() + .min(1, 'Имя не может быть пустым') + .max(50, 'Имя слишком длинное') + .optional(), + lastName: z + .string() + .min(1, 'Фамилия не может быть пустой') + .max(50, 'Фамилия слишком длинная') + .optional(), + middleName: z.string().max(50, 'Отчество слишком длинное').nullish(), + headline: z + .string() + .nullish() + .describe('Краткий заголовок или должность (например: "Senior Developer @ Company")'), + location: z.string().describe('Город или страна проживания').nullish(), + phone: z.string().describe('Номер телефона (для связи)').nullish(), + gender: z + .enum(['none', 'male', 'female', 'non_binary', 'other', 'prefer_not_to_say']) + .default('none') + .optional() + .describe( + 'Пол пользователя: none - не указан, male - мужской, female - женский, non_binary - небинарный, other - другой, prefer_not_to_say - предпочитаю не указывать', + ), + vacationStart: z.string().describe('Дата начала отпуска (ISO 8601)').nullish(), + vacationEnd: z.string().describe('Дата окончания отпуска (ISO 8601)').nullish(), + vacationMessage: z.string().nullish().describe('Сообщение автоответчика на время отпуска'), + pronouns: z + .enum(['he_him', 'she_her', 'they_them', 'other', 'none']) + .default('none') + .optional() + .describe( + 'Предпочитаемые местоимения: he_him - он/его, she_her - она/ее, they_them - они/их, other - другие, none - не указаны', + ), + pronounsCustom: z + .string() + .max(50, 'Максимальная длина 50 символов') + .nullish() + .describe('Пользовательские местоимения (заполняется, если pronouns = "other")'), + bio: z.string().max(1000, 'О себе не более 1000 символов').nullish(), + timezone: z.string().max(50).optional(), + language: z + .string() + .length(2, 'Используйте формат ISO (например, "ru" или "en")') + .optional(), + theme: z + .enum(['light', 'dark', 'system']) + .optional() + .describe('Тема оформления: light - светлая, dark - темная, system - как в системе'), + }) + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }) + .describe('Схема для частичного обновления данных профиля'); + +export class UpdateProfileDto extends createZodDto(UpdateProfileSchema) {} + +const UserActivityItemSchema = z + .object({ + id: z.string().describe('ID события активности'), + eventType: z.string().describe('Тип события активности'), + entityId: z.string().nullable().optional().describe('ID сущности, если применимо'), + metadata: z + .record(z.string(), z.unknown()) + .nullable() + .optional() + .describe('Дополнительные данные'), + createdAt: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: 'Строка не является валидной датой', + }) + .describe('Дата и время события (ISO 8601)'), + }) + .describe('Элемент активности пользователя'); + +export const UserActivityResponseSchema = createPaginationSchema(UserActivityItemSchema).describe( + 'Ответ со списком активности пользователя', +); + +export class UserActivityResponse extends createZodDto(UserActivityResponseSchema) {} diff --git a/src/user/application/use-cases/find-by-ids.query.ts b/src/user/application/use-cases/find-by-ids.query.ts new file mode 100644 index 00000000..5dc1dcf5 --- /dev/null +++ b/src/user/application/use-cases/find-by-ids.query.ts @@ -0,0 +1,11 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class FindByIdsQuery { + constructor(@Inject('IUserRepository') private readonly userRepo: IUserRepository) {} + + async execute(ids: readonly string[]) { + return this.userRepo.findByIds(ids); + } +} diff --git a/src/user/application/use-cases/find-profile.query.ts b/src/user/application/use-cases/find-profile.query.ts new file mode 100644 index 00000000..49f33e7d --- /dev/null +++ b/src/user/application/use-cases/find-profile.query.ts @@ -0,0 +1,60 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { BaseException } from '@shared/error'; +import { ImageHelper } from '@shared/utils'; + +import { UserErrorCodes, UserErrorMessages } from '../../domain/errors'; + +@Injectable() +export class FindProfileQuery { + constructor( + @Inject('IUserRepository') private readonly userRepo: IUserRepository, + private readonly cfg: ConfigService, + ) {} + + async execute(userId: string) { + const entity = await this.userRepo.findProfile(userId); + + if (!entity?.user) { + throw new BaseException( + { + code: UserErrorCodes.NOT_FOUND, + message: UserErrorMessages[UserErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const { notifications, preferences, security, user } = entity; + + const { id, email, avatarUrl, ...profile } = user; + const cdn = this.getCdnBaseUrl(); + + const avatar = ImageHelper.buildResponsiveUrls(cdn, avatarUrl); + + return { + id, + email, + profile: { + ...profile, + avatar, + }, + preferences: { + theme: preferences?.theme ?? 'system', + language: preferences?.language ?? 'ru', + timezone: preferences?.timezone ?? 'UTC', + }, + security, + notifications, + }; + } + + private getCdnBaseUrl(): string { + const domain = this.cfg.get('DOMAIN'); + const bucket = this.cfg.get('S3_BUCKET_NAME'); + const endpoint = this.cfg.get('S3_ENDPOINT'); + + return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`; + } +} diff --git a/src/user/application/use-cases/find-user.query.ts b/src/user/application/use-cases/find-user.query.ts new file mode 100644 index 00000000..7ce055d2 --- /dev/null +++ b/src/user/application/use-cases/find-user.query.ts @@ -0,0 +1,75 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { UserErrorCodes, UserErrorMessages } from '../../domain/errors'; + +import type { UserWithSecurity } from '../../domain/entities'; + +type TParams = { + email?: string; + id?: string; +}; + +@Injectable() +export class FindUserQuery { + constructor( + @Inject('IUserRepository') + private readonly repository: IUserRepository, + ) {} + + async execute( + params: TParams, + options?: { throwIfNotFound?: true; throwIfExists?: false }, + ): Promise; + async execute( + params: TParams, + options: { throwIfNotFound: false; throwIfExists?: boolean }, + ): Promise; + async execute( + params: TParams, + options: { throwIfExists: true; throwIfNotFound?: boolean }, + ): Promise; + async execute( + params: TParams, + options?: { throwIfNotFound?: boolean; throwIfExists?: boolean }, + ): Promise { + const { throwIfNotFound = true, throwIfExists = false } = options || {}; + + if (!params.email && !params.id) { + throw new BaseException( + { + code: UserErrorCodes.QUERY_PARAMS_MISSING, + message: UserErrorMessages[UserErrorCodes.QUERY_PARAMS_MISSING], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const result = params.email + ? await this.repository.findByEmail(params.email) + : await this.repository.findById(params.id!); + + if (!result && throwIfNotFound) { + throw new BaseException( + { + code: UserErrorCodes.NOT_FOUND, + message: UserErrorMessages[UserErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (result && throwIfExists) { + throw new BaseException( + { + code: UserErrorCodes.ALREADY_EXISTS, + message: UserErrorMessages[UserErrorCodes.ALREADY_EXISTS], + }, + HttpStatus.CONFLICT, + ); + } + + return result; + } +} diff --git a/src/user/application/use-cases/get-activity.query.ts b/src/user/application/use-cases/get-activity.query.ts new file mode 100644 index 00000000..4439e685 --- /dev/null +++ b/src/user/application/use-cases/get-activity.query.ts @@ -0,0 +1,38 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { Inject, Injectable } from '@nestjs/common'; +import { PaginationQuery } from '@shared/schemas'; + +@Injectable() +export class GetActivityQuery { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + ) {} + + async execute(id: string, query: PaginationQuery) { + const { limit, page } = query; + + const safeLimit = Math.min(limit, 50); + const offset = (page - 1) * safeLimit; + + const { items, total } = await this.userRepo.findActivityByUser(id, { + limit: safeLimit, + offset, + }); + + const totalPages = Math.ceil(total / safeLimit); + + return { + // TODO: реализовать полноценную пагинацию по общей схеме (hasNextPage/hasPrevPage) везде. + items, + meta: { + total, + page, + limit: safeLimit, + totalPages, + hasPrevPage: page > 1, + hasNextPage: totalPages > 0 && page < totalPages, + }, + }; + } +} diff --git a/src/user/application/use-cases/index.ts b/src/user/application/use-cases/index.ts new file mode 100644 index 00000000..76ba9b7f --- /dev/null +++ b/src/user/application/use-cases/index.ts @@ -0,0 +1,34 @@ +import { FindByIdsQuery } from './find-by-ids.query'; +import { FindProfileQuery } from './find-profile.query'; +import { FindUserQuery } from './find-user.query'; +import { GetActivityQuery } from './get-activity.query'; +import { RegisterUserUseCase } from './register-user.use-case'; +import { UpdateNotificationsUseCase } from './update-notifications.use-case'; +import { UpdatePasswordUseCase } from './update-password.use-case'; +import { UpdateProfileUseCase } from './update-profile.use-case'; + +export * from './register-user.use-case'; +export * from './update-notifications.use-case'; +export * from './update-password.use-case'; +export * from './update-profile.use-case'; + +export * from './find-profile.query'; +export * from './find-user.query'; +export * from './get-activity.query'; +export * from './find-by-ids.query'; + +export const UserUseCases = [ + RegisterUserUseCase, + UpdateNotificationsUseCase, + UpdatePasswordUseCase, + UpdateProfileUseCase, +]; + +export const UserQueries = [FindProfileQuery, FindByIdsQuery, FindUserQuery, GetActivityQuery]; + +export const USER_EXTERNAL_USE_CASES = [ + RegisterUserUseCase, + UpdatePasswordUseCase, + FindUserQuery, + FindByIdsQuery, +]; diff --git a/src/user/application/use-cases/register-user.use-case.ts b/src/user/application/use-cases/register-user.use-case.ts new file mode 100644 index 00000000..9dd931ea --- /dev/null +++ b/src/user/application/use-cases/register-user.use-case.ts @@ -0,0 +1,60 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import { BaseException } from '@shared/error'; + +import { UserErrorCodes, UserErrorMessages } from '../../domain/errors'; + +import type { NewUser } from '@core/user/domain/entities'; + +@Injectable() +export class RegisterUserUseCase { + constructor( + @Inject('IUserRepository') + private readonly repository: IUserRepository, + ) {} + + async execute(dto: NewUser & { password: string | null }) { + const existingUser = await this.repository.findByEmail(dto.email); + + if (existingUser?.user) { + throw new BaseException( + { + code: UserErrorCodes.ALREADY_EXISTS, + message: UserErrorMessages[UserErrorCodes.ALREADY_EXISTS], + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.CONFLICT, + ); + } + + try { + const user = await this.repository.create(dto); + + if (dto.password) { + await Promise.all([ + this.repository.logActivity({ + eventType: 'registered', + userId: user.id, + id: createId(), + }), + this.repository.updatePasswordHash(user.id, dto.password), + ]); + } + + return user; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: UserErrorCodes.CREATE_FAILED, + message: UserErrorMessages[UserErrorCodes.CREATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/user/application/use-cases/update-notifications.use-case.ts b/src/user/application/use-cases/update-notifications.use-case.ts new file mode 100644 index 00000000..60901584 --- /dev/null +++ b/src/user/application/use-cases/update-notifications.use-case.ts @@ -0,0 +1,63 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import { BaseException } from '@shared/error'; +import { removeUndefined } from '@shared/utils'; + +import { UserErrorCodes, UserErrorMessages } from '../../domain/errors'; +import { UpdateNotificationsDto } from '../dtos'; + +@Injectable() +export class UpdateNotificationsUseCase { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + ) {} + + async execute(id: string, dto: UpdateNotificationsDto) { + const user = await this.userRepo.findById(id); + + if (!user) { + throw new BaseException( + { + code: UserErrorCodes.NOT_FOUND, + message: UserErrorMessages[UserErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + try { + const result = await this.userRepo.updateNotifications( + id, + removeUndefined({ + email: dto.email, + push: dto.push, + }), + ); + + await this.userRepo.logActivity({ + id: createId(), + userId: id, + eventType: 'NOTIFICATIONS_UPDATED', + }); + + return { + success: result, + message: 'Настройки уведомлений обновлены', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: UserErrorCodes.UPDATE_FAILED, + message: UserErrorMessages[UserErrorCodes.UPDATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/user/application/use-cases/update-password.use-case.ts b/src/user/application/use-cases/update-password.use-case.ts new file mode 100644 index 00000000..c9aa6fda --- /dev/null +++ b/src/user/application/use-cases/update-password.use-case.ts @@ -0,0 +1,43 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { Injectable, Inject, HttpStatus } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { UserErrorCodes, UserErrorMessages } from '../../domain/errors'; + +@Injectable() +export class UpdatePasswordUseCase { + constructor( + @Inject('IUserRepository') + private readonly repository: IUserRepository, + ) {} + + async execute(email: string, password: string) { + const result = await this.repository.findByEmail(email); + + if (!result?.user) { + throw new BaseException( + { + code: UserErrorCodes.NOT_FOUND, + message: UserErrorMessages[UserErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + try { + return this.repository.updatePasswordHash(result.user.id, password); + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: UserErrorCodes.UPDATE_FAILED, + message: UserErrorMessages[UserErrorCodes.UPDATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/user/application/use-cases/update-profile.use-case.ts b/src/user/application/use-cases/update-profile.use-case.ts new file mode 100644 index 00000000..a1c23028 --- /dev/null +++ b/src/user/application/use-cases/update-profile.use-case.ts @@ -0,0 +1,90 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { Injectable, Inject, HttpStatus } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import { BaseException } from '@shared/error'; +import { removeUndefined } from '@shared/utils'; + +import { UserErrorCodes, UserErrorMessages } from '../../domain/errors'; +import { UpdateProfileDto } from '../dtos'; + +@Injectable() +export class UpdateProfileUseCase { + constructor( + @Inject('IUserRepository') + private readonly userRepo: IUserRepository, + ) {} + + async execute(id: string, dto: UpdateProfileDto) { + const entity = await this.userRepo.findById(id); + + if (!entity?.user) { + throw new BaseException( + { + code: UserErrorCodes.NOT_FOUND, + message: UserErrorMessages[UserErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + this.validatePronouns(dto); + + const { timezone, theme, language, ...profile } = dto; + + const preferences = { + timezone, + language, + theme, + }; + + try { + const result = await this.userRepo.updateProfile( + entity.user.id, + removeUndefined(profile), + preferences, + ); + + await this.userRepo.logActivity({ + id: createId(), + userId: id, + eventType: 'PROFILE_UPDATED', + }); + + return { success: result, message: 'Профиль успешно обновлен' }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: UserErrorCodes.UPDATE_FAILED, + message: UserErrorMessages[UserErrorCodes.UPDATE_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private validatePronouns(dto: UpdateProfileDto) { + if (dto.pronouns === 'other' && (!dto.pronounsCustom || dto.pronounsCustom.trim() === '')) { + throw new BaseException( + { + code: UserErrorCodes.PRONOUNS_CUSTOM_REQUIRED, + message: UserErrorMessages[UserErrorCodes.PRONOUNS_CUSTOM_REQUIRED], + }, + HttpStatus.BAD_REQUEST, + ); + } + + if (dto.pronounsCustom && dto.pronounsCustom.length > 50) { + throw new BaseException( + { + code: UserErrorCodes.PRONOUNS_CUSTOM_TOO_LONG, + message: UserErrorMessages[UserErrorCodes.PRONOUNS_CUSTOM_TOO_LONG], + }, + HttpStatus.BAD_REQUEST, + ); + } + } +} diff --git a/src/user/application/user.facade.ts b/src/user/application/user.facade.ts new file mode 100644 index 00000000..28be54f1 --- /dev/null +++ b/src/user/application/user.facade.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { PaginationQuery } from '@shared/schemas'; + +import { UpdateProfileDto, UpdateNotificationsDto } from './dtos'; +import { + FindProfileQuery, + GetActivityQuery, + UpdateNotificationsUseCase, + UpdateProfileUseCase, +} from './use-cases'; + +@Injectable() +export class UserFacade { + constructor( + private readonly findProfileQuery: FindProfileQuery, + private readonly getActivityQuery: GetActivityQuery, + private readonly updateNotificationsUC: UpdateNotificationsUseCase, + private readonly updateProfileUC: UpdateProfileUseCase, + ) {} + + public async getProfile(userId: string) { + return this.findProfileQuery.execute(userId); + } + + public async getActivity(userId: string, query: PaginationQuery) { + return this.getActivityQuery.execute(userId, query); + } + + public async updateProfile(userId: string, dto: UpdateProfileDto) { + return this.updateProfileUC.execute(userId, dto); + } + + public async updateNotifications(userId: string, dto: UpdateNotificationsDto) { + return this.updateNotificationsUC.execute(userId, dto); + } +} diff --git a/src/user/domain/entities/index.ts b/src/user/domain/entities/index.ts new file mode 100644 index 00000000..54f31af8 --- /dev/null +++ b/src/user/domain/entities/index.ts @@ -0,0 +1 @@ +export type * from './user.domain'; diff --git a/src/user/domain/entities/user.domain.ts b/src/user/domain/entities/user.domain.ts new file mode 100644 index 00000000..53918888 --- /dev/null +++ b/src/user/domain/entities/user.domain.ts @@ -0,0 +1,40 @@ +import type { + users, + userSecurity, + userNotifications, + userActivity, + userPreferences, +} from '../../infrastructure/persistence/models/user.entity'; +import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'; + +export type User = InferSelectModel; +export type NewUser = InferInsertModel; + +export type UserPreferences = InferSelectModel; +export type NewUserPreferences = InferInsertModel; + +export type UserSecurity = InferSelectModel; +export type NewUserSecurity = InferInsertModel; + +export type UserNotifications = InferSelectModel; +export type NotificationSettings = NonNullable; + +export type UserActivity = InferSelectModel; +export type NewUserActivity = InferInsertModel; + +export type UserProfile = { + readonly user: User; + readonly security: { + readonly lastPasswordChange: string | null; + readonly is2faEnabled: boolean; + }; + readonly preferences: UserPreferences | null; + readonly notifications: NotificationSettings; +}; + +export type UserWithSecurity = { + readonly user: User; + readonly security: { + readonly passwordHash: string | null; + }; +}; diff --git a/src/user/domain/errors/index.ts b/src/user/domain/errors/index.ts new file mode 100644 index 00000000..0d3cb44b --- /dev/null +++ b/src/user/domain/errors/index.ts @@ -0,0 +1 @@ +export * from './user.error'; diff --git a/src/user/domain/errors/user.error.ts b/src/user/domain/errors/user.error.ts new file mode 100644 index 00000000..86757dad --- /dev/null +++ b/src/user/domain/errors/user.error.ts @@ -0,0 +1,30 @@ +export const UserErrorCodes = { + QUERY_PARAMS_MISSING: 'USER.QUERY_PARAMS_MISSING', + WEAK_PASSWORD: 'USER.WEAK_PASSWORD', + PRONOUNS_CUSTOM_REQUIRED: 'USER.PRONOUNS_CUSTOM_REQUIRED', + PRONOUNS_CUSTOM_TOO_LONG: 'USER.PRONOUNS_CUSTOM_TOO_LONG', + NOT_FOUND: 'USER.NOT_FOUND', + ALREADY_EXISTS: 'USER.ALREADY_EXISTS', + EMAIL_ALREADY_EXISTS: 'USER.EMAIL_ALREADY_EXISTS', + DATA_CORRUPTION: 'USER.DATA_CORRUPTION', + CREATE_FAILED: 'USER.CREATE_FAILED', + UPDATE_FAILED: 'USER.UPDATE_FAILED', + DELETE_FAILED: 'USER.DELETE_FAILED', +} as const; + +export type UserErrorCode = (typeof UserErrorCodes)[keyof typeof UserErrorCodes]; + +export const UserErrorMessages: Record = { + [UserErrorCodes.QUERY_PARAMS_MISSING]: 'Не указаны параметры поиска пользователя', + [UserErrorCodes.WEAK_PASSWORD]: 'Пароль слишком простой. Используйте более сложный пароль', + [UserErrorCodes.PRONOUNS_CUSTOM_REQUIRED]: 'Пожалуйста, укажите пользовательские местоимения', + [UserErrorCodes.PRONOUNS_CUSTOM_TOO_LONG]: + 'Пользовательские местоимения не могут превышать 50 символов', + [UserErrorCodes.NOT_FOUND]: 'Пользователь не найден', + [UserErrorCodes.ALREADY_EXISTS]: 'Пользователь уже существует', + [UserErrorCodes.EMAIL_ALREADY_EXISTS]: 'Пользователь с таким email уже существует', + [UserErrorCodes.DATA_CORRUPTION]: 'Ошибка целостности данных пользователя', + [UserErrorCodes.CREATE_FAILED]: 'Не удалось создать пользователя', + [UserErrorCodes.UPDATE_FAILED]: 'Не удалось обновить данные пользователя', + [UserErrorCodes.DELETE_FAILED]: 'Не удалось удалить пользователя', +}; diff --git a/src/user/domain/repository/index.ts b/src/user/domain/repository/index.ts new file mode 100644 index 00000000..a9419f8b --- /dev/null +++ b/src/user/domain/repository/index.ts @@ -0,0 +1 @@ +export { IUserRepository } from './user.repository.interface'; diff --git a/src/user/domain/repository/user.repository.interface.ts b/src/user/domain/repository/user.repository.interface.ts new file mode 100644 index 00000000..c27abc5e --- /dev/null +++ b/src/user/domain/repository/user.repository.interface.ts @@ -0,0 +1,39 @@ +import type { + NewUser, + NewUserActivity, + User, + UserActivity, + UserNotifications, + UserPreferences, + UserProfile, + UserWithSecurity, +} from '../entities'; + +type DeepPartial = { readonly [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] }; + +export interface IUserRepository { + create(data: NewUser): Promise; + findById(id: string): Promise; + findByIds(ids: readonly string[]): Promise; + findByEmail(email: string): Promise; + findProfile(id: string): Promise; + findActivityByUser( + userId: string, + options: { readonly limit: number; readonly offset: number }, + ): Promise<{ + readonly items: readonly UserActivity[]; + readonly total: number; + }>; + updateAvatar(id: string, url: string): Promise; + updateProfile( + id: string, + data: Partial, + preferences?: Partial, + ): Promise; + updatePasswordHash(id: string, hash: string): Promise; + updateNotifications( + id: string, + settings: DeepPartial, + ): Promise; + logActivity(data: NewUserActivity): Promise; +} diff --git a/src/user/index.ts b/src/user/index.ts new file mode 100644 index 00000000..4e472d91 --- /dev/null +++ b/src/user/index.ts @@ -0,0 +1,3 @@ +export { UserModule } from './user.module'; +export { RegisterUserUseCase, FindUserQuery, UpdatePasswordUseCase } from './application/use-cases'; +export { User } from './domain/entities/user.domain'; diff --git a/src/user/infrastructure/listeners/index.ts b/src/user/infrastructure/listeners/index.ts new file mode 100644 index 00000000..17256554 --- /dev/null +++ b/src/user/infrastructure/listeners/index.ts @@ -0,0 +1,3 @@ +import { UpdateAvatarListener } from './update-avatar.listener'; + +export const LISTENERS = [UpdateAvatarListener]; diff --git a/src/user/infrastructure/listeners/update-avatar.listener.ts b/src/user/infrastructure/listeners/update-avatar.listener.ts new file mode 100644 index 00000000..08f24e65 --- /dev/null +++ b/src/user/infrastructure/listeners/update-avatar.listener.ts @@ -0,0 +1,56 @@ +import { MEDIA_JOBS, MEDIA_QUEUES, type UpdateMediaUser } from '@core/media'; +import { IUserRepository } from '@core/user/domain/repository'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Inject } from '@nestjs/common'; +import { UnrecoverableError, type Job } from 'bullmq'; + +@Processor(MEDIA_QUEUES.SAVE_ENTITY) +export class UpdateAvatarListener extends WorkerHost { + constructor( + @Inject('IUserRepository') + private readonly repository: IUserRepository, + ) { + super(); + } + + async process(job: Job) { + if (job.name !== MEDIA_JOBS.UPDATE_USER_AVATAR) { + return; + } + + const { entity, path } = job.data; + + try { + await job.updateProgress(10); + await job.log(path); + if (!path) { + throw new UnrecoverableError( + `Media processing failed: no storage path returned for entity ${entity.id}`, + ); + } + + await job.updateProgress(40); + + const userAccount = await this.repository.findById(entity.id); + + if (!userAccount) { + await job.log(`User ${entity.id} missing in database.`); + return { status: 'aborted', reason: 'USER_NOT_FOUND' }; + } + + await job.updateProgress(70); + + await this.repository.updateAvatar(userAccount.user.id, path); + + await job.updateProgress(100); + + await job.log(`Successfully updated avatar for user ${userAccount.user.id}`); + return; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await job.log(`Critical failure: ${errorMessage}`); + + throw error; + } + } +} diff --git a/src/user/infrastructure/persistence/models/index.ts b/src/user/infrastructure/persistence/models/index.ts new file mode 100644 index 00000000..b554c003 --- /dev/null +++ b/src/user/infrastructure/persistence/models/index.ts @@ -0,0 +1,7 @@ +export { + userNotifications, + userPreferences, + userSecurity, + userActivity, + users, +} from './user.entity'; diff --git a/src/user/infrastructure/persistence/models/user.entity.ts b/src/user/infrastructure/persistence/models/user.entity.ts new file mode 100644 index 00000000..1ba0ddc1 --- /dev/null +++ b/src/user/infrastructure/persistence/models/user.entity.ts @@ -0,0 +1,95 @@ +import { createId } from '@paralleldrive/cuid2'; +import { baseSchema } from '@shared/entities'; +import { varchar, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core'; + +export const users = baseSchema.table('users', { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + username: varchar('username', { length: 50 }).unique(), + headline: varchar('headline', { length: 200 }), + location: varchar('location', { length: 255 }), + firstName: varchar('first_name', { length: 50 }).notNull(), + lastName: varchar('last_name', { length: 50 }).notNull(), + middleName: varchar('middle_name', { length: 50 }), + email: varchar('email', { length: 255 }).notNull().unique(), + bio: text('bio'), + phone: varchar('phone', { length: 20 }), + vacationStart: timestamp('vacation_start', { withTimezone: true, mode: 'string' }), + vacationEnd: timestamp('vacation_end', { withTimezone: true, mode: 'string' }), + vacationMessage: varchar('vacation_message', { length: 255 }), + gender: text('gender') + .$type<'male' | 'female' | 'non_binary' | 'other' | 'none' | 'prefer_not_to_say'>() + .default('none'), + pronouns: text('pronouns') + .$type<'he_him' | 'she_her' | 'they_them' | 'other' | 'none'>() + .default('none'), + pronounsCustom: varchar('pronouns_custom', { length: 50 }), + avatarUrl: varchar('avatar_url', { length: 512 }), + emailVerified: boolean('email_verified').default(false).notNull(), + emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true, mode: 'string' }), + lastTeamId: text('last_team_id'), + deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), +}); + +export const userPreferences = baseSchema.table('user_preferences', { + userId: text('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + theme: text('theme').$type<'light' | 'dark' | 'system'>().default('system'), + timezone: varchar('timezone', { length: 50 }).default('UTC').notNull(), + language: varchar('language', { length: 5 }).default('ru').notNull(), +}); + +export const userSecurity = baseSchema.table('user_security', { + userId: text('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + passwordHash: varchar('password_hash', { length: 255 }), + recoveryEmail: varchar('recovery_email', { length: 255 }), + is2faEnabled: boolean('is_2fa_enabled').default(false).notNull(), + twoFactorSecret: text('two_factor_secret'), + lastLoginAt: timestamp('last_login_at', { withTimezone: true, mode: 'string' }), + lastPasswordChange: timestamp('last_password_change', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), +}); + +export const userNotifications = baseSchema.table('user_notifications', { + userId: text('user_id') + .primaryKey() + .references(() => users.id, { onDelete: 'cascade' }), + settings: jsonb('settings') + .$type<{ + readonly email: { + readonly task_assigned: boolean; + readonly mentions: boolean; + readonly daily_summary: boolean; + }; + readonly push: { readonly task_assigned: boolean; readonly reminders: boolean }; + }>() + .default({ + email: { task_assigned: true, mentions: true, daily_summary: false }, + push: { task_assigned: true, reminders: true }, + }) + .notNull(), +}); + +export const userActivity = baseSchema.table('user_activity', { + id: text('id').primaryKey(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + eventType: varchar('event_type', { length: 50 }).notNull(), + entityId: varchar('entity_id'), + metadata: jsonb('metadata'), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), +}); diff --git a/src/user/infrastructure/persistence/repositories/index.ts b/src/user/infrastructure/persistence/repositories/index.ts new file mode 100644 index 00000000..c9c59cf1 --- /dev/null +++ b/src/user/infrastructure/persistence/repositories/index.ts @@ -0,0 +1 @@ +export { UserRepository } from './user.repository'; diff --git a/src/user/infrastructure/persistence/repositories/user.repository.ts b/src/user/infrastructure/persistence/repositories/user.repository.ts new file mode 100644 index 00000000..7362f534 --- /dev/null +++ b/src/user/infrastructure/persistence/repositories/user.repository.ts @@ -0,0 +1,239 @@ +import { IUserRepository } from '@core/user/domain/repository'; +import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import { desc, eq, count, inArray } from 'drizzle-orm'; + +import * as sc from '../models'; + +import type { + NewUser, + NewUserActivity, + User, + UserNotifications, + UserPreferences, +} from '@core/user/domain/entities'; + +@Injectable() +export class UserRepository implements IUserRepository { + constructor( + @Inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + ) {} + + private get fullUserQuery() { + return this.db + .select() + .from(sc.users) + .leftJoin(sc.userSecurity, eq(sc.users.id, sc.userSecurity.userId)) + .leftJoin(sc.userNotifications, eq(sc.users.id, sc.userNotifications.userId)); + } + + public findProfile = async (id: string) => { + const [rows] = await this.fullUserQuery + .leftJoin(sc.userPreferences, eq(sc.users.id, sc.userPreferences.userId)) + .where(eq(sc.users.id, id)); + + if (!rows || !rows.users || !rows.user_security) { + throw new Error(`User with id ${id} not found`); + } + + const { lastPasswordChange, is2faEnabled } = rows.user_security; + + const defaultNotifications = { + email: { + task_assigned: true, + mentions: true, + daily_summary: false, + }, + push: { + task_assigned: true, + reminders: true, + }, + }; + + return { + user: rows.users, + security: { + lastPasswordChange: lastPasswordChange ?? null, + is2faEnabled: is2faEnabled ?? false, + }, + preferences: rows.user_preferences ?? null, + notifications: rows.user_notifications?.settings ?? defaultNotifications, + }; + }; + + public findByIds = async (ids: string[]) => { + if (ids.length === 0) { + return []; + } + + return this.db.select().from(sc.users).where(inArray(sc.users.id, ids)); + }; + + public findById = async (id: string) => { + const [row] = await this.fullUserQuery.where(eq(sc.users.id, id)); + if (!row || !row.user_security) { + return null; + } + return { + user: row.users, + security: { + passwordHash: row.user_security.passwordHash, + }, + }; + }; + + public findByEmail = async (email: string) => { + const [row] = await this.fullUserQuery.where(eq(sc.users.email, email.toLowerCase())); + if (!row || !row.user_security) { + return null; + } + return { + user: row.users, + security: { + passwordHash: row.user_security.passwordHash, + }, + }; + }; + + public findSecurityByUserId = async (userId: string) => { + const [result] = await this.db + .select() + .from(sc.userSecurity) + .where(eq(sc.userSecurity.userId, userId)); + return result || null; + }; + + public create = async (data: NewUser) => + this.db.transaction(async (tx) => { + const [newUser] = await tx.insert(sc.users).values(data).returning(); + + if (!newUser) { + throw new Error('Failed to create user'); + } + + await tx.insert(sc.userSecurity).values({ + userId: newUser.id, + is2faEnabled: false, + lastLoginAt: new Date().toISOString(), + passwordHash: null, + }); + + await tx.insert(sc.userNotifications).values({ + userId: newUser.id, + }); + + return newUser; + }); + + public updateProfile = async ( + id: string, + user: Partial, + preferences?: Partial, + ) => { + const results = await Promise.all([ + this.updateUser(id, user), + this.upsertPreferences(id, preferences), + ]); + + return results.some((result) => result === true); + }; + + private async updateUser(id: string, data: Partial) { + if (Object.keys(data).length === 0) { + return null; + } + + const result = await this.db + .update(sc.users) + .set({ ...data, updatedAt: new Date().toISOString() }) + .where(eq(sc.users.id, id)); + + return (result?.count ?? 0) > 0; + } + + private async upsertPreferences(userId: string, data?: Partial) { + if (!data || Object.keys(data).length === 0) { + return false; + } + + const existing = await this.db + .select({ id: sc.userPreferences.userId }) + .from(sc.userPreferences) + .where(eq(sc.userPreferences.userId, userId)) + .limit(1); + + if (existing.length === 0) { + const result = await this.db.insert(sc.userPreferences).values({ + userId, + ...data, + }); + + return (result.count ?? 0) > 0; + } else { + const result = await this.db + .update(sc.userPreferences) + .set(data) + .where(eq(sc.userPreferences.userId, userId)); + + return (result.count ?? 0) > 0; + } + } + + async updateNotifications(id: string, settings: UserNotifications['settings']) { + const result = await this.db + .update(sc.userNotifications) + .set({ settings }) + .where(eq(sc.userNotifications.userId, id)); + return (result?.count ?? 0) > 0; + } + + async updateAvatar(id: string, url: string) { + const result = await this.db + .update(sc.users) + .set({ avatarUrl: url, updatedAt: new Date().toISOString() }) + .where(eq(sc.users.id, id)); + return (result?.count ?? 0) > 0; + } + + async updatePasswordHash(id: string, hash: string) { + const result = await this.db + .insert(sc.userSecurity) + .values({ userId: id, passwordHash: hash }) + .onConflictDoUpdate({ + target: sc.userSecurity.userId, + set: { passwordHash: hash, lastPasswordChange: new Date().toISOString() }, + }); + return (result?.count ?? 0) > 0; + } + + async logActivity(data: NewUserActivity) { + const result = await this.db.insert(sc.userActivity).values({ + ...data, + id: data.id ?? createId(), + }); + return (result?.count ?? 0) > 0; + } + + async findActivityByUser(userId: string, options: { limit: number; offset: number }) { + const [totalResult, items] = await Promise.all([ + this.db + .select({ value: count() }) + .from(sc.userActivity) + .where(eq(sc.userActivity.userId, userId)), + this.db + .select() + .from(sc.userActivity) + .where(eq(sc.userActivity.userId, userId)) + .limit(options.limit) + .offset(options.offset) + .orderBy(desc(sc.userActivity.createdAt)), + ]); + + return { + items, + total: Number(totalResult[0]?.value ?? 0), + }; + } +} diff --git a/src/user/user.module.ts b/src/user/user.module.ts new file mode 100644 index 00000000..4ec60c33 --- /dev/null +++ b/src/user/user.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; + +import { UserController, UserSettingsController } from './application/controllers'; +import { USER_EXTERNAL_USE_CASES, UserQueries, UserUseCases } from './application/use-cases'; +import { UserFacade } from './application/user.facade'; +import { LISTENERS } from './infrastructure/listeners'; +import { UserRepository } from './infrastructure/persistence/repositories'; + +const REPOSITORY = { + provide: 'IUserRepository', + useClass: UserRepository, +}; + +@Module({ + imports: [], + controllers: [UserController, UserSettingsController], + providers: [REPOSITORY, ...UserUseCases, ...UserQueries, ...LISTENERS, UserFacade], + exports: [...USER_EXTERNAL_USE_CASES], +}) +export class UserModule {} diff --git a/templates/confirmation.hbs b/templates/confirmation.hbs new file mode 100644 index 00000000..a8cf39d0 --- /dev/null +++ b/templates/confirmation.hbs @@ -0,0 +1,91 @@ + + + + + + +
+
+

Task Tracker

+
+
+

Проверка безопасности

+

Привет, {{name}}! Используйте этот код для подтверждения:

+ +
{{#each codeArray}}{{this}}{{/each}}
+ +

Код будет активен в течение 15 минут.

+
+ +
+ + \ No newline at end of file diff --git a/templates/reset-password.hbs b/templates/reset-password.hbs new file mode 100644 index 00000000..735b91cd --- /dev/null +++ b/templates/reset-password.hbs @@ -0,0 +1,92 @@ + + + + + + +
+
+

Task Tracker

+
+
+

Сброс пароля

+

Здравствуйте!

+

Мы получили запрос на восстановление пароля для вашего аккаунта.
Ваш + одноразовый код для сброса:

+
{{#each codeArray}}
{{this}}
{{/each}}
+ +

Никому не сообщайте этот код. Если вы не + запрашивали сброс пароля, немедленно обратитесь в поддержку.

+
+ +
+ + \ No newline at end of file diff --git a/templates/team-invitation.hbs b/templates/team-invitation.hbs new file mode 100644 index 00000000..9ed932be --- /dev/null +++ b/templates/team-invitation.hbs @@ -0,0 +1,94 @@ + + + + + + +
+
+

Task Tracker

+
+
+

Приглашение в команду

+

Вас пригласили присоединиться к команде {{teamName}}!

+ Присоединиться к команде +

+ Если кнопка не работает, скопируйте и вставьте эту ссылку в браузер:
+ {{inviteUrl}} +

+
+ +
+ + \ No newline at end of file diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 50cda623..8c5f6905 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,24 +1,33 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { AppModule } from './../src/app.module'; - -describe('AppController (e2e)', () => { - let app: INestApplication; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); - }); +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { Test, type TestingModule } from '@nestjs/testing'; + +import { AppModule } from '../src/app.module'; + +describe('App (e2e)', () => { + let app: NestFastifyApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(new FastifyAdapter()); + + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('/health (GET)', async () => { + const res = await app.inject({ + method: 'GET', + url: '/health', + }); + + expect(res.statusCode).toBe(200); + expect(res.payload).toBe('healthy'); + }); }); diff --git a/test/jest-e2e.json b/test/jest-e2e.json deleted file mode 100644 index e9d912f3..00000000 --- a/test/jest-e2e.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", - "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - } -} diff --git a/tsconfig.build.json b/tsconfig.build.json index 64f86c6b..aed3485a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } diff --git a/tsconfig.json b/tsconfig.json index 95f5641c..d175afda 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,21 +1,63 @@ { - "compilerOptions": { - "module": "commonjs", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "target": "ES2021", - "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false - } + "compilerOptions": { + "module": "commonjs", + "declaration": false, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "resolveJsonModule": true, + "esModuleInterop": true, + "sourceMap": true, + "outDir": "./dist", + "incremental": true, + "skipLibCheck": true, + "types": ["node", "vitest/globals"], + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": false, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": false, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022"], + "paths": { + "@libs/bootstrap": ["./libs/bootstrap/src"], + "@libs/bootstrap/*": ["./libs/bootstrap/src/*"], + "@libs/config": ["./libs/config/src"], + "@libs/config/*": ["./libs/config/src/*"], + "@libs/database": ["./libs/database/src"], + "@libs/database/*": ["./libs/database/src/*"], + "@libs/health": ["./libs/health/src"], + "@libs/health/*": ["./libs/health/src/*"], + "@libs/imagor": ["./libs/imagor/src"], + "@libs/imagor/*": ["./libs/imagor/src/*"], + "@libs/metrics": ["./libs/metrics/src"], + "@libs/metrics/*": ["./libs/metrics/src/*"], + "@libs/s3": ["./libs/s3/src"], + "@libs/s3/*": ["./libs/s3/src/*"], + "@shared/*": ["./src/shared/*"], + "@core/*": ["./src/*"] + } + }, + "include": [ + "src/**/*", + "libs/**/*", + "test/**/*", + "drizzle.config.ts", + "vitest.config.ts", + "vitest.config.e2e.ts" + ], + "exclude": ["dist", "node_modules"] } diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts new file mode 100644 index 00000000..494b16e1 --- /dev/null +++ b/vitest.config.e2e.ts @@ -0,0 +1,14 @@ +import { mergeConfig, defineConfig } from 'vitest/config'; +import baseConfig from './vitest.config'; + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + include: ['test/**/*.e2e-spec.ts'], + exclude: [], + pool: 'forks', + isolate: true, + }, + }), +); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..156845ae --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,36 @@ +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + root: './', + globals: true, + environment: 'node', + include: ['**/*.spec.ts'], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/cypress/**', + '**/.{idea,git,cache,output,temp}/**', + '**/infra/**', + ], + alias: { + '@core': path.resolve(__dirname, './src'), + '@shared': path.resolve(__dirname, './src/shared'), + '@libs/bootstrap': path.join(process.cwd(), 'libs/bootstrap/src'), + '@libs/config': path.join(process.cwd(), 'libs/config/src'), + '@libs/database': path.join(process.cwd(), 'libs/database/src'), + '@libs/health': path.join(process.cwd(), 'libs/health/src'), + '@libs/imagor': path.join(process.cwd(), 'libs/s3/imagor'), + '@libs/s3': path.join(process.cwd(), 'libs/s3/src'), + }, + typecheck: { + enabled: true, + }, + }, + resolve: { + alias: { + src: path.resolve(__dirname, './src'), + }, + }, +});