diff --git a/.dev.env b/.dev.env new file mode 100644 index 00000000..f9e3698f --- /dev/null +++ b/.dev.env @@ -0,0 +1,10 @@ +VOLUMES_SQLITEDB=./.local/db +VOLUMES_MEDIA=./.local/media +VOLUMES_FILES=./.local/files +VOLUMES_LOGS=./.local/logs +VOLUMES_PLUGINS=./.local/plugins +VOLUMES_CONFIG=./.local/config +DB_SYSTEM=SQLITE +SERVER_API_DOCS_ENABLED=true +SERVER_LOG_LEVEL=debug +SERVER_ACCOUNT_ACTIVATION_DISABLED=true \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 1b29ab0f..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = { - parser: "@typescript-eslint/parser", - parserOptions: { - project: "tsconfig.json", - sourceType: "module", - ecmaVersion: "latest" - }, - plugins: ["@typescript-eslint/eslint-plugin", "simple-import-sort"], - extends: [ - "plugin:@typescript-eslint/recommended", - "plugin:prettier/recommended", - "plugin:import/recommended", - "plugin:import/typescript", - ], - root: true, - env: { - node: true, - jest: true, - }, - ignorePatterns: [".eslintrc.js"], - rules: { - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error" - }, -}; diff --git a/.github/workflows/deployment-develop.yml b/.github/workflows/deployment-develop.yml index 7f013ce8..742057f9 100644 --- a/.github/workflows/deployment-develop.yml +++ b/.github/workflows/deployment-develop.yml @@ -43,3 +43,15 @@ jobs: tags: | phalcode/gamevault-backend:unstable ghcr.io/phalcode/gamevault-backend:unstable + sonarcloud: + name: SonarCloud + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/deployment-early-access.yml b/.github/workflows/deployment-early-access.yml new file mode 100644 index 00000000..d50d1552 --- /dev/null +++ b/.github/workflows/deployment-early-access.yml @@ -0,0 +1,43 @@ +name: Build and Deploy for Early Access + +on: + push: + branches: + - early-access + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: phalcode + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: phalcode + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and Push + uses: docker/build-push-action@v4 + with: + platforms: linux/amd64,linux/arm64 + push: true + tags: | + phalcode/gamevault-backend:early-access + ghcr.io/phalcode/gamevault-backend:early-access \ No newline at end of file diff --git a/.local.env b/.local.env deleted file mode 100644 index bf78de0f..00000000 --- a/.local.env +++ /dev/null @@ -1,9 +0,0 @@ -VOLUMES_SQLITEDB=./.local/db -VOLUMES_IMAGES=./.local/images -VOLUMES_FILES=./.local/files -VOLUMES_LOGS=./.local/logs - -DB_SYSTEM=SQLITE -TESTING_GOOGLE_API_DISABLED=true -SERVER_ACCOUNT_ACTIVATION_DISABLED=true -SERVER_ADMIN_USERNAME=admin \ No newline at end of file diff --git a/.local/images/.gitkeep b/.local/config/.gitkeep similarity index 100% rename from .local/images/.gitkeep rename to .local/config/.gitkeep diff --git a/src/modules/pagination/paginated-api-response.model.ts b/.local/media/.gitkeep similarity index 100% rename from src/modules/pagination/paginated-api-response.model.ts rename to .local/media/.gitkeep diff --git a/.local/plugins/.gitkeep b/.local/plugins/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.npm-upgrade.json b/.npm-upgrade.json index 06dc6e39..3e961060 100644 --- a/.npm-upgrade.json +++ b/.npm-upgrade.json @@ -1,8 +1,12 @@ { "ignore": { "mime": { - "versions": "4", + "versions": ">3", "reason": "ESM" + }, + "better-sqlite3": { + "versions": ">9", + "reason": "TypeORM compatibility" } } } \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index feda119f..9abc78cd 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { "singleQuote": false, - "endOfLine": "auto" + "endOfLine": "auto", + "plugins": ["prettier-plugin-organize-imports"] } diff --git a/.testing.env b/.testing.env index 63a8a5e0..3b32b913 100644 --- a/.testing.env +++ b/.testing.env @@ -1,7 +1,8 @@ VOLUMES_SQLITEDB=./.local/db -VOLUMES_IMAGES=./.local/images +VOLUMES_MEDIA=./.local/media VOLUMES_FILES=./.local/files VOLUMES_LOGS=./.local/logs +VOLUMES_PLUGINS=./.local/plugins SERVER_LOG_LEVEL=debug SERVER_ADMIN_USERNAME=admin @@ -9,4 +10,4 @@ SERVER_ADMIN_PASSWORD=12345678 DB_SYSTEM=SQLITE TESTING_IN_MEMORY_DB=true TESTING_MOCK_FILES=true -RAWG_API_KEY=FAKEAPIKEY \ No newline at end of file +DB_SYNCHRONIZE=true #TODO: Remove this after fixing migration \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..bd3cda4e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "phalcode", + "projectKey": "Phalcode_gamevault-backend" + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c1dc33..b5e4de5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # GameVault Backend Server Changelog +## 13.0.0 + +Recommended Gamevault App Version: `v1.12.0.5` + +### Breaking Changes & Migration + +**A lot** has changed in this version. Honestly, I’ve almost rewritten the entire codebase. + +Please read the migration instructions below **BEFORE UPDATING**! (Migration instructions are marked **in bold**) + +**For existing servers:** The migration process may take up to 30 minutes or even longer for larger servers. During this time, clients will not be able to use the server. The container may appear as UNHEALTHY after 5 minutes during a long migration, but don’t worry—let it run as long as logs are active. Be sure to disable any auto-heal processes for GameVault to avoid interruptions. + +**After the migration:** The existing data might not be visible at first glance because we need to "merge" it. This could also take a while. Check the logs for inactivity before contacting us about this. + +- Major database and codebase overhaul! + - **I’ve done my best to migrate your existing data, but nobody’s perfect. Be sure to back up your data thoroughly before migrating, and contact us if you encounter any migration errors.** +- Some configurations and environment variables have changed. + - **Ensure your environment variables are [configured correctly](https://gamevau.lt/docs/server-docs/configuration).** +- [#140](https://github.com/Phalcode/gamevault-backend/issues/140): Introduced a new plugin framework that universally supports any metadata provider plugin and implemented a built-in IGDB Metadata Provider Plugin as the new default. + - **This is necessary, as RAWG integration has been removed. Learn how to set up the IGDB plugin [here](https://gamevaul.lt/docs/server-docs/metadata-enrichment/provider-igdb).** + - **IGDB metadata is now prioritized over RAWG, as its data quality is superior. If you want your existing data to remain the primary source, set the `METADATA_IGDB_PRIORITY` environment variable to a value lower than `-10` before running the update.** + - **We recommend first migrating the server to v13, then setting up IGDB and restarting, to minimize downtime.** + - **Old experimental plugins are no longer supported. Remove them if you were using any (Spoiler: You probably weren’t).** +- Added support for more media types beyond just images. You can now upload audio and video files as well. + - **You now need to mount your `/images` volume as `/media`.** +- Implemented parental control features. [#304](https://github.com/Phalcode/gamevault-backend/issues/304) + - **Learn how it works and how to set it up [here](https://gamevau.lt/docs/server-docs/parental-control).** +- Various API changes. + - **Check the API documentation for any updates if you're using the REST API.** + +### Changes + +- Removed RAWG integration and all configurations for it. +- Removed Google Images Boxart Scraper. (Let's be honest, it was shit anyway.) +- Optimized the Game Indexer. It now usually only reads games that have changed instead of reading all files all the time. +- Optimized startup time. +- [#161](https://github.com/Phalcode/gamevault-backend/issues/161) Implemented editing of games. +- [#423](https://github.com/Phalcode/gamevault-app/issues/423) Implemented a `news.md` (a.k.a. Message of the Day) file and a `GET /config/news` API you can use to communicate news to your users. -> **Learn how to set it up [here](https://gamevau.lt/docs/server-docs/server-news).** +- Implemented a notes field in games. +- Implemented default launch parameters, default launch executable, and default installer file fields in games. + ## 12.2.0 Recommended Gamevault App Version: `v1.11.0.0` diff --git a/Dockerfile b/Dockerfile index a983575b..af0b9efc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,9 +12,9 @@ ENV PATH=$PNPM_HOME:$PATH ENV SERVER_PORT=8080 # Create directories and set more restrictive permissions -RUN mkdir -p /files /images /logs /db \ - && chown -R node:node /files /images /logs /db \ - && chmod -R 777 /files /images /logs /db +RUN mkdir -p /files /media /logs /db /plugins \ + && chown -R node:node /files /media /logs /db /plugins \ + && chmod -R 777 /files /media /logs /db /plugins # Install pnpm and other needed tools RUN sed -i -e's/ main/ main non-free non-free-firmware contrib/g' /etc/apt/sources.list.d/debian.sources \ @@ -57,8 +57,8 @@ RUN chmod +x /usr/local/bin/entrypoint.sh EXPOSE ${SERVER_PORT}/tcp -# Periodic Healthcheck on /api/v1/health -HEALTHCHECK CMD curl -f http://localhost:${SERVER_PORT}/api/health || exit +# Periodic Healthcheck on /api/health +HEALTHCHECK --start-period=300s CMD curl -f http://localhost:${SERVER_PORT}/api/health || exit ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD [ "dist/src/main" ] diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..02b3b3a3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Supported Versions + +The following Docker Tags are supported by Phalcode and get regular security updates. + +| Version | Regular Updates | Supported | +| ------- | ---|------------------ | +| ``latest`` | :white_check_mark:|:white_check_mark: | +| ``unstable`` |:white_check_mark: |:x: | +| ``early-access`` | :x: |:x:| + +## Reporting a Vulnerability +Please report any vulnerabilities to Phalcode [via Email](mailto:contact@phalco.de). diff --git a/assets/ignored-executables.txt b/assets/ignored-executables.txt index e8a38ce7..ff812ab4 100644 --- a/assets/ignored-executables.txt +++ b/assets/ignored-executables.txt @@ -21,6 +21,7 @@ installfile installscript installwizard notification_helper +dotNetFx40_Full_setup oalinst patcher patchinstaller diff --git a/migrations.docker-compose.yml b/docker-compose.migration.yml similarity index 100% rename from migrations.docker-compose.yml rename to docker-compose.migration.yml diff --git a/docker-compose.sqlite.yml b/docker-compose.sqlite.yml new file mode 100644 index 00000000..225c0dd8 --- /dev/null +++ b/docker-compose.sqlite.yml @@ -0,0 +1,8 @@ +services: + gamevault-backend: + build: . + restart: unless-stopped + environment: + DB_SYSTEM: SQLITE + ports: + - 8080:8080 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index bfd8bd41..3ceb57de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.8" services: gamevault-backend: build: . @@ -7,14 +6,10 @@ services: DB_HOST: db DB_USERNAME: gamevault DB_PASSWORD: RANDOMPASSWORD - # The Following Line grants Admin Role to account with this username upon registration. - SERVER_ADMIN_USERNAME: admin - # Uncomment and Insert your RAWG API Key here if you have one (https://gamevau.lt/docs/server-docs/indexing-and-metadata#rawg-api-key) - # RAWG_API_KEY: YOURAPIKEYHERE ports: - 8080:8080 db: - image: postgres:15 + image: postgres:16 restart: unless-stopped environment: POSTGRES_USER: gamevault diff --git a/entrypoint.sh b/entrypoint.sh index f216caf3..0586b374 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,6 +1,14 @@ #!/bin/sh +echo "#################################################################################" +echo "# ██████╗ █████╗ ███╗ ███╗███████╗██╗ ██╗ █████╗ ██╗ ██╗██╗ ████████╗ #" +echo "# ██╔════╝ ██╔══██╗████╗ ████║██╔════╝██║ ██║██╔══██╗██║ ██║██║ ╚══██╔══╝ #" +echo "# ██║ ███╗███████║██╔████╔██║█████╗ ██║ ██║███████║██║ ██║██║ ██║ #" +echo "# ██║ ██║██╔══██║██║╚██╔╝██║██╔══╝ ╚██╗ ██╔╝██╔══██║██║ ██║██║ ██║ #" +echo "# ╚██████╔╝██║ ██║██║ ╚═╝ ██║███████╗ ╚████╔╝ ██║ ██║╚██████╔╝███████╗██║ #" +echo "# ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ #" +echo "# developed by Phalcode #" +echo "#################################################################################" set -e - # If running as root, it means the --user directive for Docker CLI/Compose was not used # Use then the PUID env to set the user and group IDs if [ "$(id -u)" = '0' ]; then diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..d6354b87 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,47 @@ +import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; +import { FlatCompat } from "@eslint/eslintrc"; +import js from "@eslint/js"; +import typescriptEslintEslintPlugin from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import globals from "globals"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + { + ignores: ["**/.eslintrc.js"], + }, + ...fixupConfigRules( + compat.extends( + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ), + ), + { + plugins: { + "@typescript-eslint": fixupPluginRules(typescriptEslintEslintPlugin), + }, + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + parser: tsParser, + ecmaVersion: "latest", + sourceType: "module", + parserOptions: { + project: "tsconfig.json", + }, + }, + }, +]; diff --git a/package.json b/package.json index 23664633..e063932e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gamevault-backend", - "version": "12.2.0", + "version": "13.0.0", "description": "the self-hosted gaming platform for drm-free games", "author": "Alkan Alper, Schäfer Philip GbR / Phalcode", "private": true, @@ -25,91 +25,94 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand" }, "dependencies": { - "@img/sharp-linux-x64": "^0.33.4", - "@nestjs/axios": "^3.0.2", - "@nestjs/common": "^10.3.9", - "@nestjs/core": "^10.3.9", + "@nestjs/common": "^10.4.5", + "@nestjs/core": "^10.4.5", "@nestjs/event-emitter": "^2.0.4", "@nestjs/passport": "^10.0.3", - "@nestjs/platform-express": "^10.3.9", - "@nestjs/platform-socket.io": "^10.3.9", - "@nestjs/schedule": "^4.0.2", - "@nestjs/swagger": "^7.3.1", + "@nestjs/platform-express": "^10.4.5", + "@nestjs/platform-socket.io": "^10.4.5", + "@nestjs/schedule": "^4.1.1", + "@nestjs/swagger": "^7.4.2", "@nestjs/typeorm": "^10.0.2", - "@nestjs/websockets": "^10.3.9", - "@types/stream-throttle": "^0.1.4", - "async-g-i-s": "^1.5.2", - "axios": "^1.7.2", + "@nestjs/websockets": "^10.4.5", "bcrypt": "^5.1.1", "better-sqlite3": "^9.0.0", "builder-pattern": "^2.2.0", - "chokidar": "^3.6.0", + "bytes": "^3.1.2", + "chokidar": "^4.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "compression": "^1.7.4", - "cookie-parser": "^1.4.6", + "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", - "express": "^4.19.2", - "fastify": "^4.27.0", - "file-type-checker": "^1.1.0", - "helmet": "^7.1.0", + "express": "^4.21.1", + "file-type-checker": "^1.1.2", + "helmet": "^8.0.0", "lodash": "^4.17.21", "mime": "^3.0.0", "morgan": "^1.10.0", "nest-winston": "^1.10.0", "nestjs-asyncapi": "^1.3.0", - "nestjs-paginate": "^8.6.2", + "nestjs-paginate": "^9.1.2", "node-7z": "^3.0.0", "passport": "^0.7.0", "passport-http": "^0.3.0", - "pg": "^8.12.0", + "pg": "^8.13.0", "reflect-metadata": "^0.2.2", - "rimraf": "^5.0.7", + "rimraf": "^6.0.1", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", - "sharp": "^0.33.4", - "socket.io": "^4.7.5", + "socket.io": "^4.8.0", "stream-throttle": "^0.1.3", "string-similarity-js": "^2.1.4", + "ts-igdb-client": "^0.4.2", "typeorm": "^0.3.20", "typeorm-naming-strategies": "^4.1.0", "unidecode": "^1.1.0", - "winston": "^3.13.0", + "winston": "^3.15.0", "winston-console-format": "^1.0.8", "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { - "@nestjs/cli": "^10.3.2", - "@nestjs/schematics": "^10.1.1", - "@nestjs/testing": "^10.3.9", + "@types/bytes": "^3.1.4", + "@types/stream-throttle": "^0.1.4", + "@eslint/compat": "^1.2.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.12.0", + "@nestjs/cli": "^10.4.5", + "@nestjs/schematics": "^10.2.0", + "@nestjs/testing": "^10.4.5", "@types/bcrypt": "^5.0.2", "@types/compression": "^1.7.5", "@types/cookie-parser": "^1.4.7", - "@types/express": "^4.17.21", - "@types/jest": "^29.5.12", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.13", + "@types/lodash": "^4.17.10", "@types/mime": "^3.0.4", "@types/morgan": "^1.9.9", - "@types/multer": "^1.4.11", - "@types/node": "^20.14.2", - "@types/node-7z": "^2.1.8", + "@types/multer": "^1.4.12", + "@types/node": "^22.7.5", + "@types/node-7z": "^2.1.10", "@types/passport-http": "^0.3.11", - "@types/pg": "^8.11.6", + "@types/pg": "^8.11.10", "@types/string-similarity": "^4.0.2", "@types/unidecode": "^0.1.3", - "@typescript-eslint/eslint-plugin": "^7.12.0", - "@typescript-eslint/parser": "^7.12.0", + "@typescript-eslint/eslint-plugin": "^8.9.0", + "@typescript-eslint/parser": "^8.9.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-import": "^2.29.1", - "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-simple-import-sort": "^12.1.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-prettier": "^5.2.1", + "globals": "^15.11.0", "jest": "^29.7.0", - "prettier": "^3.3.1", + "prettier": "^3.3.3", "prettier-plugin-jsdoc": "^1.3.0", + "prettier-plugin-organize-imports": "^4.1.0", "simple-git-hooks": "^2.11.1", - "ts-jest": "^29.1.4", + "ts-jest": "^29.2.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "~5.5.0", + "fastify": "^4.0.0" }, "jest": { "moduleFileExtensions": [ @@ -123,10 +126,10 @@ ], "testRegex": ".*\\.spec\\.ts$", "transform": { - ".+\\.(t|j)s$": "ts-jest" + ".+\\.ts$": "ts-jest" }, "collectCoverageFrom": [ - "**/*.(t|j)s" + "**/*.ts" ], "coverageDirectory": "../coverage", "testEnvironment": "node" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4665cf01..07ab7068 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,51 +8,36 @@ importers: .: dependencies: - '@img/sharp-linux-x64': - specifier: ^0.33.4 - version: 0.33.4 - '@nestjs/axios': - specifier: ^3.0.2 - version: 3.0.2(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(axios@1.7.2)(rxjs@7.8.1) '@nestjs/common': - specifier: ^10.3.9 - version: 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + specifier: ^10.4.5 + version: 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': - specifier: ^10.3.9 - version: 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) + specifier: ^10.4.5 + version: 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(@nestjs/websockets@10.4.5)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/event-emitter': specifier: ^2.0.4 - version: 2.0.4(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1)) + version: 2.0.4(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5) '@nestjs/passport': specifier: ^10.0.3 - version: 10.0.3(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(passport@0.7.0) + version: 10.0.3(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(passport@0.7.0) '@nestjs/platform-express': - specifier: ^10.3.9 - version: 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9) + specifier: ^10.4.5 + version: 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5) '@nestjs/platform-socket.io': - specifier: ^10.3.9 - version: 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.3.9)(rxjs@7.8.1) + specifier: ^10.4.5 + version: 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.5)(rxjs@7.8.1) '@nestjs/schedule': - specifier: ^4.0.2 - version: 4.0.2(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1)) + specifier: ^4.1.1 + version: 4.1.1(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5) '@nestjs/swagger': - specifier: ^7.3.1 - version: 7.3.1(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + specifier: ^7.4.2 + version: 7.4.2(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) '@nestjs/typeorm': specifier: ^10.0.2 - version: 10.0.2(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))) + version: 10.0.2(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.13.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4))) '@nestjs/websockets': - specifier: ^10.3.9 - version: 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)(@nestjs/platform-socket.io@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@types/stream-throttle': - specifier: ^0.1.4 - version: 0.1.4 - async-g-i-s: - specifier: ^1.5.2 - version: 1.5.2(node-fetch@2.7.0(encoding@0.1.13)) - axios: - specifier: ^1.7.2 - version: 1.7.2 + specifier: ^10.4.5 + version: 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(@nestjs/platform-socket.io@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1) bcrypt: specifier: ^5.1.1 version: 5.1.1(encoding@0.1.13) @@ -62,9 +47,12 @@ importers: builder-pattern: specifier: ^2.2.0 version: 2.2.0 + bytes: + specifier: ^3.1.2 + version: 3.1.2 chokidar: - specifier: ^3.6.0 - version: 3.6.0 + specifier: ^4.0.1 + version: 4.0.1 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -75,23 +63,20 @@ importers: specifier: ^1.7.4 version: 1.7.4 cookie-parser: - specifier: ^1.4.6 - version: 1.4.6 + specifier: ^1.4.7 + version: 1.4.7 dotenv: specifier: ^16.4.5 version: 16.4.5 express: - specifier: ^4.19.2 - version: 4.19.2 - fastify: - specifier: ^4.27.0 - version: 4.27.0 + specifier: ^4.21.1 + version: 4.21.1 file-type-checker: - specifier: ^1.1.0 - version: 1.1.0 + specifier: ^1.1.2 + version: 1.1.2 helmet: - specifier: ^7.1.0 - version: 7.1.0 + specifier: ^8.0.0 + version: 8.0.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -103,13 +88,13 @@ importers: version: 1.10.0 nest-winston: specifier: ^1.10.0 - version: 1.10.0(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(winston@3.13.0) + version: 1.10.0(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(winston@3.15.0) nestjs-asyncapi: specifier: ^1.3.0 - version: 1.3.0(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/swagger@7.3.1(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2))(@nestjs/websockets@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)(@nestjs/platform-socket.io@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@types/babel__core@7.20.3)(@types/node@20.14.2)(encoding@0.1.13) + version: 1.3.0(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(@nestjs/swagger@7.4.2(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2))(@nestjs/websockets@10.4.5)(@types/babel__core@7.20.3)(@types/node@22.7.5)(encoding@0.1.13) nestjs-paginate: - specifier: ^8.6.2 - version: 8.6.2(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/swagger@7.3.1(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2))(express@4.19.2)(fastify@4.27.0)(typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))) + specifier: ^9.1.2 + version: 9.1.2(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/swagger@7.4.2(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2))(express@4.21.1)(fastify@4.28.1)(typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.13.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4))) node-7z: specifier: ^3.0.0 version: 3.0.0 @@ -120,63 +105,75 @@ importers: specifier: ^0.3.0 version: 0.3.0 pg: - specifier: ^8.12.0 - version: 8.12.0 + specifier: ^8.13.0 + version: 8.13.0 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 rimraf: - specifier: ^5.0.7 - version: 5.0.7 + specifier: ^6.0.1 + version: 6.0.1 rxjs: specifier: ^7.8.1 version: 7.8.1 sanitize-filename: specifier: ^1.6.3 version: 1.6.3 - sharp: - specifier: ^0.33.4 - version: 0.33.4 socket.io: - specifier: ^4.7.5 - version: 4.7.5 + specifier: ^4.8.0 + version: 4.8.0 stream-throttle: specifier: ^0.1.3 version: 0.1.3 string-similarity-js: specifier: ^2.1.4 version: 2.1.4 + ts-igdb-client: + specifier: ^0.4.2 + version: 0.4.2 typeorm: specifier: ^0.3.20 - version: 0.3.20(better-sqlite3@9.6.0)(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + version: 0.3.20(better-sqlite3@9.6.0)(pg@8.13.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) typeorm-naming-strategies: specifier: ^4.1.0 - version: 4.1.0(typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))) + version: 4.1.0(typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.13.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4))) unidecode: specifier: ^1.1.0 version: 1.1.0 winston: - specifier: ^3.13.0 - version: 3.13.0 + specifier: ^3.15.0 + version: 3.15.0 winston-console-format: specifier: ^1.0.8 version: 1.0.8 winston-daily-rotate-file: specifier: ^5.0.0 - version: 5.0.0(winston@3.13.0) + version: 5.0.0(winston@3.15.0) devDependencies: + '@eslint/compat': + specifier: ^1.2.0 + version: 1.2.0(eslint@8.57.0) + '@eslint/eslintrc': + specifier: ^3.1.0 + version: 3.1.0 + '@eslint/js': + specifier: ^9.12.0 + version: 9.12.0 '@nestjs/cli': - specifier: ^10.3.2 - version: 10.3.2 + specifier: ^10.4.5 + version: 10.4.5 '@nestjs/schematics': - specifier: ^10.1.1 - version: 10.1.1(chokidar@3.6.0)(typescript@5.4.5) + specifier: ^10.2.0 + version: 10.2.0(chokidar@4.0.1)(typescript@5.5.4) '@nestjs/testing': - specifier: ^10.3.9 - version: 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)) + specifier: ^10.4.5 + version: 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(@nestjs/platform-express@10.4.5) '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 + '@types/bytes': + specifier: ^3.1.4 + version: 3.1.4 '@types/compression': specifier: ^1.7.5 version: 1.7.5 @@ -184,11 +181,14 @@ importers: specifier: ^1.4.7 version: 1.4.7 '@types/express': - specifier: ^4.17.21 - version: 4.17.21 + specifier: ^5.0.0 + version: 5.0.0 '@types/jest': - specifier: ^29.5.12 - version: 29.5.12 + specifier: ^29.5.13 + version: 29.5.13 + '@types/lodash': + specifier: ^4.17.10 + version: 4.17.10 '@types/mime': specifier: ^3.0.4 version: 3.0.4 @@ -196,20 +196,23 @@ importers: specifier: ^1.9.9 version: 1.9.9 '@types/multer': - specifier: ^1.4.11 - version: 1.4.11 + specifier: ^1.4.12 + version: 1.4.12 '@types/node': - specifier: ^20.14.2 - version: 20.14.2 + specifier: ^22.7.5 + version: 22.7.5 '@types/node-7z': - specifier: ^2.1.8 - version: 2.1.8 + specifier: ^2.1.10 + version: 2.1.10 '@types/passport-http': specifier: ^0.3.11 version: 0.3.11 '@types/pg': - specifier: ^8.11.6 - version: 8.11.6 + specifier: ^8.11.10 + version: 8.11.10 + '@types/stream-throttle': + specifier: ^0.1.4 + version: 0.1.4 '@types/string-similarity': specifier: ^4.0.2 version: 4.0.2 @@ -217,11 +220,11 @@ importers: specifier: ^0.1.3 version: 0.1.3 '@typescript-eslint/eslint-plugin': - specifier: ^7.12.0 - version: 7.12.0(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5) + specifier: ^8.9.0 + version: 8.9.0(@typescript-eslint/parser@8.9.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4) '@typescript-eslint/parser': - specifier: ^7.12.0 - version: 7.12.0(eslint@8.57.0)(typescript@5.4.5) + specifier: ^8.9.0 + version: 8.9.0(eslint@8.57.0)(typescript@5.5.4) eslint: specifier: ^8.56.0 version: 8.57.0 @@ -229,35 +232,41 @@ importers: specifier: ^9.1.0 version: 9.1.0(eslint@8.57.0) eslint-plugin-import: - specifier: ^2.29.1 - version: 2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0) + specifier: ^2.31.0 + version: 2.31.0(@typescript-eslint/parser@8.9.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0) eslint-plugin-prettier: - specifier: ^5.1.3 - version: 5.1.3(@types/eslint@8.4.6)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.1) - eslint-plugin-simple-import-sort: - specifier: ^12.1.0 - version: 12.1.0(eslint@8.57.0) + specifier: ^5.2.1 + version: 5.2.1(@types/eslint@8.4.6)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.3) + fastify: + specifier: ^4.0.0 + version: 4.28.1 + globals: + specifier: ^15.11.0 + version: 15.11.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + version: 29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) prettier: - specifier: ^3.3.1 - version: 3.3.1 + specifier: ^3.3.3 + version: 3.3.3 prettier-plugin-jsdoc: specifier: ^1.3.0 - version: 1.3.0(prettier@3.3.1) + version: 1.3.0(prettier@3.3.3) + prettier-plugin-organize-imports: + specifier: ^4.1.0 + version: 4.1.0(prettier@3.3.3)(typescript@5.5.4) simple-git-hooks: specifier: ^2.11.1 version: 2.11.1 ts-jest: - specifier: ^29.1.4 - version: 29.1.4(@babel/core@7.12.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.12.9))(jest@29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)))(typescript@5.4.5) + specifier: ^29.2.5 + version: 29.2.5(@babel/core@7.12.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.12.9))(jest@29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)))(typescript@5.5.4) ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.14.2)(typescript@5.4.5) + version: 10.9.2(@types/node@22.7.5)(typescript@5.5.4) typescript: - specifier: ^5.4.5 - version: 5.4.5 + specifier: ~5.5.0 + version: 5.5.4 packages: @@ -269,8 +278,17 @@ packages: resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} engines: {node: '>=6.0.0'} - '@angular-devkit/core@17.1.2': - resolution: {integrity: sha512-ku+/W/HMCBacSWFppenr9y6Lx8mDuTuQvn1IkTyBLiJOpWnzgVbx9kHDeaDchGa1PwLlJUBBrv27t3qgJOIDPw==} + '@angular-devkit/core@17.3.10': + resolution: {integrity: sha512-czdl54yxU5DOAGy/uUPNjJruoBDTgwi/V+eOgLNybYhgrc+TsY0f7uJ11yEk/pz5sCov7xIiS7RdRv96waS7vg==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^3.5.2 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/core@17.3.8': + resolution: {integrity: sha512-Q8q0voCGudbdCgJ7lXdnyaxKHbNQBARH68zPQV72WT8NWy+Gw/tys870i6L58NWbBaCJEUcIj/kb6KoakSRu+Q==} engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: chokidar: ^3.5.2 @@ -278,13 +296,17 @@ packages: chokidar: optional: true - '@angular-devkit/schematics-cli@17.1.2': - resolution: {integrity: sha512-bvXykYzSST05qFdlgIzUguNOb3z0hCa8HaTwtqdmQo9aFPf+P+/AC56I64t1iTchMjQtf3JrBQhYM25gUdcGbg==} + '@angular-devkit/schematics-cli@17.3.8': + resolution: {integrity: sha512-TjmiwWJarX7oqvNiRAroQ5/LeKUatxBOCNEuKXO/PV8e7pn/Hr/BqfFm+UcYrQoFdZplmtNAfqmbqgVziKvCpA==} engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular-devkit/schematics@17.1.2': - resolution: {integrity: sha512-8S9RuM8olFN/gwN+mjbuF1CwHX61f0i59EGXz9tXLnKRUTjsRR+8vVMTAmX0dvVAT5fJTG/T69X+HX7FeumdqA==} + '@angular-devkit/schematics@17.3.10': + resolution: {integrity: sha512-FHcNa1ktYRd0SKExCsNJpR75RffsyuPIV8kvBXzXnLHmXMqvl25G2te3yYJ9yYqy9OLy/58HZznZTxWRyUdHOg==} + 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@17.3.8': + resolution: {integrity: sha512-QRVEYpIfgkprNHc916JlPuNbLzOgrm9DZalHasnLUz4P6g7pR21olb8YCyM2OTJjombNhya9ZpckcADU5Qyvlg==} engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@apidevtools/json-schema-ref-parser@9.1.2': @@ -1008,9 +1030,6 @@ packages: '@dabh/diagnostics@2.0.3': resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} - '@emnapi/runtime@1.2.0': - resolution: {integrity: sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==} - '@eslint-community/eslint-utils@4.4.0': resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1021,26 +1040,43 @@ packages: resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint/compat@1.2.0': + resolution: {integrity: sha512-CkPWddN7J9JPrQedEr2X7AjK9y1jaMJtxZ4A/+jTMFA2+n5BWhcKHW/EbJyARqg2zzQfgtWUtVmG3hrG6+nGpg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.10.0 + peerDependenciesMeta: + eslint: + optional: true + '@eslint/eslintrc@2.1.4': resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@eslint/eslintrc@3.1.0': + resolution: {integrity: sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@8.57.0': resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@fastify/ajv-compiler@3.5.0': - resolution: {integrity: sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA==} + '@eslint/js@9.12.0': + resolution: {integrity: sha512-eohesHH8WFRUprDNyEREgqP6beG6htMeUYeCpkEgBCieCMme5r9zFWjzAJp//9S+Kub4rqE+jXe9Cp1a7IYIIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@fastify/deepmerge@1.3.0': - resolution: {integrity: sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A==} + '@fastify/ajv-compiler@3.6.0': + resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==} - '@fastify/error@3.4.0': - resolution: {integrity: sha512-e/mafFwbK3MNqxUcFBLgHhgxsF8UT1m8aj0dAlqEa2nJEgPsRtpHTZ3ObgrgkZ2M1eJHPTwgyUl/tXkvabsZdQ==} + '@fastify/error@3.4.1': + resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} '@fastify/fast-json-stringify-compiler@4.3.0': resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} + '@fastify/merge-json-schemas@0.1.1': + resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} + '@fmvilas/pseudo-yaml-ast@0.3.1': resolution: {integrity: sha512-8OAB74W2a9M3k9bjYD8AjVXkX+qO8c0SqNT5HlgOqx7AxSw8xdksEcZp7gFtfi+4njSxT6+76ZR+1ubjAwQHOg==} @@ -1058,118 +1094,6 @@ packages: '@humanwhocodes/object-schema@2.0.3': resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - '@img/sharp-darwin-arm64@0.33.4': - resolution: {integrity: sha512-p0suNqXufJs9t3RqLBO6vvrgr5OhgbWp76s5gTRvdmxmuv9E1rcaqGUsl3l4mKVmXPkTkTErXediAui4x+8PSA==} - engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.33.4': - resolution: {integrity: sha512-0l7yRObwtTi82Z6ebVI2PnHT8EB2NxBgpK2MiKJZJ7cz32R4lxd001ecMhzzsZig3Yv9oclvqqdV93jo9hy+Dw==} - engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.0.2': - resolution: {integrity: sha512-tcK/41Rq8IKlSaKRCCAuuY3lDJjQnYIW1UXU1kxcEKrfL8WR7N6+rzNoOxoQRJWTAECuKwgAHnPvqXGN8XfkHA==} - engines: {macos: '>=11', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.0.2': - resolution: {integrity: sha512-Ofw+7oaWa0HiiMiKWqqaZbaYV3/UGL2wAPeLuJTx+9cXpCRdvQhCLG0IH8YGwM0yGWGLpsF4Su9vM1o6aer+Fw==} - engines: {macos: '>=10.13', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.0.2': - resolution: {integrity: sha512-x7kCt3N00ofFmmkkdshwj3vGPCnmiDh7Gwnd4nUwZln2YjqPxV1NlTyZOvoDWdKQVDL911487HOueBvrpflagw==} - engines: {glibc: '>=2.26', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linux-arm@1.0.2': - resolution: {integrity: sha512-iLWCvrKgeFoglQxdEwzu1eQV04o8YeYGFXtfWU26Zr2wWT3q3MTzC+QTCO3ZQfWd3doKHT4Pm2kRmLbupT+sZw==} - engines: {glibc: '>=2.28', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [arm] - os: [linux] - - '@img/sharp-libvips-linux-s390x@1.0.2': - resolution: {integrity: sha512-cmhQ1J4qVhfmS6szYW7RT+gLJq9dH2i4maq+qyXayUSn9/3iY2ZeWpbAgSpSVbV2E1JUL2Gg7pwnYQ1h8rQIog==} - engines: {glibc: '>=2.28', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [s390x] - os: [linux] - - '@img/sharp-libvips-linux-x64@1.0.2': - resolution: {integrity: sha512-E441q4Qdb+7yuyiADVi5J+44x8ctlrqn8XgkDTwr4qPJzWkaHwD489iZ4nGDgcuya4iMN3ULV6NwbhRZJ9Z7SQ==} - engines: {glibc: '>=2.26', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [x64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-arm64@1.0.2': - resolution: {integrity: sha512-3CAkndNpYUrlDqkCM5qhksfE+qSIREVpyoeHIU6jd48SJZViAmznoQQLAv4hVXF7xyUB9zf+G++e2v1ABjCbEQ==} - engines: {musl: '>=1.2.2', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-x64@1.0.2': - resolution: {integrity: sha512-VI94Q6khIHqHWNOh6LLdm9s2Ry4zdjWJwH56WoiJU7NTeDwyApdZZ8c+SADC8OH98KWNQXnE01UdJ9CSfZvwZw==} - engines: {musl: '>=1.2.2', npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [x64] - os: [linux] - - '@img/sharp-linux-arm64@0.33.4': - resolution: {integrity: sha512-2800clwVg1ZQtxwSoTlHvtm9ObgAax7V6MTAB/hDT945Tfyy3hVkmiHpeLPCKYqYR1Gcmv1uDZ3a4OFwkdBL7Q==} - engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [arm64] - os: [linux] - - '@img/sharp-linux-arm@0.33.4': - resolution: {integrity: sha512-RUgBD1c0+gCYZGCCe6mMdTiOFS0Zc/XrN0fYd6hISIKcDUbAW5NtSQW9g/powkrXYm6Vzwd6y+fqmExDuCdHNQ==} - engines: {glibc: '>=2.28', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [arm] - os: [linux] - - '@img/sharp-linux-s390x@0.33.4': - resolution: {integrity: sha512-h3RAL3siQoyzSoH36tUeS0PDmb5wINKGYzcLB5C6DIiAn2F3udeFAum+gj8IbA/82+8RGCTn7XW8WTFnqag4tQ==} - engines: {glibc: '>=2.31', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [s390x] - os: [linux] - - '@img/sharp-linux-x64@0.33.4': - resolution: {integrity: sha512-GoR++s0XW9DGVi8SUGQ/U4AeIzLdNjHka6jidVwapQ/JebGVQIpi52OdyxCNVRE++n1FCLzjDovJNozif7w/Aw==} - engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - os: [linux] - - '@img/sharp-linuxmusl-arm64@0.33.4': - resolution: {integrity: sha512-nhr1yC3BlVrKDTl6cO12gTpXMl4ITBUZieehFvMntlCXFzH2bvKG76tBL2Y/OqhupZt81pR7R+Q5YhJxW0rGgQ==} - engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [arm64] - os: [linux] - - '@img/sharp-linuxmusl-x64@0.33.4': - resolution: {integrity: sha512-uCPTku0zwqDmZEOi4ILyGdmW76tH7dm8kKlOIV1XC5cLyJ71ENAAqarOHQh0RLfpIpbV5KOpXzdU6XkJtS0daw==} - engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [x64] - os: [linux] - - '@img/sharp-wasm32@0.33.4': - resolution: {integrity: sha512-Bmmauh4sXUsUqkleQahpdNXKvo+wa1V9KhT2pDA4VJGKwnKMJXiSTGphn0gnJrlooda0QxCtXc6RX1XAU6hMnQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [wasm32] - - '@img/sharp-win32-ia32@0.33.4': - resolution: {integrity: sha512-99SJ91XzUhYHbx7uhK3+9Lf7+LjwMGQZMDlO/E/YVJ7Nc3lyDFZPGhjwiYdctoH2BOzW9+TnfqcaMKt0jHLdqw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.33.4': - resolution: {integrity: sha512-3QLocdTRVIrFNye5YocZl+KKpYKP+fksi1QhmOArgx7GyhIbQp/WrJRu176jm8IxromS7RIkzMiMINVdBtC8Aw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} - cpu: [x64] - os: [win32] - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1305,8 +1229,8 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} - '@ljharb/through@2.3.11': - resolution: {integrity: sha512-ccfcIDlogiXNq5KcbAwbaO7lMh3Tm1i3khMPYpxlK8hH/W53zN81KM9coerRLOnTGu3nfXIniAmQbRI9OxbC0w==} + '@ljharb/through@2.3.13': + resolution: {integrity: sha512-/gKJun8NNiWGZJkGzI/Ragc53cOdcLNdzjLaIa+GEjguQs0ulsurx8WN0jijdK9yPqDvziX995sMRLyLt1uZMQ==} engines: {node: '>= 0.4'} '@lukeed/csprng@1.0.1': @@ -1317,22 +1241,15 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true - '@microsoft/tsdoc@0.14.2': - resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==} - - '@nestjs/axios@3.0.2': - resolution: {integrity: sha512-Z6GuOUdNQjP7FX+OuV2Ybyamse+/e0BFdTWBX5JxpBDKA+YkdLynDgG6HTF04zy6e9zPa19UX0WA2VDoehwhXQ==} - peerDependencies: - '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 - axios: ^1.3.1 - rxjs: ^6.0.0 || ^7.0.0 + '@microsoft/tsdoc@0.15.0': + resolution: {integrity: sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==} - '@nestjs/cli@10.3.2': - resolution: {integrity: sha512-aWmD1GLluWrbuC4a1Iz/XBk5p74Uj6nIVZj6Ov03JbTfgtWqGFLtXuMetvzMiHxfrHehx/myt2iKAPRhKdZvTg==} + '@nestjs/cli@10.4.5': + resolution: {integrity: sha512-FP7Rh13u8aJbHe+zZ7hM0CC4785g9Pw4lz4r2TTgRtf0zTxSWMkJaPEwyjX8SK9oWK2GsYxl+fKpwVZNbmnj9A==} engines: {node: '>= 16.14'} hasBin: true peerDependencies: - '@swc/cli': ^0.1.62 || ^0.3.0 + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 '@swc/core': ^1.3.62 peerDependenciesMeta: '@swc/cli': @@ -1340,8 +1257,8 @@ packages: '@swc/core': optional: true - '@nestjs/common@10.3.9': - resolution: {integrity: sha512-JAQONPagMa+sy/fcIqh/Hn3rkYQ9pQM51vXCFNOM5ujefxUVqn3gwFRMN8Y1+MxdUHipV+8daEj2jEm0IqJzOA==} + '@nestjs/common@10.4.5': + resolution: {integrity: sha512-N/yUyuYCBMb0+H6jHhntR7PURzji0usID/DByhOfooyk/aPGscI0aQKwOA6edlJlT92hHUvXYLJ5p3npj7KcjQ==} peerDependencies: class-transformer: '*' class-validator: '*' @@ -1353,8 +1270,8 @@ packages: class-validator: optional: true - '@nestjs/core@10.3.9': - resolution: {integrity: sha512-NzZUfWAmaf8sqhhwoRA+CuqxQe+P4Rz8PZp5U7CdCbjyeB9ZVGcBkihcJC9wMdtiOWHRndB2J8zRfs5w06jK3w==} + '@nestjs/core@10.4.5': + resolution: {integrity: sha512-wk0KJ+6tuidqAdeemsQ40BCp1BgMsSuSLG577aqXLxXYoa8FQYPrdxoSzd05znYLwJYM55fisZWb3FLF9HT2qw==} peerDependencies: '@nestjs/common': ^10.0.0 '@nestjs/microservices': ^10.0.0 @@ -1395,32 +1312,32 @@ packages: '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 passport: ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 - '@nestjs/platform-express@10.3.9': - resolution: {integrity: sha512-si/UzobP6YUtYtCT1cSyQYHHzU3yseqYT6l7OHSMVvfG1+TqxaAqI6nmrix02LO+l1YntHRXEs3p+v9a7EfrSQ==} + '@nestjs/platform-express@10.4.5': + resolution: {integrity: sha512-a629r8R8KC4skhdieQ0aIWH5vDBUFntWnWKFyDXQrll6/CllSchfWm87mWF39seaW6bXYtQtAEZY66JrngdrGA==} peerDependencies: '@nestjs/common': ^10.0.0 '@nestjs/core': ^10.0.0 - '@nestjs/platform-socket.io@10.3.9': - resolution: {integrity: sha512-l4Dwd+Y3uRya2twhViViMWBn12r5as7EYaF/VlSBLt+4dWLZPSrxGU09ubUTEo96yMJkkTjgSEYN/nGhIta7bw==} + '@nestjs/platform-socket.io@10.4.5': + resolution: {integrity: sha512-dHkHJQArhrpkX6qBdTW2ghuja3i3cCslwy4QHY6d46u+9UyANQlsNK9wt/lZnmXfCMaci8xAJvUpyODa6YtV7g==} peerDependencies: '@nestjs/common': ^10.0.0 '@nestjs/websockets': ^10.0.0 rxjs: ^7.1.0 - '@nestjs/schedule@4.0.2': - resolution: {integrity: sha512-po9oauE7fO0CjhDKvVC2tzEgjOUwhxYoIsXIVkgfu+xaDMmzzpmXY2s1LT4oP90Z+PaTtPoAHmhslnYmo4mSZg==} + '@nestjs/schedule@4.1.1': + resolution: {integrity: sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==} peerDependencies: '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 - '@nestjs/schematics@10.1.1': - resolution: {integrity: sha512-o4lfCnEeIkfJhGBbLZxTuVWcGuqDCFwg5OrvpgRUBM7vI/vONvKKiB5riVNpO+JqXoH0I42NNeDb0m4V5RREig==} + '@nestjs/schematics@10.2.0': + resolution: {integrity: sha512-fxUKABuyc2SR50mZIi7lte/p8RpEEIKcVGXNcFhD7yZ0kWoFOhqCERa4Qt4i8yrrht0PIkYvP1fPPKFWQetzVQ==} peerDependencies: typescript: '>=4.8.2' - '@nestjs/swagger@7.3.1': - resolution: {integrity: sha512-LUC4mr+5oAleEC/a2j8pNRh1S5xhKXJ1Gal5ZdRjt9XebQgbngXCdW7JTA9WOEcwGtFZN9EnKYdquzH971LZfw==} + '@nestjs/swagger@7.4.2': + resolution: {integrity: sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==} peerDependencies: '@fastify/static': ^6.0.0 || ^7.0.0 '@nestjs/common': ^9.0.0 || ^10.0.0 @@ -1436,8 +1353,8 @@ packages: class-validator: optional: true - '@nestjs/testing@10.3.9': - resolution: {integrity: sha512-z24SdpZIRtYyM5s2vnu7rbBosXJY/KcAP7oJlwgFa/h/z/wg8gzyoKy5lhibH//OZNO+pYKajV5wczxuy5WeAg==} + '@nestjs/testing@10.4.5': + resolution: {integrity: sha512-3NhmztE+fK3MuuOZhXihvMIhxm0QuDM2BneHvM5A0oVLG+STsAeGBqbDr/Ef2qsvqH5HaqvfGbVJ4N1DQnZE5A==} peerDependencies: '@nestjs/common': ^10.0.0 '@nestjs/core': ^10.0.0 @@ -1458,8 +1375,8 @@ packages: rxjs: ^7.2.0 typeorm: ^0.3.0 - '@nestjs/websockets@10.3.9': - resolution: {integrity: sha512-mKPIctbFoJ1BVYZL/sNlg0jWmkOTS0EIaJ5iULZvx83AA5K15kzettpQ3ls8u0qBsqbOe+ueqoDEpT1wHYKvyg==} + '@nestjs/websockets@10.4.5': + resolution: {integrity: sha512-LbL/HRLWQUBTUPY7swojOHdvokyVGINIiuP/VmRdhob4T751r+9i09z2RqRpP71psuom9mnRHYI1+vT2FABrAw==} peerDependencies: '@nestjs/common': ^10.0.0 '@nestjs/core': ^10.0.0 @@ -1537,8 +1454,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@pkgr/utils@2.4.2': - resolution: {integrity: sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==} + '@pkgr/core@0.1.1': + resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} '@rollup/plugin-babel@5.3.1': @@ -1558,6 +1475,9 @@ packages: peerDependencies: rollup: ^1.20.0||^2.0.0 + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -1679,6 +1599,9 @@ packages: '@types/body-parser@1.19.2': resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} + '@types/bytes@3.1.4': + resolution: {integrity: sha512-A0uYgOj3zNc4hNjHc5lYUfJQ/HVyBXiUMKdXd7ysclaE6k9oJdavQzODHuwjpUu2/boCP8afjQYi8z/GtvNCWA==} + '@types/compression@1.7.5': resolution: {integrity: sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==} @@ -1703,9 +1626,6 @@ packages: '@types/es-aggregate-error@1.0.4': resolution: {integrity: sha512-95tL6tLR8P3Utx4SxXUEc0e+k2B9VhtBozhgxKGpv30ylIuxGxf080d7mYZ08sH5UjpDv/Nd6F80tH1p+KuPIg==} - '@types/eslint-scope@3.7.4': - resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==} - '@types/eslint@8.4.6': resolution: {integrity: sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g==} @@ -1715,11 +1635,11 @@ packages: '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} - '@types/express-serve-static-core@4.17.33': - resolution: {integrity: sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==} + '@types/express-serve-static-core@5.0.0': + resolution: {integrity: sha512-AbXMTZGt40T+KON9/Fdxx0B2WK5hsgxcfXJLr5bFpZ7b4JCex2WyQPTEKdXqfHiY5nKKBScZ7yCoO6Pvgxfvnw==} - '@types/express@4.17.21': - resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + '@types/express@5.0.0': + resolution: {integrity: sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==} '@types/graceful-fs@4.1.8': resolution: {integrity: sha512-NhRH7YzWq8WiNKVavKPBmtLYZHxNY19Hh+az28O/phfp68CF45pMFud+ZzJ8ewnxnC5smIdF3dqFeiSUQ5I+pw==} @@ -1733,8 +1653,8 @@ packages: '@types/istanbul-reports@3.0.3': resolution: {integrity: sha512-1nESsePMBlf0RPRffLZi5ujYh7IH1BWL4y9pr+Bn3cJBdxz+RTP8bUFljLz9HvzhhOSWKdyBZ4DIivdL6rvgZg==} - '@types/jest@29.5.12': - resolution: {integrity: sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==} + '@types/jest@29.5.13': + resolution: {integrity: sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1742,12 +1662,18 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/lodash@4.17.10': + resolution: {integrity: sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==} + '@types/luxon@3.4.2': resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} '@types/mdast@4.0.1': resolution: {integrity: sha512-IlKct1rUTJ1T81d8OHzyop15kGv9A/ff7Gz7IJgrk6jDb4Udw77pCJ+vq8oxZf4Ghpm+616+i1s/LNg/Vh7d+g==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/mime@3.0.4': resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==} @@ -1757,14 +1683,14 @@ packages: '@types/ms@0.7.31': resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} - '@types/multer@1.4.11': - resolution: {integrity: sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==} + '@types/multer@1.4.12': + resolution: {integrity: sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==} - '@types/node-7z@2.1.8': - resolution: {integrity: sha512-VjiU7yEbczNc3EFKN4GJcAUqAMkn92P/92r6ARjMSXEdixunMD9lC79mTX81vKxTlNYXuvCJ7zvnzlDbFTt2Vw==} + '@types/node-7z@2.1.10': + resolution: {integrity: sha512-LdfuQcGAKsLafyM96+F8VekToCuGQnFy9DMM0UdS6f5pEnaP5kAixp3TQPc1NJU4C6IwLwnednktYBjp/z2LRw==} - '@types/node@20.14.2': - resolution: {integrity: sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==} + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} '@types/passport-http@0.3.11': resolution: {integrity: sha512-FO0rDRYtuha9m2ZgRx5+jrgrrkAnUzgzdItFI0dwKBC6k9pArK677Gtan67u6+Qah2nXVP3M1uZ5p90SpBT5Zg==} @@ -1772,8 +1698,8 @@ packages: '@types/passport@1.0.12': resolution: {integrity: sha512-QFdJ2TiAEoXfEQSNDISJR1Tm51I78CymqcBa8imbjo6dNNu+l2huDxxbDEIoFIwOSKMkOfHEikyDuZ38WwWsmw==} - '@types/pg@8.11.6': - resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==} + '@types/pg@8.11.10': + resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==} '@types/protocol-buffers-schema@3.4.2': resolution: {integrity: sha512-GaQpfsfFk4wGU3//d7uCGy9zy6B8QBEyWYd6+maZH+S6m861QrFvLWS5RyHj4UfIiON9tmqCz9C+oNpebDgGIw==} @@ -1784,6 +1710,9 @@ packages: '@types/range-parser@1.2.4': resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==} + '@types/send@0.17.4': + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + '@types/serve-static@1.15.0': resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==} @@ -1796,6 +1725,9 @@ packages: '@types/string-similarity@4.0.2': resolution: {integrity: sha512-LkJQ/jsXtCVMK+sKYAmX/8zEq+/46f1PTQw7YtmQwb74jemS1SlNLmARM2Zml9DgdDTWKAtc5L13WorpHPDjDA==} + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/trusted-types@2.0.5': resolution: {integrity: sha512-I3pkr8j/6tmQtKV/ZzHtuaqYSQvyjGRKH4go60Rr0IDLlFxuRT5V32uvB1mecM5G1EVAUyF/4r4QZ1GHgz+mxA==} @@ -1820,111 +1752,110 @@ packages: '@types/yauzl@2.10.2': resolution: {integrity: sha512-Km7XAtUIduROw7QPgvcft0lIupeG8a8rdKL8RiSyKvlE7dYY31fEn41HVuQsRFDuROA8tA4K2UVL+WdfFmErBA==} - '@typescript-eslint/eslint-plugin@7.12.0': - resolution: {integrity: sha512-7F91fcbuDf/d3S8o21+r3ZncGIke/+eWk0EpO21LXhDfLahriZF9CGj4fbAetEjlaBdjdSm9a6VeXbpbT6Z40Q==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/eslint-plugin@8.9.0': + resolution: {integrity: sha512-Y1n621OCy4m7/vTXNlCbMVp87zSd7NH0L9cXD8aIpOaNlzeWxIK4+Q19A68gSmTNRZn92UjocVUWDthGxtqHFg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^7.0.0 - eslint: ^8.56.0 + '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + eslint: ^8.57.0 || ^9.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/parser@7.12.0': - resolution: {integrity: sha512-dm/J2UDY3oV3TKius2OUZIFHsomQmpHtsV0FTh1WO8EKgHLQ1QCADUqscPgTpU+ih1e21FQSRjXckHn3txn6kQ==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/parser@8.9.0': + resolution: {integrity: sha512-U+BLn2rqTTHnc4FL3FJjxaXptTxmf9sNftJK62XLz4+GxG3hLHm/SUNaaXP5Y4uTiuYoL5YLy4JBCJe3+t8awQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/scope-manager@7.12.0': - resolution: {integrity: sha512-itF1pTnN6F3unPak+kutH9raIkL3lhH1YRPGgt7QQOh43DQKVJXmWkpb+vpc/TiDHs6RSd9CTbDsc/Y+Ygq7kg==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/scope-manager@8.9.0': + resolution: {integrity: sha512-bZu9bUud9ym1cabmOYH9S6TnbWRzpklVmwqICeOulTCZ9ue2/pczWzQvt/cGj2r2o1RdKoZbuEMalJJSYw3pHQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/type-utils@7.12.0': - resolution: {integrity: sha512-lib96tyRtMhLxwauDWUp/uW3FMhLA6D0rJ8T7HmH7x23Gk1Gwwu8UZ94NMXBvOELn6flSPiBrCKlehkiXyaqwA==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/type-utils@8.9.0': + resolution: {integrity: sha512-JD+/pCqlKqAk5961vxCluK+clkppHY07IbV3vett97KOV+8C6l+CPEPwpUuiMwgbOz/qrN3Ke4zzjqbT+ls+1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/types@7.12.0': - resolution: {integrity: sha512-o+0Te6eWp2ppKY3mLCU+YA9pVJxhUJE15FV7kxuD9jgwIAa+w/ycGJBMrYDTpVGUM/tgpa9SeMOugSabWFq7bg==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/types@8.9.0': + resolution: {integrity: sha512-SjgkvdYyt1FAPhU9c6FiYCXrldwYYlIQLkuc+LfAhCna6ggp96ACncdtlbn8FmnG72tUkXclrDExOpEYf1nfJQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@7.12.0': - resolution: {integrity: sha512-5bwqLsWBULv1h6pn7cMW5dXX/Y2amRqLaKqsASVwbBHMZSnHqE/HN4vT4fE0aFsiwxYvr98kqOWh1a8ZKXalCQ==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/typescript-estree@8.9.0': + resolution: {integrity: sha512-9iJYTgKLDG6+iqegehc5+EqE6sqaee7kb8vWpmHZ86EqwDjmlqNNHeqDVqb9duh+BY6WCNHfIGvuVU3Tf9Db0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/utils@7.12.0': - resolution: {integrity: sha512-Y6hhwxwDx41HNpjuYswYp6gDbkiZ8Hin9Bf5aJQn1bpTs3afYY4GX+MPYxma8jtoIV2GRwTM/UJm/2uGCVv+DQ==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/utils@8.9.0': + resolution: {integrity: sha512-PKgMmaSo/Yg/F7kIZvrgrWa1+Vwn036CdNUvYFEkYbPwOH4i8xvkaRlu148W3vtheWK9ckKRIz7PBP5oUlkrvQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 - '@typescript-eslint/visitor-keys@7.12.0': - resolution: {integrity: sha512-uZk7DevrQLL3vSnfFl5bj4sL75qC9D6EdjemIdbtkuUmIheWpuiiylSY01JxJE7+zGrOWDZrp1WxOuDntvKrHQ==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/visitor-keys@8.9.0': + resolution: {integrity: sha512-Ht4y38ubk4L5/U8xKUBfKNYGmvKvA1CANoxiTRMM+tOLk3lbF3DvzZCxJCRSE+2GdCMSh6zq9VZJc3asc1XuAA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - '@webassemblyjs/ast@1.11.5': - resolution: {integrity: sha512-LHY/GSAZZRpsNQH+/oHqhRQ5FT7eoULcBqgfyTB5nQHogFnK3/7QoN7dLnwSE/JkUAF0SrRuclT7ODqMFtWxxQ==} + '@webassemblyjs/ast@1.12.1': + resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} - '@webassemblyjs/floating-point-hex-parser@1.11.5': - resolution: {integrity: sha512-1j1zTIC5EZOtCplMBG/IEwLtUojtwFVwdyVMbL/hwWqbzlQoJsWCOavrdnLkemwNoC/EOwtUFch3fuo+cbcXYQ==} + '@webassemblyjs/floating-point-hex-parser@1.11.6': + resolution: {integrity: sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==} - '@webassemblyjs/helper-api-error@1.11.5': - resolution: {integrity: sha512-L65bDPmfpY0+yFrsgz8b6LhXmbbs38OnwDCf6NpnMUYqa+ENfE5Dq9E42ny0qz/PdR0LJyq/T5YijPnU8AXEpA==} + '@webassemblyjs/helper-api-error@1.11.6': + resolution: {integrity: sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==} - '@webassemblyjs/helper-buffer@1.11.5': - resolution: {integrity: sha512-fDKo1gstwFFSfacIeH5KfwzjykIE6ldh1iH9Y/8YkAZrhmu4TctqYjSh7t0K2VyDSXOZJ1MLhht/k9IvYGcIxg==} + '@webassemblyjs/helper-buffer@1.12.1': + resolution: {integrity: sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==} - '@webassemblyjs/helper-numbers@1.11.5': - resolution: {integrity: sha512-DhykHXM0ZABqfIGYNv93A5KKDw/+ywBFnuWybZZWcuzWHfbp21wUfRkbtz7dMGwGgT4iXjWuhRMA2Mzod6W4WA==} + '@webassemblyjs/helper-numbers@1.11.6': + resolution: {integrity: sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==} - '@webassemblyjs/helper-wasm-bytecode@1.11.5': - resolution: {integrity: sha512-oC4Qa0bNcqnjAowFn7MPCETQgDYytpsfvz4ujZz63Zu/a/v71HeCAAmZsgZ3YVKec3zSPYytG3/PrRCqbtcAvA==} + '@webassemblyjs/helper-wasm-bytecode@1.11.6': + resolution: {integrity: sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==} - '@webassemblyjs/helper-wasm-section@1.11.5': - resolution: {integrity: sha512-uEoThA1LN2NA+K3B9wDo3yKlBfVtC6rh0i4/6hvbz071E8gTNZD/pT0MsBf7MeD6KbApMSkaAK0XeKyOZC7CIA==} + '@webassemblyjs/helper-wasm-section@1.12.1': + resolution: {integrity: sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==} - '@webassemblyjs/ieee754@1.11.5': - resolution: {integrity: sha512-37aGq6qVL8A8oPbPrSGMBcp38YZFXcHfiROflJn9jxSdSMMM5dS5P/9e2/TpaJuhE+wFrbukN2WI6Hw9MH5acg==} + '@webassemblyjs/ieee754@1.11.6': + resolution: {integrity: sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==} - '@webassemblyjs/leb128@1.11.5': - resolution: {integrity: sha512-ajqrRSXaTJoPW+xmkfYN6l8VIeNnR4vBOTQO9HzR7IygoCcKWkICbKFbVTNMjMgMREqXEr0+2M6zukzM47ZUfQ==} + '@webassemblyjs/leb128@1.11.6': + resolution: {integrity: sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==} - '@webassemblyjs/utf8@1.11.5': - resolution: {integrity: sha512-WiOhulHKTZU5UPlRl53gHR8OxdGsSOxqfpqWeA2FmcwBMaoEdz6b2x2si3IwC9/fSPLfe8pBMRTHVMk5nlwnFQ==} + '@webassemblyjs/utf8@1.11.6': + resolution: {integrity: sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==} - '@webassemblyjs/wasm-edit@1.11.5': - resolution: {integrity: sha512-C0p9D2fAu3Twwqvygvf42iGCQ4av8MFBLiTb+08SZ4cEdwzWx9QeAHDo1E2k+9s/0w1DM40oflJOpkZ8jW4HCQ==} + '@webassemblyjs/wasm-edit@1.12.1': + resolution: {integrity: sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==} - '@webassemblyjs/wasm-gen@1.11.5': - resolution: {integrity: sha512-14vteRlRjxLK9eSyYFvw1K8Vv+iPdZU0Aebk3j6oB8TQiQYuO6hj9s4d7qf6f2HJr2khzvNldAFG13CgdkAIfA==} + '@webassemblyjs/wasm-gen@1.12.1': + resolution: {integrity: sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==} - '@webassemblyjs/wasm-opt@1.11.5': - resolution: {integrity: sha512-tcKwlIXstBQgbKy1MlbDMlXaxpucn42eb17H29rawYLxm5+MsEmgPzeCP8B1Cl69hCice8LeKgZpRUAPtqYPgw==} + '@webassemblyjs/wasm-opt@1.12.1': + resolution: {integrity: sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==} - '@webassemblyjs/wasm-parser@1.11.5': - resolution: {integrity: sha512-SVXUIwsLQlc8srSD7jejsfTU83g7pIGr2YYNb9oHdtldSxaOhvA5xwvIiWIfcX8PlSakgqMXsLpLfbbJ4cBYew==} + '@webassemblyjs/wasm-parser@1.12.1': + resolution: {integrity: sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==} - '@webassemblyjs/wast-printer@1.11.5': - resolution: {integrity: sha512-f7Pq3wvg3GSPUPzR0F6bmI89Hdb+u9WXrSKc4v+N0aV0q6r42WoF92Jp2jEorBEBRoRNXgjp53nBniDXcqZYPA==} + '@webassemblyjs/wast-printer@1.12.1': + resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==} '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -1956,8 +1887,8 @@ packages: acorn-globals@6.0.0: resolution: {integrity: sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==} - acorn-import-assertions@1.9.0: - resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: acorn: ^8 @@ -1984,6 +1915,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.13.0: + resolution: {integrity: sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -2017,6 +1953,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -2090,9 +2034,6 @@ packages: aproba@2.0.0: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} - archy@1.0.0: - resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==} - are-we-there-yet@1.1.7: resolution: {integrity: sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==} @@ -2112,25 +2053,25 @@ packages: array-buffer-byte-length@1.0.0: resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} + array-buffer-byte-length@1.0.1: + resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} + engines: {node: '>= 0.4'} + array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} - array-includes@3.1.7: - resolution: {integrity: sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==} + array-includes@3.1.8: + resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - - array.prototype.findlastindex@1.2.3: - resolution: {integrity: sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==} + array.prototype.findlastindex@1.2.5: + resolution: {integrity: sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==} engines: {node: '>= 0.4'} array.prototype.flat@1.3.2: @@ -2145,6 +2086,10 @@ packages: resolution: {integrity: sha512-09x0ZWFEjj4WD8PDbykUwo3t9arLn8NIzmmYEJFpYekOAQjpkGSyrQhNoRTcwwcFRu+ycWF78QZ63oWTqSjBcw==} engines: {node: '>= 0.4'} + arraybuffer.prototype.slice@1.0.3: + resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} + engines: {node: '>= 0.4'} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -2159,11 +2104,6 @@ packages: resolution: {integrity: sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==} hasBin: true - async-g-i-s@1.5.2: - resolution: {integrity: sha512-Mmu2gupaX9ghKZ6Nublzl8dzVGVwTDf6QaK7QPvGYzcdl8OlijSZrasXmKPCDZzKYpamTQeL8EJLiGlk/nstJQ==} - peerDependencies: - node-fetch: ^2.7.0 - async@3.2.4: resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} @@ -2178,12 +2118,16 @@ packages: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + avsc@5.7.7: resolution: {integrity: sha512-9cYNccliXZDByFsFliVwk5GvTq058Fj513CiR4E60ndDwmuXzTJEp/Bp8FyuRmGyYupLjHLs+JA9/CBoVS4/NQ==} engines: {node: '>=0.11'} - avvio@8.3.0: - resolution: {integrity: sha512-VBVH0jubFr9LdFASy/vNtm5giTrnbVquWBhT0fyizuNK2rQ7e7ONU2plZQWUNqtE1EmxFEb+kbSkFRkstiaS9Q==} + avvio@8.4.0: + resolution: {integrity: sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==} aws-sign2@0.7.0: resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} @@ -2191,8 +2135,8 @@ packages: aws4@1.12.0: resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} - axios@1.7.2: - resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==} + axios@1.7.7: + resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} @@ -2261,10 +2205,6 @@ packages: better-sqlite3@9.6.0: resolution: {integrity: sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==} - big-integer@1.6.51: - resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==} - engines: {node: '>=0.6'} - bin-links@2.3.0: resolution: {integrity: sha512-JzrOLHLwX2zMqKdyYZjkDgQGT+kHDkIhv2/IK2lJ00qLxV4TmFoHi8drDBb6H5Zrz1YfgHkai4e2MGPqnoUhqA==} engines: {node: '>=10'} @@ -2282,14 +2222,10 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@1.20.2: - resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - bplist-parser@0.2.0: - resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==} - engines: {node: '>= 5.10.0'} - brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -2333,10 +2269,6 @@ packages: builtins@1.0.3: resolution: {integrity: sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==} - bundle-name@3.0.0: - resolution: {integrity: sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==} - engines: {node: '>=12'} - busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -2353,8 +2285,9 @@ packages: resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==} engines: {node: '>= 10'} - call-bind@1.0.2: - resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} + call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} call-me-maybe@1.0.2: resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} @@ -2403,6 +2336,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.1: + resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} + engines: {node: '>= 14.16.0'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -2444,8 +2381,8 @@ packages: resolution: {integrity: sha512-qu3pN8Y3qHNgE2AFweciB1IfMnmZ/fsNTEE+NOFjmGB2F/7rLhnhzppvpCnN4FovtP26k8lHyy9ptEbNwWFLzw==} engines: {node: '>=6'} - cli-table3@0.6.3: - resolution: {integrity: sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==} + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} cli-width@3.0.0: @@ -2509,10 +2446,6 @@ packages: color@3.2.1: resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} - color@4.2.3: - resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} - engines: {node: '>=12.5.0'} - colors@1.4.0: resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} engines: {node: '>=0.1.90'} @@ -2539,8 +2472,8 @@ packages: resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} engines: {node: '>= 6'} - comment-json@4.2.3: - resolution: {integrity: sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw==} + comment-json@4.2.5: + resolution: {integrity: sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==} engines: {node: '>= 6'} comment-parser@1.4.0: @@ -2592,8 +2525,8 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-parser@1.4.6: - resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} engines: {node: '>= 0.8.0'} cookie-signature@1.0.6: @@ -2603,12 +2536,12 @@ packages: resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==} engines: {node: '>= 0.6'} - cookie@0.5.0: - resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} - cookie@0.6.0: - resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} core-js-compat@3.33.1: @@ -2669,6 +2602,18 @@ packages: resolution: {integrity: sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==} engines: {node: '>=10'} + data-view-buffer@1.0.1: + resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.1: + resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.0: + resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} + engines: {node: '>= 0.4'} + dayjs@1.11.10: resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} @@ -2730,14 +2675,6 @@ packages: resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} engines: {node: '>=0.10.0'} - default-browser-id@3.0.0: - resolution: {integrity: sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==} - engines: {node: '>=12'} - - default-browser@4.0.0: - resolution: {integrity: sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==} - engines: {node: '>=14.16'} - defaults@1.0.3: resolution: {integrity: sha512-s82itHOnYrN0Ib8r+z7laQz3sdE+4FP3d9Q7VLO7U+KRT+CR0GsWuyHxzdAY82I7cXv0G/twrqomTJLOssO5HA==} @@ -2745,9 +2682,9 @@ packages: resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==} engines: {node: '>= 0.4'} - define-lazy-prop@3.0.0: - resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} - engines: {node: '>=12'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} @@ -2801,10 +2738,6 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -2838,6 +2771,11 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + electron-to-chromium@1.4.666: resolution: {integrity: sha512-q4lkcbQrUdlzWCUOxk6fwEza6bNCfV12oi4AJph5UibguD1aTfL4uD0nuzFv9hbPANXQMuUS0MxPSHQ1gqq5dg==} @@ -2858,6 +2796,10 @@ packages: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} @@ -2872,8 +2814,16 @@ packages: resolution: {integrity: sha512-IML/R4eG/pUS5w7OfcDE0jKrljWS9nwnEfsxWCIJF5eO6AHo6+Hlv+lQbdlAYsiJPHzUthLm1RUjnBzWOs45cw==} engines: {node: '>=10.2.0'} - enhanced-resolve@5.15.0: - resolution: {integrity: sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==} + engine.io@6.6.1: + resolution: {integrity: sha512-NEpDCw9hrvBW+hVEOK4T7v0jFJ++KgtPl4jKFwsZVfG1XhS0dCrSb3VMb9gPAd7VAdW52VT1EnaNiU2vM8C0og==} + engines: {node: '>=10.2.0'} + + enhanced-resolve@5.17.0: + resolution: {integrity: sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==} + engines: {node: '>=10.13.0'} + + enhanced-resolve@5.17.1: + resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} engines: {node: '>=10.13.0'} entities@2.1.0: @@ -2893,19 +2843,39 @@ packages: resolution: {integrity: sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw==} engines: {node: '>= 0.4'} + es-abstract@1.23.3: + resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} + engines: {node: '>= 0.4'} + es-aggregate-error@1.0.11: resolution: {integrity: sha512-DCiZiNlMlbvofET/cE55My387NiLvuGToBEZDdK9U2G3svDCjL8WOgO5Il6lO83nQ8qmag/R9nArdpaFQ/m3lA==} engines: {node: '>= 0.4'} + es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.2.1: resolution: {integrity: sha512-9978wrXM50Y4rTMmW5kXIC09ZdXQZqkE4mxhwkd8VbzsGkXGPgV4zWuqQJgCEzYngdo2dYDa0l8xhX4fkSwJSg==} + es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + engines: {node: '>= 0.4'} + es-set-tostringtag@2.0.1: resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==} engines: {node: '>= 0.4'} - es-shim-unscopables@1.0.0: - resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==} + es-set-tostringtag@2.0.3: + resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.0.2: + resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} es-to-primitive@1.2.1: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} @@ -2930,10 +2900,6 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - escape-string-regexp@5.0.0: - resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} - engines: {node: '>=12'} - escodegen@2.1.0: resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} engines: {node: '>=6.0'} @@ -2948,8 +2914,8 @@ packages: eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - eslint-module-utils@2.8.0: - resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} + eslint-module-utils@2.12.0: + resolution: {integrity: sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -2969,18 +2935,18 @@ packages: eslint-import-resolver-webpack: optional: true - eslint-plugin-import@2.29.1: - resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} + eslint-plugin-import@2.31.0: + resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 peerDependenciesMeta: '@typescript-eslint/parser': optional: true - eslint-plugin-prettier@5.1.3: - resolution: {integrity: sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==} + eslint-plugin-prettier@5.2.1: + resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: '@types/eslint': '>=8.0.0' @@ -2993,11 +2959,6 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-simple-import-sort@12.1.0: - resolution: {integrity: sha512-Y2fqAfC11TcG/WP3TrI1Gi3p3nc8XJyEOJYHyEPEGI/UAgNx6akxxlX74p7SbAQdLcgASKhj8M0GKvH3vq/+ig==} - peerDependencies: - eslint: '>=5.0.0' - eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} @@ -3010,11 +2971,19 @@ packages: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-visitor-keys@4.1.0: + resolution: {integrity: sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint@8.57.0: resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} hasBin: true + espree@10.2.0: + resolution: {integrity: sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@9.6.1: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3066,10 +3035,6 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} - execa@7.1.1: - resolution: {integrity: sha512-wH0eMf/UXckdUYnO21+HDztteVv05rq2GXksxT4fCGeHkBhw1DROXh40wcjMcRqDOWE7iPJ4n3M7e2+YFP+76Q==} - engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} - exit@0.1.2: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} @@ -3082,8 +3047,8 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - express@4.19.2: - resolution: {integrity: sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==} + express@4.21.1: + resolution: {integrity: sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==} engines: {node: '>= 0.10.0'} extend@3.0.2: @@ -3120,15 +3085,15 @@ packages: fast-diff@1.2.0: resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==} - fast-glob@3.3.0: - resolution: {integrity: sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==} + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-json-stringify@5.8.0: - resolution: {integrity: sha512-VVwK8CFMSALIvt14U8AvrSzQAwN/0vaVRiFFUVlpnXSnDGrSkOAO5MtzyN8oQNjLd5AqTW5OZRgyjoNuAuR3jQ==} + fast-json-stringify@5.16.1: + resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} @@ -3136,8 +3101,8 @@ packages: fast-memoize@2.5.2: resolution: {integrity: sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==} - fast-querystring@1.1.1: - resolution: {integrity: sha512-qR2r+e3HvhEFmpdHMv//U8FnFlnYjaC6QKDuaXALDkw2kvHO8WDjxH+f/rHGR4Me4pnk8p9JAkRNTjYHAKRn2Q==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} fast-redact@3.1.2: resolution: {integrity: sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw==} @@ -3146,11 +3111,11 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@2.2.0: - resolution: {integrity: sha512-cIusKBIt/R/oI6z/1nyfe2FvGKVTohVRfvkOhvx0nCEW+xf5NoCXjAHcWp93uOUBchzYcsvPlrapAdX1uW+YGg==} + fast-uri@2.4.0: + resolution: {integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==} - fastify@4.27.0: - resolution: {integrity: sha512-ci9IXzbigB8dyi0mSy3faa3Bsj0xWAPb9JeT4KRzubdSb6pNhcADRUaXCBml6V1Ss/a05kbtQls5LBmhHydoTA==} + fastify@4.28.1: + resolution: {integrity: sha512-kFWUtpNr4i7t5vY2EJPCN2KgMVpuqfU4NjnJNCgiNB900oiDeYqaNDRcAfeBbOF5hGixixxcKnOU4KN9z6QncQ==} fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -3168,10 +3133,6 @@ packages: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} - figures@5.0.0: - resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==} - engines: {node: '>=14'} - file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -3179,12 +3140,15 @@ packages: file-stream-rotator@0.6.1: resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==} - file-type-checker@1.1.0: - resolution: {integrity: sha512-I+v39PW0UzcCuSUMaZIbWusce8vHrhyfbVT62bNXX/V6MzOM3B8b011Iih8qnEDC12Ub8cuO0mjeIlGuCeBa7g==} + file-type-checker@1.1.2: + resolution: {integrity: sha512-HodBNiinBQNHQfXhXzAuHkU2udHF3LFS6PAOEZqxW+BjotZVCaMR7ckpTTnvLi718dbzRavnjRX0kbSb5pJG3g==} file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + filelist@1.0.4: + resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + filename-reserved-regex@2.0.0: resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} engines: {node: '>=4'} @@ -3197,12 +3161,12 @@ packages: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} - finalhandler@1.2.0: - resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} engines: {node: '>= 0.8'} - find-my-way@8.1.0: - resolution: {integrity: sha512-41QwjCGcVTODUmLLqTMeoHeiozbMXYMAE1CKFiDyi9zVZ2Vjh0yz3MF0WQZoIb+cmzP/XlbFjlF2NtJmvZHznA==} + find-my-way@8.2.2: + resolution: {integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==} engines: {node: '>=14'} find-up@4.1.0: @@ -3310,6 +3274,10 @@ packages: resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==} engines: {node: '>= 0.4'} + function.prototype.name@1.1.6: + resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} + engines: {node: '>= 0.4'} + functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} @@ -3331,6 +3299,10 @@ packages: get-intrinsic@1.2.1: resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==} + get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -3347,6 +3319,10 @@ packages: resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} engines: {node: '>= 0.4'} + get-symbol-description@1.0.2: + resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} + engines: {node: '>= 0.4'} + getpass@0.1.7: resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} @@ -3369,12 +3345,19 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + glob@10.4.2: + resolution: {integrity: sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==} + engines: {node: '>=16 || 14 >=14.18'} + hasBin: true + + glob@11.0.0: + resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} + engines: {node: 20 || >=22} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - - glob@9.3.1: - resolution: {integrity: sha512-qERvJb7IGsnkx6YYmaaGvDpf77c951hICMdWaFXyH3PlVob8sbPJJyJX0kWkiCWyXUzoy9UOTNjGg0RbD8bYIw==} - engines: {node: '>=16 || 14 >=14.17'} + deprecated: Glob versions prior to v9 are no longer supported global-dirs@3.0.1: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} @@ -3388,20 +3371,27 @@ packages: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.11.0: + resolution: {integrity: sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==} + engines: {node: '>=18'} + globalthis@1.0.3: resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==} engines: {node: '>= 0.4'} - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} graceful-fs@4.2.10: resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grapheme-splitter@1.0.4: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} @@ -3435,16 +3425,23 @@ packages: has-property-descriptors@1.0.0: resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-proto@1.0.1: resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} engines: {node: '>= 0.4'} + has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + has-symbols@1.0.3: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} - has-tostringtag@1.0.0: - resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} has-unicode@2.0.1: @@ -3454,13 +3451,13 @@ packages: resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} engines: {node: '>= 0.4.0'} - hasown@2.0.0: - resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - helmet@7.1.0: - resolution: {integrity: sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==} - engines: {node: '>=16.0.0'} + helmet@8.0.0: + resolution: {integrity: sha512-VyusHLEIIO5mjQPUI1wpOAEu+wl6Q0998jzTxqUYGE45xCIcAxy3MsbEK/yyJUJ3ADeMoB6MornPH6GMWAf+Pw==} + engines: {node: '>=18.0.0'} highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -3499,10 +3496,6 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} - human-signals@4.3.1: - resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} - engines: {node: '>=14.18.0'} - humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} @@ -3564,17 +3557,17 @@ packages: resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} engines: {node: '>=12.0.0'} - inquirer@9.2.12: - resolution: {integrity: sha512-mg3Fh9g2zfuVWJn6lhST0O7x4n03k7G8Tx5nvikJkbq8/CK47WDVm+UznF0G6s5Zi0KcyUisr6DU8T67N5U+1Q==} - engines: {node: '>=14.18.0'} + inquirer@9.2.15: + resolution: {integrity: sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==} + engines: {node: '>=18'} internal-slot@1.0.5: resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} engines: {node: '>= 0.4'} - interpret@1.4.0: - resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} - engines: {node: '>= 0.10'} + internal-slot@1.0.7: + resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} + engines: {node: '>= 0.4'} ip@2.0.1: resolution: {integrity: sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==} @@ -3586,6 +3579,10 @@ packages: is-array-buffer@3.0.2: resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} + is-array-buffer@3.0.4: + resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} + engines: {node: '>= 0.4'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -3610,19 +3607,17 @@ packages: is-core-module@2.13.1: resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} - is-date-object@1.0.5: - resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} engines: {node: '>= 0.4'} - is-docker@2.2.1: - resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} - engines: {node: '>=8'} - hasBin: true + is-data-view@1.0.1: + resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} + engines: {node: '>= 0.4'} - is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true + is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} @@ -3644,11 +3639,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} - engines: {node: '>=14.16'} - hasBin: true - is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -3660,6 +3650,10 @@ packages: resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} engines: {node: '>= 0.4'} + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + is-number-object@1.0.7: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} engines: {node: '>= 0.4'} @@ -3686,14 +3680,14 @@ packages: is-shared-array-buffer@1.0.2: resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==} + is-shared-array-buffer@1.0.3: + resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} + engines: {node: '>= 0.4'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-string@1.0.7: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} @@ -3706,6 +3700,10 @@ packages: resolution: {integrity: sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==} engines: {node: '>= 0.4'} + is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} + engines: {node: '>= 0.4'} + is-typedarray@1.0.0: resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} @@ -3713,17 +3711,9 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - is-unicode-supported@1.3.0: - resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} - engines: {node: '>=12'} - is-weakref@1.0.2: resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} - is-wsl@2.2.0: - resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} - engines: {node: '>=8'} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -3771,6 +3761,19 @@ packages: resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} engines: {node: '>=14'} + jackspeak@3.4.0: + resolution: {integrity: sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==} + engines: {node: '>=14'} + + jackspeak@4.0.1: + resolution: {integrity: sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==} + engines: {node: 20 || >=22} + + jake@10.9.1: + resolution: {integrity: sha512-61btcOHNnLnsOdtLgA5efqQWjnSi/vow5HbI7HMdKKWqvrKR1bLK3BPlJn9gcSaP2ewuamUSMB5XEy76KUIS2w==} + engines: {node: '>=10'} + hasBin: true + jest-changed-files@29.7.0: resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3956,6 +3959,9 @@ packages: json-schema-migrate@0.2.0: resolution: {integrity: sha512-dq4/oHWmtw/+0ytnXsDqVn+VsVweTEmzm5jLgguPn9BjSzn6/q58ZiZx3BHiQyJs612f0T5Z+MrUEUUY5DHsRg==} + json-schema-ref-resolver@1.0.1: + resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==} + json-schema-traverse@0.3.1: resolution: {integrity: sha512-4JD/Ivzg7PoW8NzdrBSr3UFwC9mHgvI7Z6z3QGBsSHgKaRTUDmyZAAKJo2UbG1kUVfS9WS8bi36N49U1xw43DA==} @@ -3993,12 +3999,12 @@ packages: jsonc-parser@2.2.1: resolution: {integrity: sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w==} - jsonc-parser@3.2.0: - resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} - jsonc-parser@3.2.1: resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@1.0.1: resolution: {integrity: sha512-KbsDJNRfRPF5v49tMNf9sqyyGqGLBcz1v5kZT01kG5ns5mQSltwxCKVmUzVKtEinkUnTDtSrp6ngWpV7Xw0ZlA==} @@ -4060,8 +4066,8 @@ packages: libphonenumber-js@1.10.53: resolution: {integrity: sha512-sDTnnqlWK4vH4AlDQuswz3n4Hx7bIQWTpIcScJX+Sp7St3LXHmfiax/ZFfyYxHmkdCvydOLSuvtAO/XpXiSySw==} - light-my-request@5.11.0: - resolution: {integrity: sha512-qkFCeloXCOMpmEdZ/MV91P8AT4fjwFXWaAFz3lUeStM8RcoM1ks4J/F8r1b3r6y/H4u3ACEJ1T+Gv5bopj7oDA==} + light-my-request@5.14.0: + resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==} limiter@1.1.5: resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} @@ -4124,6 +4130,10 @@ packages: logform@2.4.2: resolution: {integrity: sha512-W4c9himeAwXEdZ05dQNerhFz2XG80P9Oj0loPUMV23VC2it0orMHQhJm4hdnnor3rd1HsGf6a2lPwBM1zeXHGw==} + logform@2.6.1: + resolution: {integrity: sha512-CdaO738xRapbKIMVn2m4F6KTj4j7ooJ8POVnebSgKo3KBz5axNXRAL7ZdRjIV6NOr2Uf4vjtRkxrFETOioCqSA==} + engines: {node: '>= 12.0.0'} + loglevel@1.8.1: resolution: {integrity: sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==} engines: {node: '>= 0.6.0'} @@ -4132,6 +4142,14 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@10.3.0: + resolution: {integrity: sha512-CQl19J/g+Hbjbv4Y3mFNNXFEL/5t/KCg8POCuUqd4rMKjGG+j1ybER83hxV58zL+dFI1PTkt3GNFSHRt+d8qEQ==} + engines: {node: 14 || >=16.14} + + lru-cache@11.0.0: + resolution: {integrity: sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -4147,8 +4165,8 @@ packages: resolution: {integrity: sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==} engines: {node: '>=12'} - magic-string@0.30.5: - resolution: {integrity: sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==} + magic-string@0.30.8: + resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} make-dir@3.1.0: @@ -4195,8 +4213,8 @@ packages: resolution: {integrity: sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw==} engines: {node: '>= 4.0.0'} - merge-descriptors@1.0.1: - resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -4298,19 +4316,19 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + minimatch@10.0.1: + resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@7.4.2: - resolution: {integrity: sha512-xy4q7wou3vUoC9k1xGTXc+awNdGaGVHtFUaey8tiX4H1QRc04DZ/rmDFwNm2EBsuYEhAZ6SgMmYf3InGY6OauA==} + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} minimatch@9.0.4: @@ -4347,14 +4365,14 @@ packages: resolution: {integrity: sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw==} engines: {node: '>=8'} - minipass@4.2.5: - resolution: {integrity: sha512-+yQl7SX3bIT83Lhb4BVorMAHVuqsskxRdlmO9kTpyukp8vsm2Sn/fUOV9xlnG8/a5JsypJzap21lz/y3FBMJ8Q==} - engines: {node: '>=8'} - minipass@5.0.0: resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} engines: {node: '>=8'} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} @@ -4448,13 +4466,13 @@ packages: '@nestjs/websockets': optional: true - nestjs-paginate@8.6.2: - resolution: {integrity: sha512-VJN8mdi/TKpB3wQFoGFhxqZtejr2XJDqc9v1zZQkLQilIrBWfpGxzwyOkoThm2QCBhsF65yH/32drwjSzOm7kA==} + nestjs-paginate@9.1.2: + resolution: {integrity: sha512-GNgXwrCqrFBHof1uG6uXRzX7qSp9Vyo3WjglquyFBzaF2nFNWt9pkuZ3sRdusA25KoLU2DBfrxqz7fVPM9AzJA==} peerDependencies: - '@nestjs/common': ^10.3.3 - '@nestjs/swagger': ^7.3.0 - express: ^4.18.2 - fastify: ^4.26.1 + '@nestjs/common': ^10.0.0 + '@nestjs/swagger': ^7.0.0 + express: ^4.0.0 + fastify: ^4.0.0 typeorm: ^0.3.17 nimma@0.2.2: @@ -4546,10 +4564,6 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} - npm-run-path@5.1.0: - resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - npmlog@4.1.2: resolution: {integrity: sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==} @@ -4587,6 +4601,10 @@ packages: object-inspect@1.12.3: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} + object-inspect@1.13.2: + resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -4595,15 +4613,20 @@ packages: resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==} engines: {node: '>= 0.4'} - object.fromentries@2.0.7: - resolution: {integrity: sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==} + object.assign@4.1.5: + resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} engines: {node: '>= 0.4'} - object.groupby@1.0.1: - resolution: {integrity: sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==} + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} - object.values@1.1.7: - resolution: {integrity: sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==} + object.values@1.2.0: + resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} obuf@1.1.2: @@ -4634,14 +4657,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - - open@9.1.0: - resolution: {integrity: sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==} - engines: {node: '>=14.16'} - openapi-sampler@1.3.1: resolution: {integrity: sha512-Ert9mvc2tLPmmInwSyGZS+v4Ogu9/YoZuq9oP3EdUklg2cad6+IGndP9yqJJwbgdXwZibiq5fpv6vYujchdJFg==} @@ -4681,6 +4696,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.0: + resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} + pacote@11.3.5: resolution: {integrity: sha512-fT375Yczn4zi+6Hkk2TBe1x1sP8FgFsEIZ2/iWaXY2r/NkhDJfxbcn5paz1+RTFCyNf+dPnaoBDJoAxXSU8Bkg==} engines: {node: '>=10'} @@ -4734,10 +4752,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -4745,11 +4759,19 @@ packages: resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} engines: {node: '>=16 || 14 >=14.17'} - path-to-regexp@0.1.7: - resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} - path-to-regexp@3.2.0: - resolution: {integrity: sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==} + path-to-regexp@0.1.10: + resolution: {integrity: sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==} + + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} @@ -4767,8 +4789,8 @@ packages: pg-cloudflare@1.1.1: resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} - pg-connection-string@2.6.4: - resolution: {integrity: sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==} + pg-connection-string@2.7.0: + resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} @@ -4778,14 +4800,17 @@ packages: resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} engines: {node: '>=4'} - pg-pool@3.6.2: - resolution: {integrity: sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==} + pg-pool@3.7.0: + resolution: {integrity: sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==} peerDependencies: pg: '>=8.0' pg-protocol@1.6.1: resolution: {integrity: sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==} + pg-protocol@1.7.0: + resolution: {integrity: sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==} + pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} @@ -4794,8 +4819,8 @@ packages: resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} engines: {node: '>=10'} - pg@8.12.0: - resolution: {integrity: sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==} + pg@8.13.0: + resolution: {integrity: sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==} engines: {node: '>= 8.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -4813,18 +4838,18 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@3.0.1: - resolution: {integrity: sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==} - engines: {node: '>=10'} + picomatch@4.0.1: + resolution: {integrity: sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==} + engines: {node: '>=12'} - pino-abstract-transport@1.2.0: - resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==} + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} pino-std-serializers@7.0.0: resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} - pino@9.1.0: - resolution: {integrity: sha512-qUcgfrlyOtjwhNLdbhoL7NR4NkHjzykAPw0V2QLFbvu/zss29h4NkRnibyFzBrNCbzCOY3WZ9hhKSwfOkNggYA==} + pino@9.5.0: + resolution: {integrity: sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==} hasBin: true pirates@4.0.6: @@ -4843,6 +4868,10 @@ packages: resolution: {integrity: sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g==} engines: {node: '>=12.0.0'} + possible-typed-array-names@1.0.0: + resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} + engines: {node: '>= 0.4'} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -4897,8 +4926,18 @@ packages: peerDependencies: prettier: ^3.0.0 - prettier@3.3.1: - resolution: {integrity: sha512-7CAwy5dRsxs8PHXT3twixW9/OEll8MLE0VRPCJyl7CkS6VHGPSlsVaWTiASPTyGyYRyApxlaWTzwUxVNrhcwDg==} + prettier-plugin-organize-imports@4.1.0: + resolution: {integrity: sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==} + peerDependencies: + prettier: '>=2.0' + typescript: '>=2.9' + vue-tsc: ^2.1.0 + peerDependenciesMeta: + vue-tsc: + optional: true + + prettier@3.3.3: + resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} engines: {node: '>=14'} hasBin: true @@ -4912,15 +4951,11 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - process-warning@2.2.0: - resolution: {integrity: sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg==} - process-warning@3.0.0: resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} - process@0.11.10: - resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} - engines: {node: '>= 0.6.0'} + process-warning@4.0.0: + resolution: {integrity: sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==} progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} @@ -4983,8 +5018,8 @@ packages: resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} engines: {node: '>=0.6.0', teleport: '>=0.2.0'} - qs@6.11.0: - resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} qs@6.5.3: @@ -5048,10 +5083,6 @@ packages: resolution: {integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==} engines: {node: '>= 6'} - readable-stream@4.3.0: - resolution: {integrity: sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - readdir-scoped-modules@1.1.0: resolution: {integrity: sha512-asaikDeqAQg7JifRsZn1NJZXo9E+VwlyCfbkZhwyISinqk5zNS6266HS5kah6P0SaQKGF6SkNnZVHUzHFYxYDw==} deprecated: This functionality has been moved to @npmcli/fs @@ -5060,14 +5091,14 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.0.1: + resolution: {integrity: sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==} + engines: {node: '>= 14.16.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} - rechoir@0.6.2: - resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} - engines: {node: '>= 0.10'} - reflect-metadata@0.2.1: resolution: {integrity: sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==} deprecated: This version has a critical bug in fallback handling. Please upgrade to reflect-metadata@0.2.2 or newer. @@ -5092,6 +5123,10 @@ packages: resolution: {integrity: sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==} engines: {node: '>= 0.4'} + regexp.prototype.flags@1.5.2: + resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} + engines: {node: '>= 0.4'} + regexpu-core@5.3.2: resolution: {integrity: sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==} engines: {node: '>=4'} @@ -5148,9 +5183,9 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} - ret@0.2.2: - resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==} - engines: {node: '>=4'} + ret@0.4.3: + resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==} + engines: {node: '>=10'} retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} @@ -5160,25 +5195,22 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rfdc@1.3.0: - resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} rimraf@2.2.8: resolution: {integrity: sha512-R5KMKHnPAQaZMqLOsyuyUmcIjSeDm+73eoqQpaXA7AZ22BL+6C+1mcUscgOsNd8WVlJuvlgAPsegcx7pjlV0Dg==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rimraf@4.4.1: - resolution: {integrity: sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==} - engines: {node: '>=14'} - hasBin: true - - rimraf@5.0.7: - resolution: {integrity: sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==} - engines: {node: '>=14.18'} + rimraf@6.0.1: + resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} + engines: {node: 20 || >=22} hasBin: true rollup@2.79.1: @@ -5186,10 +5218,6 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - run-applescript@5.0.0: - resolution: {integrity: sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==} - engines: {node: '>=12'} - run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -5208,6 +5236,10 @@ packages: resolution: {integrity: sha512-9dVEFruWIsnie89yym+xWTAYASdpw3CJV7Li/6zBewGf9z2i1j31rP6jnY0pHEO4QZh6N0K11bFjWmdR8UGdPQ==} engines: {node: '>=0.4'} + safe-array-concat@1.1.2: + resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} + engines: {node: '>=0.4'} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -5217,8 +5249,12 @@ packages: safe-regex-test@1.0.0: resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==} - safe-regex2@2.0.0: - resolution: {integrity: sha512-PaUSFsUaNNuKwkBijoAPHAK6/eM6VirvyPWlZ7BAQy4D+hCvh4B6lIG+nPdhbFfIbP+gTGBcrdsOaUs0F+ZBOQ==} + safe-regex-test@1.0.3: + resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} + engines: {node: '>= 0.4'} + + safe-regex2@3.1.0: + resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==} safe-stable-stringify@1.1.1: resolution: {integrity: sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==} @@ -5260,15 +5296,20 @@ packages: engines: {node: '>=10'} hasBin: true - send@0.18.0: - resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} serialize-javascript@6.0.1: resolution: {integrity: sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==} - serve-static@1.15.0: - resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} set-blocking@2.0.0: @@ -5277,6 +5318,10 @@ packages: set-cookie-parser@2.6.0: resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + set-function-name@2.0.1: resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} engines: {node: '>= 0.4'} @@ -5288,10 +5333,6 @@ packages: resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==} hasBin: true - sharp@0.33.4: - resolution: {integrity: sha512-7i/dt5kGl7qR4gwPRD2biwD2/SvBn3O04J77XKFgL2OnZtQw+AG9wnuS/csmu80nPRHLYE9E41fyEiG8nhH6/Q==} - engines: {libvips: '>=8.15.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5300,13 +5341,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shelljs@0.8.5: - resolution: {integrity: sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==} - engines: {node: '>=4'} - hasBin: true - - side-channel@1.0.4: - resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} + side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -5357,6 +5394,10 @@ packages: resolution: {integrity: sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==} engines: {node: '>=10.2.0'} + socket.io@4.8.0: + resolution: {integrity: sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA==} + engines: {node: '>=10.2.0'} + socks-proxy-agent@6.2.1: resolution: {integrity: sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==} engines: {node: '>= 10'} @@ -5365,8 +5406,8 @@ packages: resolution: {integrity: sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==} engines: {node: '>= 10.13.0', npm: '>= 3.0.0'} - sonic-boom@4.0.1: - resolution: {integrity: sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==} + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} @@ -5445,12 +5486,20 @@ packages: resolution: {integrity: sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==} engines: {node: '>= 0.4'} - string.prototype.trimend@1.0.6: - resolution: {integrity: sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==} + string.prototype.trim@1.2.9: + resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.8: + resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} string.prototype.trimstart@1.0.6: resolution: {integrity: sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==} + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -5481,10 +5530,6 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -5513,8 +5558,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - swagger-ui-dist@5.11.2: - resolution: {integrity: sha512-jQG0cRgJNMZ7aCoiFofnoojeSaa/+KgWaDlfgs8QN+BXoGMpxeMVY5OEnjq4OlNvF3yjftO8c9GRAgcHlO+u7A==} + swagger-ui-dist@5.17.14: + resolution: {integrity: sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==} symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} @@ -5523,8 +5568,8 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - synckit@0.8.6: - resolution: {integrity: sha512-laHF2savN6sMeHCjLRkheIU4wo3Zg9Ln5YOjOo7sZ5dVQW8yF5pPE5SIw1dsPhq3TRp1jisKRCdPhfs/1WMqDA==} + synckit@0.9.1: + resolution: {integrity: sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==} engines: {node: ^14.18.0 || >=16.0.0} tapable@2.2.1: @@ -5580,8 +5625,8 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - thread-stream@3.0.0: - resolution: {integrity: sha512-oUIFjxaUT6knhPtWgDMc29zF1FcSl0yXpapkyrQrCGEfYA2HUZXCilUtKyYIv6HkCyqSPAMkY+EG0GbyIrNDQg==} + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -5589,10 +5634,6 @@ packages: tiny-merge-patch@0.1.2: resolution: {integrity: sha512-NLoA//tTMBPTr0oGdq+fxnvVR0tDa8tOcG9ZGbuovGzROadZ404qOV4g01jeWa5S8MC9nAOvu5bQgCW7s8tlWQ==} - titleize@3.0.0: - resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} - engines: {node: '>=12'} - tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -5608,8 +5649,8 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - toad-cache@3.3.0: - resolution: {integrity: sha512-3oDzcogWGHZdkwrHyvJVpPjA7oNzY6ENOV3PsWJY9XYPZ6INo94Yd47s5may1U+nleBPwDhrRiTPMIvKaa3MQg==} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} toidentifier@1.0.1: @@ -5655,8 +5696,14 @@ packages: peerDependencies: typescript: '>=4.2.0' - ts-jest@29.1.4: - resolution: {integrity: sha512-YiHwDhSvCiItoAgsKtoLFCuakDzDsJ1DLDnSouTaTmdOcOwIkSzbLXduaQ6M5DRVhuZC/NYaaZ/mtHbWMv/S6Q==} + ts-apicalypse@0.4.2: + resolution: {integrity: sha512-A02KFDFZHYTft0fTkxr5AERLb//XK/qjvGxrX8uNCwZAvFpLI/4TrOVxbq6kV2caZXWAn8eZZfRzVn1xd/cSMw==} + + ts-igdb-client@0.4.2: + resolution: {integrity: sha512-VGQbIyy75GbHW0WGGHBXsdJzuj8wOW/jiWURmQWltpkFjCTMSqqx9gTPSUUcJ0W2kdlWcyMU4TDDHo4N3Fij7A==} + + ts-jest@29.2.5: + resolution: {integrity: sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -5710,6 +5757,9 @@ packages: tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -5740,17 +5790,33 @@ packages: resolution: {integrity: sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==} engines: {node: '>= 0.4'} + typed-array-buffer@1.0.2: + resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} + engines: {node: '>= 0.4'} + typed-array-byte-length@1.0.0: resolution: {integrity: sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==} engines: {node: '>= 0.4'} + typed-array-byte-length@1.0.1: + resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} + engines: {node: '>= 0.4'} + typed-array-byte-offset@1.0.0: resolution: {integrity: sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==} engines: {node: '>= 0.4'} + typed-array-byte-offset@1.0.2: + resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} + engines: {node: '>= 0.4'} + typed-array-length@1.0.4: resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} + typed-array-length@1.0.6: + resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} + engines: {node: '>= 0.4'} + typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} @@ -5830,8 +5896,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.4.5: - resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + typescript@5.5.4: + resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} engines: {node: '>=14.17'} hasBin: true @@ -5848,8 +5914,8 @@ packages: unbzip2-stream@1.4.3: resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.19.6: + resolution: {integrity: sha512-e/vggGopEfTKSvj4ihnOLTsqhrKRN3LeO6qSN/GxohhuRv8qH9bNQ4B8W7e/vFL+0XTnmHPB4/kegunZGA4Org==} unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} @@ -5892,10 +5958,6 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - untildify@4.0.0: - resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} - engines: {node: '>=8'} - update-browserslist-db@1.0.13: resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} hasBin: true @@ -5931,6 +5993,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@3.4.0: resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. @@ -5979,8 +6045,8 @@ packages: walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} - watchpack@2.4.0: - resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} + watchpack@2.4.1: + resolution: {integrity: sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==} engines: {node: '>=10.13.0'} wcwidth@1.0.1: @@ -6008,8 +6074,8 @@ packages: resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} engines: {node: '>=10.13.0'} - webpack@5.90.1: - resolution: {integrity: sha512-SstPdlAC5IvgFnhiRok8hqJo/+ArAbNv7rhU4fnWGHNVfN59HSQFaxZDSAL3IFG2YmqxuRs+IU33milSxbPlog==} + webpack@5.94.0: + resolution: {integrity: sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==} engines: {node: '>=10.13.0'} hasBin: true peerDependencies: @@ -6038,6 +6104,10 @@ packages: resolution: {integrity: sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==} engines: {node: '>= 0.4'} + which-typed-array@1.1.15: + resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -6059,8 +6129,8 @@ packages: resolution: {integrity: sha512-ajBj65K5I7denzer2IYW6+2bNIVqLGDHqDw3Ow8Ohh+vdW+rv4MZ6eiDvHoKhfJFZ2auyN8byXieDDJ96ViONg==} engines: {node: '>= 12.0.0'} - winston@3.13.0: - resolution: {integrity: sha512-rwidmA1w3SE4j0E5MuIufFhyJPBDG7Nu71RkZor1p2+qHvJSZ9GYDA81AyleQcZbh/+V6HjeBdfnTZJm9rSeQQ==} + winston@3.15.0: + resolution: {integrity: sha512-RhruH2Cj0bV0WgNL+lOfoUBI4DVfdUNjVnJGVovWZmrcKtrFTTRzgXYK2O9cymSGjrERCtaAeHwMNnUWXlwZow==} engines: {node: '>= 12.0.0'} wrap-ansi@6.2.0: @@ -6109,6 +6179,18 @@ packages: utf-8-validate: optional: true + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.7.0: resolution: {integrity: sha512-c2gsP0PRwcLFzUiA8Mkr37/MI7ilIlHQxaEAtd0uNMbVMoy8puJyafRlm0bV9MbGSabUPeLrRRaqIBcFcA2Pqg==} engines: {node: '>=10.0.0'} @@ -6180,33 +6262,75 @@ snapshots: '@jridgewell/gen-mapping': 0.3.2 '@jridgewell/trace-mapping': 0.3.22 - '@angular-devkit/core@17.1.2(chokidar@3.6.0)': + '@angular-devkit/core@17.3.10(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 + + '@angular-devkit/core@17.3.10(chokidar@4.0.1)': + dependencies: + ajv: 8.12.0 + ajv-formats: 2.1.1(ajv@8.12.0) + jsonc-parser: 3.2.1 + picomatch: 4.0.1 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.1 + + '@angular-devkit/core@17.3.8(chokidar@3.6.0)': dependencies: ajv: 8.12.0 ajv-formats: 2.1.1(ajv@8.12.0) - jsonc-parser: 3.2.0 - picomatch: 3.0.1 + jsonc-parser: 3.2.1 + picomatch: 4.0.1 rxjs: 7.8.1 source-map: 0.7.4 optionalDependencies: chokidar: 3.6.0 - '@angular-devkit/schematics-cli@17.1.2(chokidar@3.6.0)': + '@angular-devkit/schematics-cli@17.3.8(chokidar@3.6.0)': dependencies: - '@angular-devkit/core': 17.1.2(chokidar@3.6.0) - '@angular-devkit/schematics': 17.1.2(chokidar@3.6.0) + '@angular-devkit/core': 17.3.8(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.8(chokidar@3.6.0) ansi-colors: 4.1.3 - inquirer: 9.2.12 + inquirer: 9.2.15 symbol-observable: 4.0.0 yargs-parser: 21.1.1 transitivePeerDependencies: - chokidar - '@angular-devkit/schematics@17.1.2(chokidar@3.6.0)': + '@angular-devkit/schematics@17.3.10(chokidar@3.6.0)': + dependencies: + '@angular-devkit/core': 17.3.10(chokidar@3.6.0) + jsonc-parser: 3.2.1 + magic-string: 0.30.8 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@17.3.10(chokidar@4.0.1)': + dependencies: + '@angular-devkit/core': 17.3.10(chokidar@4.0.1) + jsonc-parser: 3.2.1 + magic-string: 0.30.8 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@17.3.8(chokidar@3.6.0)': dependencies: - '@angular-devkit/core': 17.1.2(chokidar@3.6.0) - jsonc-parser: 3.2.0 - magic-string: 0.30.5 + '@angular-devkit/core': 17.3.8(chokidar@3.6.0) + jsonc-parser: 3.2.1 + magic-string: 0.30.8 ora: 5.4.1 rxjs: 7.8.1 transitivePeerDependencies: @@ -6248,7 +6372,7 @@ snapshots: - encoding - supports-color - '@asyncapi/generator@1.13.1(@types/babel__core@7.20.3)(@types/node@20.14.2)(encoding@0.1.13)': + '@asyncapi/generator@1.13.1(@types/babel__core@7.20.3)(@types/node@22.7.5)(encoding@0.1.13)': dependencies: '@asyncapi/generator-react-sdk': 0.2.25(@types/babel__core@7.20.3)(encoding@0.1.13) '@asyncapi/parser': 2.1.0(encoding@0.1.13) @@ -6273,7 +6397,7 @@ snapshots: semver: 7.6.0 simple-git: 3.20.0 source-map-support: 0.5.21 - ts-node: 10.9.2(@types/node@20.14.2)(typescript@4.9.5) + ts-node: 10.9.2(@types/node@22.7.5)(typescript@4.9.5) typescript: 4.9.5 transitivePeerDependencies: - '@swc/core' @@ -7344,11 +7468,6 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 - '@emnapi/runtime@1.2.0': - dependencies: - tslib: 2.6.2 - optional: true - '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': dependencies: eslint: 8.57.0 @@ -7356,6 +7475,10 @@ snapshots: '@eslint-community/regexpp@4.10.0': {} + '@eslint/compat@1.2.0(eslint@8.57.0)': + optionalDependencies: + eslint: 8.57.0 + '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 @@ -7370,21 +7493,39 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/eslintrc@3.1.0': + dependencies: + ajv: 6.12.6 + debug: 4.3.4 + espree: 10.2.0 + globals: 14.0.0 + ignore: 5.3.1 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + '@eslint/js@8.57.0': {} - '@fastify/ajv-compiler@3.5.0': + '@eslint/js@9.12.0': {} + + '@fastify/ajv-compiler@3.6.0': dependencies: ajv: 8.12.0 ajv-formats: 2.1.1(ajv@8.12.0) - fast-uri: 2.2.0 + fast-uri: 2.4.0 - '@fastify/deepmerge@1.3.0': {} - - '@fastify/error@3.4.0': {} + '@fastify/error@3.4.1': {} '@fastify/fast-json-stringify-compiler@4.3.0': dependencies: - fast-json-stringify: 5.8.0 + fast-json-stringify: 5.16.1 + + '@fastify/merge-json-schemas@0.1.1': + dependencies: + fast-deep-equal: 3.1.3 '@fmvilas/pseudo-yaml-ast@0.3.1': dependencies: @@ -7404,80 +7545,6 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@img/sharp-darwin-arm64@0.33.4': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.0.2 - optional: true - - '@img/sharp-darwin-x64@0.33.4': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.0.2 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.0.2': - optional: true - - '@img/sharp-libvips-darwin-x64@1.0.2': - optional: true - - '@img/sharp-libvips-linux-arm64@1.0.2': - optional: true - - '@img/sharp-libvips-linux-arm@1.0.2': - optional: true - - '@img/sharp-libvips-linux-s390x@1.0.2': - optional: true - - '@img/sharp-libvips-linux-x64@1.0.2': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.0.2': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.0.2': - optional: true - - '@img/sharp-linux-arm64@0.33.4': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.0.2 - optional: true - - '@img/sharp-linux-arm@0.33.4': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.0.2 - optional: true - - '@img/sharp-linux-s390x@0.33.4': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.0.2 - optional: true - - '@img/sharp-linux-x64@0.33.4': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.0.2 - - '@img/sharp-linuxmusl-arm64@0.33.4': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.0.2 - optional: true - - '@img/sharp-linuxmusl-x64@0.33.4': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.0.2 - optional: true - - '@img/sharp-wasm32@0.33.4': - dependencies: - '@emnapi/runtime': 1.2.0 - optional: true - - '@img/sharp-win32-ia32@0.33.4': - optional: true - - '@img/sharp-win32-x64@0.33.4': - optional: true - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -7502,27 +7569,27 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 20.14.2 + '@types/node': 22.7.5 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))': + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4))': 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.14.2 + '@types/node': 22.7.5 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.10 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -7547,7 +7614,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.2 + '@types/node': 22.7.5 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -7565,7 +7632,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.14.2 + '@types/node': 22.7.5 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -7587,7 +7654,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.18 - '@types/node': 20.14.2 + '@types/node': 22.7.5 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -7657,7 +7724,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.5 '@types/istanbul-reports': 3.0.3 - '@types/node': 20.14.2 + '@types/node': 22.7.5 '@types/yargs': 17.0.29 chalk: 4.1.2 @@ -7715,9 +7782,9 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} - '@ljharb/through@2.3.11': + '@ljharb/through@2.3.13': dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 '@lukeed/csprng@1.0.1': {} @@ -7736,186 +7803,177 @@ snapshots: - encoding - supports-color - '@microsoft/tsdoc@0.14.2': {} - - '@nestjs/axios@3.0.2(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(axios@1.7.2)(rxjs@7.8.1)': - dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - axios: 1.7.2 - rxjs: 7.8.1 + '@microsoft/tsdoc@0.15.0': {} - '@nestjs/cli@10.3.2': + '@nestjs/cli@10.4.5': dependencies: - '@angular-devkit/core': 17.1.2(chokidar@3.6.0) - '@angular-devkit/schematics': 17.1.2(chokidar@3.6.0) - '@angular-devkit/schematics-cli': 17.1.2(chokidar@3.6.0) - '@nestjs/schematics': 10.1.1(chokidar@3.6.0)(typescript@5.3.3) + '@angular-devkit/core': 17.3.8(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.8(chokidar@3.6.0) + '@angular-devkit/schematics-cli': 17.3.8(chokidar@3.6.0) + '@nestjs/schematics': 10.2.0(chokidar@3.6.0)(typescript@5.3.3) chalk: 4.1.2 chokidar: 3.6.0 - cli-table3: 0.6.3 + cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.3.3)(webpack@5.90.1) - glob: 10.3.10 + fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.3.3)(webpack@5.94.0) + glob: 10.4.2 inquirer: 8.2.6 node-emoji: 1.11.0 ora: 5.4.1 - rimraf: 4.4.1 - shelljs: 0.8.5 - source-map-support: 0.5.21 tree-kill: 1.2.2 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.1.0 typescript: 5.3.3 - webpack: 5.90.1 + webpack: 5.94.0 webpack-node-externals: 3.0.0 transitivePeerDependencies: - esbuild - uglify-js - webpack-cli - '@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)': dependencies: iterare: 1.2.1 reflect-metadata: 0.2.2 rxjs: 7.8.1 - tslib: 2.6.2 + tslib: 2.7.0 uid: 2.0.2 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.14.1 - '@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/core@10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(@nestjs/websockets@10.4.5)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1)': dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nuxtjs/opencollective': 0.3.2(encoding@0.1.13) fast-safe-stringify: 2.1.1 iterare: 1.2.1 - path-to-regexp: 3.2.0 + path-to-regexp: 3.3.0 reflect-metadata: 0.2.2 rxjs: 7.8.1 - tslib: 2.6.2 + tslib: 2.7.0 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9) - '@nestjs/websockets': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)(@nestjs/platform-socket.io@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/platform-express': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5) + '@nestjs/websockets': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(@nestjs/platform-socket.io@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1) transitivePeerDependencies: - encoding - '@nestjs/event-emitter@2.0.4(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))': + '@nestjs/event-emitter@2.0.4(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)': dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(@nestjs/websockets@10.4.5)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) eventemitter2: 6.4.9 - '@nestjs/mapped-types@2.0.5(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.0.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) reflect-metadata: 0.2.2 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.14.1 - '@nestjs/passport@10.0.3(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(passport@0.7.0)': + '@nestjs/passport@10.0.3(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(passport@0.7.0)': dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) passport: 0.7.0 - '@nestjs/platform-express@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)': + '@nestjs/platform-express@10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)': dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) - body-parser: 1.20.2 + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(@nestjs/websockets@10.4.5)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) + body-parser: 1.20.3 cors: 2.8.5 - express: 4.19.2 + express: 4.21.1 multer: 1.4.4-lts.1 - tslib: 2.6.2 + tslib: 2.7.0 transitivePeerDependencies: - supports-color - '@nestjs/platform-socket.io@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.3.9)(rxjs@7.8.1)': + '@nestjs/platform-socket.io@10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.5)(rxjs@7.8.1)': dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/websockets': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)(@nestjs/platform-socket.io@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/websockets': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(@nestjs/platform-socket.io@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1) rxjs: 7.8.1 socket.io: 4.7.5 - tslib: 2.6.2 + tslib: 2.7.0 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@nestjs/schedule@4.0.2(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))': + '@nestjs/schedule@4.1.1(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)': dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(@nestjs/websockets@10.4.5)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) cron: 3.1.7 - uuid: 9.0.1 + uuid: 10.0.0 - '@nestjs/schematics@10.1.1(chokidar@3.6.0)(typescript@5.3.3)': + '@nestjs/schematics@10.2.0(chokidar@3.6.0)(typescript@5.3.3)': dependencies: - '@angular-devkit/core': 17.1.2(chokidar@3.6.0) - '@angular-devkit/schematics': 17.1.2(chokidar@3.6.0) - comment-json: 4.2.3 - jsonc-parser: 3.2.1 + '@angular-devkit/core': 17.3.10(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.10(chokidar@3.6.0) + comment-json: 4.2.5 + jsonc-parser: 3.3.1 pluralize: 8.0.0 typescript: 5.3.3 transitivePeerDependencies: - chokidar - '@nestjs/schematics@10.1.1(chokidar@3.6.0)(typescript@5.4.5)': + '@nestjs/schematics@10.2.0(chokidar@4.0.1)(typescript@5.5.4)': dependencies: - '@angular-devkit/core': 17.1.2(chokidar@3.6.0) - '@angular-devkit/schematics': 17.1.2(chokidar@3.6.0) - comment-json: 4.2.3 - jsonc-parser: 3.2.1 + '@angular-devkit/core': 17.3.10(chokidar@4.0.1) + '@angular-devkit/schematics': 17.3.10(chokidar@4.0.1) + comment-json: 4.2.5 + jsonc-parser: 3.3.1 pluralize: 8.0.0 - typescript: 5.4.5 + typescript: 5.5.4 transitivePeerDependencies: - chokidar - '@nestjs/swagger@7.3.1(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': + '@nestjs/swagger@7.4.2(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': dependencies: - '@microsoft/tsdoc': 0.14.2 - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/mapped-types': 2.0.5(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + '@microsoft/tsdoc': 0.15.0 + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(@nestjs/websockets@10.4.5)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/mapped-types': 2.0.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) js-yaml: 4.1.0 lodash: 4.17.21 - path-to-regexp: 3.2.0 + path-to-regexp: 3.3.0 reflect-metadata: 0.2.2 - swagger-ui-dist: 5.11.2 + swagger-ui-dist: 5.17.14 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.14.1 - '@nestjs/testing@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9))': + '@nestjs/testing@10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(@nestjs/platform-express@10.4.5)': dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) - tslib: 2.6.2 + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(@nestjs/websockets@10.4.5)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) + tslib: 2.7.0 optionalDependencies: - '@nestjs/platform-express': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9) + '@nestjs/platform-express': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5) - '@nestjs/typeorm@10.0.2(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)))': + '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.13.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)))': dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(@nestjs/websockets@10.4.5)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) reflect-metadata: 0.2.2 rxjs: 7.8.1 - typeorm: 0.3.20(better-sqlite3@9.6.0)(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + typeorm: 0.3.20(better-sqlite3@9.6.0)(pg@8.13.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) uuid: 9.0.1 - '@nestjs/websockets@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)(@nestjs/platform-socket.io@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/websockets@10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(@nestjs/platform-socket.io@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1)': dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(@nestjs/websockets@10.4.5)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) iterare: 1.2.1 object-hash: 3.0.0 reflect-metadata: 0.2.2 rxjs: 7.8.1 - tslib: 2.6.2 + tslib: 2.7.0 optionalDependencies: - '@nestjs/platform-socket.io': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.3.9)(rxjs@7.8.1) + '@nestjs/platform-socket.io': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.5)(rxjs@7.8.1) '@nodelib/fs.scandir@2.1.5': dependencies: @@ -7959,7 +8017,7 @@ snapshots: read-package-json-fast: 2.0.3 readdir-scoped-modules: 1.1.0 rimraf: 3.0.2 - semver: 7.6.0 + semver: 7.6.3 ssri: 8.0.1 treeverse: 1.0.4 walk-up-path: 1.0.0 @@ -7970,7 +8028,7 @@ snapshots: '@npmcli/fs@1.1.1': dependencies: '@gar/promisify': 1.1.3 - semver: 7.6.0 + semver: 7.6.3 '@npmcli/git@2.1.0': dependencies: @@ -7980,7 +8038,7 @@ snapshots: npm-pick-manifest: 6.1.1 promise-inflight: 1.0.1 promise-retry: 2.0.1 - semver: 7.6.0 + semver: 7.6.3 which: 2.0.2 transitivePeerDependencies: - bluebird @@ -8002,7 +8060,7 @@ snapshots: cacache: 15.3.0 json-parse-even-better-errors: 2.3.1 pacote: 11.3.5 - semver: 7.6.0 + semver: 7.6.3 transitivePeerDependencies: - bluebird - supports-color @@ -8046,14 +8104,7 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@pkgr/utils@2.4.2': - dependencies: - cross-spawn: 7.0.3 - fast-glob: 3.3.0 - is-glob: 4.0.3 - open: 9.1.0 - picocolors: 1.0.0 - tslib: 2.6.2 + '@pkgr/core@0.1.1': {} '@rollup/plugin-babel@5.3.1(@babel/core@7.12.9)(@types/babel__core@7.20.3)(rollup@2.79.1)': dependencies: @@ -8071,6 +8122,8 @@ snapshots: picomatch: 2.3.1 rollup: 2.79.1 + '@rtsao/scc@1.1.0': {} + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.0': @@ -8119,7 +8172,7 @@ snapshots: fast-memoize: 2.5.2 immer: 9.0.21 lodash: 4.17.21 - tslib: 2.6.2 + tslib: 2.7.0 urijs: 1.19.11 '@stoplight/json@3.21.0': @@ -8157,7 +8210,7 @@ snapshots: nimma: 0.2.2 pony-cause: 1.1.1 simple-eval: 1.0.0 - tslib: 2.6.2 + tslib: 2.7.0 transitivePeerDependencies: - encoding @@ -8166,7 +8219,7 @@ snapshots: '@stoplight/json': 3.21.0 '@stoplight/spectral-core': 1.18.3(encoding@0.1.13) '@types/json-schema': 7.0.15 - tslib: 2.6.2 + tslib: 2.7.0 transitivePeerDependencies: - encoding @@ -8182,7 +8235,7 @@ snapshots: ajv-errors: 3.0.0(ajv@8.12.0) ajv-formats: 2.1.1(ajv@8.12.0) lodash: 4.17.21 - tslib: 2.6.2 + tslib: 2.7.0 transitivePeerDependencies: - encoding @@ -8191,7 +8244,7 @@ snapshots: '@stoplight/json': 3.21.0 '@stoplight/types': 13.20.0 '@stoplight/yaml': 4.2.3 - tslib: 2.6.2 + tslib: 2.7.0 '@stoplight/spectral-ref-resolver@1.0.4(encoding@0.1.13)': dependencies: @@ -8199,7 +8252,7 @@ snapshots: '@stoplight/json-ref-resolver': 3.1.6 '@stoplight/spectral-runtime': 1.1.2(encoding@0.1.13) dependency-graph: 0.11.0 - tslib: 2.6.2 + tslib: 2.7.0 transitivePeerDependencies: - encoding @@ -8211,7 +8264,7 @@ snapshots: abort-controller: 3.0.0 lodash: 4.17.21 node-fetch: 2.7.0(encoding@0.1.13) - tslib: 2.6.2 + tslib: 2.7.0 transitivePeerDependencies: - encoding @@ -8237,7 +8290,7 @@ snapshots: '@stoplight/ordered-object-literal': 1.0.4 '@stoplight/types': 13.20.0 '@stoplight/yaml-ast-parser': 0.0.48 - tslib: 2.6.2 + tslib: 2.7.0 '@tootallnate/once@1.1.2': {} @@ -8272,30 +8325,32 @@ snapshots: '@types/bcrypt@5.0.2': dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 '@types/body-parser@1.19.2': dependencies: '@types/connect': 3.4.35 - '@types/node': 20.14.2 + '@types/node': 22.7.5 + + '@types/bytes@3.1.4': {} '@types/compression@1.7.5': dependencies: - '@types/express': 4.17.21 + '@types/express': 5.0.0 '@types/connect@3.4.35': dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 '@types/cookie-parser@1.4.7': dependencies: - '@types/express': 4.17.21 + '@types/express': 5.0.0 '@types/cookie@0.4.1': {} '@types/cors@2.8.15': dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 '@types/debug@4.1.7': dependencies: @@ -8307,38 +8362,35 @@ snapshots: '@types/es-aggregate-error@1.0.4': dependencies: - '@types/node': 20.14.2 - - '@types/eslint-scope@3.7.4': - dependencies: - '@types/eslint': 8.4.6 - '@types/estree': 1.0.5 + '@types/node': 22.7.5 '@types/eslint@8.4.6': dependencies: '@types/estree': 1.0.5 '@types/json-schema': 7.0.15 + optional: true '@types/estree@0.0.39': {} '@types/estree@1.0.5': {} - '@types/express-serve-static-core@4.17.33': + '@types/express-serve-static-core@5.0.0': dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 '@types/qs': 6.9.7 '@types/range-parser': 1.2.4 + '@types/send': 0.17.4 - '@types/express@4.17.21': + '@types/express@5.0.0': dependencies: '@types/body-parser': 1.19.2 - '@types/express-serve-static-core': 4.17.33 + '@types/express-serve-static-core': 5.0.0 '@types/qs': 6.9.7 '@types/serve-static': 1.15.0 '@types/graceful-fs@4.1.8': dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 '@types/istanbul-lib-coverage@2.0.5': {} @@ -8350,7 +8402,7 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.2 - '@types/jest@29.5.12': + '@types/jest@29.5.13': dependencies: expect: 29.7.0 pretty-format: 29.7.0 @@ -8359,68 +8411,79 @@ snapshots: '@types/json5@0.0.29': {} + '@types/lodash@4.17.10': {} + '@types/luxon@3.4.2': {} '@types/mdast@4.0.1': dependencies: '@types/unist': 3.0.0 + '@types/mime@1.3.5': {} + '@types/mime@3.0.4': {} '@types/morgan@1.9.9': dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 '@types/ms@0.7.31': {} - '@types/multer@1.4.11': + '@types/multer@1.4.12': dependencies: - '@types/express': 4.17.21 + '@types/express': 5.0.0 - '@types/node-7z@2.1.8': + '@types/node-7z@2.1.10': dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 - '@types/node@20.14.2': + '@types/node@22.7.5': dependencies: - undici-types: 5.26.5 + undici-types: 6.19.6 '@types/passport-http@0.3.11': dependencies: - '@types/express': 4.17.21 + '@types/express': 5.0.0 '@types/passport': 1.0.12 '@types/passport@1.0.12': dependencies: - '@types/express': 4.17.21 + '@types/express': 5.0.0 - '@types/pg@8.11.6': + '@types/pg@8.11.10': dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 pg-protocol: 1.6.1 pg-types: 4.0.2 '@types/protocol-buffers-schema@3.4.2': dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 '@types/qs@6.9.7': {} '@types/range-parser@1.2.4': {} + '@types/send@0.17.4': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.7.5 + '@types/serve-static@1.15.0': dependencies: '@types/mime': 3.0.4 - '@types/node': 20.14.2 + '@types/node': 22.7.5 '@types/stack-utils@2.0.2': {} '@types/stream-throttle@0.1.4': dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 '@types/string-similarity@4.0.2': {} + '@types/triple-beam@1.3.5': {} + '@types/trusted-types@2.0.5': {} '@types/unidecode@0.1.3': {} @@ -8439,166 +8502,166 @@ snapshots: '@types/yauzl@2.10.2': dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 optional: true - '@typescript-eslint/eslint-plugin@7.12.0(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)': + '@typescript-eslint/eslint-plugin@8.9.0(@typescript-eslint/parser@8.9.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)': dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5) - '@typescript-eslint/scope-manager': 7.12.0 - '@typescript-eslint/type-utils': 7.12.0(eslint@8.57.0)(typescript@5.4.5) - '@typescript-eslint/utils': 7.12.0(eslint@8.57.0)(typescript@5.4.5) - '@typescript-eslint/visitor-keys': 7.12.0 + '@typescript-eslint/parser': 8.9.0(eslint@8.57.0)(typescript@5.5.4) + '@typescript-eslint/scope-manager': 8.9.0 + '@typescript-eslint/type-utils': 8.9.0(eslint@8.57.0)(typescript@5.5.4) + '@typescript-eslint/utils': 8.9.0(eslint@8.57.0)(typescript@5.5.4) + '@typescript-eslint/visitor-keys': 8.9.0 eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.1 natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.4.5) + ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: - typescript: 5.4.5 + typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5)': + '@typescript-eslint/parser@8.9.0(eslint@8.57.0)(typescript@5.5.4)': dependencies: - '@typescript-eslint/scope-manager': 7.12.0 - '@typescript-eslint/types': 7.12.0 - '@typescript-eslint/typescript-estree': 7.12.0(typescript@5.4.5) - '@typescript-eslint/visitor-keys': 7.12.0 + '@typescript-eslint/scope-manager': 8.9.0 + '@typescript-eslint/types': 8.9.0 + '@typescript-eslint/typescript-estree': 8.9.0(typescript@5.5.4) + '@typescript-eslint/visitor-keys': 8.9.0 debug: 4.3.4 eslint: 8.57.0 optionalDependencies: - typescript: 5.4.5 + typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@7.12.0': + '@typescript-eslint/scope-manager@8.9.0': dependencies: - '@typescript-eslint/types': 7.12.0 - '@typescript-eslint/visitor-keys': 7.12.0 + '@typescript-eslint/types': 8.9.0 + '@typescript-eslint/visitor-keys': 8.9.0 - '@typescript-eslint/type-utils@7.12.0(eslint@8.57.0)(typescript@5.4.5)': + '@typescript-eslint/type-utils@8.9.0(eslint@8.57.0)(typescript@5.5.4)': dependencies: - '@typescript-eslint/typescript-estree': 7.12.0(typescript@5.4.5) - '@typescript-eslint/utils': 7.12.0(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/typescript-estree': 8.9.0(typescript@5.5.4) + '@typescript-eslint/utils': 8.9.0(eslint@8.57.0)(typescript@5.5.4) debug: 4.3.4 - eslint: 8.57.0 - ts-api-utils: 1.3.0(typescript@5.4.5) + ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: - typescript: 5.4.5 + typescript: 5.5.4 transitivePeerDependencies: + - eslint - supports-color - '@typescript-eslint/types@7.12.0': {} + '@typescript-eslint/types@8.9.0': {} - '@typescript-eslint/typescript-estree@7.12.0(typescript@5.4.5)': + '@typescript-eslint/typescript-estree@8.9.0(typescript@5.5.4)': dependencies: - '@typescript-eslint/types': 7.12.0 - '@typescript-eslint/visitor-keys': 7.12.0 + '@typescript-eslint/types': 8.9.0 + '@typescript-eslint/visitor-keys': 8.9.0 debug: 4.3.4 - globby: 11.1.0 + fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.4 - semver: 7.6.0 - ts-api-utils: 1.3.0(typescript@5.4.5) + semver: 7.6.3 + ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: - typescript: 5.4.5 + typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@7.12.0(eslint@8.57.0)(typescript@5.4.5)': + '@typescript-eslint/utils@8.9.0(eslint@8.57.0)(typescript@5.5.4)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@typescript-eslint/scope-manager': 7.12.0 - '@typescript-eslint/types': 7.12.0 - '@typescript-eslint/typescript-estree': 7.12.0(typescript@5.4.5) + '@typescript-eslint/scope-manager': 8.9.0 + '@typescript-eslint/types': 8.9.0 + '@typescript-eslint/typescript-estree': 8.9.0(typescript@5.5.4) eslint: 8.57.0 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/visitor-keys@7.12.0': + '@typescript-eslint/visitor-keys@8.9.0': dependencies: - '@typescript-eslint/types': 7.12.0 + '@typescript-eslint/types': 8.9.0 eslint-visitor-keys: 3.4.3 '@ungap/structured-clone@1.2.0': {} - '@webassemblyjs/ast@1.11.5': + '@webassemblyjs/ast@1.12.1': dependencies: - '@webassemblyjs/helper-numbers': 1.11.5 - '@webassemblyjs/helper-wasm-bytecode': 1.11.5 + '@webassemblyjs/helper-numbers': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/floating-point-hex-parser@1.11.5': {} + '@webassemblyjs/floating-point-hex-parser@1.11.6': {} - '@webassemblyjs/helper-api-error@1.11.5': {} + '@webassemblyjs/helper-api-error@1.11.6': {} - '@webassemblyjs/helper-buffer@1.11.5': {} + '@webassemblyjs/helper-buffer@1.12.1': {} - '@webassemblyjs/helper-numbers@1.11.5': + '@webassemblyjs/helper-numbers@1.11.6': dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.11.5 - '@webassemblyjs/helper-api-error': 1.11.5 + '@webassemblyjs/floating-point-hex-parser': 1.11.6 + '@webassemblyjs/helper-api-error': 1.11.6 '@xtuc/long': 4.2.2 - '@webassemblyjs/helper-wasm-bytecode@1.11.5': {} + '@webassemblyjs/helper-wasm-bytecode@1.11.6': {} - '@webassemblyjs/helper-wasm-section@1.11.5': + '@webassemblyjs/helper-wasm-section@1.12.1': dependencies: - '@webassemblyjs/ast': 1.11.5 - '@webassemblyjs/helper-buffer': 1.11.5 - '@webassemblyjs/helper-wasm-bytecode': 1.11.5 - '@webassemblyjs/wasm-gen': 1.11.5 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/wasm-gen': 1.12.1 - '@webassemblyjs/ieee754@1.11.5': + '@webassemblyjs/ieee754@1.11.6': dependencies: '@xtuc/ieee754': 1.2.0 - '@webassemblyjs/leb128@1.11.5': + '@webassemblyjs/leb128@1.11.6': dependencies: '@xtuc/long': 4.2.2 - '@webassemblyjs/utf8@1.11.5': {} + '@webassemblyjs/utf8@1.11.6': {} - '@webassemblyjs/wasm-edit@1.11.5': + '@webassemblyjs/wasm-edit@1.12.1': dependencies: - '@webassemblyjs/ast': 1.11.5 - '@webassemblyjs/helper-buffer': 1.11.5 - '@webassemblyjs/helper-wasm-bytecode': 1.11.5 - '@webassemblyjs/helper-wasm-section': 1.11.5 - '@webassemblyjs/wasm-gen': 1.11.5 - '@webassemblyjs/wasm-opt': 1.11.5 - '@webassemblyjs/wasm-parser': 1.11.5 - '@webassemblyjs/wast-printer': 1.11.5 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/helper-wasm-section': 1.12.1 + '@webassemblyjs/wasm-gen': 1.12.1 + '@webassemblyjs/wasm-opt': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + '@webassemblyjs/wast-printer': 1.12.1 - '@webassemblyjs/wasm-gen@1.11.5': + '@webassemblyjs/wasm-gen@1.12.1': dependencies: - '@webassemblyjs/ast': 1.11.5 - '@webassemblyjs/helper-wasm-bytecode': 1.11.5 - '@webassemblyjs/ieee754': 1.11.5 - '@webassemblyjs/leb128': 1.11.5 - '@webassemblyjs/utf8': 1.11.5 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/ieee754': 1.11.6 + '@webassemblyjs/leb128': 1.11.6 + '@webassemblyjs/utf8': 1.11.6 - '@webassemblyjs/wasm-opt@1.11.5': + '@webassemblyjs/wasm-opt@1.12.1': dependencies: - '@webassemblyjs/ast': 1.11.5 - '@webassemblyjs/helper-buffer': 1.11.5 - '@webassemblyjs/wasm-gen': 1.11.5 - '@webassemblyjs/wasm-parser': 1.11.5 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/wasm-gen': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 - '@webassemblyjs/wasm-parser@1.11.5': + '@webassemblyjs/wasm-parser@1.12.1': dependencies: - '@webassemblyjs/ast': 1.11.5 - '@webassemblyjs/helper-api-error': 1.11.5 - '@webassemblyjs/helper-wasm-bytecode': 1.11.5 - '@webassemblyjs/ieee754': 1.11.5 - '@webassemblyjs/leb128': 1.11.5 - '@webassemblyjs/utf8': 1.11.5 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-api-error': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/ieee754': 1.11.6 + '@webassemblyjs/leb128': 1.11.6 + '@webassemblyjs/utf8': 1.11.6 - '@webassemblyjs/wast-printer@1.11.5': + '@webassemblyjs/wast-printer@1.12.1': dependencies: - '@webassemblyjs/ast': 1.11.5 + '@webassemblyjs/ast': 1.12.1 '@xtuc/long': 4.2.2 '@xtuc/ieee754@1.2.0': {} @@ -8627,7 +8690,7 @@ snapshots: acorn: 7.4.1 acorn-walk: 7.2.0 - acorn-import-assertions@1.9.0(acorn@8.11.3): + acorn-import-attributes@1.9.5(acorn@8.11.3): dependencies: acorn: 8.11.3 @@ -8635,6 +8698,10 @@ snapshots: dependencies: acorn: 8.11.3 + acorn-jsx@5.3.2(acorn@8.13.0): + dependencies: + acorn: 8.13.0 + acorn-walk@7.2.0: {} acorn-walk@8.2.0: {} @@ -8643,6 +8710,8 @@ snapshots: acorn@8.11.3: {} + acorn@8.13.0: {} + agent-base@6.0.2: dependencies: debug: 4.3.4 @@ -8670,6 +8739,10 @@ snapshots: optionalDependencies: ajv: 8.12.0 + ajv-formats@3.0.1(ajv@8.12.0): + optionalDependencies: + ajv: 8.12.0 + ajv-keywords@3.5.2(ajv@6.12.6): dependencies: ajv: 6.12.6 @@ -8741,8 +8814,6 @@ snapshots: aproba@2.0.0: {} - archy@1.0.0: {} - are-we-there-yet@1.1.7: dependencies: delegates: 1.0.0 @@ -8763,56 +8834,72 @@ snapshots: array-buffer-byte-length@1.0.0: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 is-array-buffer: 3.0.2 + array-buffer-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + is-array-buffer: 3.0.4 + array-flatten@1.1.1: {} array-ify@1.0.0: {} - array-includes@3.1.7: + array-includes@3.1.8: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.1 - get-intrinsic: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 is-string: 1.0.7 array-timsort@1.0.3: {} - array-union@2.1.0: {} - - array.prototype.findlastindex@1.2.3: + array.prototype.findlastindex@1.2.5: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.1 - es-shim-unscopables: 1.0.0 - get-intrinsic: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.0.2 array.prototype.flat@1.3.2: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.1 - es-shim-unscopables: 1.0.0 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 array.prototype.flatmap@1.3.2: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.1 - es-shim-unscopables: 1.0.0 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 arraybuffer.prototype.slice@1.0.1: dependencies: array-buffer-byte-length: 1.0.0 - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 - get-intrinsic: 1.2.1 + get-intrinsic: 1.2.4 is-array-buffer: 3.0.2 is-shared-array-buffer: 1.0.2 + arraybuffer.prototype.slice@1.0.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + is-array-buffer: 3.0.4 + is-shared-array-buffer: 1.0.3 + asap@2.0.6: {} asn1@0.2.6: @@ -8823,10 +8910,6 @@ snapshots: astring@1.8.6: {} - async-g-i-s@1.5.2(node-fetch@2.7.0(encoding@0.1.13)): - dependencies: - node-fetch: 2.7.0(encoding@0.1.13) - async@3.2.4: {} asynckit@0.4.0: {} @@ -8835,22 +8918,22 @@ snapshots: available-typed-arrays@1.0.5: {} + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.0.0 + avsc@5.7.7: {} - avvio@8.3.0: + avvio@8.4.0: dependencies: - '@fastify/error': 3.4.0 - archy: 1.0.0 - debug: 4.3.4 + '@fastify/error': 3.4.1 fastq: 1.17.1 - transitivePeerDependencies: - - supports-color aws-sign2@0.7.0: {} aws4@1.12.0: {} - axios@1.7.2: + axios@1.7.7: dependencies: follow-redirects: 1.15.6 form-data: 4.0.0 @@ -9003,8 +9086,6 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.1 - big-integer@1.6.51: {} - bin-links@2.3.0: dependencies: cmd-shim: 4.1.0 @@ -9028,7 +9109,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.0 - body-parser@1.20.2: + body-parser@1.20.3: dependencies: bytes: 3.1.2 content-type: 1.0.5 @@ -9038,17 +9119,13 @@ snapshots: http-errors: 2.0.0 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.11.0 + qs: 6.13.0 raw-body: 2.5.2 type-is: 1.6.18 unpipe: 1.0.0 transitivePeerDependencies: - supports-color - bplist-parser@0.2.0: - dependencies: - big-integer: 1.6.51 - brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -9097,10 +9174,6 @@ snapshots: builtins@1.0.3: {} - bundle-name@3.0.0: - dependencies: - run-applescript: 5.0.0 - busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -9132,10 +9205,13 @@ snapshots: transitivePeerDependencies: - bluebird - call-bind@1.0.2: + call-bind@1.0.7: dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 function-bind: 1.1.2 - get-intrinsic: 1.2.1 + get-intrinsic: 1.2.4 + set-function-length: 1.2.2 call-me-maybe@1.0.2: {} @@ -9180,6 +9256,10 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + chokidar@4.0.1: + dependencies: + readdirp: 4.0.1 + chownr@1.1.4: {} chownr@2.0.0: {} @@ -9215,7 +9295,7 @@ snapshots: cli-spinners@2.7.0: {} - cli-table3@0.6.3: + cli-table3@0.6.5: dependencies: string-width: 4.2.3 optionalDependencies: @@ -9275,11 +9355,6 @@ snapshots: color-convert: 1.9.3 color-string: 1.9.1 - color@4.2.3: - dependencies: - color-convert: 2.0.1 - color-string: 1.9.1 - colors@1.4.0: {} colorspace@1.1.4: @@ -9299,7 +9374,7 @@ snapshots: commander@6.2.1: {} - comment-json@4.2.3: + comment-json@4.2.5: dependencies: array-timsort: 1.0.3 core-util-is: 1.0.3 @@ -9361,18 +9436,18 @@ snapshots: convert-source-map@2.0.0: {} - cookie-parser@1.4.6: + cookie-parser@1.4.7: dependencies: - cookie: 0.4.1 + cookie: 0.7.2 cookie-signature: 1.0.6 cookie-signature@1.0.6: {} cookie@0.4.1: {} - cookie@0.5.0: {} + cookie@0.7.1: {} - cookie@0.6.0: {} + cookie@0.7.2: {} core-js-compat@3.33.1: dependencies: @@ -9396,13 +9471,13 @@ snapshots: optionalDependencies: typescript: 5.3.3 - create-jest@29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)): + create-jest@29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.10 - jest-config: 29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -9448,6 +9523,24 @@ snapshots: whatwg-mimetype: 2.3.0 whatwg-url: 8.7.0 + data-view-buffer@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-offset@1.0.0: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + dayjs@1.11.10: {} debug@2.6.9: @@ -9482,34 +9575,26 @@ snapshots: deepmerge@4.2.2: {} - default-browser-id@3.0.0: - dependencies: - bplist-parser: 0.2.0 - untildify: 4.0.0 - - default-browser@4.0.0: - dependencies: - bundle-name: 3.0.0 - default-browser-id: 3.0.0 - execa: 7.1.1 - titleize: 3.0.0 - defaults@1.0.3: dependencies: clone: 1.0.4 define-data-property@1.1.1: dependencies: - get-intrinsic: 1.2.1 + get-intrinsic: 1.2.4 gopd: 1.0.1 has-property-descriptors: 1.0.0 - define-lazy-prop@3.0.0: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 define-properties@1.2.1: dependencies: - define-data-property: 1.1.1 - has-property-descriptors: 1.0.0 + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 object-keys: 1.1.1 delayed-stream@1.0.0: {} @@ -9543,10 +9628,6 @@ snapshots: diff@4.0.2: {} - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -9576,6 +9657,10 @@ snapshots: ee-first@1.1.1: {} + ejs@3.1.10: + dependencies: + jake: 10.9.1 + electron-to-chromium@1.4.666: {} emittery@0.13.1: {} @@ -9588,6 +9673,8 @@ snapshots: encodeurl@1.0.2: {} + encodeurl@2.0.0: {} + encoding@0.1.13: dependencies: iconv-lite: 0.6.3 @@ -9603,7 +9690,7 @@ snapshots: dependencies: '@types/cookie': 0.4.1 '@types/cors': 2.8.15 - '@types/node': 20.14.2 + '@types/node': 22.7.5 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.4.1 @@ -9616,9 +9703,31 @@ snapshots: - supports-color - utf-8-validate - enhanced-resolve@5.15.0: + engine.io@6.6.1: dependencies: - graceful-fs: 4.2.10 + '@types/cookie': 0.4.1 + '@types/cors': 2.8.15 + '@types/node': 22.7.5 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.4.1 + cors: 2.8.5 + debug: 4.3.4 + engine.io-parser: 5.2.1 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + enhanced-resolve@5.17.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + enhanced-resolve@5.17.1: + dependencies: + graceful-fs: 4.2.11 tapable: 2.2.1 entities@2.1.0: {} @@ -9636,16 +9745,16 @@ snapshots: array-buffer-byte-length: 1.0.0 arraybuffer.prototype.slice: 1.0.1 available-typed-arrays: 1.0.5 - call-bind: 1.0.2 + call-bind: 1.0.7 es-set-tostringtag: 2.0.1 es-to-primitive: 1.2.1 function.prototype.name: 1.1.5 - get-intrinsic: 1.2.1 + get-intrinsic: 1.2.4 get-symbol-description: 1.0.0 globalthis: 1.0.3 gopd: 1.0.1 has: 1.0.3 - has-property-descriptors: 1.0.0 + has-property-descriptors: 1.0.2 has-proto: 1.0.1 has-symbols: 1.0.3 internal-slot: 1.0.5 @@ -9664,7 +9773,7 @@ snapshots: safe-array-concat: 1.0.0 safe-regex-test: 1.0.0 string.prototype.trim: 1.2.7 - string.prototype.trimend: 1.0.6 + string.prototype.trimend: 1.0.8 string.prototype.trimstart: 1.0.6 typed-array-buffer: 1.0.0 typed-array-byte-length: 1.0.0 @@ -9673,28 +9782,93 @@ snapshots: unbox-primitive: 1.0.2 which-typed-array: 1.1.11 - es-aggregate-error@1.0.11: - dependencies: - define-data-property: 1.1.1 + es-abstract@1.23.3: + dependencies: + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + data-view-buffer: 1.0.1 + data-view-byte-length: 1.0.1 + data-view-byte-offset: 1.0.0 + es-define-property: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-set-tostringtag: 2.0.3 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.6 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 + globalthis: 1.0.3 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 + is-callable: 1.2.7 + is-data-view: 1.0.1 + is-negative-zero: 2.0.3 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + is-string: 1.0.7 + is-typed-array: 1.1.13 + is-weakref: 1.0.2 + object-inspect: 1.13.2 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + safe-array-concat: 1.1.2 + safe-regex-test: 1.0.3 + string.prototype.trim: 1.2.9 + string.prototype.trimend: 1.0.8 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.2 + typed-array-byte-length: 1.0.1 + typed-array-byte-offset: 1.0.2 + typed-array-length: 1.0.6 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.15 + + es-aggregate-error@1.0.11: + dependencies: + define-data-property: 1.1.1 define-properties: 1.2.1 es-abstract: 1.22.1 function-bind: 1.1.2 - get-intrinsic: 1.2.1 + get-intrinsic: 1.2.4 globalthis: 1.0.3 has-property-descriptors: 1.0.0 set-function-name: 2.0.1 + es-define-property@1.0.0: + dependencies: + get-intrinsic: 1.2.4 + + es-errors@1.3.0: {} + es-module-lexer@1.2.1: {} + es-object-atoms@1.0.0: + dependencies: + es-errors: 1.3.0 + es-set-tostringtag@2.0.1: dependencies: - get-intrinsic: 1.2.1 + get-intrinsic: 1.2.4 has: 1.0.3 - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 - es-shim-unscopables@1.0.0: + es-set-tostringtag@2.0.3: dependencies: - has: 1.0.3 + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.0.2: + dependencies: + hasown: 2.0.2 es-to-primitive@1.2.1: dependencies: @@ -9712,8 +9886,6 @@ snapshots: escape-string-regexp@4.0.0: {} - escape-string-regexp@5.0.0: {} - escodegen@2.1.0: dependencies: esprima: 4.0.1 @@ -9729,62 +9901,60 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: debug: 3.2.7 - is-core-module: 2.13.1 + is-core-module: 2.15.1 resolve: 1.22.8 transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint@8.57.0): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.9.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/parser': 8.9.0(eslint@8.57.0)(typescript@5.5.4) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.9.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0): dependencies: - array-includes: 3.1.7 - array.prototype.findlastindex: 1.2.3 + '@rtsao/scc': 1.1.0 + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.5 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 debug: 3.2.7 doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.12.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint@8.57.0) - hasown: 2.0.0 - is-core-module: 2.13.1 + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.9.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@8.57.0) + hasown: 2.0.2 + is-core-module: 2.15.1 is-glob: 4.0.3 minimatch: 3.1.2 - object.fromentries: 2.0.7 - object.groupby: 1.0.1 - object.values: 1.1.7 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.0 semver: 6.3.1 + string.prototype.trimend: 1.0.8 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 7.12.0(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/parser': 8.9.0(eslint@8.57.0)(typescript@5.5.4) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-prettier@5.1.3(@types/eslint@8.4.6)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.1): + eslint-plugin-prettier@5.2.1(@types/eslint@8.4.6)(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.3): dependencies: eslint: 8.57.0 - prettier: 3.3.1 + prettier: 3.3.3 prettier-linter-helpers: 1.0.0 - synckit: 0.8.6 + synckit: 0.9.1 optionalDependencies: '@types/eslint': 8.4.6 eslint-config-prettier: 9.1.0(eslint@8.57.0) - eslint-plugin-simple-import-sort@12.1.0(eslint@8.57.0): - dependencies: - eslint: 8.57.0 - eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 @@ -9797,6 +9967,8 @@ snapshots: eslint-visitor-keys@3.4.3: {} + eslint-visitor-keys@4.1.0: {} + eslint@8.57.0: dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) @@ -9840,6 +10012,12 @@ snapshots: transitivePeerDependencies: - supports-color + espree@10.2.0: + dependencies: + acorn: 8.13.0 + acorn-jsx: 5.3.2(acorn@8.13.0) + eslint-visitor-keys: 4.1.0 + espree@9.6.1: dependencies: acorn: 8.11.3 @@ -9884,18 +10062,6 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 - execa@7.1.1: - dependencies: - cross-spawn: 7.0.3 - get-stream: 6.0.1 - human-signals: 4.3.1 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.1.0 - onetime: 6.0.0 - signal-exit: 3.0.7 - strip-final-newline: 3.0.0 - exit@0.1.2: {} expand-template@2.0.3: {} @@ -9908,34 +10074,34 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 - express@4.19.2: + express@4.21.1: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.2 + body-parser: 1.20.3 content-disposition: 0.5.4 content-type: 1.0.5 - cookie: 0.6.0 + cookie: 0.7.1 cookie-signature: 1.0.6 debug: 2.6.9 depd: 2.0.0 - encodeurl: 1.0.2 + encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.2.0 + finalhandler: 1.3.1 fresh: 0.5.2 http-errors: 2.0.0 - merge-descriptors: 1.0.1 + merge-descriptors: 1.0.3 methods: 1.1.2 on-finished: 2.4.1 parseurl: 1.3.3 - path-to-regexp: 0.1.7 + path-to-regexp: 0.1.10 proxy-addr: 2.0.7 - qs: 6.11.0 + qs: 6.13.0 range-parser: 1.2.1 safe-buffer: 5.2.1 - send: 0.18.0 - serve-static: 1.15.0 + send: 0.19.0 + serve-static: 1.16.2 setprototypeof: 1.2.0 statuses: 2.0.1 type-is: 1.6.18 @@ -9976,7 +10142,7 @@ snapshots: fast-diff@1.2.0: {} - fast-glob@3.3.0: + fast-glob@3.3.2: dependencies: '@nodelib/fs.stat': 2.0.5 '@nodelib/fs.walk': 1.2.8 @@ -9986,20 +10152,21 @@ snapshots: fast-json-stable-stringify@2.1.0: {} - fast-json-stringify@5.8.0: + fast-json-stringify@5.16.1: dependencies: - '@fastify/deepmerge': 1.3.0 + '@fastify/merge-json-schemas': 0.1.1 ajv: 8.12.0 - ajv-formats: 2.1.1(ajv@8.12.0) + ajv-formats: 3.0.1(ajv@8.12.0) fast-deep-equal: 3.1.3 - fast-uri: 2.2.0 - rfdc: 1.3.0 + fast-uri: 2.4.0 + json-schema-ref-resolver: 1.0.1 + rfdc: 1.4.1 fast-levenshtein@2.0.6: {} fast-memoize@2.5.2: {} - fast-querystring@1.1.1: + fast-querystring@1.1.2: dependencies: fast-decode-uri-component: 1.0.1 @@ -10007,28 +10174,26 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@2.2.0: {} + fast-uri@2.4.0: {} - fastify@4.27.0: + fastify@4.28.1: dependencies: - '@fastify/ajv-compiler': 3.5.0 - '@fastify/error': 3.4.0 + '@fastify/ajv-compiler': 3.6.0 + '@fastify/error': 3.4.1 '@fastify/fast-json-stringify-compiler': 4.3.0 abstract-logging: 2.0.1 - avvio: 8.3.0 + avvio: 8.4.0 fast-content-type-parse: 1.1.0 - fast-json-stringify: 5.8.0 - find-my-way: 8.1.0 - light-my-request: 5.11.0 - pino: 9.1.0 + fast-json-stringify: 5.16.1 + find-my-way: 8.2.2 + light-my-request: 5.14.0 + pino: 9.5.0 process-warning: 3.0.0 proxy-addr: 2.0.7 - rfdc: 1.3.0 + rfdc: 1.4.1 secure-json-parse: 2.7.0 - semver: 7.6.0 - toad-cache: 3.3.0 - transitivePeerDependencies: - - supports-color + semver: 7.6.3 + toad-cache: 3.7.0 fastq@1.17.1: dependencies: @@ -10048,11 +10213,6 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 - figures@5.0.0: - dependencies: - escape-string-regexp: 5.0.0 - is-unicode-supported: 1.3.0 - file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -10061,10 +10221,14 @@ snapshots: dependencies: moment: 2.29.4 - file-type-checker@1.1.0: {} + file-type-checker@1.1.2: {} file-uri-to-path@1.0.0: {} + filelist@1.0.4: + dependencies: + minimatch: 5.1.6 + filename-reserved-regex@2.0.0: {} filenamify@4.3.0: @@ -10077,10 +10241,10 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.2.0: + finalhandler@1.3.1: dependencies: debug: 2.6.9 - encodeurl: 1.0.2 + encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 @@ -10089,11 +10253,11 @@ snapshots: transitivePeerDependencies: - supports-color - find-my-way@8.1.0: + find-my-way@8.2.2: dependencies: fast-deep-equal: 3.1.3 - fast-querystring: 1.1.1 - safe-regex2: 2.0.0 + fast-querystring: 1.1.2 + safe-regex2: 3.1.0 find-up@4.1.0: dependencies: @@ -10132,7 +10296,7 @@ snapshots: forever-agent@0.6.1: {} - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.3.3)(webpack@5.90.1): + fork-ts-checker-webpack-plugin@9.0.2(typescript@5.3.3)(webpack@5.94.0): dependencies: '@babel/code-frame': 7.23.5 chalk: 4.1.2 @@ -10147,7 +10311,7 @@ snapshots: semver: 7.6.0 tapable: 2.2.1 typescript: 5.3.3 - webpack: 5.90.1 + webpack: 5.94.0 form-data@2.3.3: dependencies: @@ -10182,7 +10346,7 @@ snapshots: fs-extra@10.1.0: dependencies: - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 jsonfile: 6.1.0 universalify: 2.0.0 @@ -10207,9 +10371,16 @@ snapshots: function.prototype.name@1.1.5: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.1 + es-abstract: 1.23.3 + functions-have-names: 1.2.3 + + function.prototype.name@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 functions-have-names: 1.2.3 functions-have-names@1.2.3: {} @@ -10248,6 +10419,14 @@ snapshots: has-proto: 1.0.1 has-symbols: 1.0.3 + get-intrinsic@1.2.4: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.1 + has-symbols: 1.0.3 + hasown: 2.0.2 + get-package-type@0.1.0: {} get-stream@5.2.0: @@ -10258,8 +10437,14 @@ snapshots: get-symbol-description@1.0.0: dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.1 + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + + get-symbol-description@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 getpass@0.1.7: dependencies: @@ -10285,6 +10470,24 @@ snapshots: minipass: 5.0.0 path-scurry: 1.10.1 + glob@10.4.2: + dependencies: + foreground-child: 3.1.1 + jackspeak: 3.4.0 + minimatch: 9.0.4 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 1.11.1 + + glob@11.0.0: + dependencies: + foreground-child: 3.1.1 + jackspeak: 4.0.1 + minimatch: 10.0.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 2.0.0 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -10294,13 +10497,6 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 - glob@9.3.1: - dependencies: - fs.realpath: 1.0.0 - minimatch: 7.4.2 - minipass: 4.2.5 - path-scurry: 1.10.1 - global-dirs@3.0.1: dependencies: ini: 2.0.0 @@ -10311,25 +10507,22 @@ snapshots: dependencies: type-fest: 0.20.2 + globals@14.0.0: {} + + globals@15.11.0: {} + globalthis@1.0.3: dependencies: define-properties: 1.2.1 - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.0 - ignore: 5.3.1 - merge2: 1.4.1 - slash: 3.0.0 - gopd@1.0.1: dependencies: get-intrinsic: 1.2.1 graceful-fs@4.2.10: {} + graceful-fs@4.2.11: {} + grapheme-splitter@1.0.4: {} graphemer@1.4.0: {} @@ -10351,13 +10544,19 @@ snapshots: has-property-descriptors@1.0.0: dependencies: - get-intrinsic: 1.2.1 + get-intrinsic: 1.2.4 + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.0 has-proto@1.0.1: {} + has-proto@1.0.3: {} + has-symbols@1.0.3: {} - has-tostringtag@1.0.0: + has-tostringtag@1.0.2: dependencies: has-symbols: 1.0.3 @@ -10367,11 +10566,11 @@ snapshots: dependencies: function-bind: 1.1.2 - hasown@2.0.0: + hasown@2.0.2: dependencies: function-bind: 1.1.2 - helmet@7.1.0: {} + helmet@8.0.0: {} highlight.js@10.7.3: {} @@ -10418,8 +10617,6 @@ snapshots: human-signals@2.1.0: {} - human-signals@4.3.1: {} - humanize-ms@1.2.1: dependencies: ms: 2.1.3 @@ -10488,15 +10685,15 @@ snapshots: through: 2.3.8 wrap-ansi: 6.2.0 - inquirer@9.2.12: + inquirer@9.2.15: dependencies: - '@ljharb/through': 2.3.11 + '@ljharb/through': 2.3.13 ansi-escapes: 4.3.2 chalk: 5.3.0 cli-cursor: 3.1.0 cli-width: 4.1.0 external-editor: 3.1.0 - figures: 5.0.0 + figures: 3.2.0 lodash: 4.17.21 mute-stream: 1.0.0 ora: 5.4.1 @@ -10508,11 +10705,15 @@ snapshots: internal-slot@1.0.5: dependencies: - get-intrinsic: 1.2.1 + get-intrinsic: 1.2.4 has: 1.0.3 - side-channel: 1.0.4 + side-channel: 1.0.6 - interpret@1.4.0: {} + internal-slot@1.0.7: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.0.6 ip@2.0.1: {} @@ -10520,10 +10721,15 @@ snapshots: is-array-buffer@3.0.2: dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.1 + call-bind: 1.0.7 + get-intrinsic: 1.2.4 is-typed-array: 1.1.10 + is-array-buffer@3.0.4: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + is-arrayish@0.2.1: {} is-arrayish@0.3.2: {} @@ -10538,22 +10744,26 @@ snapshots: is-boolean-object@1.1.2: dependencies: - call-bind: 1.0.2 - has-tostringtag: 1.0.0 + call-bind: 1.0.7 + has-tostringtag: 1.0.2 is-callable@1.2.7: {} is-core-module@2.13.1: dependencies: - hasown: 2.0.0 + hasown: 2.0.2 - is-date-object@1.0.5: + is-core-module@2.15.1: dependencies: - has-tostringtag: 1.0.0 + hasown: 2.0.2 - is-docker@2.2.1: {} + is-data-view@1.0.1: + dependencies: + is-typed-array: 1.1.13 - is-docker@3.0.0: {} + is-date-object@1.0.5: + dependencies: + has-tostringtag: 1.0.2 is-extglob@2.1.1: {} @@ -10569,19 +10779,17 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-inside-container@1.0.0: - dependencies: - is-docker: 3.0.0 - is-interactive@1.0.0: {} is-lambda@1.0.1: {} is-negative-zero@2.0.2: {} + is-negative-zero@2.0.3: {} + is-number-object@1.0.7: dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 is-number@7.0.0: {} @@ -10593,20 +10801,22 @@ snapshots: is-regex@1.1.4: dependencies: - call-bind: 1.0.2 - has-tostringtag: 1.0.0 + call-bind: 1.0.7 + has-tostringtag: 1.0.2 is-shared-array-buffer@1.0.2: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 - is-stream@2.0.1: {} + is-shared-array-buffer@1.0.3: + dependencies: + call-bind: 1.0.7 - is-stream@3.0.0: {} + is-stream@2.0.1: {} is-string@1.0.7: dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 is-symbol@1.0.4: dependencies: @@ -10615,24 +10825,22 @@ snapshots: is-typed-array@1.1.10: dependencies: available-typed-arrays: 1.0.5 - call-bind: 1.0.2 + call-bind: 1.0.7 for-each: 0.3.3 gopd: 1.0.1 - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 + + is-typed-array@1.1.13: + dependencies: + which-typed-array: 1.1.15 is-typedarray@1.0.0: {} is-unicode-supported@0.1.0: {} - is-unicode-supported@1.3.0: {} - is-weakref@1.0.2: dependencies: - call-bind: 1.0.2 - - is-wsl@2.2.0: - dependencies: - is-docker: 2.2.1 + call-bind: 1.0.7 isarray@1.0.0: {} @@ -10671,7 +10879,7 @@ snapshots: '@babel/parser': 7.23.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.0 - semver: 7.6.0 + semver: 7.6.3 transitivePeerDependencies: - supports-color @@ -10702,6 +10910,25 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@3.4.0: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jackspeak@4.0.1: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jake@10.9.1: + dependencies: + async: 3.2.4 + chalk: 4.1.2 + filelist: 1.0.4 + minimatch: 3.1.2 + jest-changed-files@29.7.0: dependencies: execa: 5.1.1 @@ -10714,7 +10941,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.2 + '@types/node': 22.7.5 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.1 @@ -10734,16 +10961,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)): + jest-cli@29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + create-jest: 29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.6.2 @@ -10753,7 +10980,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)): dependencies: '@babel/core': 7.23.9 '@jest/test-sequencer': 29.7.0 @@ -10778,8 +11005,8 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 20.14.2 - ts-node: 10.9.2(@types/node@20.14.2)(typescript@5.4.5) + '@types/node': 22.7.5 + ts-node: 10.9.2(@types/node@22.7.5)(typescript@5.5.4) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -10808,7 +11035,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.2 + '@types/node': 22.7.5 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -10818,7 +11045,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.8 - '@types/node': 20.14.2 + '@types/node': 22.7.5 anymatch: 3.1.2 fb-watchman: 2.0.2 graceful-fs: 4.2.10 @@ -10857,7 +11084,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.14.2 + '@types/node': 22.7.5 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -10892,7 +11119,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.2 + '@types/node': 22.7.5 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.10 @@ -10920,7 +11147,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.2 + '@types/node': 22.7.5 chalk: 4.1.2 cjs-module-lexer: 1.2.3 collect-v8-coverage: 1.0.2 @@ -10959,14 +11186,14 @@ snapshots: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.6.0 + semver: 7.6.3 transitivePeerDependencies: - supports-color jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.14.2 + '@types/node': 22.7.5 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.10 @@ -10985,7 +11212,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.14.2 + '@types/node': 22.7.5 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -10994,23 +11221,23 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 20.14.2 + '@types/node': 22.7.5 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)): + jest@29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + jest-cli: 29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -11084,6 +11311,10 @@ snapshots: dependencies: ajv: 5.5.2 + json-schema-ref-resolver@1.0.1: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse@0.3.1: {} json-schema-traverse@0.4.1: {} @@ -11111,17 +11342,17 @@ snapshots: jsonc-parser@2.2.1: {} - jsonc-parser@3.2.0: {} - jsonc-parser@3.2.1: {} + jsonc-parser@3.3.1: {} + jsonfile@1.0.1: {} jsonfile@6.1.0: dependencies: universalify: 2.0.0 optionalDependencies: - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 jsonparse@1.3.1: {} @@ -11164,10 +11395,10 @@ snapshots: libphonenumber-js@1.10.53: {} - light-my-request@5.11.0: + light-my-request@5.14.0: dependencies: - cookie: 0.5.0 - process-warning: 2.2.0 + cookie: 0.7.2 + process-warning: 3.0.0 set-cookie-parser: 2.6.0 limiter@1.1.5: {} @@ -11223,12 +11454,25 @@ snapshots: safe-stable-stringify: 2.3.1 triple-beam: 1.4.1 + logform@2.6.1: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.3.1 + triple-beam: 1.4.1 + loglevel@1.8.1: {} loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 + lru-cache@10.3.0: {} + + lru-cache@11.0.0: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -11241,7 +11485,7 @@ snapshots: luxon@3.4.3: {} - magic-string@0.30.5: + magic-string@0.30.8: dependencies: '@jridgewell/sourcemap-codec': 1.4.15 @@ -11251,7 +11495,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.6.0 + semver: 7.6.3 make-error@1.3.6: {} @@ -11320,7 +11564,7 @@ snapshots: dependencies: fs-monkey: 1.0.3 - merge-descriptors@1.0.1: {} + merge-descriptors@1.0.3: {} merge-stream@2.0.0: {} @@ -11478,15 +11722,17 @@ snapshots: mimic-fn@2.1.0: {} - mimic-fn@4.0.0: {} - mimic-response@3.1.0: {} + minimatch@10.0.1: + dependencies: + brace-expansion: 2.0.1 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 - minimatch@7.4.2: + minimatch@5.1.6: dependencies: brace-expansion: 2.0.1 @@ -11529,10 +11775,10 @@ snapshots: dependencies: yallist: 4.0.0 - minipass@4.2.5: {} - minipass@5.0.0: {} + minipass@7.1.2: {} + minizlib@2.1.2: dependencies: minipass: 3.3.4 @@ -11604,23 +11850,23 @@ snapshots: neo-async@2.6.2: {} - nest-winston@1.10.0(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(winston@3.13.0): + nest-winston@1.10.0(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(winston@3.15.0): dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) fast-safe-stringify: 2.1.1 - winston: 3.13.0 + winston: 3.15.0 - ? nestjs-asyncapi@1.3.0(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/swagger@7.3.1(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2))(@nestjs/websockets@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)(@nestjs/platform-socket.io@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@types/babel__core@7.20.3)(@types/node@20.14.2)(encoding@0.1.13) - : dependencies: - '@asyncapi/generator': 1.13.1(@types/babel__core@7.20.3)(@types/node@20.14.2)(encoding@0.1.13) + nestjs-asyncapi@1.3.0(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(@nestjs/swagger@7.4.2(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2))(@nestjs/websockets@10.4.5)(@types/babel__core@7.20.3)(@types/node@22.7.5)(encoding@0.1.13): + dependencies: + '@asyncapi/generator': 1.13.1(@types/babel__core@7.20.3)(@types/node@22.7.5)(encoding@0.1.13) '@asyncapi/html-template': 0.28.4(encoding@0.1.13) - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/swagger': 7.3.1(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(@nestjs/websockets@10.4.5)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/swagger': 7.4.2(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) js-yaml: 4.1.0 reflect-metadata: 0.2.1 optionalDependencies: - '@nestjs/websockets': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)(@nestjs/platform-socket.io@10.3.9)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/websockets': 10.4.5(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(@nestjs/platform-socket.io@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1) transitivePeerDependencies: - '@swc/core' - '@swc/wasm' @@ -11633,14 +11879,14 @@ snapshots: - supports-color - utf-8-validate - nestjs-paginate@8.6.2(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/swagger@7.3.1(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2))(express@4.19.2)(fastify@4.27.0)(typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))): + nestjs-paginate@9.1.2(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/swagger@7.4.2(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2))(express@4.21.1)(fastify@4.28.1)(typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.13.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4))): dependencies: - '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/swagger': 7.3.1(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(@nestjs/websockets@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) - express: 4.19.2 - fastify: 4.27.0 + '@nestjs/common': 10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/swagger': 7.4.2(@nestjs/common@10.4.5(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + express: 4.21.1 + fastify: 4.28.1 lodash: 4.17.21 - typeorm: 0.3.20(better-sqlite3@9.6.0)(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + typeorm: 0.3.20(better-sqlite3@9.6.0)(pg@8.13.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) nimma@0.2.2: dependencies: @@ -11697,7 +11943,7 @@ snapshots: npmlog: 4.1.2 request: 2.88.2 rimraf: 3.0.2 - semver: 7.6.0 + semver: 7.6.3 tar: 6.1.11 which: 2.0.2 @@ -11717,14 +11963,14 @@ snapshots: npm-install-checks@4.0.0: dependencies: - semver: 7.6.0 + semver: 7.6.3 npm-normalize-package-bin@1.0.1: {} npm-package-arg@8.1.5: dependencies: hosted-git-info: 4.1.0 - semver: 7.6.0 + semver: 7.6.3 validate-npm-package-name: 3.0.0 npm-packlist@2.2.2: @@ -11739,7 +11985,7 @@ snapshots: npm-install-checks: 4.0.0 npm-normalize-package-bin: 1.0.1 npm-package-arg: 8.1.5 - semver: 7.6.0 + semver: 7.6.3 npm-registry-fetch@11.0.0: dependencies: @@ -11757,10 +12003,6 @@ snapshots: dependencies: path-key: 3.1.1 - npm-run-path@5.1.0: - dependencies: - path-key: 4.0.0 - npmlog@4.1.2: dependencies: are-we-there-yet: 1.1.7 @@ -11795,33 +12037,42 @@ snapshots: object-inspect@1.12.3: {} + object-inspect@1.13.2: {} + object-keys@1.1.1: {} object.assign@4.1.4: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 has-symbols: 1.0.3 object-keys: 1.1.1 - object.fromentries@2.0.7: + object.assign@4.1.5: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.1 + has-symbols: 1.0.3 + object-keys: 1.1.1 - object.groupby@1.0.1: + object.fromentries@2.0.8: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.1 - get-intrinsic: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 - object.values@1.1.7: + object.groupby@1.0.3: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.1 + es-abstract: 1.23.3 + + object.values@1.2.0: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 obuf@1.1.2: {} @@ -11849,17 +12100,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - - open@9.1.0: - dependencies: - default-browser: 4.0.0 - define-lazy-prop: 3.0.0 - is-inside-container: 1.0.0 - is-wsl: 2.2.0 - openapi-sampler@1.3.1: dependencies: '@types/json-schema': 7.0.15 @@ -11910,6 +12150,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.0: {} + pacote@11.3.5: dependencies: '@npmcli/git': 2.1.0 @@ -11980,8 +12222,6 @@ snapshots: path-key@3.1.1: {} - path-key@4.0.0: {} - path-parse@1.0.7: {} path-scurry@1.10.1: @@ -11989,9 +12229,19 @@ snapshots: lru-cache: 9.1.1 minipass: 5.0.0 - path-to-regexp@0.1.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.3.0 + minipass: 7.1.2 + + path-scurry@2.0.0: + dependencies: + lru-cache: 11.0.0 + minipass: 7.1.2 + + path-to-regexp@0.1.10: {} - path-to-regexp@3.2.0: {} + path-to-regexp@3.3.0: {} path-type@4.0.0: {} @@ -12004,18 +12254,20 @@ snapshots: pg-cloudflare@1.1.1: optional: true - pg-connection-string@2.6.4: {} + pg-connection-string@2.7.0: {} pg-int8@1.0.1: {} pg-numeric@1.0.2: {} - pg-pool@3.6.2(pg@8.12.0): + pg-pool@3.7.0(pg@8.13.0): dependencies: - pg: 8.12.0 + pg: 8.13.0 pg-protocol@1.6.1: {} + pg-protocol@1.7.0: {} + pg-types@2.2.0: dependencies: pg-int8: 1.0.1 @@ -12034,11 +12286,11 @@ snapshots: postgres-interval: 3.0.0 postgres-range: 1.1.4 - pg@8.12.0: + pg@8.13.0: dependencies: - pg-connection-string: 2.6.4 - pg-pool: 3.6.2(pg@8.12.0) - pg-protocol: 1.6.1 + pg-connection-string: 2.7.0 + pg-pool: 3.7.0(pg@8.13.0) + pg-protocol: 1.7.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: @@ -12052,28 +12304,27 @@ snapshots: picomatch@2.3.1: {} - picomatch@3.0.1: {} + picomatch@4.0.1: {} - pino-abstract-transport@1.2.0: + pino-abstract-transport@2.0.0: dependencies: - readable-stream: 4.3.0 split2: 4.1.0 pino-std-serializers@7.0.0: {} - pino@9.1.0: + pino@9.5.0: dependencies: atomic-sleep: 1.0.0 fast-redact: 3.1.2 on-exit-leak-free: 2.1.0 - pino-abstract-transport: 1.2.0 + pino-abstract-transport: 2.0.0 pino-std-serializers: 7.0.0 - process-warning: 3.0.0 + process-warning: 4.0.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.3.1 - sonic-boom: 4.0.1 - thread-stream: 3.0.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 pirates@4.0.6: {} @@ -12085,6 +12336,8 @@ snapshots: pony-cause@1.1.1: {} + possible-typed-array-names@1.0.0: {} + postgres-array@2.0.0: {} postgres-array@3.0.2: {} @@ -12128,16 +12381,21 @@ snapshots: dependencies: fast-diff: 1.2.0 - prettier-plugin-jsdoc@1.3.0(prettier@3.3.1): + prettier-plugin-jsdoc@1.3.0(prettier@3.3.3): dependencies: binary-searching: 2.0.5 comment-parser: 1.4.0 mdast-util-from-markdown: 2.0.0 - prettier: 3.3.1 + prettier: 3.3.3 transitivePeerDependencies: - supports-color - prettier@3.3.1: {} + prettier-plugin-organize-imports@4.1.0(prettier@3.3.3)(typescript@5.5.4): + dependencies: + prettier: 3.3.3 + typescript: 5.5.4 + + prettier@3.3.3: {} pretty-format@29.7.0: dependencies: @@ -12149,11 +12407,9 @@ snapshots: process-nextick-args@2.0.1: {} - process-warning@2.2.0: {} - process-warning@3.0.0: {} - process@0.11.10: {} + process-warning@4.0.0: {} progress@2.0.3: {} @@ -12221,9 +12477,9 @@ snapshots: q@1.5.1: {} - qs@6.11.0: + qs@6.13.0: dependencies: - side-channel: 1.0.4 + side-channel: 1.0.6 qs@6.5.3: {} @@ -12299,13 +12555,6 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 - readable-stream@4.3.0: - dependencies: - abort-controller: 3.0.0 - buffer: 6.0.3 - events: 3.3.0 - process: 0.11.10 - readdir-scoped-modules@1.1.0: dependencies: debuglog: 1.0.1 @@ -12317,11 +12566,9 @@ snapshots: dependencies: picomatch: 2.3.1 - real-require@0.2.0: {} + readdirp@4.0.1: {} - rechoir@0.6.2: - dependencies: - resolve: 1.22.8 + real-require@0.2.0: {} reflect-metadata@0.2.1: {} @@ -12341,10 +12588,17 @@ snapshots: regexp.prototype.flags@1.5.0: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 functions-have-names: 1.2.3 + regexp.prototype.flags@1.5.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-errors: 1.3.0 + set-function-name: 2.0.1 + regexpu-core@5.3.2: dependencies: '@babel/regjsgen': 0.8.0 @@ -12414,13 +12668,13 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - ret@0.2.2: {} + ret@0.4.3: {} retry@0.12.0: {} reusify@1.0.4: {} - rfdc@1.3.0: {} + rfdc@1.4.1: {} rimraf@2.2.8: {} @@ -12428,22 +12682,15 @@ snapshots: dependencies: glob: 7.2.3 - rimraf@4.4.1: + rimraf@6.0.1: dependencies: - glob: 9.3.1 - - rimraf@5.0.7: - dependencies: - glob: 10.3.10 + glob: 11.0.0 + package-json-from-dist: 1.0.0 rollup@2.79.1: optionalDependencies: fsevents: 2.3.2 - run-applescript@5.0.0: - dependencies: - execa: 5.1.1 - run-async@2.4.1: {} run-async@3.0.0: {} @@ -12458,8 +12705,15 @@ snapshots: safe-array-concat@1.0.0: dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.1 + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + isarray: 2.0.5 + + safe-array-concat@1.1.2: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 has-symbols: 1.0.3 isarray: 2.0.5 @@ -12469,13 +12723,19 @@ snapshots: safe-regex-test@1.0.0: dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.1 + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + is-regex: 1.1.4 + + safe-regex-test@1.0.3: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 is-regex: 1.1.4 - safe-regex2@2.0.0: + safe-regex2@3.1.0: dependencies: - ret: 0.2.2 + ret: 0.4.3 safe-stable-stringify@1.1.1: {} @@ -12512,7 +12772,9 @@ snapshots: dependencies: lru-cache: 6.0.0 - send@0.18.0: + semver@7.6.3: {} + + send@0.19.0: dependencies: debug: 2.6.9 depd: 2.0.0 @@ -12534,12 +12796,12 @@ snapshots: dependencies: randombytes: 2.1.0 - serve-static@1.15.0: + serve-static@1.16.2: dependencies: - encodeurl: 1.0.2 + encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.18.0 + send: 0.19.0 transitivePeerDependencies: - supports-color @@ -12547,6 +12809,15 @@ snapshots: set-cookie-parser@2.6.0: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + set-function-name@2.0.1: dependencies: define-data-property: 1.1.1 @@ -12560,49 +12831,18 @@ snapshots: inherits: 2.0.4 safe-buffer: 5.2.1 - sharp@0.33.4: - dependencies: - color: 4.2.3 - detect-libc: 2.0.3 - semver: 7.6.0 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.33.4 - '@img/sharp-darwin-x64': 0.33.4 - '@img/sharp-libvips-darwin-arm64': 1.0.2 - '@img/sharp-libvips-darwin-x64': 1.0.2 - '@img/sharp-libvips-linux-arm': 1.0.2 - '@img/sharp-libvips-linux-arm64': 1.0.2 - '@img/sharp-libvips-linux-s390x': 1.0.2 - '@img/sharp-libvips-linux-x64': 1.0.2 - '@img/sharp-libvips-linuxmusl-arm64': 1.0.2 - '@img/sharp-libvips-linuxmusl-x64': 1.0.2 - '@img/sharp-linux-arm': 0.33.4 - '@img/sharp-linux-arm64': 0.33.4 - '@img/sharp-linux-s390x': 0.33.4 - '@img/sharp-linux-x64': 0.33.4 - '@img/sharp-linuxmusl-arm64': 0.33.4 - '@img/sharp-linuxmusl-x64': 0.33.4 - '@img/sharp-wasm32': 0.33.4 - '@img/sharp-win32-ia32': 0.33.4 - '@img/sharp-win32-x64': 0.33.4 - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} - shelljs@0.8.5: - dependencies: - glob: 7.2.3 - interpret: 1.4.0 - rechoir: 0.6.2 - - side-channel@1.0.4: + side-channel@1.0.6: dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.1 - object-inspect: 1.12.3 + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.2 signal-exit@3.0.7: {} @@ -12668,6 +12908,20 @@ snapshots: - supports-color - utf-8-validate + socket.io@4.8.0: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.3.4 + engine.io: 6.6.1 + socket.io-adapter: 2.5.2 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socks-proxy-agent@6.2.1: dependencies: agent-base: 6.0.2 @@ -12681,7 +12935,7 @@ snapshots: ip: 2.0.1 smart-buffer: 4.2.0 - sonic-boom@4.0.1: + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 @@ -12763,21 +13017,34 @@ snapshots: string.prototype.trim@1.2.7: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.1 + es-abstract: 1.23.3 - string.prototype.trimend@1.0.6: + string.prototype.trim@1.2.9: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + + string.prototype.trimend@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 string.prototype.trimstart@1.0.6: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.22.1 + es-abstract: 1.23.3 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 string_decoder@1.1.1: dependencies: @@ -12805,8 +13072,6 @@ snapshots: strip-final-newline@2.0.0: {} - strip-final-newline@3.0.0: {} - strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -12829,16 +13094,16 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - swagger-ui-dist@5.11.2: {} + swagger-ui-dist@5.17.14: {} symbol-observable@4.0.0: {} symbol-tree@3.2.4: {} - synckit@0.8.6: + synckit@0.9.1: dependencies: - '@pkgr/utils': 2.4.2 - tslib: 2.6.2 + '@pkgr/core': 0.1.1 + tslib: 2.7.0 tapable@2.2.1: {} @@ -12866,14 +13131,14 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 - terser-webpack-plugin@5.3.10(webpack@5.90.1): + terser-webpack-plugin@5.3.10(webpack@5.94.0): dependencies: '@jridgewell/trace-mapping': 0.3.22 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.1 terser: 5.27.0 - webpack: 5.90.1 + webpack: 5.94.0 terser@5.27.0: dependencies: @@ -12900,7 +13165,7 @@ snapshots: dependencies: any-promise: 1.3.0 - thread-stream@3.0.0: + thread-stream@3.1.0: dependencies: real-require: 0.2.0 @@ -12908,8 +13173,6 @@ snapshots: tiny-merge-patch@0.1.2: {} - titleize@3.0.0: {} - tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -12922,7 +13185,7 @@ snapshots: dependencies: is-number: 7.0.0 - toad-cache@3.3.0: {} + toad-cache@3.7.0: {} toidentifier@1.0.1: {} @@ -12958,21 +13221,35 @@ snapshots: dependencies: utf8-byte-length: 1.0.4 - ts-api-utils@1.3.0(typescript@5.4.5): + ts-api-utils@1.3.0(typescript@5.5.4): dependencies: - typescript: 5.4.5 + typescript: 5.5.4 - ts-jest@29.1.4(@babel/core@7.12.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.12.9))(jest@29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)))(typescript@5.4.5): + ts-apicalypse@0.4.2: + dependencies: + axios: 1.7.7 + transitivePeerDependencies: + - debug + + ts-igdb-client@0.4.2: + dependencies: + axios: 1.7.7 + ts-apicalypse: 0.4.2 + transitivePeerDependencies: + - debug + + ts-jest@29.2.5(@babel/core@7.12.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.12.9))(jest@29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)))(typescript@5.5.4): dependencies: bs-logger: 0.2.6 + ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@20.14.2)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + jest: 29.7.0(@types/node@22.7.5)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 - semver: 7.6.0 - typescript: 5.4.5 + semver: 7.6.3 + typescript: 5.5.4 yargs-parser: 21.1.1 optionalDependencies: '@babel/core': 7.12.9 @@ -12980,14 +13257,14 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.12.9) - ts-node@10.9.2(@types/node@20.14.2)(typescript@4.9.5): + ts-node@10.9.2(@types/node@22.7.5)(typescript@4.9.5): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.9 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.3 - '@types/node': 20.14.2 + '@types/node': 22.7.5 acorn: 8.11.3 acorn-walk: 8.2.0 arg: 4.1.3 @@ -12998,28 +13275,28 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5): + ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.9 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.3 - '@types/node': 20.14.2 + '@types/node': 22.7.5 acorn: 8.11.3 acorn-walk: 8.2.0 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.4.5 + typescript: 5.5.4 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 tsconfig-paths-webpack-plugin@4.1.0: dependencies: chalk: 4.1.2 - enhanced-resolve: 5.15.0 + enhanced-resolve: 5.17.0 tsconfig-paths: 4.2.0 tsconfig-paths@3.15.0: @@ -13039,6 +13316,8 @@ snapshots: tslib@2.6.2: {} + tslib@2.7.0: {} + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -13062,42 +13341,74 @@ snapshots: typed-array-buffer@1.0.0: dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.1 + call-bind: 1.0.7 + get-intrinsic: 1.2.4 is-typed-array: 1.1.10 + typed-array-buffer@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 + typed-array-byte-length@1.0.0: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 for-each: 0.3.3 has-proto: 1.0.1 is-typed-array: 1.1.10 + typed-array-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + typed-array-byte-offset@1.0.0: dependencies: available-typed-arrays: 1.0.5 - call-bind: 1.0.2 + call-bind: 1.0.7 for-each: 0.3.3 has-proto: 1.0.1 is-typed-array: 1.1.10 + typed-array-byte-offset@1.0.2: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + typed-array-length@1.0.4: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 for-each: 0.3.3 is-typed-array: 1.1.10 + typed-array-length@1.0.6: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + possible-typed-array-names: 1.0.0 + typedarray-to-buffer@3.1.5: dependencies: is-typedarray: 1.0.0 typedarray@0.0.6: {} - typeorm-naming-strategies@4.1.0(typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5))): + typeorm-naming-strategies@4.1.0(typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.13.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4))): dependencies: - typeorm: 0.3.20(better-sqlite3@9.6.0)(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) + typeorm: 0.3.20(better-sqlite3@9.6.0)(pg@8.13.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)) - typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)): + typeorm@0.3.20(better-sqlite3@9.6.0)(pg@8.13.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.5.4)): dependencies: '@sqltools/formatter': 1.2.5 app-root-path: 3.1.0 @@ -13116,8 +13427,8 @@ snapshots: yargs: 17.6.2 optionalDependencies: better-sqlite3: 9.6.0 - pg: 8.12.0 - ts-node: 10.9.2(@types/node@20.14.2)(typescript@5.4.5) + pg: 8.13.0 + ts-node: 10.9.2(@types/node@22.7.5)(typescript@5.5.4) transitivePeerDependencies: - supports-color @@ -13125,7 +13436,7 @@ snapshots: typescript@5.3.3: {} - typescript@5.4.5: {} + typescript@5.5.4: {} uc.micro@1.0.6: {} @@ -13135,7 +13446,7 @@ snapshots: unbox-primitive@1.0.2: dependencies: - call-bind: 1.0.2 + call-bind: 1.0.7 has-bigints: 1.0.2 has-symbols: 1.0.3 which-boxed-primitive: 1.0.2 @@ -13145,7 +13456,7 @@ snapshots: buffer: 5.7.1 through: 2.3.8 - undici-types@5.26.5: {} + undici-types@6.19.6: {} unicode-canonical-property-names-ecmascript@2.0.0: {} @@ -13178,8 +13489,6 @@ snapshots: unpipe@1.0.0: {} - untildify@4.0.0: {} - update-browserslist-db@1.0.13(browserslist@4.22.3): dependencies: browserslist: 4.22.3 @@ -13211,6 +13520,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@10.0.0: {} + uuid@3.4.0: {} uuid@9.0.1: {} @@ -13255,10 +13566,10 @@ snapshots: dependencies: makeerror: 1.0.12 - watchpack@2.4.0: + watchpack@2.4.1: dependencies: glob-to-regexp: 0.4.1 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 wcwidth@1.0.1: dependencies: @@ -13278,31 +13589,30 @@ snapshots: webpack-sources@3.2.3: {} - webpack@5.90.1: + webpack@5.94.0: dependencies: - '@types/eslint-scope': 3.7.4 '@types/estree': 1.0.5 - '@webassemblyjs/ast': 1.11.5 - '@webassemblyjs/wasm-edit': 1.11.5 - '@webassemblyjs/wasm-parser': 1.11.5 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/wasm-edit': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 acorn: 8.11.3 - acorn-import-assertions: 1.9.0(acorn@8.11.3) + acorn-import-attributes: 1.9.5(acorn@8.11.3) browserslist: 4.22.3 chrome-trace-event: 1.0.3 - enhanced-resolve: 5.15.0 + enhanced-resolve: 5.17.1 es-module-lexer: 1.2.1 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 json-parse-even-better-errors: 2.3.1 loader-runner: 4.3.0 mime-types: 2.1.35 neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(webpack@5.90.1) - watchpack: 2.4.0 + terser-webpack-plugin: 5.3.10(webpack@5.94.0) + watchpack: 2.4.1 webpack-sources: 3.2.3 transitivePeerDependencies: - '@swc/core' @@ -13337,10 +13647,18 @@ snapshots: which-typed-array@1.1.11: dependencies: available-typed-arrays: 1.0.5 - call-bind: 1.0.2 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.2 + + which-typed-array@1.1.15: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 for-each: 0.3.3 gopd: 1.0.1 - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 which@2.0.2: dependencies: @@ -13356,12 +13674,12 @@ snapshots: logform: 2.4.2 triple-beam: 1.4.1 - winston-daily-rotate-file@5.0.0(winston@3.13.0): + winston-daily-rotate-file@5.0.0(winston@3.15.0): dependencies: file-stream-rotator: 0.6.1 object-hash: 3.0.0 triple-beam: 1.4.1 - winston: 3.13.0 + winston: 3.15.0 winston-transport: 4.7.0 winston-transport@4.7.0: @@ -13370,13 +13688,13 @@ snapshots: readable-stream: 3.6.0 triple-beam: 1.4.1 - winston@3.13.0: + winston@3.15.0: dependencies: '@colors/colors': 1.6.0 '@dabh/diagnostics': 2.0.3 async: 3.2.4 is-stream: 2.0.1 - logform: 2.4.2 + logform: 2.6.1 one-time: 1.0.0 readable-stream: 3.6.0 safe-stable-stringify: 2.3.1 @@ -13420,6 +13738,8 @@ snapshots: ws@8.11.0: {} + ws@8.17.1: {} + ws@8.7.0: {} xml-name-validator@3.0.0: {} diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..a58c954a --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,3 @@ +sonar.projectKey=Phalcode_gamevault-backend +sonar.organization=phalcode +sonar.sources=src \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 69313604..9681120c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,45 +5,31 @@ import { ScheduleModule } from "@nestjs/schedule"; import { DisableApiIfInterceptor } from "./interceptors/disable-api-if.interceptor"; import { AdminModule } from "./modules/admin/admin.module"; -import { BoxartsModule } from "./modules/boxarts/boxarts.module"; +import { ConfigModule } from "./modules/config/config.module"; import { DatabaseModule } from "./modules/database/database.module"; -import { DevelopersModule } from "./modules/developers/developers.module"; -import { FilesModule } from "./modules/files/files.module"; import { GamesModule } from "./modules/games/games.module"; import { GarbageCollectionModule } from "./modules/garbage-collection/garbage-collection.module"; -import { GenresModule } from "./modules/genres/genres.module"; import { DefaultStrategy } from "./modules/guards/basic-auth.strategy"; import { HealthModule } from "./modules/health/health.module"; -import { ImagesModule } from "./modules/images/images.module"; -import { PluginModule } from "./modules/plugins/plugin.module"; +import { MediaModule } from "./modules/media/media.module"; +import { MetadataModule } from "./modules/metadata/metadata.module"; import { ProgressModule } from "./modules/progresses/progress.module"; -import { RawgModule } from "./modules/providers/rawg/rawg.module"; -import { PublishersModule } from "./modules/publishers/publishers.module"; -import { StoresModule } from "./modules/stores/stores.module"; -import { TagsModule } from "./modules/tags/tags.module"; import { UsersModule } from "./modules/users/users.module"; @Module({ imports: [ - GarbageCollectionModule, - AdminModule, + ConfigModule, DatabaseModule, - ScheduleModule.forRoot(), - BoxartsModule, - DevelopersModule, - PublishersModule, - StoresModule, - FilesModule, + MediaModule, + GamesModule, UsersModule, - ImagesModule, - TagsModule, - RawgModule, ProgressModule, - HealthModule, - GenresModule, - GamesModule, + MetadataModule, + AdminModule, + ScheduleModule.forRoot(), EventEmitterModule.forRoot(), - PluginModule, + GarbageCollectionModule, + HealthModule, ], providers: [ DefaultStrategy, diff --git a/src/configuration.ts b/src/configuration.ts index a4743e7c..16e9c27f 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,3 +1,5 @@ +import bytes from "bytes"; +import { toLower } from "lodash"; import packageJson from "../package.json"; import globals from "./globals"; @@ -5,7 +7,7 @@ function parseBooleanEnvVariable( environmentVariable: string, defaultCase: boolean = false, ): boolean { - switch (environmentVariable?.toLocaleLowerCase()) { + switch (toLower(environmentVariable)) { case "0": case "false": case "no": @@ -49,18 +51,6 @@ function parseNumber( return number; } -function parseNumberList( - environmentVariable: string, - defaultList: number[] = [], -): number[] { - return environmentVariable - ? environmentVariable - .split(",") - .map((item) => Number(item.trim())) - .filter((item) => !isNaN(item)) - : defaultList; -} - function parseKibibytesToBytes( environmentVariable: string, defaultValue?: number, @@ -72,6 +62,10 @@ function parseKibibytesToBytes( return bytes; } +export function getMaxBodySizeInBytes() { + return Math.max(bytes("10mb"), configuration.MEDIA.MAX_SIZE); +} + const configuration = { SERVER: { PORT: parseNumber(process.env.SERVER_PORT, 8080), @@ -107,12 +101,18 @@ const configuration = { ONLINE_ACTIVITIES_DISABLED: parseBooleanEnvVariable( process.env.SERVER_ONLINE_ACTIVITIES_DISABLED, ), + STACK_TRACE_LIMIT: parseNumber( + process.env.CONFIGURATION_STACK_TRACE_LIMIT, + 10, + ), } as const, VOLUMES: { + CONFIG: parsePath(process.env.VOLUMES_CONFIG, "/config"), FILES: parsePath(process.env.VOLUMES_FILES, "/files"), - IMAGES: parsePath(process.env.VOLUMES_IMAGES, "/images"), + MEDIA: parsePath(process.env.VOLUMES_MEDIA, "/media"), LOGS: parsePath(process.env.VOLUMES_LOGS, "/logs"), SQLITEDB: parsePath(process.env.VOLUMES_SQLITEDB, "/db"), + PLUGINS: parsePath(process.env.VOLUMES_PLUGINS, "/plugins"), } as const, DB: { SYSTEM: process.env.DB_SYSTEM || "POSTGRESQL", @@ -136,19 +136,6 @@ const configuration = { ), }, } as const, - RAWG_API: { - URL: process.env.RAWG_API_URL || "https://api.rawg.io/api", - KEY: process.env.RAWG_API_KEY || "", - CACHE_DAYS: parseNumber(process.env.RAWG_API_CACHE_DAYS, 30), - INCLUDED_STORES: parseNumberList( - process.env.RAWG_API_INCLUDED_STORES, - globals.DEFAULT_INCLUDED_RAWG_STORES, - ), - INCLUDED_PLATFORMS: parseNumberList( - process.env.RAWG_API_INCLUDED_PLATFORMS, - globals.DEFAULT_INCLUDED_RAWG_PLATFORMS, - ), - } as const, USERS: { REQUIRE_EMAIL: parseBooleanEnvVariable(process.env.USERS_REQUIRE_EMAIL), REQUIRE_FIRST_NAME: parseBooleanEnvVariable( @@ -157,6 +144,15 @@ const configuration = { REQUIRE_LAST_NAME: parseBooleanEnvVariable( process.env.USERS_REQUIRE_LAST_NAME, ), + REQUIRE_BIRTH_DATE: parseBooleanEnvVariable( + process.env.USERS_REQUIRE_BIRTH_DATE, + ), + } as const, + PARENTAL: { + AGE_RESTRICTION_ENABLED: parseBooleanEnvVariable( + process.env.PARENTAL_AGE_RESTRICTION_ENABLED, + ), + AGE_OF_MAJORITY: parseNumber(process.env.PARENTAL_AGE_OF_MAJORITY, 18), } as const, GAMES: { INDEX_INTERVAL_IN_MINUTES: parseNumber( @@ -171,49 +167,62 @@ const configuration = { process.env.SEARCH_RECURSIVE, true, ), - DEFAULT_ARCHIVE_PASSWORD: process.env.GAMES_DEFAULT_ARCHIVE_PASSWORD || "Anything", + DEFAULT_ARCHIVE_PASSWORD: + process.env.GAMES_DEFAULT_ARCHIVE_PASSWORD || "Anything", } as const, - IMAGE: { - MAX_SIZE_IN_KB: - parseNumber(process.env.IMAGE_MAX_SIZE_IN_KB, 1000) * 10_000, - GOOGLE_API_RATE_LIMIT_COOLDOWN_IN_HOURS: parseNumber( - process.env.IMAGE_GOOGLE_API_RATE_LIMIT_COOLDOWN_IN_HOURS, - 24, - ), - SUPPORTED_IMAGE_FORMATS: parseList( - process.env.GAMES_SUPPORTED_IMAGE_FORMATS, - globals.SUPPORTED_IMAGE_FORMATS, - ), - GC_DISABLED: parseBooleanEnvVariable(process.env.IMAGE_GC_DISABLED, false), + MEDIA: { + MAX_SIZE: bytes(toLower(process.env.MEDIA_MAX_SIZE)) ?? bytes("10mb"), + SUPPORTED_FORMATS: parseList( + process.env.MEDIA_SUPPORTED_FORMATS, + globals.SUPPORTED_MEDIA_FORMATS, + ), + GC_DISABLED: parseBooleanEnvVariable(process.env.MEDIA_GC_DISABLED, false), GC_INTERVAL_IN_MINUTES: parseNumber( - process.env.IMAGE_GC_INTERVAL_IN_MINUTES, - 24, + process.env.MEDIA_GC_INTERVAL_IN_MINUTES, + 60, ), } as const, + METADATA: { + TTL_IN_DAYS: parseNumber(process.env.METADATA_TTL_IN_DAYS, 30), + IGDB: { + ENABLED: parseBooleanEnvVariable(process.env.METADATA_IGDB_ENABLED, true), + PRIORITY: parseNumber(process.env.METADATA_IGDB_PRIORITY, 10), + REQUEST_INTERVAL_MS: parseNumber( + process.env.METADATA_IGDB_REQUEST_INTERVAL_MS, + 250, + ), + CLIENT_ID: process.env.METADATA_IGDB_CLIENT_ID || undefined, + CLIENT_SECRET: process.env.METADATA_IGDB_CLIENT_SECRET || undefined, + } as const, + } as const, TESTING: { AUTHENTICATION_DISABLED: parseBooleanEnvVariable( process.env.TESTING_AUTHENTICATION_DISABLED, ), MOCK_FILES: parseBooleanEnvVariable(process.env.TESTING_MOCK_FILES), IN_MEMORY_DB: parseBooleanEnvVariable(process.env.TESTING_IN_MEMORY_DB), - RAWG_API_DISABLED: parseBooleanEnvVariable( - process.env.TESTING_RAWG_API_DISABLED, - ), - GOOGLE_API_DISABLED: parseBooleanEnvVariable( - process.env.TESTING_GOOGLE_API_DISABLED, - ), - } as const, - PLUGIN: { - ENABLED: parseBooleanEnvVariable(process.env.PLUGIN_ENABLED), - SOURCES: parseList(process.env.PLUGIN_SOURCES, []), + MOCK_PROVIDERS: parseBooleanEnvVariable(process.env.TESTING_MOCK_PROVIDERS), } as const, } as const; export function getCensoredConfiguration() { - const censoredConfig = JSON.parse(JSON.stringify(configuration)); - censoredConfig.DB.PASSWORD = "**REDACTED**"; - censoredConfig.SERVER.ADMIN_PASSWORD = "**REDACTED**"; - censoredConfig.RAWG_API.KEY = "**REDACTED**"; + const censoredConfig = JSON.parse( + JSON.stringify(configuration, (_k, v) => (v === undefined ? null : v)), + ); + censoredConfig.DB.PASSWORD = censoredConfig.DB.PASSWORD + ? "**REDACTED**" + : null; + censoredConfig.SERVER.ADMIN_PASSWORD = censoredConfig.SERVER.ADMIN_PASSWORD + ? "**REDACTED**" + : null; + censoredConfig.METADATA.IGDB.CLIENT_ID = censoredConfig.METADATA.IGDB + .CLIENT_ID + ? "**REDACTED**" + : null; + censoredConfig.METADATA.IGDB.CLIENT_SECRET = censoredConfig.METADATA.IGDB + .CLIENT_SECRET + ? "**REDACTED**" + : null; return censoredConfig; } diff --git a/src/decorators/conditional-registration.decorator.ts b/src/decorators/conditional-registration.decorator.ts index 6453166f..00ff22df 100644 --- a/src/decorators/conditional-registration.decorator.ts +++ b/src/decorators/conditional-registration.decorator.ts @@ -1,9 +1,7 @@ -import { noop } from "rxjs"; - import configuration from "../configuration"; import { Public } from "./public.decorator"; export const ConditionalRegistration = configuration.SERVER .REGISTRATION_DISABLED - ? noop + ? () => {} : Public(); diff --git a/src/filters/all-filters.filter.ts b/src/filters/all-filters.filter.ts deleted file mode 100644 index 750db51f..00000000 --- a/src/filters/all-filters.filter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { FilterOperator, FilterSuffix } from "nestjs-paginate/lib"; - -export const all_filters = [ - FilterSuffix.NOT, - FilterOperator.EQ, - FilterOperator.NULL, - FilterOperator.IN, - FilterOperator.GT, - FilterOperator.GTE, - FilterOperator.LT, - FilterOperator.LTE, - FilterOperator.BTW, - FilterOperator.SW, - FilterOperator.ILIKE, - FilterOperator.CONTAINS, -]; diff --git a/src/filters/http-exception.filter.ts b/src/filters/http-exception.filter.ts index b51496e2..d1dcbbea 100644 --- a/src/filters/http-exception.filter.ts +++ b/src/filters/http-exception.filter.ts @@ -9,7 +9,7 @@ import { Request, Response } from "express"; @Catch() export class LoggingExceptionFilter implements ExceptionFilter { - private readonly logger = new Logger(LoggingExceptionFilter.name); + private readonly logger = new Logger(this.constructor.name); /** Handles exceptions that occur during request processing. */ catch(error: Error, host: ArgumentsHost) { diff --git a/src/filters/websocket-exceptions.filter.ts b/src/filters/websocket-exceptions.filter.ts index 94c6b936..3e7e397e 100644 --- a/src/filters/websocket-exceptions.filter.ts +++ b/src/filters/websocket-exceptions.filter.ts @@ -3,7 +3,7 @@ import { BaseWsExceptionFilter, WsException } from "@nestjs/websockets"; @Catch(HttpException) export class WebsocketExceptionsFilter extends BaseWsExceptionFilter { - private readonly logger = new Logger(WebsocketExceptionsFilter.name); + private readonly logger = new Logger(this.constructor.name); catch(error: HttpException, host: ArgumentsHost) { this.logger.error({ message: `Unhandled ${error.name} occurred in websocket: ${error.message}`, diff --git a/src/globals.ts b/src/globals.ts index 984e1406..b7bc8a7b 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -2,8 +2,6 @@ import { applyDecorators, Type } from "@nestjs/common"; import { ApiExtraModels, ApiOkResponse, getSchemaPath } from "@nestjs/swagger"; import { PaginatedEntity } from "./modules/database/models/paginated-entity.model"; -import { RawgPlatform } from "./modules/providers/rawg/models/platforms"; -import { RawgStore } from "./modules/providers/rawg/models/stores"; export const ApiOkResponsePaginated = >( dataDto: DataDto, @@ -77,34 +75,76 @@ export default { ".z", ], EXECUTABLE_FORMATS: [".exe", ".sh"], - SUPPORTED_IMAGE_FORMATS: [ - "image/bmp", - "image/jpeg", - "image/png", - "image/tiff", - "image/gif", - "image/x-icon", + SUPPORTED_MEDIA_FORMATS: [ + "audio/mpeg", // MP3 + "audio/wav", // WAV + "audio/ogg", // OGG + "audio/aac", // AAC + "audio/flac", // FLAC + "audio/x-ms-wma", // WMA + "audio/amr", // AMR + "audio/mp4", // MP4 Audio + "image/bmp", // BMP + "image/jpeg", // JPEG + "image/png", // PNG + "image/tiff", // TIFF + "image/gif", // GIF + "image/x-icon", // ICO + "video/mp4", // MP4 + "video/x-msvideo", // AVI + "video/quicktime", // MOV + "video/x-ms-wmv", // WMV + "video/x-flv", // FLV + "video/x-matroska", // MKV + "video/webm", // WEBM + "video/mpeg", // MPEG + "video/3gpp", // 3GP ], - DEFAULT_INCLUDED_RAWG_STORES: Object.values(RawgStore).filter( - (platform) => - platform !== RawgStore["All Stores"] && platform !== RawgStore["Itch.io"], - ), - DEFAULT_INCLUDED_RAWG_PLATFORMS: [RawgPlatform["All Platforms"]], + RESERVED_PROVIDER_SLUGS: ["gamevault", "user"], }; export interface FindOptions { + /** + * Fields to select + */ + select?: string[]; + /** * Indicates whether deleted (sub)entities should be loaded. Subentities may * be deleted by app-logic afterwards. - * - * @default false */ - loadDeletedEntities: boolean; + loadDeletedEntities?: boolean; /** * Indicates whether related entities should be loaded. - * - * @default false */ - loadRelations: boolean | string[]; + loadRelations?: boolean | string[]; + + /** + * Filter for age restriction purposes. + */ + filterByAge?: number; +} + +export interface GameVaultPluginModule { + metadata: GameVaultPluginModuleMetadataV1; +} + +export interface GameVaultPluginModuleMetadataV1 { + // Name of the Plugin. + name: string; + // Your Name, Email,Username or Company of the author. + author: string; + // Version of the Plugin, e.g. "1.0.0". + version?: string; + // Describe what the Plugin does and use how to use it. + description?: string; + // Website or Github URL of the Plugin. + website?: string; + // License Name or License URL of the Plugin. + license?: string; + // Some keywords to describe the Plugin. + keywords?: string[]; + // Dependencies of the Plugin. (Plugins needed to use this Plugin) + dependencies?: GameVaultPluginModuleMetadataV1[]; } diff --git a/src/logging.ts b/src/logging.ts index 501d83dd..e36b8b3b 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -4,6 +4,12 @@ import { consoleFormat } from "winston-console-format"; import DailyRotateFile from "winston-daily-rotate-file"; import configuration from "./configuration"; +import { GamevaultGame } from "./modules/games/gamevault-game.entity"; +import { Media } from "./modules/media/media.entity"; +import { Metadata } from "./modules/metadata/models/metadata.interface"; +import { MetadataProvider } from "./modules/metadata/providers/abstract.metadata-provider.service"; +import { Progress } from "./modules/progresses/progress.entity"; +import { GamevaultUser } from "./modules/users/gamevault-user.entity"; const transports: winston.transport[] = []; @@ -61,5 +67,69 @@ const stream = { }, }; +function logGamevaultGame(game: GamevaultGame) { + return { + id: game?.id, + title: game?.title, + file_path: game?.file_path, + }; +} + +function logGamevaultUser(user: GamevaultUser) { + return { + id: user?.id, + username: user?.username, + role: user?.role, + }; +} + +function logMetadata(metadata: Metadata) { + return { + id: metadata?.id, + provider_slug: metadata?.provider_slug, + provider_data_id: metadata?.provider_data_id, + created_at: metadata.created_at, + updated_at: metadata.updated_at, + }; +} + +function logProgress(progress: Progress) { + return { + id: progress?.id, + minutes_played: progress?.minutes_played, + state: progress?.state, + last_played_at: progress?.last_played_at, + user: logGamevaultUser(progress?.user), + game: logGamevaultGame(progress?.game), + }; +} + +function logMetadataProvider(provider: MetadataProvider) { + return { + slug: provider?.slug, + priority: provider?.priority, + enabled: provider?.enabled, + request_interval_ms: provider?.request_interval_ms, + }; +} + +function logMedia(media: Media) { + return { + id: media?.id, + source_url: media?.source_url, + file_path: media?.file_path, + type: media?.type, + uploader: logGamevaultUser(media?.uploader), + }; +} + export default logger; -export { stream }; +export { + logGamevaultGame, + logGamevaultUser, + logMedia, + logMetadata, + logMetadataProvider, + logProgress, + stream, +}; diff --git a/src/main.ts b/src/main.ts index efcf6ef2..cbca4450 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,20 +8,66 @@ import { NestFactory, Reflector } from "@nestjs/core"; import { NestExpressApplication } from "@nestjs/platform-express"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import compression from "compression"; -//import { AsyncApiDocumentBuilder, AsyncApiModule } from "nestjs-asyncapi"; import cookieparser from "cookie-parser"; +import { readdir } from "fs/promises"; import helmet from "helmet"; import morgan from "morgan"; +//import { AsyncApiDocumentBuilder, AsyncApiModule } from "nestjs-asyncapi"; +import { join, resolve } from "path"; import { AppModule } from "./app.module"; -import configuration, { getCensoredConfiguration } from "./configuration"; +import configuration, { + getCensoredConfiguration, + getMaxBodySizeInBytes, +} from "./configuration"; import { LoggingExceptionFilter } from "./filters/http-exception.filter"; -import { default as logger, default as winston, stream } from "./logging"; -import { ApiVersionMiddleware } from "./middleware/remove-api-version.middleware"; +import { GameVaultPluginModule } from "./globals"; +import { default as logger, stream, default as winston } from "./logging"; +import { LegacyRoutesMiddleware } from "./middleware/legacy-routes.middleware"; import { AuthenticationGuard } from "./modules/guards/authentication.guard"; import { AuthorizationGuard } from "./modules/guards/authorization.guard"; +async function loadPlugins() { + const pluginModuleFiles = ( + await readdir(configuration.VOLUMES.PLUGINS, { + encoding: "utf8", + recursive: true, + withFileTypes: true, + }) + ).filter((file) => file.isFile() && file.name.includes(".plugin.module.")); + const plugins = await Promise.all( + pluginModuleFiles.map( + (file) => import(resolve(join(file.path, file.name))), + ), + ); + + for (const plugin of plugins) { + const instance: GameVaultPluginModule = new plugin.default(); + logger.log({ + context: "PluginLoader", + message: `Loaded plugin.`, + plugin: plugin.default, + metadata: instance.metadata, + }); + } + + return plugins.map((module) => module.default); +} + async function bootstrap(): Promise { + // Load Modules & Plugins + const builtinModules = Reflect.getOwnMetadata("imports", AppModule); + const pluginModules = await loadPlugins(); + + logger.log({ + context: "PluginLoader", + message: `Loaded ${pluginModules.length} plugins.`, + plugins: pluginModules, + }); + const modules = [...builtinModules, ...pluginModules]; + + Reflect.defineMetadata("imports", modules, AppModule); + // Create App const app = await NestFactory.create(AppModule, { logger: winston, }); @@ -40,15 +86,28 @@ async function bootstrap(): Promise { } else { app.enableCors(); } - // GZIP app.use(compression()); + + // Set Max Body Size + + const maxBodySettings = { + limit: `${getMaxBodySizeInBytes()}b`, + extended: true, + }; + app.useBodyParser("json", maxBodySettings); + app.useBodyParser("urlencoded", maxBodySettings); + app.useBodyParser("text", maxBodySettings); + app.useBodyParser("raw", maxBodySettings); + // Security Measurements app.use(helmet({ contentSecurityPolicy: false })); + // Cookies app.use(cookieparser()); - app.use(new ApiVersionMiddleware().use); + // Support Legacy Routes + app.use(new LegacyRoutesMiddleware().use); // Skips logs for /health calls app.use( @@ -64,7 +123,6 @@ async function bootstrap(): Promise { transform: true, }), ); - // Logs HTTP 4XX and 5XX as warns and errors app.useGlobalFilters(new LoggingExceptionFilter()); @@ -109,37 +167,36 @@ async function bootstrap(): Promise { .build(), ), ); - /* Skip until it works on docker - await AsyncApiModule.setup( - "api/docs/async", - app, - AsyncApiModule.createDocument( - app, - new AsyncApiDocumentBuilder() - .setTitle("GameVault Backend Server") - .setDescription( - "Asynchronous Socket.IO Backend for GameVault, the self-hosted gaming platform for drm-free games. To make a request, you need to authenticate with the X-Socket-Secret Header during the handshake. You can get this secret by using the /users/me REST API.", - ) - .setContact("Phalcode", "https://phalco.de", "contact@phalco.de") - .setExternalDoc("Documentation", "https://gamevau.lt") - .setDefaultContentType("application/json") - .setVersion(configuration.SERVER.VERSION) - .addServer("Local GameVault Server", { - url: "localhost:8080", - protocol: "ws", - }) - .addServer("Demo GameVault Server", { - url: "demo.gamevau.lt", - protocol: "wss", - }) - .setLicense( - "Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)", - "https://github.com/Phalcode/gamevault-backend/LICENSE", - ) - .build(), - ), - ); - */ + // TODO: Leads to EACCES: permission denied, mkdir '/root/.npm/_cacache/tmp' running in docker for some reason + //await AsyncApiModule.setup( + // "api/docs/async", + // app, + // AsyncApiModule.createDocument( + // app, + // new AsyncApiDocumentBuilder() + // .setTitle("GameVault Backend Server") + // .setDescription( + // "Asynchronous Socket.IO Backend for GameVault, the self-hosted gaming platform for drm-free games. To make a request, you need to authenticate with the X-Socket-Secret Header during the handshake. You can get this secret by using the /users/me REST API.", + // ) + // .setContact("Phalcode", "https://phalco.de", "contact@phalco.de") + // .setExternalDoc("Documentation", "https://gamevau.lt") + // .setDefaultContentType("application/json") + // .setVersion(configuration.SERVER.VERSION) + // .addServer("Local GameVault Server", { + // url: "localhost:8080", + // protocol: "ws", + // }) + // .addServer("Demo GameVault Server", { + // url: "demo.gamevau.lt", + // protocol: "wss", + // }) + // .setLicense( + // "Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)", + // "https://github.com/Phalcode/gamevault-backend/LICENSE", + // ) + // .build(), + // ), + //); } // Provide fancy pants landing page @@ -155,10 +212,16 @@ async function bootstrap(): Promise { await app.listen(configuration.SERVER.PORT); logger.log({ + context: "Initialization", message: `Started GameVault Server.`, version: configuration.SERVER.VERSION, port: configuration.SERVER.PORT, config: getCensoredConfiguration(), }); } -bootstrap(); + +Error.stackTraceLimit = configuration.SERVER.STACK_TRACE_LIMIT; +bootstrap().catch((error) => { + logger.fatal({ message: "A fatal error occured", error }); + throw error; +}); diff --git a/src/middleware/remove-api-version.middleware.ts b/src/middleware/legacy-routes.middleware.ts similarity index 59% rename from src/middleware/remove-api-version.middleware.ts rename to src/middleware/legacy-routes.middleware.ts index ca9450bc..2032d084 100644 --- a/src/middleware/remove-api-version.middleware.ts +++ b/src/middleware/legacy-routes.middleware.ts @@ -2,11 +2,14 @@ import { Injectable, NestMiddleware } from "@nestjs/common"; import { NextFunction, Request, Response } from "express"; @Injectable() -export class ApiVersionMiddleware implements NestMiddleware { +export class LegacyRoutesMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { // Replace /api/v1/ with /api/ in the request URL req.url = req.url.replace("/api/v1/", "/api/"); + // Redirect /api/files/reindex to newer /api/games/reindex" + req.url = req.url.replace("/api/files/reindex", "/api/games/reindex"); + next(); } } diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index d665ea7e..a712fe46 100644 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -26,8 +26,8 @@ import { Role } from "../users/models/role.enum"; @ApiTags("admin") export class AdminController { constructor( - private healthService: HealthService, - private databaseService: DatabaseService, + private readonly healthService: HealthService, + private readonly databaseService: DatabaseService, ) {} @Get("health") @@ -82,6 +82,6 @@ export class AdminController { file: Express.Multer.File, @Headers("X-Database-Password") password: string, ) { - return await this.databaseService.restore(file, password); + return this.databaseService.restore(file, password); } } diff --git a/src/modules/boxarts/boxarts.module.ts b/src/modules/boxarts/boxarts.module.ts deleted file mode 100644 index 16f99523..00000000 --- a/src/modules/boxarts/boxarts.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { forwardRef, Module } from "@nestjs/common"; - -import { GamesModule } from "../games/games.module"; -import { ImagesModule } from "../images/images.module"; -import { BoxArtsService } from "./boxarts.service"; - -@Module({ - imports: [forwardRef(() => GamesModule), ImagesModule], - controllers: [], - providers: [BoxArtsService], - exports: [BoxArtsService], -}) -export class BoxartsModule {} diff --git a/src/modules/boxarts/boxarts.service.ts b/src/modules/boxarts/boxarts.service.ts deleted file mode 100644 index a9f1927f..00000000 --- a/src/modules/boxarts/boxarts.service.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; -import gis, { Result } from "async-g-i-s"; - -import configuration from "../../configuration"; -import { Game } from "../games/game.entity"; -import { GamesService } from "../games/games.service"; -import { ImagesService } from "../images/images.service"; - -@Injectable() -export class BoxArtsService { - private readonly logger = new Logger(BoxArtsService.name); - private cooldownGames = 0; - private cooldownStartTime = 0; - private readonly cooldownDurationInMilliseconds: number = - configuration.IMAGE.GOOGLE_API_RATE_LIMIT_COOLDOWN_IN_HOURS * 3600000; - - constructor( - @Inject(forwardRef(() => GamesService)) - private gamesService: GamesService, - private imagesService: ImagesService, - ) {} - - public async checkMultiple(games: Game[]): Promise { - if (configuration.TESTING.GOOGLE_API_DISABLED) { - this.logger.warn({ - message: "Skipping Box Art Search.", - reason: "TESTING_GOOGLE_API_DISABLED is set to true", - }); - return games; - } - - this.logger.log({ - message: "Starting Box Art Check.", - gamesCount: games.length, - }); - - for (let i = 0; i < games.length; i++) { - try { - games[i] = await this.check(games[i]); - this.logger.debug({ - message: `Checked Box Art.`, - game: { - id: games[i].id, - file_path: games[i].file_path, - }, - box_image: games[i].box_image, - }); - } catch (error) { - this.logger.error({ - message: "Box Art Check Failed.", - game: { - id: games[i].id, - file_path: games[i].file_path, - }, - box_image: games[i].box_image, - error, - }); - } - } - - this.logger.log({ - message: "Finished Box Art Check.", - gamesCount: games.length, - }); - - return games; - } - - /** - * Checks if the box art for the game is available and searches for it if - * necessary. - * - * @param game - The game to check the box art for. - */ - public async check(game: Game): Promise { - // Check if the Google API is disabled - if (configuration.TESTING.GOOGLE_API_DISABLED) { - this.logger.warn({ - message: "Skipping Box Art Search.", - reason: "TESTING_GOOGLE_API_DISABLED is set to true", - }); - return game; - } - - // Check if the box art is still available - if ( - game.box_image?.id && - (await this.imagesService.isAvailable(game.box_image.id)) - ) { - this.logger.debug({ - message: "Box Art is still available.", - game: { - id: game.id, - file_path: game.file_path, - }, - box_image: game.box_image, - }); - return game; - } - - // Check if the cooldown is active - if ( - this.cooldownGames >= 5 && - Date.now() - this.cooldownStartTime < this.cooldownDurationInMilliseconds - ) { - const remainingCooldown = - this.cooldownStartTime + - this.cooldownDurationInMilliseconds - - Date.now(); - - this.logger.warn({ - message: "Skipping Box Art Search.", - reason: "Cooldown active. The cooldown expires after a server restart.", - remainingCooldown: this.formatCooldownTime(remainingCooldown), - }); - return game; - } - - let results: Result[] = []; - - // Try SteamGridDB - results = await this.search( - `"${game.title}" site:steamgriddb.com -profile`, - ); - - if (!results.length) { - // Try PCGAMINGWIKI - results = await this.search(`"${game.title}" site:www.pcgamingwiki.com`); - } - - if (!results.length) { - // Perform a broad image search on Google - results = await this.search(`"${game.title}" game box art`); - } - - if (!results.length) { - this.cooldownGames++; - if (this.cooldownGames >= 5) { - this.cooldownStartTime = Date.now(); - const remainingCooldown = - this.cooldownStartTime + - this.cooldownDurationInMilliseconds - - Date.now(); - this.logger.warn({ - message: "No Box Art Images found for multiple games.", - game: { - id: game.id, - file_path: game.file_path, - }, - reason: - "You probably hit the Google Image Search Rate-Limit. Cooldown activated.", - remainingCooldown: this.formatCooldownTime(remainingCooldown), - }); - } - return game; - } - - // Download the matching image and return - const returnedGame = await this.downloadMatchingImage(game, results); - this.cooldownGames = 0; - return returnedGame; - } - - /** - * Search for images with a specific aspect ratio using the given search - * query. - * - * @param title - The title of the search. - * @param searchQuery - The search query. - * @returns An array of matching images. - */ - private async search(searchQuery: string): Promise { - try { - // Define the target aspect ratio and tolerance - const targetAspectRatio = 0.66; - const tolerance = 0.1; - - // Perform the image search - const searchResults = await gis(searchQuery); - // Filter the search results based on the aspect ratio - const matches = searchResults.filter((image) => { - const aspectRatio = image.width / image.height; - const aspectRatioDifference = Math.abs(aspectRatio - targetAspectRatio); - return aspectRatioDifference <= tolerance; - }); - - // Log the number of matches found - this.logger.debug({ - message: `Found ${matches.length} matching Box Art Images.`, - searchQuery, - matchesCount: matches.length, - matches, - }); - - // Return the matching images - return matches; - } catch (error) { - // Log an error if the search fails - this.logger.error({ - message: `Box Art Search failed`, - searchQuery, - error, - }); - } - } - - /** - * Downloads the first matching image from the given array of images and - * updates the game object with the downloaded image. If no matching image is - * found, logs an error message. - * - * @param game - The game object to update with the downloaded image. - * @param images - The array of images to search for a matching image. - * @returns The updated game object. - */ - private async downloadMatchingImage( - game: Game, - images: Result[], - ): Promise { - let found = false; - - // Iterate through the images array - for (const image of images) { - try { - // Download the image by URL - game.box_image = await this.imagesService.downloadByUrl(image.url); - - // Save the updated game object - await this.gamesService.save(game); - - // Log the details of the downloaded image - this.logger.log({ - message: `Saved new Box Art Image.`, - game: { - id: game.id, - file_path: game.file_path, - }, - image, - }); - - found = true; - break; - } catch (error) { - // Log an error if image download fails - this.logger.error({ - message: "Error downloading Box Art Image.", - game: { - id: game.id, - file_path: game.file_path, - }, - image, - error, - }); - } - } - - if (!found) { - // Log an error if no matching image is found - this.logger.error({ - message: `Could not download Box Art Image for game.`, - game: { - id: game.id, - file_path: game.file_path, - }, - matchingImagesCount: images.length, - }); - } - - // Return the updated game object - return game; - } - - /** Formats the cooldown time into a string representation. */ - private formatCooldownTime(milliseconds: number): string { - const hours = Math.floor(milliseconds / 3_600_000); - const minutes = Math.floor((milliseconds % 3_600_000) / 60_000); - const seconds = Math.floor((milliseconds % 60_000) / 1000); - - return `${hours}h ${minutes}m ${seconds}s`; - } -} diff --git a/src/modules/config/config.controller.ts b/src/modules/config/config.controller.ts new file mode 100644 index 00000000..5b2f62e6 --- /dev/null +++ b/src/modules/config/config.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, StreamableFile } from "@nestjs/common"; +import { + ApiBasicAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; + +import { createReadStream, existsSync } from "fs"; +import configuration from "../../configuration"; +import { MinimumRole } from "../../decorators/minimum-role.decorator"; +import { Health } from "../health/models/health.model"; +import { Role } from "../users/models/role.enum"; + +@ApiBasicAuth() +@Controller("config") +@ApiTags("config") +export class ConfigController { + @Get("news") + @ApiOkResponse({ type: () => Health }) + @ApiOperation({ + summary: "returns the news.md file from the config directory.", + operationId: "getNews", + }) + @MinimumRole(Role.GUEST) + async getNews(): Promise { + if (!existsSync(`${configuration.VOLUMES.CONFIG}/news.md`)) { + return; + } + return new StreamableFile( + createReadStream(`${configuration.VOLUMES.CONFIG}/news.md`), + ); + } +} diff --git a/src/modules/config/config.module.ts b/src/modules/config/config.module.ts new file mode 100644 index 00000000..df51c370 --- /dev/null +++ b/src/modules/config/config.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { ConfigController } from "./config.controller"; + +@Module({ + controllers: [ConfigController], +}) +export class ConfigModule {} diff --git a/src/modules/database/database.service.ts b/src/modules/database/database.service.ts index 52e8bcfb..197b64cd 100644 --- a/src/modules/database/database.service.ts +++ b/src/modules/database/database.service.ts @@ -20,10 +20,10 @@ import configuration from "../../configuration"; @Injectable() export class DatabaseService { - private readonly logger = new Logger(DatabaseService.name); - private execPromise = promisify(exec); + private readonly logger = new Logger(this.constructor.name); + private readonly execPromise = promisify(exec); - constructor(private dataSource: DataSource) {} + constructor(private readonly dataSource: DataSource) {} async backup(password: string): Promise { if (configuration.TESTING.IN_MEMORY_DB) { @@ -83,17 +83,17 @@ export class DatabaseService { async connect() { this.logger.log("Connecting Database..."); - return await this.dataSource.initialize(); + return this.dataSource.initialize(); } async disconnect() { this.logger.log("Disconnecting Database..."); - return await this.dataSource.destroy(); + return this.dataSource.destroy(); } async migrate() { this.logger.log("Migrating Database..."); - return await this.dataSource.runMigrations(); + return this.dataSource.runMigrations(); } async backupPostgresql(backupFilePath: string): Promise { diff --git a/src/modules/database/db_configuration.ts b/src/modules/database/db_configuration.ts index 00e66254..cf200d65 100644 --- a/src/modules/database/db_configuration.ts +++ b/src/modules/database/db_configuration.ts @@ -2,17 +2,16 @@ import { TypeOrmModuleOptions } from "@nestjs/typeorm"; import { readFileSync } from "fs"; import pg from "pg"; import { TlsOptions } from "tls"; +import { SnakeNamingStrategy } from "typeorm-naming-strategies"; import { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions"; import { PostgresConnectionOptions } from "typeorm/driver/postgres/PostgresConnectionOptions"; -import { SnakeNamingStrategy } from "typeorm-naming-strategies"; import configuration from "../../configuration"; const baseConfig: TypeOrmModuleOptions = { autoLoadEntities: true, - entities: ["dist/**/*.entity.js"], + entities: ["dist/**/*.*entity.js"], synchronize: configuration.DB.SYNCHRONIZE, - cache: { alwaysEnabled: true, ignoreErrors: true }, namingStrategy: new SnakeNamingStrategy(), migrationsRun: !configuration.DB.SYNCHRONIZE, logging: configuration.DB.DEBUG, diff --git a/src/modules/database/legacy-entities/database.v12-entity.ts b/src/modules/database/legacy-entities/database.v12-entity.ts new file mode 100644 index 00000000..af1f208d --- /dev/null +++ b/src/modules/database/legacy-entities/database.v12-entity.ts @@ -0,0 +1,47 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + CreateDateColumn, + DeleteDateColumn, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, + VersionColumn, +} from "typeorm"; + +export abstract class DatabaseEntityV12 { + @Index() + @PrimaryGeneratedColumn() + @ApiProperty({ + example: 1, + description: "Unique gamevault-identifier of the entity", + }) + id: number; + + @CreateDateColumn() + @ApiProperty({ + description: "date the entity was created", + example: "2021-01-01T00:00:00.000Z", + }) + created_at: Date; + + @UpdateDateColumn() + @ApiPropertyOptional({ + description: "date the entity was updated", + example: "2021-01-01T00:00:00.000Z", + }) + updated_at?: Date; + + @DeleteDateColumn() + @ApiPropertyOptional({ + description: "date the entity was soft-deleted (null if not deleted)", + example: "2021-01-01T00:00:00.000Z", + }) + deleted_at?: Date; + + @VersionColumn() + @ApiProperty({ + description: "incremental version number of the entity", + example: 1, + }) + entity_version: number; +} diff --git a/src/modules/developers/developer.entity.ts b/src/modules/database/legacy-entities/developer.v12-entity.ts similarity index 64% rename from src/modules/developers/developer.entity.ts rename to src/modules/database/legacy-entities/developer.v12-entity.ts index 8ceb82d4..632882db 100644 --- a/src/modules/developers/developer.entity.ts +++ b/src/modules/database/legacy-entities/developer.v12-entity.ts @@ -1,15 +1,15 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Column, Entity, Index, ManyToMany } from "typeorm"; +import { DatabaseEntityV12 } from "./database.v12-entity"; +import { GameV12 } from "./game.v12-entity"; -import { DatabaseEntity } from "../database/database.entity"; -import { Game } from "../games/game.entity"; - -@Entity() -export class Developer extends DatabaseEntity { +@Entity("v12_developer", { synchronize: false }) +export class DeveloperV12 extends DatabaseEntityV12 { @Index() @Column({ nullable: true }) @ApiPropertyOptional({ example: 1000, + description: "unique rawg-api-identifier of the developer", }) rawg_id?: number; @@ -22,11 +22,11 @@ export class Developer extends DatabaseEntity { }) name: string; - @ManyToMany(() => Game, (game) => game.developers) + @ManyToMany(() => GameV12, (game) => game.developers) @ApiProperty({ description: "games developed by the developer", - type: () => Game, + type: () => GameV12, isArray: true, }) - games: Game[]; + games: GameV12[]; } diff --git a/src/modules/games/game.entity.ts b/src/modules/database/legacy-entities/game.v12-entity.ts similarity index 70% rename from src/modules/games/game.entity.ts rename to src/modules/database/legacy-entities/game.v12-entity.ts index 8109baa7..fcffba28 100644 --- a/src/modules/games/game.entity.ts +++ b/src/modules/database/legacy-entities/game.v12-entity.ts @@ -9,20 +9,19 @@ import { OneToMany, OneToOne, } from "typeorm"; - -import { DatabaseEntity } from "../database/database.entity"; -import { Developer } from "../developers/developer.entity"; -import { Genre } from "../genres/genre.entity"; -import { Image } from "../images/image.entity"; -import { Progress } from "../progresses/progress.entity"; -import { Publisher } from "../publishers/publisher.entity"; -import { Store } from "../stores/store.entity"; -import { Tag } from "../tags/tag.entity"; -import { GamevaultUser } from "../users/gamevault-user.entity"; -import { GameType } from "./models/game-type.enum"; - -@Entity() -export class Game extends DatabaseEntity { +import { GameType } from "../../games/models/game-type.enum"; +import { DatabaseEntityV12 } from "./database.v12-entity"; +import { DeveloperV12 } from "./developer.v12-entity"; +import { GamevaultUserV12 } from "./gamevault-user.v12-entity"; +import { GenreV12 } from "./genre.v12-entity"; +import { ImageV12 } from "./image.v12-entity"; +import { ProgressV12 } from "./progress.v12-entity"; +import { PublisherV12 } from "./publisher.v12-entity"; +import { StoreV12 } from "./store.v12-entity"; +import { TagV12 } from "./tag.v12-entity"; + +@Entity("v12_game", { synchronize: false }) +export class GameV12 extends DatabaseEntityV12 { @Column({ nullable: true }) @ApiPropertyOptional({ description: "unique rawg-api-identifier of the game", @@ -111,7 +110,7 @@ export class Game extends DatabaseEntity { }) description?: string; - @OneToOne(() => Image, { + @OneToOne(() => ImageV12, { nullable: true, eager: true, onDelete: "CASCADE", @@ -120,11 +119,11 @@ export class Game extends DatabaseEntity { @JoinColumn() @ApiPropertyOptional({ description: "box image of the game", - type: () => Image, + type: () => ImageV12, }) - box_image?: Image; + box_image?: ImageV12; - @OneToOne(() => Image, { + @OneToOne(() => ImageV12, { nullable: true, eager: true, onDelete: "CASCADE", @@ -133,9 +132,9 @@ export class Game extends DatabaseEntity { @JoinColumn() @ApiPropertyOptional({ description: "background image of the game", - type: () => Image, + type: () => ImageV12, }) - background_image?: Image; + background_image?: ImageV12; @Column({ nullable: true }) @ApiPropertyOptional({ @@ -180,64 +179,64 @@ export class Game extends DatabaseEntity { }) type: GameType; - @OneToMany(() => Progress, (progress) => progress.game) + @OneToMany(() => ProgressV12, (progress) => progress.game) @ApiPropertyOptional({ description: "progresses associated to the game", - type: () => Progress, + type: () => ProgressV12, isArray: true, }) - progresses?: Progress[]; + progresses?: ProgressV12[]; - @JoinTable() - @ManyToMany(() => Publisher, (publisher) => publisher.games) + @JoinTable({ name: "v12_game_publishers_v12_publisher" }) + @ManyToMany(() => PublisherV12, (publisher) => publisher.games) @ApiPropertyOptional({ description: "publishers of the game", - type: () => Publisher, + type: () => PublisherV12, isArray: true, }) - publishers?: Publisher[]; + publishers?: PublisherV12[]; - @JoinTable() - @ManyToMany(() => Developer, (developer) => developer.games) + @JoinTable({ name: "v12_game_developers_v12_developer" }) + @ManyToMany(() => DeveloperV12, (developer) => developer.games) @ApiPropertyOptional({ description: "developers of the game", - type: () => Developer, + type: () => DeveloperV12, isArray: true, }) - developers?: Developer[]; + developers?: DeveloperV12[]; - @JoinTable() - @ManyToMany(() => Store, (store) => store.games) + @JoinTable({ name: "v12_game_stores_v12_store" }) + @ManyToMany(() => StoreV12, (store) => store.games) @ApiPropertyOptional({ description: "stores of the game", - type: () => Store, + type: () => StoreV12, isArray: true, }) - stores?: Store[]; + stores?: StoreV12[]; - @JoinTable() - @ManyToMany(() => Tag, (tag) => tag.games) + @JoinTable({ name: "v12_game_tags_v12_tag" }) + @ManyToMany(() => TagV12, (tag) => tag.games) @ApiPropertyOptional({ description: "tags of the game", - type: () => Tag, + type: () => TagV12, isArray: true, }) - tags?: Tag[]; + tags?: TagV12[]; - @JoinTable() - @ManyToMany(() => Genre, (genre) => genre.games) + @JoinTable({ name: "v12_game_genres_v12_genre" }) + @ManyToMany(() => GenreV12, (genre) => genre.games) @ApiPropertyOptional({ description: "genres of the game", - type: () => Genre, + type: () => GenreV12, isArray: true, }) - genres?: Genre[]; + genres?: GenreV12[]; - @ManyToMany(() => GamevaultUser, (user) => user.bookmarked_games) + @ManyToMany(() => GamevaultUserV12, (user) => user.bookmarked_games) @ApiProperty({ description: "users that bookmarked this game", - type: () => Game, + type: () => GameV12, isArray: true, }) - bookmarked_users?: GamevaultUser[]; + bookmarked_users?: GamevaultUserV12[]; } diff --git a/src/modules/database/legacy-entities/gamevault-user.v12-entity.ts b/src/modules/database/legacy-entities/gamevault-user.v12-entity.ts new file mode 100644 index 00000000..6e0bce67 --- /dev/null +++ b/src/modules/database/legacy-entities/gamevault-user.v12-entity.ts @@ -0,0 +1,125 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + OneToMany, + OneToOne, +} from "typeorm"; +import { Role } from "../../users/models/role.enum"; +import { DatabaseEntityV12 } from "./database.v12-entity"; +import { GameV12 } from "./game.v12-entity"; +import { ImageV12 } from "./image.v12-entity"; +import { ProgressV12 } from "./progress.v12-entity"; +@Entity("v12_gamevault_user", { synchronize: false }) +export class GamevaultUserV12 extends DatabaseEntityV12 { + @Index() + @Column({ unique: true }) + @ApiProperty({ example: "JohnDoe", description: "username of the user" }) + username: string; + + @Column({ select: false }) + @ApiProperty({ + description: "encrypted password of the user", + example: "Hunter2", + }) + password: string; + + @Column({ select: false, unique: true, length: 64 }) + @ApiProperty({ + description: + "the user's socket secret is used for authentication with the server over the websocket protocol.", + example: "fd9c4f417fb494aeacef28a70eba95128d9f2521374852cdb12ecb746888b892", + }) + socket_secret: string; + + @OneToOne(() => ImageV12, { + nullable: true, + eager: true, + onDelete: "CASCADE", + orphanedRowAction: "soft-delete", + }) + @JoinColumn() + @ApiPropertyOptional({ + type: () => ImageV12, + description: "the user's profile picture", + }) + profile_picture?: ImageV12; + + @OneToOne(() => ImageV12, { + nullable: true, + eager: true, + onDelete: "CASCADE", + orphanedRowAction: "soft-delete", + }) + @JoinColumn() + @ApiPropertyOptional({ + type: () => ImageV12, + description: "the user's profile art (background-picture)", + }) + background_image?: ImageV12; + + @Column({ unique: true, nullable: true }) + @ApiProperty({ + example: "john.doe@mail.com", + description: "email address of the user", + }) + email: string; + + @Column({ nullable: true }) + @ApiProperty({ example: "John", description: "first name of the user" }) + first_name: string; + + @Column({ nullable: true }) + @ApiProperty({ example: "Doe", description: "last name of the user" }) + last_name: string; + + @Column({ default: false }) + @ApiProperty({ + description: "indicates if the user is activated", + example: false, + }) + activated: boolean; + + @OneToMany(() => ProgressV12, (progress) => progress.user) + @ApiPropertyOptional({ + description: "progresses of the user", + type: () => ProgressV12, + isArray: true, + }) + progresses?: ProgressV12[]; + + @Column({ + type: "simple-enum", + enum: Role, + default: Role.USER, + }) + @ApiProperty({ + type: "enum", + enum: Role, + example: Role.EDITOR, + description: + "The role determines the set of permissions and access rights for a user in the system.", + }) + role: Role; + + @OneToMany(() => ImageV12, (image) => image.uploader) + @ApiPropertyOptional({ + description: "images uploaded by this user", + type: () => ImageV12, + isArray: true, + }) + uploaded_images?: ImageV12[]; + + @ManyToMany(() => GameV12, (game) => game.bookmarked_users) + @JoinTable({ name: "v12_bookmark" }) + @ApiProperty({ + description: "games bookmarked by this user", + type: () => GameV12, + isArray: true, + }) + bookmarked_games?: GameV12[]; +} diff --git a/src/modules/genres/genre.entity.ts b/src/modules/database/legacy-entities/genre.v12-entity.ts similarity index 63% rename from src/modules/genres/genre.entity.ts rename to src/modules/database/legacy-entities/genre.v12-entity.ts index bc8749c7..3b963de7 100644 --- a/src/modules/genres/genre.entity.ts +++ b/src/modules/database/legacy-entities/genre.v12-entity.ts @@ -1,11 +1,10 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Column, Entity, Index, ManyToMany } from "typeorm"; +import { DatabaseEntityV12 } from "./database.v12-entity"; +import { GameV12 } from "./game.v12-entity"; -import { DatabaseEntity } from "../database/database.entity"; -import { Game } from "../games/game.entity"; - -@Entity() -export class Genre extends DatabaseEntity { +@Entity("v12_genre", { synchronize: false }) +export class GenreV12 extends DatabaseEntityV12 { @Index() @Column({ nullable: true }) @ApiPropertyOptional({ @@ -22,11 +21,11 @@ export class Genre extends DatabaseEntity { }) name: string; - @ManyToMany(() => Game, (game) => game.genres) + @ManyToMany(() => GameV12, (game) => game.genres) @ApiProperty({ description: "games of the genre", - type: () => Game, + type: () => GameV12, isArray: true, }) - games: Game[]; + games: GameV12[]; } diff --git a/src/modules/images/image.entity.ts b/src/modules/database/legacy-entities/image.v12-entity.ts similarity index 70% rename from src/modules/images/image.entity.ts rename to src/modules/database/legacy-entities/image.v12-entity.ts index cea783e4..6db3891b 100644 --- a/src/modules/images/image.entity.ts +++ b/src/modules/database/legacy-entities/image.v12-entity.ts @@ -1,11 +1,10 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; import { Column, Entity, Index, ManyToOne } from "typeorm"; +import { DatabaseEntityV12 } from "./database.v12-entity"; +import { GamevaultUserV12 } from "./gamevault-user.v12-entity"; -import { DatabaseEntity } from "../database/database.entity"; -import { GamevaultUser } from "../users/gamevault-user.entity"; - -@Entity() -export class Image extends DatabaseEntity { +@Entity("v12_image", { synchronize: false }) +export class ImageV12 extends DatabaseEntityV12 { @Column({ nullable: true }) @ApiPropertyOptional({ example: @@ -30,12 +29,12 @@ export class Image extends DatabaseEntity { }) mediaType?: string; - @ManyToOne(() => GamevaultUser, (user) => user.uploaded_images, { + @ManyToOne(() => GamevaultUserV12, (user) => user.uploaded_images, { nullable: true, }) @ApiPropertyOptional({ description: "the uploader of the image", - type: () => GamevaultUser, + type: () => GamevaultUserV12, }) - uploader?: GamevaultUser; + uploader?: GamevaultUserV12; } diff --git a/src/modules/database/legacy-entities/progress.v12-entity.ts b/src/modules/database/legacy-entities/progress.v12-entity.ts new file mode 100644 index 00000000..9750f984 --- /dev/null +++ b/src/modules/database/legacy-entities/progress.v12-entity.ts @@ -0,0 +1,48 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Column, Entity, Index, ManyToOne } from "typeorm"; +import { State } from "../../progresses/models/state.enum"; +import { DatabaseEntityV12 } from "./database.v12-entity"; +import { GameV12 } from "./game.v12-entity"; +import { GamevaultUserV12 } from "./gamevault-user.v12-entity"; + +@Entity("v12_progress", { synchronize: false }) +export class ProgressV12 extends DatabaseEntityV12 { + @Index() + @ManyToOne(() => GamevaultUserV12, (user) => user.progresses) + @ApiPropertyOptional({ + description: "user the progress belongs to", + type: () => GamevaultUserV12, + }) + user?: GamevaultUserV12; + + @Index() + @ManyToOne(() => GameV12, (game) => game.progresses) + @ApiPropertyOptional({ + description: "game the progress belongs to", + type: () => GameV12, + }) + game?: GameV12; + + @Column({ default: 0 }) + @ApiProperty({ + description: "playtime in minutes", + example: 25, + }) + minutes_played: number; + + @Column({ type: "simple-enum", enum: State, default: State.UNPLAYED }) + @ApiProperty({ + description: "state of the game progress", + type: "enum", + enum: State, + example: State.PLAYING, + }) + state: State; + + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: "date the progress was updated", + example: "2020-01-01T00:00:00.000Z", + }) + last_played_at?: Date; +} diff --git a/src/modules/publishers/publisher.entity.ts b/src/modules/database/legacy-entities/publisher.v12-entity.ts similarity index 64% rename from src/modules/publishers/publisher.entity.ts rename to src/modules/database/legacy-entities/publisher.v12-entity.ts index b62eef7a..30312658 100644 --- a/src/modules/publishers/publisher.entity.ts +++ b/src/modules/database/legacy-entities/publisher.v12-entity.ts @@ -1,11 +1,10 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Column, Entity, Index, ManyToMany } from "typeorm"; +import { DatabaseEntityV12 } from "./database.v12-entity"; +import { GameV12 } from "./game.v12-entity"; -import { DatabaseEntity } from "../database/database.entity"; -import { Game } from "../games/game.entity"; - -@Entity() -export class Publisher extends DatabaseEntity { +@Entity("v12_publisher", { synchronize: false }) +export class PublisherV12 extends DatabaseEntityV12 { @Index() @Column({ nullable: true }) @ApiPropertyOptional({ @@ -22,11 +21,11 @@ export class Publisher extends DatabaseEntity { }) name: string; - @ManyToMany(() => Game, (game) => game.publishers) + @ManyToMany(() => GameV12, (game) => game.publishers) @ApiProperty({ description: "games published by the publisher", - type: () => Game, + type: () => GameV12, isArray: true, }) - games: Game[]; + games: GameV12[]; } diff --git a/src/modules/stores/store.entity.ts b/src/modules/database/legacy-entities/store.v12-entity.ts similarity index 64% rename from src/modules/stores/store.entity.ts rename to src/modules/database/legacy-entities/store.v12-entity.ts index 8ba86d79..8db09951 100644 --- a/src/modules/stores/store.entity.ts +++ b/src/modules/database/legacy-entities/store.v12-entity.ts @@ -1,11 +1,10 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Column, Entity, Index, ManyToMany } from "typeorm"; +import { DatabaseEntityV12 } from "./database.v12-entity"; +import { GameV12 } from "./game.v12-entity"; -import { DatabaseEntity } from "../database/database.entity"; -import { Game } from "../games/game.entity"; - -@Entity() -export class Store extends DatabaseEntity { +@Entity("v12_store", { synchronize: false }) +export class StoreV12 extends DatabaseEntityV12 { @Index() @Column({ nullable: true }) @ApiPropertyOptional({ @@ -22,11 +21,11 @@ export class Store extends DatabaseEntity { }) name: string; - @ManyToMany(() => Game, (game) => game.stores) + @ManyToMany(() => GameV12, (game) => game.stores) @ApiProperty({ description: "games available on the store", - type: () => Game, + type: () => GameV12, isArray: true, }) - games: Game[]; + games: GameV12[]; } diff --git a/src/modules/tags/tag.entity.ts b/src/modules/database/legacy-entities/tag.v12-entity.ts similarity index 64% rename from src/modules/tags/tag.entity.ts rename to src/modules/database/legacy-entities/tag.v12-entity.ts index e6b96910..ca436345 100644 --- a/src/modules/tags/tag.entity.ts +++ b/src/modules/database/legacy-entities/tag.v12-entity.ts @@ -1,11 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Column, Entity, Index, ManyToMany } from "typeorm"; -import { DatabaseEntity } from "../database/database.entity"; -import { Game } from "../games/game.entity"; +import { DatabaseEntityV12 } from "./database.v12-entity"; +import { GameV12 } from "./game.v12-entity"; -@Entity() -export class Tag extends DatabaseEntity { +@Entity("v12_tag", { synchronize: false }) +export class TagV12 extends DatabaseEntityV12 { @Index() @Column({ nullable: true }) @ApiPropertyOptional({ @@ -22,11 +22,11 @@ export class Tag extends DatabaseEntity { }) name: string; - @ManyToMany(() => Game, (game) => game.tags) + @ManyToMany(() => GameV12, (game) => game.tags) @ApiProperty({ description: "games tagged with the tag", - type: () => Game, + type: () => GameV12, isArray: true, }) - games: Game[]; + games: GameV12[]; } diff --git a/src/modules/database/migrations/postgres.migration-config.ts b/src/modules/database/migrations/postgres.migration-config.ts index b87a2375..8bf50ddb 100644 --- a/src/modules/database/migrations/postgres.migration-config.ts +++ b/src/modules/database/migrations/postgres.migration-config.ts @@ -9,9 +9,9 @@ export const dataSource = new DataSource({ username: "gamevault", password: "gamevault", database: "gamevault", - entities: ["dist/**/*.entity.js"], + entities: ["dist/**/*.*entity.js"], migrations: ["dist/src/modules/database/migrations/postgres/*.js"], namingStrategy: new SnakeNamingStrategy(), synchronize: false, - cache: { alwaysEnabled: true, ignoreErrors: true }, + logging: true, }); diff --git a/src/modules/database/migrations/postgres/1689458400000-init.ts b/src/modules/database/migrations/postgres/1689458400000-init.ts index d4a0a46a..496c9c21 100644 --- a/src/modules/database/migrations/postgres/1689458400000-init.ts +++ b/src/modules/database/migrations/postgres/1689458400000-init.ts @@ -3,7 +3,7 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class Init1689458400000 implements MigrationInterface { name = "Init1689458400000"; - private readonly logger = new Logger(Init1689458400000.name); + private readonly logger = new Logger(this.constructor.name); public async up(queryRunner: QueryRunner): Promise { if ( (await queryRunner.hasTable("crackpipe_user")) && diff --git a/src/modules/database/migrations/postgres/1691366400000-remove-image-deduplication.ts b/src/modules/database/migrations/postgres/1691366400000-remove-image-deduplication.ts index a06b4433..3020f32c 100644 --- a/src/modules/database/migrations/postgres/1691366400000-remove-image-deduplication.ts +++ b/src/modules/database/migrations/postgres/1691366400000-remove-image-deduplication.ts @@ -33,7 +33,7 @@ export class RemoveImageDeduplication1691366400000 for (const foreignKey of gamevaultUserForeignKeysToDrop) { try { await queryRunner.dropForeignKey("gamevault_user", foreignKey); - } catch (error) {} + } catch {} } await queryRunner.createForeignKeys("gamevault_user", [ diff --git a/src/modules/database/migrations/postgres/1696967362000-delete-empty-progresses.ts b/src/modules/database/migrations/postgres/1696967362000-delete-empty-progresses.ts index 5f758be6..14b44811 100644 --- a/src/modules/database/migrations/postgres/1696967362000-delete-empty-progresses.ts +++ b/src/modules/database/migrations/postgres/1696967362000-delete-empty-progresses.ts @@ -2,7 +2,7 @@ import { Logger } from "@nestjs/common"; import { MigrationInterface, QueryRunner } from "typeorm"; export class DeleteEmptyProgresses1696967362000 implements MigrationInterface { - private readonly logger = new Logger(DeleteEmptyProgresses1696967362000.name); + private readonly logger = new Logger(this.constructor.name); name?: string; transaction?: boolean; diff --git a/src/modules/database/migrations/postgres/1728421385000-v13-final.ts b/src/modules/database/migrations/postgres/1728421385000-v13-final.ts new file mode 100644 index 00000000..00fcf165 --- /dev/null +++ b/src/modules/database/migrations/postgres/1728421385000-v13-final.ts @@ -0,0 +1,1511 @@ +import { Logger, NotImplementedException } from "@nestjs/common"; +import { randomBytes } from "crypto"; +import { existsSync } from "fs"; +import { toLower, uniqBy } from "lodash"; +import { In, MigrationInterface, QueryRunner } from "typeorm"; +import { GamevaultGame } from "../../../games/gamevault-game.entity"; +import { Media } from "../../../media/media.entity"; +import { DeveloperMetadata } from "../../../metadata/developers/developer.metadata.entity"; +import { GameMetadata } from "../../../metadata/games/game.metadata.entity"; +import { GenreMetadata } from "../../../metadata/genres/genre.metadata.entity"; +import { PublisherMetadata } from "../../../metadata/publishers/publisher.metadata.entity"; +import { TagMetadata } from "../../../metadata/tags/tag.metadata.entity"; +import { DeveloperV12 } from "../../legacy-entities/developer.v12-entity"; +import { GameV12 } from "../../legacy-entities/game.v12-entity"; +import { GamevaultUserV12 } from "../../legacy-entities/gamevault-user.v12-entity"; +import { GenreV12 } from "../../legacy-entities/genre.v12-entity"; +import { ImageV12 } from "../../legacy-entities/image.v12-entity"; +import { ProgressV12 } from "../../legacy-entities/progress.v12-entity"; +import { PublisherV12 } from "../../legacy-entities/publisher.v12-entity"; +import { TagV12 } from "../../legacy-entities/tag.v12-entity"; + +export class V13Final1728421385000 implements MigrationInterface { + private readonly logger = new Logger(this.constructor.name); + name = "V13Final1728421385000"; + legacyProviderSlug = "rawg-legacy"; + + public async up(queryRunner: QueryRunner): Promise { + if (await this.checkMigrationRun(queryRunner)) { + return; + } + + const totalSteps = 6; + let currentStep = 1; + + this.logger.warn( + `IMPORTANT INFORMATIONS TO MIGRATE TO V13: + - Be sure to back up your data thoroughly before migrating, and contact us if you encounter any migration errors. + - The migration process may take up to 30 minutes or even longer for larger servers. During this time, clients will not be able to use the server. + - The container might appear as UNHEALTHY during the migration. This is expected behavior, and no action is needed. Let it complete the process. + - Make sure to disable any Docker auto-heal features for GameVault, as they might terminate the container during the migration. + - Restarting the server will restart the migration. + - Even when the server is running after the migration, it will take a while until you see all your game data. Be patient and check the logs for inactivity.`, + ); + + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("Alright, let's pray it all works."); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣤⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⢻⡿⠿⣿⣿⣿⣿⡿⠿⣿⡇⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⢀⣴⣦⡈⠻⣦⣤⣿⣿⣧⣤⣶⠏⢀⣦⣄⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⢀⣴⣿⣿⣿⣷⣤⣈⠙⠛⠛⠛⢉⣠⣴⣿⣿⣿⣷⣄⠀⠀⠀"); + console.warn("⠀⠀⢠⣿⣿⣿⣿⠟⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⢻⣿⣿⣿⣆⠀⠀"); + console.warn("⠀⢀⣿⣿⣿⣿⠃⣰⣿⣿⡿⠛⠋⠉⠛⠻⣿⣿⣷⡀⠹⣿⣿⣿⡆⠀"); + console.warn("⠀⣸⣿⣿⣿⠃⣰⣿⣿⠋⣠⣾⡇⢸⣷⣦⠈⣿⣿⣿⡄⢹⣿⣿⣿⠀"); + console.warn("⠀⣿⣿⣿⠋⠀⠉⠉⠉⠀⣿⣿⡇⢸⣿⣿⡇⠉⠉⠉⠁⠀⢻⣿⣿⡆"); + console.warn("⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇"); + console.warn("⠀⠙⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠃⠘⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠁"); + console.warn("⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀ "); + console.warn("⠀⠀⠀⠀⠀⠀⠈⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠃⠀⠀⠀⠀⠀⠀ "); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Renaming tables - Running...`, + ); + await this.part1_rename_tables(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Renaming tables - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Generating new schema - Running...`, + ); + await this.part2_generate_new_schema(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Generating new schema - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating data - Running...`, + ); + await this.part3_migrate_data(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating data - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Deleting old tables - Running...`, + ); + await this.part4_delete_old_tables(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Deleting old tables - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Synchronizing changes - Running...`, + ); + await this.part5_sync(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Synchronizing changes - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Sorting title - Running...`, + ); + await this.part6_sorting_title(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Sorting title - Completed`, + ); + + this.logger.log("info", "Migration completed successfully."); + } + + private async checkMigrationRun(queryRunner: QueryRunner): Promise { + const migrations = await queryRunner.query( + `SELECT * FROM migrations WHERE name LIKE 'V13Part%'`, + ); + + if (migrations.length > 0) { + this.logger.warn("V13 MIGRATIONS ALREADY RUN SKIPPING..."); + return true; + } + + return false; + } + + private async part1_rename_tables(queryRunner: QueryRunner) { + if (existsSync("/images")) { + throw new Error( + "Oof... Your media volume mount point is still pointing to /images. This is deprecated since v13.0.0. From now on, mount your images to /media instead of /images. Did you even bother to read the migration instructions? 🥲", + ); + } + + // Rename all existing tables, so no conflicts appear + await queryRunner.query(`ALTER TABLE "image" RENAME TO "v12_image"`); + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_f03b89f33671086e6733828e79"`, + ); + await queryRunner.renameTable("game", "v12_game"); + await queryRunner.renameTable("gamevault_user", "v12_gamevault_user"); + + await queryRunner.renameTable("progress", "v12_progress"); + await queryRunner.renameTable("developer", "v12_developer"); + await queryRunner.renameTable("genre", "v12_genre"); + await queryRunner.renameTable("publisher", "v12_publisher"); + await queryRunner.renameTable("store", "v12_store"); + await queryRunner.renameTable("tag", "v12_tag"); + + await queryRunner.renameTable("bookmark", "v12_bookmark"); + await queryRunner.renameColumn( + "v12_bookmark", + "gamevault_user_id", + "v12_gamevault_user_id", + ); + await queryRunner.renameColumn("v12_bookmark", "game_id", "v12_game_id"); + + await queryRunner.renameTable( + "game_developers_developer", + "v12_game_developers_v12_developer", + ); + await queryRunner.renameColumn( + "v12_game_developers_v12_developer", + "game_id", + "v12_game_id", + ); + await queryRunner.renameColumn( + "v12_game_developers_v12_developer", + "developer_id", + "v12_developer_id", + ); + + await queryRunner.renameTable( + "game_genres_genre", + "v12_game_genres_v12_genre", + ); + await queryRunner.renameColumn( + "v12_game_genres_v12_genre", + "game_id", + "v12_game_id", + ); + await queryRunner.renameColumn( + "v12_game_genres_v12_genre", + "genre_id", + "v12_genre_id", + ); + + await queryRunner.renameTable( + "game_publishers_publisher", + "v12_game_publishers_v12_publisher", + ); + await queryRunner.renameColumn( + "v12_game_publishers_v12_publisher", + "game_id", + "v12_game_id", + ); + await queryRunner.renameColumn( + "v12_game_publishers_v12_publisher", + "publisher_id", + "v12_publisher_id", + ); + + await queryRunner.renameTable( + "game_stores_store", + "v12_game_stores_v12_store", + ); + await queryRunner.renameColumn( + "v12_game_stores_v12_store", + "game_id", + "v12_game_id", + ); + await queryRunner.renameColumn( + "v12_game_stores_v12_store", + "store_id", + "v12_store_id", + ); + + await queryRunner.renameTable("game_tags_tag", "v12_game_tags_v12_tag"); + await queryRunner.renameColumn( + "v12_game_tags_v12_tag", + "game_id", + "v12_game_id", + ); + await queryRunner.renameColumn( + "v12_game_tags_v12_tag", + "tag_id", + "v12_tag_id", + ); + + await queryRunner.dropTable("query-result-cache", true); + + //Final Cleanup Measures + await queryRunner.query(`DROP SEQUENCE IF EXISTS crackpipe_user_id_seq`); + await queryRunner.query( + `ALTER SEQUENCE IF EXISTS gamevault_user_id_seq RENAME TO v12_gamevault_user_id_seq`, + ); + await queryRunner.query( + `ALTER SEQUENCE IF EXISTS image_id_seq RENAME TO v12_image_id_seq`, + ); + } + + private async part2_generate_new_schema(queryRunner: QueryRunner) { + this.logger.log("Creating ENUM type: progress_state_enum"); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'progress_state_enum' AND typtype = 'e') THEN + CREATE TYPE "public"."progress_state_enum" AS ENUM( + 'UNPLAYED', + 'INFINITE', + 'PLAYING', + 'COMPLETED', + 'ABORTED_TEMPORARY', + 'ABORTED_PERMANENT' + ); + END IF; + END $$; + `); + + this.logger.log("Creating ENUM type: gamevault_game_type_enum"); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'gamevault_game_type_enum' AND typtype = 'e') THEN + CREATE TYPE "public"."gamevault_game_type_enum" AS ENUM( + 'UNDETECTABLE', + 'WINDOWS_SETUP', + 'WINDOWS_PORTABLE', + 'LINUX_PORTABLE' + ); + END IF; + END $$; + `); + + this.logger.log("Creating ENUM type: gamevault_user_role_enum"); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'gamevault_user_role_enum' AND typtype = 'e') THEN + CREATE TYPE "public"."gamevault_user_role_enum" AS ENUM('0', '1', '2', '3'); + END IF; + END $$; + `); + + this.logger.log("Creating table: media"); + await queryRunner.query(` + CREATE TABLE "media" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + "entity_version" integer NOT NULL, + "source_url" character varying, + "file_path" character varying, + "type" character varying NOT NULL, + "uploader_id" integer, + CONSTRAINT "UQ_62649abcfe2e99bd6215511e231" UNIQUE ("file_path"), + CONSTRAINT "PK_f4e0fcac36e050de337b670d8bd" PRIMARY KEY ("id") + ) + `); + + this.logger.log("Creating table: developer_metadata"); + await queryRunner.query(` + CREATE TABLE "developer_metadata" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + "entity_version" integer NOT NULL, + "provider_slug" character varying NOT NULL, + "provider_data_id" character varying NOT NULL, + "name" character varying NOT NULL, + CONSTRAINT "PK_3797936110f483ab684d700e487" PRIMARY KEY ("id") + ) + `); + + this.logger.log("Creating table: genre_metadata"); + await queryRunner.query(` + CREATE TABLE "genre_metadata" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + "entity_version" integer NOT NULL, + "provider_slug" character varying NOT NULL, + "provider_data_id" character varying NOT NULL, + "name" character varying NOT NULL, + CONSTRAINT "PK_ab9cd344970e9df47d3d6c8b5be" PRIMARY KEY ("id") + ) + `); + + this.logger.log("Creating table: publisher_metadata"); + await queryRunner.query(` + CREATE TABLE "publisher_metadata" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + "entity_version" integer NOT NULL, + "provider_slug" character varying NOT NULL, + "provider_data_id" character varying NOT NULL, + "name" character varying NOT NULL, + CONSTRAINT "PK_73e957f8e68ba1111ac3b79adc4" PRIMARY KEY ("id") + ) + `); + + this.logger.log("Creating table: tag_metadata"); + await queryRunner.query(` + CREATE TABLE "tag_metadata" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + "entity_version" integer NOT NULL, + "provider_slug" character varying NOT NULL, + "provider_data_id" character varying NOT NULL, + "name" character varying NOT NULL, + CONSTRAINT "PK_96d7cccf17f8cb2cfa25388cbdd" PRIMARY KEY ("id") + ) + `); + + this.logger.log("Creating table: game_metadata"); + await queryRunner.query(` + CREATE TABLE "game_metadata" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + "entity_version" integer NOT NULL, + "provider_slug" character varying, + "provider_data_id" character varying, + "provider_data_url" character varying, + "provider_priority" integer, + "age_rating" integer, + "title" character varying, + "release_date" TIMESTAMP, + "description" character varying, + "notes" character varying, + "average_playtime" integer, + "url_screenshots" text, + "url_trailers" text, + "url_gameplays" text, + "url_websites" text, + "rating" double precision, + "early_access" boolean, + "launch_parameters" character varying, + "launch_executable" character varying, + "installer_executable" character varying, + "cover_id" integer, + "background_id" integer, + CONSTRAINT "PK_7af272a017b850a4ce7a6c2886a" PRIMARY KEY ("id") + ) + `); + + this.logger.log("Creating table: progress"); + await queryRunner.query(` + CREATE TABLE "progress" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + "entity_version" integer NOT NULL, + "minutes_played" integer NOT NULL DEFAULT '0', + "state" "public"."progress_state_enum" NOT NULL DEFAULT 'UNPLAYED', + "last_played_at" TIMESTAMP, + "user_id" integer, + "game_id" integer, + CONSTRAINT "PK_79abdfd87a688f9de756a162b6f" PRIMARY KEY ("id") + ) + `); + + this.logger.log("Creating table: gamevault_game"); + await queryRunner.query(` + CREATE TABLE "gamevault_game" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + "entity_version" integer NOT NULL, + "file_path" character varying NOT NULL, + "size" bigint NOT NULL DEFAULT '0', + "title" character varying, + "sort_title" character varying, + "version" character varying, + "release_date" TIMESTAMP, + "early_access" boolean NOT NULL DEFAULT false, + "download_count" integer NOT NULL DEFAULT '0', + "type" "public"."gamevault_game_type_enum" NOT NULL DEFAULT 'UNDETECTABLE', + "user_metadata_id" integer, + "metadata_id" integer, + CONSTRAINT "UQ_91d454956bd20f46b646b05b91f" UNIQUE ("file_path"), + CONSTRAINT "REL_edc9b16a9e16d394b2ca3b49b1" UNIQUE ("user_metadata_id"), + CONSTRAINT "REL_aab0797ae3873a5ef2817d0989" UNIQUE ("metadata_id"), + CONSTRAINT "PK_dc16bc448f2591a832533f25d95" PRIMARY KEY ("id") + ) + `); + + this.logger.log("Creating table: gamevault_user"); + await queryRunner.query(` + CREATE TABLE "gamevault_user" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + "entity_version" integer NOT NULL, + "username" character varying NOT NULL, + "password" character varying NOT NULL, + "socket_secret" character varying(64) NOT NULL, + "email" character varying, + "first_name" character varying, + "last_name" character varying, + "birth_date" TIMESTAMP, + "activated" boolean NOT NULL DEFAULT false, + "role" "public"."gamevault_user_role_enum" NOT NULL DEFAULT '1', + "avatar_id" integer, + "background_id" integer, + CONSTRAINT "UQ_4c835305e86b28e416cfe13dace" UNIQUE ("username"), + CONSTRAINT "UQ_284621e91b3886db5ebd901384a" UNIQUE ("email"), + CONSTRAINT "REL_872748cf76003216d011ae0feb" UNIQUE ("avatar_id"), + CONSTRAINT "REL_0bd4a25fe30450010869557666" UNIQUE ("background_id"), + CONSTRAINT "PK_c2a3f8b06558be9508161af22e2" PRIMARY KEY ("id") + ) + `); + + this.logger.log( + "Creating join table: game_metadata_gamevault_games_gamevault_game", + ); + await queryRunner.query(` + CREATE TABLE "game_metadata_gamevault_games_gamevault_game" ( + "game_metadata_id" integer NOT NULL, + "gamevault_game_id" integer NOT NULL, + CONSTRAINT "PK_5b1fb42d2970bc93bc217cea7a6" PRIMARY KEY ("game_metadata_id", "gamevault_game_id") + ) + `); + + this.logger.log( + "Creating join table: game_metadata_publishers_publisher_metadata", + ); + await queryRunner.query(` + CREATE TABLE "game_metadata_publishers_publisher_metadata" ( + "game_metadata_id" integer NOT NULL, + "publisher_metadata_id" integer NOT NULL, + CONSTRAINT "PK_435376dbd181413d7fb87ce294b" PRIMARY KEY ("game_metadata_id", "publisher_metadata_id") + ) + `); + + this.logger.log( + "Creating join table: game_metadata_developers_developer_metadata", + ); + await queryRunner.query(` + CREATE TABLE "game_metadata_developers_developer_metadata" ( + "game_metadata_id" integer NOT NULL, + "developer_metadata_id" integer NOT NULL, + CONSTRAINT "PK_74cda1e4aaea01fd41001f3e76f" PRIMARY KEY ("game_metadata_id", "developer_metadata_id") + ) + `); + + this.logger.log("Creating join table: game_metadata_tags_tag_metadata"); + await queryRunner.query(` + CREATE TABLE "game_metadata_tags_tag_metadata" ( + "game_metadata_id" integer NOT NULL, + "tag_metadata_id" integer NOT NULL, + CONSTRAINT "PK_b26a645d9ab3212edd7adf50ca0" PRIMARY KEY ("game_metadata_id", "tag_metadata_id") + ) + `); + + this.logger.log("Creating join table: game_metadata_genres_genre_metadata"); + await queryRunner.query(` + CREATE TABLE "game_metadata_genres_genre_metadata" ( + "game_metadata_id" integer NOT NULL, + "genre_metadata_id" integer NOT NULL, + CONSTRAINT "PK_a6ac649aebd65563fa73159f6da" PRIMARY KEY ("game_metadata_id", "genre_metadata_id") + ) + `); + + this.logger.log( + "Creating join table: gamevault_game_provider_metadata_game_metadata", + ); + await queryRunner.query(` + CREATE TABLE "gamevault_game_provider_metadata_game_metadata" ( + "gamevault_game_id" integer NOT NULL, + "game_metadata_id" integer NOT NULL, + CONSTRAINT "PK_ce0d864677026881405540a60b3" PRIMARY KEY ("gamevault_game_id", "game_metadata_id") + ) + `); + + this.logger.log("Creating join table: bookmark"); + await queryRunner.query(` + CREATE TABLE "bookmark" ( + "gamevault_user_id" integer NOT NULL, + "gamevault_game_id" integer NOT NULL, + CONSTRAINT "PK_c0f9972ee1277cb6da40463192b" PRIMARY KEY ("gamevault_user_id", "gamevault_game_id") + ) + `); + + await queryRunner.query(` + CREATE INDEX "IDX_f4e0fcac36e050de337b670d8b" ON "media" ("id") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_62649abcfe2e99bd6215511e23" ON "media" ("file_path") + `); + await queryRunner.query(` + CREATE INDEX "IDX_3797936110f483ab684d700e48" ON "developer_metadata" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_8d642e3a72cb76d343639c3281" ON "developer_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_414ccae60b54eb1580bca0c28f" ON "developer_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_16b10ff59b57ea2b920ccdec2d" ON "developer_metadata" ("name") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_DEVELOPER_METADATA" ON "developer_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_ab9cd344970e9df47d3d6c8b5b" ON "genre_metadata" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_bcbc44cdfbf2977f55c52651aa" ON "genre_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_7258256a052ef3ff3e882fa471" ON "genre_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_bf40614141adff790cb659c902" ON "genre_metadata" ("name") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_GENRE_METADATA" ON "genre_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_73e957f8e68ba1111ac3b79adc" ON "publisher_metadata" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_16f6954549be1a71c53654c939" ON "publisher_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_e9ec06cab4b92d64ba257b4eed" ON "publisher_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_73c3afaa08bae7e58471e83c8e" ON "publisher_metadata" ("name") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_PUBLISHER_METADATA" ON "publisher_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_96d7cccf17f8cb2cfa25388cbd" ON "tag_metadata" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_d914734a79b8145479a748d0a5" ON "tag_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_a1b923a5cf28e468500e7e0b59" ON "tag_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_a5f8eb5e083ca5fb83cd152777" ON "tag_metadata" ("name") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_TAG_METADATA" ON "tag_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_7af272a017b850a4ce7a6c2886" ON "game_metadata" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_e9a00e38e7969570d9ab66dd27" ON "game_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_4f0b69ca308a906932c84ea0d5" ON "game_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_21c321551d9c772d56e07b2a1a" ON "game_metadata" ("title") + `); + await queryRunner.query(` + CREATE INDEX "IDX_47070ef56d911fa9824f3277e2" ON "game_metadata" ("release_date") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_GAME_METADATA" ON "game_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_79abdfd87a688f9de756a162b6" ON "progress" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_ddcaca3a9db9d77105d51c02c2" ON "progress" ("user_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_feaddf361921db1df3a6fe3965" ON "progress" ("game_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_dc16bc448f2591a832533f25d9" ON "gamevault_game" ("id") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_91d454956bd20f46b646b05b91" ON "gamevault_game" ("file_path") + `); + await queryRunner.query(` + CREATE INDEX "IDX_73e99cf1379987ed7c5983d74f" ON "gamevault_game" ("release_date") + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_c2a3f8b06558be9508161af22e" ON "gamevault_user" ("id") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_4c835305e86b28e416cfe13dac" ON "gamevault_user" ("username") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_e0da4bbf1074bca2d980a81077" ON "gamevault_user" ("socket_secret") + `); + await queryRunner.query(` + CREATE INDEX "IDX_4edfac51e323a4993aec668eb4" ON "gamevault_user" ("birth_date") + `); + await queryRunner.query(` + CREATE INDEX "IDX_178abeeb628ebcdb70239c08d4" ON "game_metadata_gamevault_games_gamevault_game" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_c5afe975cb06f9624d5f5aa8ff" ON "game_metadata_gamevault_games_gamevault_game" ("gamevault_game_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_6d9f174cdbce41bb5b934271a9" ON "game_metadata_publishers_publisher_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_71ffc2cb90c863a5c225efa295" ON "game_metadata_publishers_publisher_metadata" ("publisher_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_2b99b13a4b75f1396c49990e6d" ON "game_metadata_developers_developer_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_3741d615695a161ffc5a41e748" ON "game_metadata_developers_developer_metadata" ("developer_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_f6c8361e5e167251a06355c168" ON "game_metadata_tags_tag_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_a4f3fec63ccb14d466924a11ef" ON "game_metadata_tags_tag_metadata" ("tag_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_c7d2d3ca1a28eab7d55e99ff24" ON "game_metadata_genres_genre_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_0482ce35adf40c9128eaa1ae89" ON "game_metadata_genres_genre_metadata" ("genre_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_8602b8a76c7952d1155118933f" ON "gamevault_game_provider_metadata_game_metadata" ("gamevault_game_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_0b9f583ebc16b0bb8cbfaf00f8" ON "gamevault_game_provider_metadata_game_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_6f00464edf85ddfedbd2580842" ON "bookmark" ("gamevault_user_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_3c8d93fdd9e34a97f5a5903129" ON "bookmark" ("gamevault_game_id") + `); + await queryRunner.query(` + ALTER TABLE "media" + ADD CONSTRAINT "FK_8bd1ad5f79df58cfd7ad9c42fb5" FOREIGN KEY ("uploader_id") REFERENCES "gamevault_user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "game_metadata" + ADD CONSTRAINT "FK_9aefd37a55b610cea5ea583cdf6" FOREIGN KEY ("cover_id") REFERENCES "media"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "game_metadata" + ADD CONSTRAINT "FK_6f44518f2a088b90a8cc804d12f" FOREIGN KEY ("background_id") REFERENCES "media"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "progress" + ADD CONSTRAINT "FK_ddcaca3a9db9d77105d51c02c24" FOREIGN KEY ("user_id") REFERENCES "gamevault_user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "progress" + ADD CONSTRAINT "FK_feaddf361921db1df3a6fe3965a" FOREIGN KEY ("game_id") REFERENCES "gamevault_game"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "gamevault_game" + ADD CONSTRAINT "FK_edc9b16a9e16d394b2ca3b49b12" FOREIGN KEY ("user_metadata_id") REFERENCES "game_metadata"("id") ON DELETE + SET NULL ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "gamevault_game" + ADD CONSTRAINT "FK_aab0797ae3873a5ef2817d09891" FOREIGN KEY ("metadata_id") REFERENCES "game_metadata"("id") ON DELETE + SET NULL ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "gamevault_user" + ADD CONSTRAINT "FK_872748cf76003216d011ae0febb" FOREIGN KEY ("avatar_id") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "gamevault_user" + ADD CONSTRAINT "FK_0bd4a25fe304500108695576666" FOREIGN KEY ("background_id") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "game_metadata_gamevault_games_gamevault_game" + ADD CONSTRAINT "FK_178abeeb628ebcdb70239c08d46" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata"("id") ON DELETE CASCADE ON UPDATE CASCADE + `); + await queryRunner.query(` + ALTER TABLE "game_metadata_gamevault_games_gamevault_game" + ADD CONSTRAINT "FK_c5afe975cb06f9624d5f5aa8ff7" FOREIGN KEY ("gamevault_game_id") REFERENCES "gamevault_game"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "game_metadata_publishers_publisher_metadata" + ADD CONSTRAINT "FK_6d9f174cdbce41bb5b934271a9b" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata"("id") ON DELETE CASCADE ON UPDATE CASCADE + `); + await queryRunner.query(` + ALTER TABLE "game_metadata_publishers_publisher_metadata" + ADD CONSTRAINT "FK_71ffc2cb90c863a5c225efa2950" FOREIGN KEY ("publisher_metadata_id") REFERENCES "publisher_metadata"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "game_metadata_developers_developer_metadata" + ADD CONSTRAINT "FK_2b99b13a4b75f1396c49990e6de" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata"("id") ON DELETE CASCADE ON UPDATE CASCADE + `); + await queryRunner.query(` + ALTER TABLE "game_metadata_developers_developer_metadata" + ADD CONSTRAINT "FK_3741d615695a161ffc5a41e748c" FOREIGN KEY ("developer_metadata_id") REFERENCES "developer_metadata"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "game_metadata_tags_tag_metadata" + ADD CONSTRAINT "FK_f6c8361e5e167251a06355c168a" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata"("id") ON DELETE CASCADE ON UPDATE CASCADE + `); + await queryRunner.query(` + ALTER TABLE "game_metadata_tags_tag_metadata" + ADD CONSTRAINT "FK_a4f3fec63ccb14d466924a11efc" FOREIGN KEY ("tag_metadata_id") REFERENCES "tag_metadata"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "game_metadata_genres_genre_metadata" + ADD CONSTRAINT "FK_c7d2d3ca1a28eab7d55e99ff24b" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata"("id") ON DELETE CASCADE ON UPDATE CASCADE + `); + await queryRunner.query(` + ALTER TABLE "game_metadata_genres_genre_metadata" + ADD CONSTRAINT "FK_0482ce35adf40c9128eaa1ae894" FOREIGN KEY ("genre_metadata_id") REFERENCES "genre_metadata"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "gamevault_game_provider_metadata_game_metadata" + ADD CONSTRAINT "FK_8602b8a76c7952d1155118933f4" FOREIGN KEY ("gamevault_game_id") REFERENCES "gamevault_game"("id") ON DELETE CASCADE ON UPDATE CASCADE + `); + await queryRunner.query(` + ALTER TABLE "gamevault_game_provider_metadata_game_metadata" + ADD CONSTRAINT "FK_0b9f583ebc16b0bb8cbfaf00f8f" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + await queryRunner.query(` + ALTER TABLE "bookmark" + ADD CONSTRAINT "FK_6f00464edf85ddfedbd25808428" FOREIGN KEY ("gamevault_user_id") REFERENCES "gamevault_user"("id") ON DELETE CASCADE ON UPDATE CASCADE + `); + await queryRunner.query(` + ALTER TABLE "bookmark" + ADD CONSTRAINT "FK_3c8d93fdd9e34a97f5a5903129b" FOREIGN KEY ("gamevault_game_id") REFERENCES "gamevault_game"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + } + + private async part3_migrate_data(queryRunner: QueryRunner) { + const totalSteps = 10; + let currentStep = 1; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Disabling auto-increment IDs - Sub-Step 1 - Running...`, + ); + await this.toggleAutoIncrementId(queryRunner, false); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Disabling auto-increment IDs - Sub-Step 1 - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating images - Sub-Step 2 - Running...`, + ); + await this.migrateImages(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating images - Sub-Step 2 - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating tags - Sub-Step 3 - Running...`, + ); + await this.migrateTags(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating tags - Sub-Step 3 - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating genres - Sub-Step 4 - Running...`, + ); + await this.migrateGenres(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating genres - Sub-Step 4 - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating developers - Sub-Step 5 - Running...`, + ); + await this.migrateDevelopers(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating developers - Sub-Step 5 - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating publishers - Sub-Step 6 - Running...`, + ); + await this.migratePublishers(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating publishers - Sub-Step 6 - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating games - Sub-Step 7 - Running...`, + ); + await this.migrateGames(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating games - Sub-Step 7 - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating users - Sub-Step 8 - Running...`, + ); + await this.migrateUsers(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating users - Sub-Step 8 - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating bookmarks - Sub-Step 9 - Running...`, + ); + await this.migrateBookmarks(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating bookmarks - Sub-Step 9 - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating progresses - Sub-Step 10 - Running...`, + ); + await this.migrateProgresses(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating progresses - Sub-Step 10 - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Enabling auto-increment IDs - Sub-Step 11 - Running...`, + ); + await this.toggleAutoIncrementId(queryRunner, true); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Enabling auto-increment IDs - Sub-Step 11 - Completed`, + ); + } + + private async part4_delete_old_tables(queryRunner: QueryRunner) { + await queryRunner.query(`DROP TABLE IF EXISTS "v12_bookmark" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_developer" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_game" CASCADE`); + await queryRunner.query( + `DROP TABLE IF EXISTS "v12_game_developers_v12_developer" CASCADE`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS "v12_game_genres_v12_genre" CASCADE`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS "v12_game_publishers_v12_publisher" CASCADE`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS "v12_game_stores_v12_store" CASCADE`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS "v12_game_tags_v12_tag" CASCADE`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS "v12_gamevault_user" CASCADE`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_genre" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_image" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_progress" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_publisher" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_store" CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_tag" CASCADE`); + } + + private async part5_sync(queryRunner: QueryRunner) { + await queryRunner.query(` + ALTER TABLE "gamevault_user" + DROP CONSTRAINT IF EXISTS "UQ_e0da4bbf1074bca2d980a810771" + `); + await queryRunner.query(` + ALTER TABLE "gamevault_user" + ADD CONSTRAINT "UQ_e0da4bbf1074bca2d980a810771" UNIQUE ("socket_secret") + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_c2a3f8b06558be9508161af22e" ON "gamevault_user" ("id") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_4c835305e86b28e416cfe13dac" ON "gamevault_user" ("username") + `); + } + + private async part6_sorting_title(queryRunner: QueryRunner) { + const gameRepository = queryRunner.manager.getRepository("gamevault_game"); + + // Fetch all games + const games = await gameRepository.find({ + withDeleted: true, + select: ["id", "title"], + }); + + // Update each game with the new sort_title + for (const game of games) { + const sortTitle = this.createSortTitle(game.title); // Apply the sorting function + await gameRepository.update(game.id, { sort_title: sortTitle }); + } + } + + private async toggleAutoIncrementId( + queryRunner: QueryRunner, + enable: boolean, + ) { + const tableNames = [ + "media", + "tag_metadata", + "genre_metadata", + "developer_metadata", + "publisher_metadata", + "gamevault_game", + "gamevault_user", + "progress", + ]; + for (const tableName of tableNames) { + if (enable) { + const [{ maxid }] = await queryRunner.query( + `SELECT MAX(id) as maxid FROM ${tableName}`, + ); + const maxId = Number(maxid) + 1 || 1; + + await queryRunner.query( + `ALTER SEQUENCE ${tableName}_id_seq RESTART WITH ${maxId}`, + ); + await queryRunner.query( + `ALTER TABLE ${tableName} ALTER COLUMN id SET DEFAULT nextval('${tableName}_id_seq')`, + ); + } else { + await queryRunner.query( + `ALTER TABLE ${tableName} ALTER COLUMN id DROP DEFAULT`, + ); + } + } + } + + private async migrateImages(queryRunner: QueryRunner): Promise { + const images = await queryRunner.manager.find(ImageV12, { + withDeleted: true, + }); + this.logger.log({ + message: `Found ${images.length} images in the V12 database.`, + }); + + for (const image of images) { + this.logger.log({ + message: `Migrating image ID ${image.id}, Source: ${image.source}`, + }); + + await queryRunner.query( + ` + INSERT INTO "media" ("id", "created_at", "updated_at", "deleted_at", "entity_version", "source_url", "file_path", "type", "uploader_id") + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9); + `, + [ + image.id, + image.created_at, + image.updated_at, + image.deleted_at, + image.entity_version, + image.source, + image.path.replace("/images/", "/media/"), + image.mediaType ?? "application/octet-stream", + image.uploader?.id, + ], + ); + } + } + + private async migrateTags(queryRunner: QueryRunner): Promise { + const tags = uniqBy( + await queryRunner.manager.find(TagV12, { withDeleted: true }), + "rawg_id", + ); + this.logger.log({ + message: `Found ${tags.length} tags in the V12 database.`, + }); + + for (const tag of tags) { + this.logger.log({ + message: `Migrating tag ID ${tag.id}, Name: ${tag.name}`, + }); + await queryRunner.query( + ` + INSERT INTO "tag_metadata"("id", "created_at", "updated_at", "deleted_at", "entity_version", "provider_slug", "provider_data_id", "name") + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, + [ + tag.id, + tag.created_at, + tag.updated_at, + tag.deleted_at, + tag.entity_version, + this.legacyProviderSlug, + tag.rawg_id?.toString(), + tag.name, + ], + ); + } + } + + private async migrateGenres(queryRunner: QueryRunner): Promise { + const genres = uniqBy( + await queryRunner.manager.find(GenreV12, { + withDeleted: true, + }), + "rawg_id", + ); + this.logger.log({ + message: `Found ${genres.length} genres in the V12 database.`, + }); + + for (const genre of genres) { + this.logger.log({ + message: `Migrating genre ID ${genre.id}, Name: ${genre.name}`, + }); + + await queryRunner.query( + ` + INSERT INTO "genre_metadata" ("id", "created_at", "updated_at", "deleted_at", "entity_version", "provider_slug", "provider_data_id", "name") + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, + [ + genre.id, + genre.created_at, + genre.updated_at, + genre.deleted_at, + genre.entity_version, + this.legacyProviderSlug, + genre.rawg_id?.toString(), + genre.name, + ], + ); + } + } + + private async migrateDevelopers(queryRunner: QueryRunner): Promise { + const developers = uniqBy( + await queryRunner.manager.find(DeveloperV12, { + withDeleted: true, + }), + "rawg_id", + ); + this.logger.log({ + message: `Found ${developers.length} developers in the V12 database.`, + }); + + for (const developer of developers) { + this.logger.log({ + message: `Migrating developer ID ${developer.id}, Name: ${developer.name}`, + }); + + await queryRunner.query( + ` + INSERT INTO "developer_metadata" ("id", "created_at", "updated_at", "deleted_at", "entity_version", "provider_slug", "provider_data_id", "name") + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, + [ + developer.id, + developer.created_at, + developer.updated_at, + developer.deleted_at, + developer.entity_version, + this.legacyProviderSlug, + developer.rawg_id?.toString(), + developer.name, + ], + ); + } + } + + private async migratePublishers(queryRunner: QueryRunner): Promise { + const publishers = uniqBy( + await queryRunner.manager.find(PublisherV12, { + withDeleted: true, + }), + "rawg_id", + ); + this.logger.log({ + message: `Found ${publishers.length} publishers in the V12 database.`, + }); + + for (const publisher of publishers) { + this.logger.log({ + message: `Migrating publisher ID ${publisher.id}, Name: ${publisher.name}`, + }); + + await queryRunner.query( + ` + INSERT INTO "publisher_metadata" ("id", "created_at", "updated_at", "deleted_at", "entity_version", "provider_slug", "provider_data_id", "name") + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, + [ + publisher.id, + publisher.created_at, + publisher.updated_at, + publisher.deleted_at, + publisher.entity_version, + this.legacyProviderSlug, + publisher.rawg_id?.toString(), + publisher.name, + ], + ); + } + } + + private async migrateGames(queryRunner: QueryRunner): Promise { + const games = await queryRunner.manager.find(GameV12, { + relations: [ + "box_image", + "background_image", + "publishers", + "developers", + "tags", + "genres", + ], + withDeleted: true, + relationLoadStrategy: "query", + }); + this.logger.log({ + message: `Found ${games.length} games in the V12 database.`, + }); + + for (const game of games) { + this.logger.log({ + message: `Migrating game ID ${game.id}, Title: ${game.title}`, + }); + + await queryRunner.query( + ` + INSERT INTO "gamevault_game" ("id", "created_at", "updated_at", "deleted_at", "entity_version", "file_path", "size", "title", "sort_title", "version", "release_date", "early_access", "download_count", "type", "user_metadata_id", "metadata_id") + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, DEFAULT, $9, $10, $11, $12, $13, DEFAULT, DEFAULT) + `, + [ + game.id, + game.created_at, + game.updated_at, + game.deleted_at, + game.entity_version, + game.file_path, + game.size, + game.title, + game.version, + game.release_date, + game.early_access, + 0, + game.type, + ], + ); + + const migratedGame = await queryRunner.manager.findOne(GamevaultGame, { + where: { id: game.id }, + relations: [ + "progresses", + "progresses.user", + "bookmarked_users", + "metadata", + "provider_metadata", + "user_metadata", + ], + withDeleted: true, + }); + + const cover = game.box_image + ? await queryRunner.manager.findOne(Media, { + where: { id: game.box_image.id }, + withDeleted: true, + }) + : undefined; + if (cover) + this.logger.log({ message: `Linked cover image, ID: ${cover?.id}` }); + + const background = game.background_image + ? await queryRunner.manager.findOne(Media, { + where: { id: game.background_image.id }, + withDeleted: true, + }) + : undefined; + if (background) + this.logger.log({ + message: `Linked background image, ID: ${background?.id}`, + }); + + if (!game.rawg_id) { + if (cover || background) { + const userMetadata = await queryRunner.manager.save(GameMetadata, { + provider_slug: "user", + provider_data_id: game.id?.toString(), + cover, + background, + }); + migratedGame.user_metadata = userMetadata; + await queryRunner.manager.save(GamevaultGame, migratedGame); + this.logger.log({ + message: `User metadata saved successfully. Metadata ID: ${userMetadata.id}, Title: ${userMetadata.title}`, + }); + continue; + } + + this.logger.log({ + message: `No rawg_id or custom images found. Skipping metadata for game ID: ${game.id}.`, + }); + continue; + } + + const tags = game.tags?.length + ? await queryRunner.manager.findBy(TagMetadata, { + provider_slug: this.legacyProviderSlug, + provider_data_id: In(game.tags.map((t) => t.rawg_id)), + }) + : []; + this.logger.log({ + message: `Linked ${tags.length} tags for game ID: ${game.id}`, + }); + + const genres = game.genres?.length + ? await queryRunner.manager.findBy(GenreMetadata, { + provider_slug: this.legacyProviderSlug, + provider_data_id: In(game.genres.map((g) => g.rawg_id)), + }) + : []; + this.logger.log({ + message: `Linked ${genres.length} genres for game ID: ${game.id}`, + }); + + const developers = game.developers?.length + ? await queryRunner.manager.findBy(DeveloperMetadata, { + provider_slug: this.legacyProviderSlug, + provider_data_id: In(game.developers.map((d) => d.rawg_id)), + }) + : []; + this.logger.log({ + message: `Linked ${developers.length} developers for game ID: ${game.id}`, + }); + + const publishers = game.publishers?.length + ? await queryRunner.manager.findBy(PublisherMetadata, { + provider_slug: this.legacyProviderSlug, + provider_data_id: In(game.publishers.map((p) => p.rawg_id)), + }) + : []; + this.logger.log({ + message: `Linked ${publishers.length} publishers for game ID: ${game.id}`, + }); + + const existingMetadata = await queryRunner.manager.findOneBy( + GameMetadata, + { + provider_slug: this.legacyProviderSlug, + provider_data_id: game.rawg_id.toString(), + }, + ); + + if (existingMetadata) { + this.logger.log({ + message: `Rawg Metadata already exists for game ID ${game.id}. Linking...`, + }); + migratedGame.provider_metadata = [existingMetadata]; + } else { + const gameMetadata = await queryRunner.manager.save(GameMetadata, { + provider_slug: this.legacyProviderSlug, + provider_data_id: game.rawg_id.toString(), + title: game.rawg_title, + release_date: game.rawg_release_date, + description: game.description, + average_playtime: game.average_playtime, + cover, + background, + url_websites: [game.website_url], + rating: game.metacritic_rating, + early_access: game.early_access, + tags, + genres, + developers, + publishers, + }); + migratedGame.provider_metadata = [gameMetadata]; + this.logger.log({ + message: `New Game metadata saved successfully. Metadata ID: ${gameMetadata.id}, Title: ${gameMetadata.title}`, + }); + } + + const newGame = await queryRunner.manager.save( + GamevaultGame, + migratedGame, + ); + this.logger.log({ + message: "Migrated Game Successfully.", + savedGame: newGame, + }); + } + } + + private async migrateUsers(queryRunner: QueryRunner): Promise { + const users = await queryRunner.manager.find(GamevaultUserV12, { + withDeleted: true, + select: [ + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "username", + "password", + "socket_secret", + "profile_picture", + "background_image", + "email", + "first_name", + "last_name", + "activated", + "role", + ], + relations: ["profile_picture", "background_image"], + relationLoadStrategy: "query", + }); + this.logger.log({ + message: `Found ${users.length} users in the V12 database.`, + }); + + for (const user of users) { + this.logger.log({ + message: `Migrating user ID ${user.id}, Username: ${user.username}`, + }); + + const avatar = user.profile_picture + ? await queryRunner.manager.findOne(Media, { + where: { id: user.profile_picture.id }, + withDeleted: true, + }) + : undefined; + if (avatar) + this.logger.log({ + message: `Linked avatar image, ID: ${avatar?.id}`, + }); + + const background = user.background_image + ? await queryRunner.manager.findOne(Media, { + where: { id: user.background_image.id }, + withDeleted: true, + }) + : undefined; + if (background) + this.logger.log({ + message: `Linked background image, ID: ${background?.id}`, + }); + + await queryRunner.query( + ` + INSERT INTO "gamevault_user" ("created_at", "updated_at", "deleted_at", "entity_version", "username", "password", "socket_secret", "email", "first_name", "last_name", "birth_date", "activated", "role", "avatar_id", "background_id", "id") + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, DEFAULT, $11, $12, $13, $14, $15) + `, + [ + user.created_at, + user.updated_at, + user.deleted_at, + user.entity_version, + user.username, + user.password, + user.socket_secret ?? randomBytes(32).toString("hex"), + user.email, + user.first_name, + user.last_name, + user.activated, + user.role.valueOf(), + avatar?.id, + background?.id, + user.id, + ], + ); + } + } + + private async migrateBookmarks(queryRunner: QueryRunner): Promise { + await queryRunner.query( + ` + INSERT INTO "bookmark" ("gamevault_user_id", "gamevault_game_id") + SELECT "v12_gamevault_user_id", "v12_game_id" FROM "v12_bookmark" + `, + ); + } + + private async migrateProgresses(queryRunner: QueryRunner): Promise { + const progresses = await queryRunner.manager.find(ProgressV12, { + withDeleted: true, + loadEagerRelations: true, + relations: ["user", "game"], + relationLoadStrategy: "query", + }); + this.logger.log({ + message: `Found ${progresses.length} progresses in the V12 database.`, + }); + + for (const progress of progresses) { + this.logger.log({ + message: `Migrating progress ID ${progress.id}, User: ${progress.user?.id}, Game: ${progress.game?.id}`, + }); + + await queryRunner.query( + ` + INSERT INTO "progress" ("id", "created_at", "updated_at", "deleted_at", "entity_version", "minutes_played", "state", "last_played_at", "user_id", "game_id") + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, + [ + progress.id, + progress.created_at, + progress.updated_at, + progress.deleted_at, + progress.entity_version, + progress.minutes_played, + progress.state, + progress.last_played_at, + progress.user?.id, + progress.game?.id, + ], + ); + } + } + + private createSortTitle(title: string): string { + // List of leading articles to be removed + const articles: string[] = ["the", "a", "an"]; + + // Convert the title to lowercase + let sortTitle: string = toLower(title).trim(); + + // Remove any leading article + for (const article of articles) { + const articleWithSpace = `${article} `; + if (sortTitle.startsWith(articleWithSpace)) { + sortTitle = sortTitle.substring(articleWithSpace.length); + break; + } + } + + // Remove special characters except alphanumeric and spaces + // Replace multiple spaces with a single space and trim + return sortTitle + .replace(/[^a-z0-9\s]/g, "") + .replace(/\s+/g, " ") + .trim(); + } + + public async down(): Promise { + throw new NotImplementedException( + "There is no way to undo this migration.", + ); + } +} diff --git a/src/modules/database/migrations/sqlite.migration-config.ts b/src/modules/database/migrations/sqlite.migration-config.ts index 4e54c473..4b9e6237 100644 --- a/src/modules/database/migrations/sqlite.migration-config.ts +++ b/src/modules/database/migrations/sqlite.migration-config.ts @@ -4,10 +4,10 @@ import { SnakeNamingStrategy } from "typeorm-naming-strategies"; export const dataSource = new DataSource({ name: "sqlite", type: "better-sqlite3", - database: "../../../.local/db/database.sqlite", - entities: ["dist/**/*.entity.js"], + database: ".local/db/database.sqlite", + entities: ["dist/**/*.*entity.js"], migrations: ["dist/src/modules/database/migrations/sqlite/*.js"], namingStrategy: new SnakeNamingStrategy(), synchronize: false, - cache: { alwaysEnabled: true, ignoreErrors: true }, + logging: true, }); diff --git a/src/modules/database/migrations/sqlite/1689458400000-init.ts b/src/modules/database/migrations/sqlite/1689458400000-init.ts index 6401cce5..76f1289e 100644 --- a/src/modules/database/migrations/sqlite/1689458400000-init.ts +++ b/src/modules/database/migrations/sqlite/1689458400000-init.ts @@ -3,7 +3,7 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class Init1689458400000 implements MigrationInterface { name = "Init1689458400000"; - private readonly logger = new Logger(Init1689458400000.name); + private readonly logger = new Logger(this.constructor.name); public async up(queryRunner: QueryRunner): Promise { if ( (await queryRunner.hasTable("crackpipe_user")) && diff --git a/src/modules/database/migrations/sqlite/1691366400000-remove-image-deduplication.ts b/src/modules/database/migrations/sqlite/1691366400000-remove-image-deduplication.ts index 9c2221b9..af88683a 100644 --- a/src/modules/database/migrations/sqlite/1691366400000-remove-image-deduplication.ts +++ b/src/modules/database/migrations/sqlite/1691366400000-remove-image-deduplication.ts @@ -33,7 +33,7 @@ export class RemoveImageDeduplication1691366400000 for (const foreignKey of gamevaultUserForeignKeysToDrop) { try { await queryRunner.dropForeignKey("gamevault_user", foreignKey); - } catch (error) {} + } catch {} } await queryRunner.createForeignKeys("gamevault_user", [ diff --git a/src/modules/database/migrations/sqlite/1696967362000-delete-empty-progresses.ts b/src/modules/database/migrations/sqlite/1696967362000-delete-empty-progresses.ts index 5f758be6..14b44811 100644 --- a/src/modules/database/migrations/sqlite/1696967362000-delete-empty-progresses.ts +++ b/src/modules/database/migrations/sqlite/1696967362000-delete-empty-progresses.ts @@ -2,7 +2,7 @@ import { Logger } from "@nestjs/common"; import { MigrationInterface, QueryRunner } from "typeorm"; export class DeleteEmptyProgresses1696967362000 implements MigrationInterface { - private readonly logger = new Logger(DeleteEmptyProgresses1696967362000.name); + private readonly logger = new Logger(this.constructor.name); name?: string; transaction?: boolean; diff --git a/src/modules/database/migrations/sqlite/1728421385000-v13-final.ts b/src/modules/database/migrations/sqlite/1728421385000-v13-final.ts new file mode 100644 index 00000000..58606050 --- /dev/null +++ b/src/modules/database/migrations/sqlite/1728421385000-v13-final.ts @@ -0,0 +1,4894 @@ +import { Logger, NotImplementedException } from "@nestjs/common"; +import { randomBytes } from "crypto"; +import { existsSync } from "fs"; +import { toLower, uniqBy } from "lodash"; +import { In, MigrationInterface, QueryRunner } from "typeorm"; +import { GamevaultGame } from "../../../games/gamevault-game.entity"; +import { Media } from "../../../media/media.entity"; +import { DeveloperMetadata } from "../../../metadata/developers/developer.metadata.entity"; +import { GameMetadata } from "../../../metadata/games/game.metadata.entity"; +import { GenreMetadata } from "../../../metadata/genres/genre.metadata.entity"; +import { PublisherMetadata } from "../../../metadata/publishers/publisher.metadata.entity"; +import { TagMetadata } from "../../../metadata/tags/tag.metadata.entity"; +import { State } from "../../../progresses/models/state.enum"; +import { Progress } from "../../../progresses/progress.entity"; +import { GamevaultUser } from "../../../users/gamevault-user.entity"; +import { DeveloperV12 } from "../../legacy-entities/developer.v12-entity"; +import { GameV12 } from "../../legacy-entities/game.v12-entity"; +import { GamevaultUserV12 } from "../../legacy-entities/gamevault-user.v12-entity"; +import { GenreV12 } from "../../legacy-entities/genre.v12-entity"; +import { ImageV12 } from "../../legacy-entities/image.v12-entity"; +import { ProgressV12 } from "../../legacy-entities/progress.v12-entity"; +import { PublisherV12 } from "../../legacy-entities/publisher.v12-entity"; +import { TagV12 } from "../../legacy-entities/tag.v12-entity"; + +export class V13Final1728421385000 implements MigrationInterface { + private readonly logger = new Logger(this.constructor.name); + name = "V13Final1728421385000"; + legacyProviderSlug = "rawg-legacy"; + + public async up(queryRunner: QueryRunner): Promise { + if (await this.checkMigrationRun(queryRunner)) { + return; + } + + const totalSteps = 7; + let currentStep = 1; + + this.logger.warn( + `IMPORTANT INFORMATIONS TO MIGRATE TO V13: + - Be sure to back up your data thoroughly before migrating, and contact us if you encounter any migration errors. + - The migration process may take up to 30 minutes or even longer for larger servers. During this time, clients will not be able to use the server. + - The container might appear as UNHEALTHY during the migration. This is expected behavior, and no action is needed. Let it complete the process. + - Make sure to disable any Docker auto-heal features for GameVault, as they might terminate the container during the migration. + - Restarting the server will restart the migration. + - Even when the server is running after the migration, it will take a while until you see all your game data. Be patient and check the logs for inactivity.`, + ); + + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("Alright, let's pray it all works."); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣤⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⢻⡿⠿⣿⣿⣿⣿⡿⠿⣿⡇⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⢀⣴⣦⡈⠻⣦⣤⣿⣿⣧⣤⣶⠏⢀⣦⣄⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⢀⣴⣿⣿⣿⣷⣤⣈⠙⠛⠛⠛⢉⣠⣴⣿⣿⣿⣷⣄⠀⠀⠀"); + console.warn("⠀⠀⢠⣿⣿⣿⣿⠟⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⢻⣿⣿⣿⣆⠀⠀"); + console.warn("⠀⢀⣿⣿⣿⣿⠃⣰⣿⣿⡿⠛⠋⠉⠛⠻⣿⣿⣷⡀⠹⣿⣿⣿⡆⠀"); + console.warn("⠀⣸⣿⣿⣿⠃⣰⣿⣿⠋⣠⣾⡇⢸⣷⣦⠈⣿⣿⣿⡄⢹⣿⣿⣿⠀"); + console.warn("⠀⣿⣿⣿⠋⠀⠉⠉⠉⠀⣿⣿⡇⢸⣿⣿⡇⠉⠉⠉⠁⠀⢻⣿⣿⡆"); + console.warn("⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇"); + console.warn("⠀⠙⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠃⠘⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠁"); + console.warn("⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀ "); + console.warn("⠀⠀⠀⠀⠀⠀⠈⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠃⠀⠀⠀⠀⠀⠀ "); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + console.warn("⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"); + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Synchronizing - Running...`, + ); + await this.part0_sync(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Synchronizing - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Renaming tables - Running...`, + ); + await this.part1_rename_tables(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Renaming tables - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Generating new schema - Running...`, + ); + await this.part2_generate_new_schema(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Generating new schema - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating data - Running...`, + ); + await this.part3_migrate_data(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Migrating data - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Enabling auto-increment - Running...`, + ); + await this.part4_enable_auto_increment(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Enabling auto-increment - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Deleting old tables - Running...`, + ); + await this.part5_delete_old_tables(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Deleting old tables - Completed`, + ); + currentStep++; + + this.logger.log( + `Step ${currentStep}/${totalSteps}: Sorting title - Running...`, + ); + await this.part6_sorting_title(queryRunner); + this.logger.log( + `Step ${currentStep}/${totalSteps}: Sorting title - Completed`, + ); + + this.logger.log("info", "Migration completed successfully."); + } + + private async checkMigrationRun(queryRunner: QueryRunner): Promise { + const migrations = await queryRunner.query( + `SELECT * FROM migrations WHERE name LIKE 'V13Part%'`, + ); + + if (migrations.length > 0) { + this.logger.warn("V13 MIGRATIONS ALREADY RUN SKIPPING..."); + for (const migration of migrations) { + this.logger.warn(JSON.stringify(migration)); + } + return true; + } + + return false; + } + + private async part0_sync(queryRunner: QueryRunner) { + await queryRunner.query(` + DROP INDEX "IDX_5c0cd47a75116720223e43db85" + `); + await queryRunner.query(` + DROP INDEX "IDX_5c2989f7bc37f907cfd937c0fd" + `); + await queryRunner.query(` + DROP INDEX "IDX_71b846918f80786eed6bfb68b7" + `); + await queryRunner.query(` + CREATE TABLE "temporary_developer" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_5c2989f7bc37f907cfd937c0fd0" UNIQUE ("name"), + CONSTRAINT "UQ_5c0cd47a75116720223e43db853" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_developer"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "developer" + `); + await queryRunner.query(` + DROP TABLE "developer" + `); + await queryRunner.query(` + ALTER TABLE "temporary_developer" + RENAME TO "developer" + `); + await queryRunner.query(` + CREATE INDEX "IDX_5c0cd47a75116720223e43db85" ON "developer" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_5c2989f7bc37f907cfd937c0fd" ON "developer" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_71b846918f80786eed6bfb68b7" ON "developer" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_672bce67ec8cb2d7755c158ad6" + `); + await queryRunner.query(` + DROP INDEX "IDX_dd8cd9e50dd049656e4be1f7e8" + `); + await queryRunner.query(` + DROP INDEX "IDX_0285d4f1655d080cfcf7d1ab14" + `); + await queryRunner.query(` + CREATE TABLE "temporary_genre" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_dd8cd9e50dd049656e4be1f7e8c" UNIQUE ("name"), + CONSTRAINT "UQ_672bce67ec8cb2d7755c158ad65" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_genre"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "genre" + `); + await queryRunner.query(` + DROP TABLE "genre" + `); + await queryRunner.query(` + ALTER TABLE "temporary_genre" + RENAME TO "genre" + `); + await queryRunner.query(` + CREATE INDEX "IDX_672bce67ec8cb2d7755c158ad6" ON "genre" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_dd8cd9e50dd049656e4be1f7e8" ON "genre" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_0285d4f1655d080cfcf7d1ab14" ON "genre" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_4a0539222ee1307f657f875003" + `); + await queryRunner.query(` + DROP INDEX "IDX_9dc496f2e5b912da9edd2aa445" + `); + await queryRunner.query(` + DROP INDEX "IDX_70a5936b43177f76161724da3e" + `); + await queryRunner.query(` + CREATE TABLE "temporary_publisher" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_9dc496f2e5b912da9edd2aa4455" UNIQUE ("name"), + CONSTRAINT "UQ_4a0539222ee1307f657f875003b" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_publisher"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "publisher" + `); + await queryRunner.query(` + DROP TABLE "publisher" + `); + await queryRunner.query(` + ALTER TABLE "temporary_publisher" + RENAME TO "publisher" + `); + await queryRunner.query(` + CREATE INDEX "IDX_4a0539222ee1307f657f875003" ON "publisher" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_9dc496f2e5b912da9edd2aa445" ON "publisher" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_70a5936b43177f76161724da3e" ON "publisher" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_45c4541783f264043ec2a5864d" + `); + await queryRunner.query(` + DROP INDEX "IDX_66df34da7fb037e24fc7fee642" + `); + await queryRunner.query(` + DROP INDEX "IDX_f3172007d4de5ae8e7692759d7" + `); + await queryRunner.query(` + CREATE TABLE "temporary_store" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_66df34da7fb037e24fc7fee642b" UNIQUE ("name"), + CONSTRAINT "UQ_45c4541783f264043ec2a5864d6" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_store"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "store" + `); + await queryRunner.query(` + DROP TABLE "store" + `); + await queryRunner.query(` + ALTER TABLE "temporary_store" + RENAME TO "store" + `); + await queryRunner.query(` + CREATE INDEX "IDX_45c4541783f264043ec2a5864d" ON "store" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_66df34da7fb037e24fc7fee642" ON "store" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_f3172007d4de5ae8e7692759d7" ON "store" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_289102542903593026bd16e4e1" + `); + await queryRunner.query(` + DROP INDEX "IDX_6a9775008add570dc3e5a0bab7" + `); + await queryRunner.query(` + DROP INDEX "IDX_8e4052373c579afc1471f52676" + `); + await queryRunner.query(` + CREATE TABLE "temporary_tag" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_6a9775008add570dc3e5a0bab7b" UNIQUE ("name"), + CONSTRAINT "UQ_289102542903593026bd16e4e1b" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_tag"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "tag" + `); + await queryRunner.query(` + DROP TABLE "tag" + `); + await queryRunner.query(` + ALTER TABLE "temporary_tag" + RENAME TO "tag" + `); + await queryRunner.query(` + CREATE INDEX "IDX_289102542903593026bd16e4e1" ON "tag" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_6a9775008add570dc3e5a0bab7" ON "tag" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_8e4052373c579afc1471f52676" ON "tag" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_5c0cd47a75116720223e43db85" + `); + await queryRunner.query(` + DROP INDEX "IDX_5c2989f7bc37f907cfd937c0fd" + `); + await queryRunner.query(` + DROP INDEX "IDX_71b846918f80786eed6bfb68b7" + `); + await queryRunner.query(` + CREATE TABLE "temporary_developer" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_5c2989f7bc37f907cfd937c0fd0" UNIQUE ("name"), + CONSTRAINT "UQ_5c0cd47a75116720223e43db853" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_developer"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "developer" + `); + await queryRunner.query(` + DROP TABLE "developer" + `); + await queryRunner.query(` + ALTER TABLE "temporary_developer" + RENAME TO "developer" + `); + await queryRunner.query(` + CREATE INDEX "IDX_5c0cd47a75116720223e43db85" ON "developer" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_5c2989f7bc37f907cfd937c0fd" ON "developer" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_71b846918f80786eed6bfb68b7" ON "developer" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_672bce67ec8cb2d7755c158ad6" + `); + await queryRunner.query(` + DROP INDEX "IDX_dd8cd9e50dd049656e4be1f7e8" + `); + await queryRunner.query(` + DROP INDEX "IDX_0285d4f1655d080cfcf7d1ab14" + `); + await queryRunner.query(` + CREATE TABLE "temporary_genre" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_dd8cd9e50dd049656e4be1f7e8c" UNIQUE ("name"), + CONSTRAINT "UQ_672bce67ec8cb2d7755c158ad65" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_genre"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "genre" + `); + await queryRunner.query(` + DROP TABLE "genre" + `); + await queryRunner.query(` + ALTER TABLE "temporary_genre" + RENAME TO "genre" + `); + await queryRunner.query(` + CREATE INDEX "IDX_672bce67ec8cb2d7755c158ad6" ON "genre" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_dd8cd9e50dd049656e4be1f7e8" ON "genre" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_0285d4f1655d080cfcf7d1ab14" ON "genre" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_4a0539222ee1307f657f875003" + `); + await queryRunner.query(` + DROP INDEX "IDX_9dc496f2e5b912da9edd2aa445" + `); + await queryRunner.query(` + DROP INDEX "IDX_70a5936b43177f76161724da3e" + `); + await queryRunner.query(` + CREATE TABLE "temporary_publisher" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_9dc496f2e5b912da9edd2aa4455" UNIQUE ("name"), + CONSTRAINT "UQ_4a0539222ee1307f657f875003b" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_publisher"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "publisher" + `); + await queryRunner.query(` + DROP TABLE "publisher" + `); + await queryRunner.query(` + ALTER TABLE "temporary_publisher" + RENAME TO "publisher" + `); + await queryRunner.query(` + CREATE INDEX "IDX_4a0539222ee1307f657f875003" ON "publisher" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_9dc496f2e5b912da9edd2aa445" ON "publisher" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_70a5936b43177f76161724da3e" ON "publisher" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_45c4541783f264043ec2a5864d" + `); + await queryRunner.query(` + DROP INDEX "IDX_66df34da7fb037e24fc7fee642" + `); + await queryRunner.query(` + DROP INDEX "IDX_f3172007d4de5ae8e7692759d7" + `); + await queryRunner.query(` + CREATE TABLE "temporary_store" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_66df34da7fb037e24fc7fee642b" UNIQUE ("name"), + CONSTRAINT "UQ_45c4541783f264043ec2a5864d6" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_store"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "store" + `); + await queryRunner.query(` + DROP TABLE "store" + `); + await queryRunner.query(` + ALTER TABLE "temporary_store" + RENAME TO "store" + `); + await queryRunner.query(` + CREATE INDEX "IDX_45c4541783f264043ec2a5864d" ON "store" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_66df34da7fb037e24fc7fee642" ON "store" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_f3172007d4de5ae8e7692759d7" ON "store" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_289102542903593026bd16e4e1" + `); + await queryRunner.query(` + DROP INDEX "IDX_6a9775008add570dc3e5a0bab7" + `); + await queryRunner.query(` + DROP INDEX "IDX_8e4052373c579afc1471f52676" + `); + await queryRunner.query(` + CREATE TABLE "temporary_tag" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_6a9775008add570dc3e5a0bab7b" UNIQUE ("name"), + CONSTRAINT "UQ_289102542903593026bd16e4e1b" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_tag"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "tag" + `); + await queryRunner.query(` + DROP TABLE "tag" + `); + await queryRunner.query(` + ALTER TABLE "temporary_tag" + RENAME TO "tag" + `); + await queryRunner.query(` + CREATE INDEX "IDX_289102542903593026bd16e4e1" ON "tag" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_6a9775008add570dc3e5a0bab7" ON "tag" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_8e4052373c579afc1471f52676" ON "tag" ("id") + `); + } + + private async part1_rename_tables(queryRunner: QueryRunner) { + if (existsSync("/images")) { + throw new Error( + "Oof... Your media volume mount point is still pointing to /images. This is deprecated since v13.0.0. From now on, mount your images to /media instead of /images. Did you even bother to read the migration instructions? 🥲", + ); + } + + // Rename all existing tables, so no conflicts appear + await queryRunner.query(`ALTER TABLE "image" RENAME TO "v12_image"`); + await queryRunner.query(`DROP INDEX "IDX_f03b89f33671086e6733828e79"`); + await queryRunner.renameTable("game", "v12_game"); + await queryRunner.renameTable("gamevault_user", "v12_gamevault_user"); + + await queryRunner.renameTable("progress", "v12_progress"); + await queryRunner.renameTable("developer", "v12_developer"); + await queryRunner.renameTable("genre", "v12_genre"); + await queryRunner.renameTable("publisher", "v12_publisher"); + await queryRunner.renameTable("store", "v12_store"); + await queryRunner.renameTable("tag", "v12_tag"); + + await queryRunner.renameTable("bookmark", "v12_bookmark"); + await queryRunner.renameColumn( + "v12_bookmark", + "gamevault_user_id", + "v12_gamevault_user_id", + ); + await queryRunner.renameColumn("v12_bookmark", "game_id", "v12_game_id"); + + await queryRunner.renameTable( + "game_developers_developer", + "v12_game_developers_v12_developer", + ); + await queryRunner.renameColumn( + "v12_game_developers_v12_developer", + "game_id", + "v12_game_id", + ); + await queryRunner.renameColumn( + "v12_game_developers_v12_developer", + "developer_id", + "v12_developer_id", + ); + + await queryRunner.renameTable( + "game_genres_genre", + "v12_game_genres_v12_genre", + ); + await queryRunner.renameColumn( + "v12_game_genres_v12_genre", + "game_id", + "v12_game_id", + ); + await queryRunner.renameColumn( + "v12_game_genres_v12_genre", + "genre_id", + "v12_genre_id", + ); + + await queryRunner.renameTable( + "game_publishers_publisher", + "v12_game_publishers_v12_publisher", + ); + await queryRunner.renameColumn( + "v12_game_publishers_v12_publisher", + "game_id", + "v12_game_id", + ); + await queryRunner.renameColumn( + "v12_game_publishers_v12_publisher", + "publisher_id", + "v12_publisher_id", + ); + + await queryRunner.renameTable( + "game_stores_store", + "v12_game_stores_v12_store", + ); + await queryRunner.renameColumn( + "v12_game_stores_v12_store", + "game_id", + "v12_game_id", + ); + await queryRunner.renameColumn( + "v12_game_stores_v12_store", + "store_id", + "v12_store_id", + ); + + await queryRunner.renameTable("game_tags_tag", "v12_game_tags_v12_tag"); + await queryRunner.renameColumn( + "v12_game_tags_v12_tag", + "game_id", + "v12_game_id", + ); + await queryRunner.renameColumn( + "v12_game_tags_v12_tag", + "tag_id", + "v12_tag_id", + ); + + await queryRunner.dropTable("query-result-cache", true); + } + private async part2_generate_new_schema(queryRunner: QueryRunner) { + await queryRunner.query(` + DROP INDEX "IDX_d6db1ab4ee9ad9dbe86c64e4cc" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_image" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "source" varchar, + "path" varchar, + "media_type" varchar, + "uploader_id" integer, + CONSTRAINT "UQ_f03b89f33671086e6733828e79c" UNIQUE ("path") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_image"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "source", + "path", + "media_type", + "uploader_id" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "source", + "path", + "media_type", + "uploader_id" + FROM "v12_image" + `); + await queryRunner.query(` + DROP TABLE "v12_image" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_image" + RENAME TO "v12_image" + `); + await queryRunner.query(` + CREATE INDEX "IDX_d6db1ab4ee9ad9dbe86c64e4cc" ON "v12_image" ("id") + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_gamevault_user" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "username" varchar NOT NULL, + "password" varchar NOT NULL, + "email" varchar, + "first_name" varchar, + "last_name" varchar, + "activated" boolean NOT NULL DEFAULT (0), + "role" varchar CHECK("role" IN ('0', '1', '2', '3')) NOT NULL DEFAULT (1), + "profile_picture_id" integer, + "background_image_id" integer, + CONSTRAINT "UQ_ad2fda40ce941655c838fb1435f" UNIQUE ("username"), + CONSTRAINT "UQ_d0e7d50057240e5752a2c303ffb" UNIQUE ("email") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_gamevault_user"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "username", + "password", + "email", + "first_name", + "last_name", + "activated", + "role", + "profile_picture_id", + "background_image_id" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "username", + "password", + "email", + "first_name", + "last_name", + "activated", + "role", + "profile_picture_id", + "background_image_id" + FROM "v12_gamevault_user" + `); + await queryRunner.query(` + DROP TABLE "v12_gamevault_user" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_gamevault_user" + RENAME TO "v12_gamevault_user" + `); + await queryRunner.query(` + DROP INDEX "IDX_8d759a72ce42e6444af6860181" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_game" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "title" varchar NOT NULL, + "rawg_title" varchar, + "version" varchar, + "release_date" datetime NOT NULL, + "rawg_release_date" datetime, + "cache_date" datetime, + "file_path" varchar NOT NULL, + "size" bigint NOT NULL DEFAULT (0), + "description" varchar, + "website_url" varchar, + "metacritic_rating" integer, + "average_playtime" integer, + "early_access" boolean NOT NULL, + "box_image_id" integer, + "background_image_id" integer, + "type" varchar CHECK( + "type" IN ( + 'UNDETECTABLE', + 'WINDOWS_PORTABLE', + 'WINDOWS_SETUP' + ) + ) NOT NULL DEFAULT ('UNDETECTABLE'), + CONSTRAINT "UQ_95628db340ba8b2c1ed6add021c" UNIQUE ("file_path") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_game"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "title", + "rawg_title", + "version", + "release_date", + "rawg_release_date", + "cache_date", + "file_path", + "size", + "description", + "website_url", + "metacritic_rating", + "average_playtime", + "early_access", + "box_image_id", + "background_image_id", + "type" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "title", + "rawg_title", + "version", + "release_date", + "rawg_release_date", + "cache_date", + "file_path", + "size", + "description", + "website_url", + "metacritic_rating", + "average_playtime", + "early_access", + "box_image_id", + "background_image_id", + "type" + FROM "v12_game" + `); + await queryRunner.query(` + DROP TABLE "v12_game" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_game" + RENAME TO "v12_game" + `); + await queryRunner.query(` + CREATE INDEX "IDX_8d759a72ce42e6444af6860181" ON "v12_game" ("title") + `); + await queryRunner.query(` + DROP INDEX "IDX_d6db1ab4ee9ad9dbe86c64e4cc" + `); + await queryRunner.query(` + DROP INDEX "IDX_039ad5528f914321b2fc6b1fff" + `); + await queryRunner.query(` + DROP INDEX "IDX_54a35803b834868362fa4c2629" + `); + await queryRunner.query(` + DROP INDEX "IDX_907a95c00ab6d81140c1a1b4a3" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_developer" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_54a35803b834868362fa4c26290" UNIQUE ("name"), + CONSTRAINT "UQ_039ad5528f914321b2fc6b1fffc" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_developer"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_developer" + `); + await queryRunner.query(` + DROP TABLE "v12_developer" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_developer" + RENAME TO "v12_developer" + `); + await queryRunner.query(` + CREATE INDEX "IDX_039ad5528f914321b2fc6b1fff" ON "v12_developer" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_54a35803b834868362fa4c2629" ON "v12_developer" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_907a95c00ab6d81140c1a1b4a3" ON "v12_developer" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_888c3736e64117aba956e90f65" + `); + await queryRunner.query(` + DROP INDEX "IDX_8a0e8d0364e3637f00d655af94" + `); + await queryRunner.query(` + DROP INDEX "IDX_cf2ba84ceb90f80049fce15995" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_genre" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_8a0e8d0364e3637f00d655af947" UNIQUE ("name"), + CONSTRAINT "UQ_888c3736e64117aba956e90f658" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_genre"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_genre" + `); + await queryRunner.query(` + DROP TABLE "v12_genre" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_genre" + RENAME TO "v12_genre" + `); + await queryRunner.query(` + CREATE INDEX "IDX_888c3736e64117aba956e90f65" ON "v12_genre" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_8a0e8d0364e3637f00d655af94" ON "v12_genre" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_cf2ba84ceb90f80049fce15995" ON "v12_genre" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_ba10ea475597187820c3b4fd28" + `); + await queryRunner.query(` + DROP INDEX "IDX_2263bfd2f8ed59b0f54f6d3ae9" + `); + await queryRunner.query(` + DROP INDEX "IDX_f2f05b756501810d84eea1d651" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_publisher" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_2263bfd2f8ed59b0f54f6d3ae99" UNIQUE ("name"), + CONSTRAINT "UQ_ba10ea475597187820c3b4fd281" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_publisher"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_publisher" + `); + await queryRunner.query(` + DROP TABLE "v12_publisher" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_publisher" + RENAME TO "v12_publisher" + `); + await queryRunner.query(` + CREATE INDEX "IDX_ba10ea475597187820c3b4fd28" ON "v12_publisher" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_2263bfd2f8ed59b0f54f6d3ae9" ON "v12_publisher" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_f2f05b756501810d84eea1d651" ON "v12_publisher" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_4a2e62473659b6263b17a5497c" + `); + await queryRunner.query(` + DROP INDEX "IDX_6695d0cc38a598edd65fcba0ee" + `); + await queryRunner.query(` + DROP INDEX "IDX_e2db9da8c8288f3ff795994d4d" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_store" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_6695d0cc38a598edd65fcba0ee4" UNIQUE ("name"), + CONSTRAINT "UQ_4a2e62473659b6263b17a5497c3" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_store"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_store" + `); + await queryRunner.query(` + DROP TABLE "v12_store" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_store" + RENAME TO "v12_store" + `); + await queryRunner.query(` + CREATE INDEX "IDX_4a2e62473659b6263b17a5497c" ON "v12_store" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_6695d0cc38a598edd65fcba0ee" ON "v12_store" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_e2db9da8c8288f3ff795994d4d" ON "v12_store" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_b60ff4525bb354df761a2eba44" + `); + await queryRunner.query(` + DROP INDEX "IDX_636a93cb92150e4660bf07a3bc" + `); + await queryRunner.query(` + DROP INDEX "IDX_0e129f8ad40f587596e0f8d8ff" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_tag" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_636a93cb92150e4660bf07a3bc1" UNIQUE ("name"), + CONSTRAINT "UQ_b60ff4525bb354df761a2eba441" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_tag"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_tag" + `); + await queryRunner.query(` + DROP TABLE "v12_tag" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_tag" + RENAME TO "v12_tag" + `); + await queryRunner.query(` + CREATE INDEX "IDX_b60ff4525bb354df761a2eba44" ON "v12_tag" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_636a93cb92150e4660bf07a3bc" ON "v12_tag" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_0e129f8ad40f587596e0f8d8ff" ON "v12_tag" ("id") + `); + await queryRunner.query(` + CREATE TABLE "media" ( + "id" integer PRIMARY KEY NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "source_url" varchar, + "file_path" varchar, + "type" varchar NOT NULL, + "uploader_id" integer, + CONSTRAINT "UQ_62649abcfe2e99bd6215511e231" UNIQUE ("file_path") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_f4e0fcac36e050de337b670d8b" ON "media" ("id") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_62649abcfe2e99bd6215511e23" ON "media" ("file_path") + `); + await queryRunner.query(` + CREATE TABLE "developer_metadata" ( + "id" integer PRIMARY KEY NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "provider_slug" varchar NOT NULL, + "provider_data_id" varchar NOT NULL, + "name" varchar NOT NULL + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_3797936110f483ab684d700e48" ON "developer_metadata" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_8d642e3a72cb76d343639c3281" ON "developer_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_414ccae60b54eb1580bca0c28f" ON "developer_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_16b10ff59b57ea2b920ccdec2d" ON "developer_metadata" ("name") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_DEVELOPER_METADATA" ON "developer_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE TABLE "genre_metadata" ( + "id" integer PRIMARY KEY NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "provider_slug" varchar NOT NULL, + "provider_data_id" varchar NOT NULL, + "name" varchar NOT NULL + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_ab9cd344970e9df47d3d6c8b5b" ON "genre_metadata" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_bcbc44cdfbf2977f55c52651aa" ON "genre_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_7258256a052ef3ff3e882fa471" ON "genre_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_bf40614141adff790cb659c902" ON "genre_metadata" ("name") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_GENRE_METADATA" ON "genre_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE TABLE "publisher_metadata" ( + "id" integer PRIMARY KEY NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "provider_slug" varchar NOT NULL, + "provider_data_id" varchar NOT NULL, + "name" varchar NOT NULL + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_73e957f8e68ba1111ac3b79adc" ON "publisher_metadata" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_16f6954549be1a71c53654c939" ON "publisher_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_e9ec06cab4b92d64ba257b4eed" ON "publisher_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_73c3afaa08bae7e58471e83c8e" ON "publisher_metadata" ("name") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_PUBLISHER_METADATA" ON "publisher_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE TABLE "tag_metadata" ( + "id" integer PRIMARY KEY NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "provider_slug" varchar NOT NULL, + "provider_data_id" varchar NOT NULL, + "name" varchar NOT NULL + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_96d7cccf17f8cb2cfa25388cbd" ON "tag_metadata" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_d914734a79b8145479a748d0a5" ON "tag_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_a1b923a5cf28e468500e7e0b59" ON "tag_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_a5f8eb5e083ca5fb83cd152777" ON "tag_metadata" ("name") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_TAG_METADATA" ON "tag_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE TABLE "game_metadata" ( + "id" integer PRIMARY KEY NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "provider_slug" varchar, + "provider_data_id" varchar, + "provider_data_url" varchar, + "provider_priority" integer, + "age_rating" integer, + "title" varchar, + "release_date" datetime, + "description" varchar, + "notes" varchar, + "average_playtime" integer, + "url_screenshots" text, + "url_trailers" text, + "url_gameplays" text, + "url_websites" text, + "rating" float, + "early_access" boolean, + "launch_parameters" varchar, + "launch_executable" varchar, + "installer_executable" varchar, + "cover_id" integer, + "background_id" integer + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_7af272a017b850a4ce7a6c2886" ON "game_metadata" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_e9a00e38e7969570d9ab66dd27" ON "game_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_4f0b69ca308a906932c84ea0d5" ON "game_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_21c321551d9c772d56e07b2a1a" ON "game_metadata" ("title") + `); + await queryRunner.query(` + CREATE INDEX "IDX_47070ef56d911fa9824f3277e2" ON "game_metadata" ("release_date") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_GAME_METADATA" ON "game_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE TABLE "progress" ( + "id" integer PRIMARY KEY NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "minutes_played" integer NOT NULL DEFAULT (0), + "state" varchar CHECK( + "state" IN ( + 'UNPLAYED', + 'INFINITE', + 'PLAYING', + 'COMPLETED', + 'ABORTED_TEMPORARY', + 'ABORTED_PERMANENT' + ) + ) NOT NULL DEFAULT ('UNPLAYED'), + "last_played_at" datetime, + "user_id" integer, + "game_id" integer + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_79abdfd87a688f9de756a162b6" ON "progress" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_ddcaca3a9db9d77105d51c02c2" ON "progress" ("user_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_feaddf361921db1df3a6fe3965" ON "progress" ("game_id") + `); + await queryRunner.query(` + CREATE TABLE "gamevault_game" ( + "id" integer PRIMARY KEY NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "file_path" varchar NOT NULL, + "size" bigint NOT NULL DEFAULT (0), + "title" varchar, + "sort_title" varchar, + "version" varchar, + "release_date" datetime, + "early_access" boolean NOT NULL DEFAULT (0), + "download_count" integer NOT NULL DEFAULT (0), + "type" varchar CHECK( + "type" IN ( + 'UNDETECTABLE', + 'WINDOWS_SETUP', + 'WINDOWS_PORTABLE', + 'LINUX_PORTABLE' + ) + ) NOT NULL DEFAULT ('UNDETECTABLE'), + "user_metadata_id" integer, + "metadata_id" integer, + CONSTRAINT "UQ_91d454956bd20f46b646b05b91f" UNIQUE ("file_path"), + CONSTRAINT "REL_edc9b16a9e16d394b2ca3b49b1" UNIQUE ("user_metadata_id"), + CONSTRAINT "REL_aab0797ae3873a5ef2817d0989" UNIQUE ("metadata_id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_dc16bc448f2591a832533f25d9" ON "gamevault_game" ("id") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_91d454956bd20f46b646b05b91" ON "gamevault_game" ("file_path") + `); + await queryRunner.query(` + CREATE INDEX "IDX_73e99cf1379987ed7c5983d74f" ON "gamevault_game" ("release_date") + `); + await queryRunner.query(` + CREATE TABLE "gamevault_user" ( + "id" integer PRIMARY KEY NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "username" varchar NOT NULL, + "password" varchar NOT NULL, + "socket_secret" varchar(64) NOT NULL, + "email" varchar, + "first_name" varchar, + "last_name" varchar, + "birth_date" datetime, + "activated" boolean NOT NULL DEFAULT (0), + "role" varchar CHECK("role" IN ('0', '1', '2', '3')) NOT NULL DEFAULT (1), + "avatar_id" integer, + "background_id" integer, + CONSTRAINT "UQ_4c835305e86b28e416cfe13dace" UNIQUE ("username"), + CONSTRAINT "UQ_e0da4bbf1074bca2d980a810771" UNIQUE ("socket_secret"), + CONSTRAINT "UQ_284621e91b3886db5ebd901384a" UNIQUE ("email"), + CONSTRAINT "REL_872748cf76003216d011ae0feb" UNIQUE ("avatar_id"), + CONSTRAINT "REL_0bd4a25fe30450010869557666" UNIQUE ("background_id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_c2a3f8b06558be9508161af22e" ON "gamevault_user" ("id") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_4c835305e86b28e416cfe13dac" ON "gamevault_user" ("username") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_e0da4bbf1074bca2d980a81077" ON "gamevault_user" ("socket_secret") + `); + await queryRunner.query(` + CREATE INDEX "IDX_4edfac51e323a4993aec668eb4" ON "gamevault_user" ("birth_date") + `); + await queryRunner.query(` + CREATE TABLE "game_metadata_gamevault_games_gamevault_game" ( + "game_metadata_id" integer NOT NULL, + "gamevault_game_id" integer NOT NULL, + PRIMARY KEY ("game_metadata_id", "gamevault_game_id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_178abeeb628ebcdb70239c08d4" ON "game_metadata_gamevault_games_gamevault_game" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_c5afe975cb06f9624d5f5aa8ff" ON "game_metadata_gamevault_games_gamevault_game" ("gamevault_game_id") + `); + await queryRunner.query(` + CREATE TABLE "game_metadata_publishers_publisher_metadata" ( + "game_metadata_id" integer NOT NULL, + "publisher_metadata_id" integer NOT NULL, + PRIMARY KEY ("game_metadata_id", "publisher_metadata_id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_6d9f174cdbce41bb5b934271a9" ON "game_metadata_publishers_publisher_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_71ffc2cb90c863a5c225efa295" ON "game_metadata_publishers_publisher_metadata" ("publisher_metadata_id") + `); + await queryRunner.query(` + CREATE TABLE "game_metadata_developers_developer_metadata" ( + "game_metadata_id" integer NOT NULL, + "developer_metadata_id" integer NOT NULL, + PRIMARY KEY ("game_metadata_id", "developer_metadata_id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_2b99b13a4b75f1396c49990e6d" ON "game_metadata_developers_developer_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_3741d615695a161ffc5a41e748" ON "game_metadata_developers_developer_metadata" ("developer_metadata_id") + `); + await queryRunner.query(` + CREATE TABLE "game_metadata_tags_tag_metadata" ( + "game_metadata_id" integer NOT NULL, + "tag_metadata_id" integer NOT NULL, + PRIMARY KEY ("game_metadata_id", "tag_metadata_id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_f6c8361e5e167251a06355c168" ON "game_metadata_tags_tag_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_a4f3fec63ccb14d466924a11ef" ON "game_metadata_tags_tag_metadata" ("tag_metadata_id") + `); + await queryRunner.query(` + CREATE TABLE "game_metadata_genres_genre_metadata" ( + "game_metadata_id" integer NOT NULL, + "genre_metadata_id" integer NOT NULL, + PRIMARY KEY ("game_metadata_id", "genre_metadata_id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_c7d2d3ca1a28eab7d55e99ff24" ON "game_metadata_genres_genre_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_0482ce35adf40c9128eaa1ae89" ON "game_metadata_genres_genre_metadata" ("genre_metadata_id") + `); + await queryRunner.query(` + CREATE TABLE "gamevault_game_provider_metadata_game_metadata" ( + "gamevault_game_id" integer NOT NULL, + "game_metadata_id" integer NOT NULL, + PRIMARY KEY ("gamevault_game_id", "game_metadata_id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_8602b8a76c7952d1155118933f" ON "gamevault_game_provider_metadata_game_metadata" ("gamevault_game_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_0b9f583ebc16b0bb8cbfaf00f8" ON "gamevault_game_provider_metadata_game_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE TABLE "bookmark" ( + "gamevault_user_id" integer NOT NULL, + "gamevault_game_id" integer NOT NULL, + PRIMARY KEY ("gamevault_user_id", "gamevault_game_id") + ) + `); + await queryRunner.query(` + CREATE INDEX "IDX_6f00464edf85ddfedbd2580842" ON "bookmark" ("gamevault_user_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_3c8d93fdd9e34a97f5a5903129" ON "bookmark" ("gamevault_game_id") + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_gamevault_user" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "username" varchar NOT NULL, + "password" varchar NOT NULL, + "email" varchar, + "first_name" varchar, + "last_name" varchar, + "activated" boolean NOT NULL DEFAULT (0), + "role" varchar CHECK("role" IN ('0', '1', '2', '3')) NOT NULL DEFAULT (1), + "profile_picture_id" integer, + "background_image_id" integer, + "socket_secret" varchar(64), + CONSTRAINT "UQ_ad2fda40ce941655c838fb1435f" UNIQUE ("username"), + CONSTRAINT "UQ_d0e7d50057240e5752a2c303ffb" UNIQUE ("email"), + CONSTRAINT "UQ_ef1c27a5c7e1f58650e6b0e6122" UNIQUE ("socket_secret") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_gamevault_user"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "username", + "password", + "email", + "first_name", + "last_name", + "activated", + "role", + "profile_picture_id", + "background_image_id" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "username", + "password", + "email", + "first_name", + "last_name", + "activated", + "role", + "profile_picture_id", + "background_image_id" + FROM "v12_gamevault_user" + `); + await queryRunner.query(` + DROP TABLE "v12_gamevault_user" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_gamevault_user" + RENAME TO "v12_gamevault_user" + `); + await queryRunner.query(` + DROP INDEX "IDX_039ad5528f914321b2fc6b1fff" + `); + await queryRunner.query(` + DROP INDEX "IDX_54a35803b834868362fa4c2629" + `); + await queryRunner.query(` + DROP INDEX "IDX_907a95c00ab6d81140c1a1b4a3" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_developer" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_54a35803b834868362fa4c26290" UNIQUE ("name"), + CONSTRAINT "UQ_039ad5528f914321b2fc6b1fffc" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_developer"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_developer" + `); + await queryRunner.query(` + DROP TABLE "v12_developer" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_developer" + RENAME TO "v12_developer" + `); + await queryRunner.query(` + CREATE INDEX "IDX_039ad5528f914321b2fc6b1fff" ON "v12_developer" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_54a35803b834868362fa4c2629" ON "v12_developer" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_907a95c00ab6d81140c1a1b4a3" ON "v12_developer" ("id") + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_gamevault_user" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "username" varchar NOT NULL, + "password" varchar NOT NULL, + "email" varchar, + "first_name" varchar, + "last_name" varchar, + "activated" boolean NOT NULL DEFAULT (0), + "role" varchar CHECK("role" IN ('0', '1', '2', '3')) NOT NULL DEFAULT (1), + "profile_picture_id" integer, + "background_image_id" integer, + "socket_secret" varchar(64), + CONSTRAINT "UQ_ad2fda40ce941655c838fb1435f" UNIQUE ("username"), + CONSTRAINT "UQ_d0e7d50057240e5752a2c303ffb" UNIQUE ("email"), + CONSTRAINT "UQ_ef1c27a5c7e1f58650e6b0e6122" UNIQUE ("socket_secret"), + CONSTRAINT "UQ_a69f2a821e2f94bb605c0807181" UNIQUE ("profile_picture_id"), + CONSTRAINT "UQ_3778cbe5dc4d3fee22f07873de6" UNIQUE ("background_image_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_gamevault_user"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "username", + "password", + "email", + "first_name", + "last_name", + "activated", + "role", + "profile_picture_id", + "background_image_id", + "socket_secret" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "username", + "password", + "email", + "first_name", + "last_name", + "activated", + "role", + "profile_picture_id", + "background_image_id", + "socket_secret" + FROM "v12_gamevault_user" + `); + await queryRunner.query(` + DROP TABLE "v12_gamevault_user" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_gamevault_user" + RENAME TO "v12_gamevault_user" + `); + await queryRunner.query(` + DROP INDEX "IDX_888c3736e64117aba956e90f65" + `); + await queryRunner.query(` + DROP INDEX "IDX_8a0e8d0364e3637f00d655af94" + `); + await queryRunner.query(` + DROP INDEX "IDX_cf2ba84ceb90f80049fce15995" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_genre" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_8a0e8d0364e3637f00d655af947" UNIQUE ("name"), + CONSTRAINT "UQ_888c3736e64117aba956e90f658" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_genre"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_genre" + `); + await queryRunner.query(` + DROP TABLE "v12_genre" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_genre" + RENAME TO "v12_genre" + `); + await queryRunner.query(` + CREATE INDEX "IDX_888c3736e64117aba956e90f65" ON "v12_genre" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_8a0e8d0364e3637f00d655af94" ON "v12_genre" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_cf2ba84ceb90f80049fce15995" ON "v12_genre" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_ba10ea475597187820c3b4fd28" + `); + await queryRunner.query(` + DROP INDEX "IDX_2263bfd2f8ed59b0f54f6d3ae9" + `); + await queryRunner.query(` + DROP INDEX "IDX_f2f05b756501810d84eea1d651" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_publisher" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_2263bfd2f8ed59b0f54f6d3ae99" UNIQUE ("name"), + CONSTRAINT "UQ_ba10ea475597187820c3b4fd281" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_publisher"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_publisher" + `); + await queryRunner.query(` + DROP TABLE "v12_publisher" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_publisher" + RENAME TO "v12_publisher" + `); + await queryRunner.query(` + CREATE INDEX "IDX_ba10ea475597187820c3b4fd28" ON "v12_publisher" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_2263bfd2f8ed59b0f54f6d3ae9" ON "v12_publisher" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_f2f05b756501810d84eea1d651" ON "v12_publisher" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_4a2e62473659b6263b17a5497c" + `); + await queryRunner.query(` + DROP INDEX "IDX_6695d0cc38a598edd65fcba0ee" + `); + await queryRunner.query(` + DROP INDEX "IDX_e2db9da8c8288f3ff795994d4d" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_store" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_6695d0cc38a598edd65fcba0ee4" UNIQUE ("name"), + CONSTRAINT "UQ_4a2e62473659b6263b17a5497c3" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_store"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_store" + `); + await queryRunner.query(` + DROP TABLE "v12_store" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_store" + RENAME TO "v12_store" + `); + await queryRunner.query(` + CREATE INDEX "IDX_4a2e62473659b6263b17a5497c" ON "v12_store" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_6695d0cc38a598edd65fcba0ee" ON "v12_store" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_e2db9da8c8288f3ff795994d4d" ON "v12_store" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_8d759a72ce42e6444af6860181" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_game" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "title" varchar NOT NULL, + "rawg_title" varchar, + "version" varchar, + "release_date" datetime, + "rawg_release_date" datetime, + "cache_date" datetime, + "file_path" varchar NOT NULL, + "size" bigint NOT NULL DEFAULT (0), + "description" varchar, + "website_url" varchar, + "metacritic_rating" integer, + "average_playtime" integer, + "early_access" boolean NOT NULL, + "box_image_id" integer, + "background_image_id" integer, + "type" varchar CHECK( + "type" IN ( + 'UNDETECTABLE', + 'WINDOWS_SETUP', + 'WINDOWS_PORTABLE', + 'LINUX_PORTABLE' + ) + ) NOT NULL DEFAULT ('UNDETECTABLE'), + CONSTRAINT "UQ_95628db340ba8b2c1ed6add021c" UNIQUE ("file_path"), + CONSTRAINT "UQ_ec66cc0eac714939df6576d0121" UNIQUE ("box_image_id"), + CONSTRAINT "UQ_1ccf51eea9ed1b50a9e3f7a5db4" UNIQUE ("background_image_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_game"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "title", + "rawg_title", + "version", + "release_date", + "rawg_release_date", + "cache_date", + "file_path", + "size", + "description", + "website_url", + "metacritic_rating", + "average_playtime", + "early_access", + "box_image_id", + "background_image_id", + "type" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "title", + "rawg_title", + "version", + "release_date", + "rawg_release_date", + "cache_date", + "file_path", + "size", + "description", + "website_url", + "metacritic_rating", + "average_playtime", + "early_access", + "box_image_id", + "background_image_id", + "type" + FROM "v12_game" + `); + await queryRunner.query(` + DROP TABLE "v12_game" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_game" + RENAME TO "v12_game" + `); + await queryRunner.query(` + CREATE INDEX "IDX_8d759a72ce42e6444af6860181" ON "v12_game" ("title") + `); + await queryRunner.query(` + DROP INDEX "IDX_b60ff4525bb354df761a2eba44" + `); + await queryRunner.query(` + DROP INDEX "IDX_636a93cb92150e4660bf07a3bc" + `); + await queryRunner.query(` + DROP INDEX "IDX_0e129f8ad40f587596e0f8d8ff" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_tag" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_636a93cb92150e4660bf07a3bc1" UNIQUE ("name"), + CONSTRAINT "UQ_b60ff4525bb354df761a2eba441" UNIQUE ("rawg_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_tag"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_tag" + `); + await queryRunner.query(` + DROP TABLE "v12_tag" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_tag" + RENAME TO "v12_tag" + `); + await queryRunner.query(` + CREATE INDEX "IDX_b60ff4525bb354df761a2eba44" ON "v12_tag" ("rawg_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_636a93cb92150e4660bf07a3bc" ON "v12_tag" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_0e129f8ad40f587596e0f8d8ff" ON "v12_tag" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_9104e2e6a962d5cc0b17c3705d" ON "v12_image" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_fe74c6fdec37f411e4e042e1c7" ON "v12_image" ("path") + `); + await queryRunner.query(` + CREATE INDEX "IDX_59b393e6f4ed2f9a57e15835a9" ON "v12_gamevault_user" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_d8fa7fb9bde6aa79885c4eed33" ON "v12_gamevault_user" ("username") + `); + await queryRunner.query(` + CREATE INDEX "IDX_d99731b484bc5fec1cfee9e0fc" ON "v12_game" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_179bcdd73ab43366d14defc706" ON "v12_game" ("release_date") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_95628db340ba8b2c1ed6add021" ON "v12_game" ("file_path") + `); + await queryRunner.query(` + DROP INDEX "IDX_f4e0fcac36e050de337b670d8b" + `); + await queryRunner.query(` + DROP INDEX "IDX_62649abcfe2e99bd6215511e23" + `); + await queryRunner.query(` + CREATE TABLE "temporary_media" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "source_url" varchar, + "file_path" varchar, + "type" varchar NOT NULL, + "uploader_id" integer, + CONSTRAINT "UQ_62649abcfe2e99bd6215511e231" UNIQUE ("file_path"), + CONSTRAINT "FK_8bd1ad5f79df58cfd7ad9c42fb5" FOREIGN KEY ("uploader_id") REFERENCES "gamevault_user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_media"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "source_url", + "file_path", + "type", + "uploader_id" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "source_url", + "file_path", + "type", + "uploader_id" + FROM "media" + `); + await queryRunner.query(` + DROP TABLE "media" + `); + await queryRunner.query(` + ALTER TABLE "temporary_media" + RENAME TO "media" + `); + await queryRunner.query(` + CREATE INDEX "IDX_f4e0fcac36e050de337b670d8b" ON "media" ("id") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_62649abcfe2e99bd6215511e23" ON "media" ("file_path") + `); + await queryRunner.query(` + DROP INDEX "IDX_7af272a017b850a4ce7a6c2886" + `); + await queryRunner.query(` + DROP INDEX "IDX_e9a00e38e7969570d9ab66dd27" + `); + await queryRunner.query(` + DROP INDEX "IDX_4f0b69ca308a906932c84ea0d5" + `); + await queryRunner.query(` + DROP INDEX "IDX_21c321551d9c772d56e07b2a1a" + `); + await queryRunner.query(` + DROP INDEX "IDX_47070ef56d911fa9824f3277e2" + `); + await queryRunner.query(` + DROP INDEX "UQ_GAME_METADATA" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "provider_slug" varchar, + "provider_data_id" varchar, + "provider_data_url" varchar, + "provider_priority" integer, + "age_rating" integer, + "title" varchar, + "release_date" datetime, + "description" varchar, + "notes" varchar, + "average_playtime" integer, + "url_screenshots" text, + "url_trailers" text, + "url_gameplays" text, + "url_websites" text, + "rating" float, + "early_access" boolean, + "launch_parameters" varchar, + "launch_executable" varchar, + "installer_executable" varchar, + "cover_id" integer, + "background_id" integer, + CONSTRAINT "FK_9aefd37a55b610cea5ea583cdf6" FOREIGN KEY ("cover_id") REFERENCES "media" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT "FK_6f44518f2a088b90a8cc804d12f" FOREIGN KEY ("background_id") REFERENCES "media" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "provider_slug", + "provider_data_id", + "provider_data_url", + "provider_priority", + "age_rating", + "title", + "release_date", + "description", + "notes", + "average_playtime", + "url_screenshots", + "url_trailers", + "url_gameplays", + "url_websites", + "rating", + "early_access", + "launch_parameters", + "launch_executable", + "installer_executable", + "cover_id", + "background_id" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "provider_slug", + "provider_data_id", + "provider_data_url", + "provider_priority", + "age_rating", + "title", + "release_date", + "description", + "notes", + "average_playtime", + "url_screenshots", + "url_trailers", + "url_gameplays", + "url_websites", + "rating", + "early_access", + "launch_parameters", + "launch_executable", + "installer_executable", + "cover_id", + "background_id" + FROM "game_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata" + RENAME TO "game_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_7af272a017b850a4ce7a6c2886" ON "game_metadata" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_e9a00e38e7969570d9ab66dd27" ON "game_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_4f0b69ca308a906932c84ea0d5" ON "game_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_21c321551d9c772d56e07b2a1a" ON "game_metadata" ("title") + `); + await queryRunner.query(` + CREATE INDEX "IDX_47070ef56d911fa9824f3277e2" ON "game_metadata" ("release_date") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_GAME_METADATA" ON "game_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_79abdfd87a688f9de756a162b6" + `); + await queryRunner.query(` + DROP INDEX "IDX_ddcaca3a9db9d77105d51c02c2" + `); + await queryRunner.query(` + DROP INDEX "IDX_feaddf361921db1df3a6fe3965" + `); + await queryRunner.query(` + CREATE TABLE "temporary_progress" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "minutes_played" integer NOT NULL DEFAULT (0), + "state" varchar CHECK( + "state" IN ( + 'UNPLAYED', + 'INFINITE', + 'PLAYING', + 'COMPLETED', + 'ABORTED_TEMPORARY', + 'ABORTED_PERMANENT' + ) + ) NOT NULL DEFAULT ('UNPLAYED'), + "last_played_at" datetime, + "user_id" integer, + "game_id" integer, + CONSTRAINT "FK_ddcaca3a9db9d77105d51c02c24" FOREIGN KEY ("user_id") REFERENCES "gamevault_user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT "FK_feaddf361921db1df3a6fe3965a" FOREIGN KEY ("game_id") REFERENCES "gamevault_game" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_progress"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "minutes_played", + "state", + "last_played_at", + "user_id", + "game_id" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "minutes_played", + "state", + "last_played_at", + "user_id", + "game_id" + FROM "progress" + `); + await queryRunner.query(` + DROP TABLE "progress" + `); + await queryRunner.query(` + ALTER TABLE "temporary_progress" + RENAME TO "progress" + `); + await queryRunner.query(` + CREATE INDEX "IDX_79abdfd87a688f9de756a162b6" ON "progress" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_ddcaca3a9db9d77105d51c02c2" ON "progress" ("user_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_feaddf361921db1df3a6fe3965" ON "progress" ("game_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_dc16bc448f2591a832533f25d9" + `); + await queryRunner.query(` + DROP INDEX "IDX_91d454956bd20f46b646b05b91" + `); + await queryRunner.query(` + DROP INDEX "IDX_73e99cf1379987ed7c5983d74f" + `); + await queryRunner.query(` + CREATE TABLE "temporary_gamevault_game" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "file_path" varchar NOT NULL, + "size" bigint NOT NULL DEFAULT (0), + "title" varchar, + "sort_title" varchar, + "version" varchar, + "release_date" datetime, + "early_access" boolean NOT NULL DEFAULT (0), + "download_count" integer NOT NULL DEFAULT (0), + "type" varchar CHECK( + "type" IN ( + 'UNDETECTABLE', + 'WINDOWS_SETUP', + 'WINDOWS_PORTABLE', + 'LINUX_PORTABLE' + ) + ) NOT NULL DEFAULT ('UNDETECTABLE'), + "user_metadata_id" integer, + "metadata_id" integer, + CONSTRAINT "UQ_91d454956bd20f46b646b05b91f" UNIQUE ("file_path"), + CONSTRAINT "REL_edc9b16a9e16d394b2ca3b49b1" UNIQUE ("user_metadata_id"), + CONSTRAINT "REL_aab0797ae3873a5ef2817d0989" UNIQUE ("metadata_id"), + CONSTRAINT "FK_edc9b16a9e16d394b2ca3b49b12" FOREIGN KEY ("user_metadata_id") REFERENCES "game_metadata" ("id") ON DELETE + SET NULL ON UPDATE NO ACTION, + CONSTRAINT "FK_aab0797ae3873a5ef2817d09891" FOREIGN KEY ("metadata_id") REFERENCES "game_metadata" ("id") ON DELETE + SET NULL ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_gamevault_game"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "file_path", + "size", + "title", + "version", + "release_date", + "early_access", + "download_count", + "type", + "user_metadata_id", + "metadata_id" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "file_path", + "size", + "title", + "version", + "release_date", + "early_access", + "download_count", + "type", + "user_metadata_id", + "metadata_id" + FROM "gamevault_game" + `); + await queryRunner.query(` + DROP TABLE "gamevault_game" + `); + await queryRunner.query(` + ALTER TABLE "temporary_gamevault_game" + RENAME TO "gamevault_game" + `); + await queryRunner.query(` + CREATE INDEX "IDX_dc16bc448f2591a832533f25d9" ON "gamevault_game" ("id") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_91d454956bd20f46b646b05b91" ON "gamevault_game" ("file_path") + `); + await queryRunner.query(` + CREATE INDEX "IDX_73e99cf1379987ed7c5983d74f" ON "gamevault_game" ("release_date") + `); + await queryRunner.query(` + DROP INDEX "IDX_c2a3f8b06558be9508161af22e" + `); + await queryRunner.query(` + DROP INDEX "IDX_4c835305e86b28e416cfe13dac" + `); + await queryRunner.query(` + DROP INDEX "IDX_e0da4bbf1074bca2d980a81077" + `); + await queryRunner.query(` + DROP INDEX "IDX_4edfac51e323a4993aec668eb4" + `); + await queryRunner.query(` + CREATE TABLE "temporary_gamevault_user" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "username" varchar NOT NULL, + "password" varchar NOT NULL, + "socket_secret" varchar(64) NOT NULL, + "email" varchar, + "first_name" varchar, + "last_name" varchar, + "birth_date" datetime, + "activated" boolean NOT NULL DEFAULT (0), + "role" varchar CHECK("role" IN ('0', '1', '2', '3')) NOT NULL DEFAULT (1), + "avatar_id" integer, + "background_id" integer, + CONSTRAINT "UQ_4c835305e86b28e416cfe13dace" UNIQUE ("username"), + CONSTRAINT "UQ_e0da4bbf1074bca2d980a810771" UNIQUE ("socket_secret"), + CONSTRAINT "UQ_284621e91b3886db5ebd901384a" UNIQUE ("email"), + CONSTRAINT "REL_872748cf76003216d011ae0feb" UNIQUE ("avatar_id"), + CONSTRAINT "REL_0bd4a25fe30450010869557666" UNIQUE ("background_id"), + CONSTRAINT "FK_872748cf76003216d011ae0febb" FOREIGN KEY ("avatar_id") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_0bd4a25fe304500108695576666" FOREIGN KEY ("background_id") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_gamevault_user"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "username", + "password", + "socket_secret", + "email", + "first_name", + "last_name", + "birth_date", + "activated", + "role", + "avatar_id", + "background_id" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "username", + "password", + "socket_secret", + "email", + "first_name", + "last_name", + "birth_date", + "activated", + "role", + "avatar_id", + "background_id" + FROM "gamevault_user" + `); + await queryRunner.query(` + DROP TABLE "gamevault_user" + `); + await queryRunner.query(` + ALTER TABLE "temporary_gamevault_user" + RENAME TO "gamevault_user" + `); + await queryRunner.query(` + CREATE INDEX "IDX_c2a3f8b06558be9508161af22e" ON "gamevault_user" ("id") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_4c835305e86b28e416cfe13dac" ON "gamevault_user" ("username") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_e0da4bbf1074bca2d980a81077" ON "gamevault_user" ("socket_secret") + `); + await queryRunner.query(` + CREATE INDEX "IDX_4edfac51e323a4993aec668eb4" ON "gamevault_user" ("birth_date") + `); + await queryRunner.query(` + DROP INDEX "IDX_9104e2e6a962d5cc0b17c3705d" + `); + await queryRunner.query(` + DROP INDEX "IDX_fe74c6fdec37f411e4e042e1c7" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_image" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "source" varchar, + "path" varchar, + "media_type" varchar, + "uploader_id" integer, + CONSTRAINT "UQ_f03b89f33671086e6733828e79c" UNIQUE ("path"), + CONSTRAINT "FK_2feca751bd268e1f80b094c7fff" FOREIGN KEY ("uploader_id") REFERENCES "v12_gamevault_user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_image"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "source", + "path", + "media_type", + "uploader_id" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "source", + "path", + "media_type", + "uploader_id" + FROM "v12_image" + `); + await queryRunner.query(` + DROP TABLE "v12_image" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_image" + RENAME TO "v12_image" + `); + await queryRunner.query(` + CREATE INDEX "IDX_9104e2e6a962d5cc0b17c3705d" ON "v12_image" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_fe74c6fdec37f411e4e042e1c7" ON "v12_image" ("path") + `); + await queryRunner.query(` + DROP INDEX "IDX_59b393e6f4ed2f9a57e15835a9" + `); + await queryRunner.query(` + DROP INDEX "IDX_d8fa7fb9bde6aa79885c4eed33" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_gamevault_user" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "username" varchar NOT NULL, + "password" varchar NOT NULL, + "email" varchar, + "first_name" varchar, + "last_name" varchar, + "activated" boolean NOT NULL DEFAULT (0), + "role" varchar CHECK("role" IN ('0', '1', '2', '3')) NOT NULL DEFAULT (1), + "profile_picture_id" integer, + "background_image_id" integer, + "socket_secret" varchar(64), + CONSTRAINT "UQ_ad2fda40ce941655c838fb1435f" UNIQUE ("username"), + CONSTRAINT "UQ_d0e7d50057240e5752a2c303ffb" UNIQUE ("email"), + CONSTRAINT "UQ_ef1c27a5c7e1f58650e6b0e6122" UNIQUE ("socket_secret"), + CONSTRAINT "UQ_a69f2a821e2f94bb605c0807181" UNIQUE ("profile_picture_id"), + CONSTRAINT "UQ_3778cbe5dc4d3fee22f07873de6" UNIQUE ("background_image_id"), + CONSTRAINT "FK_add8fb3363cdc1f4f5248797c1f" FOREIGN KEY ("profile_picture_id") REFERENCES "v12_image" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_b67991f386cfd93877a8c42d134" FOREIGN KEY ("background_image_id") REFERENCES "v12_image" ("id") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_gamevault_user"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "username", + "password", + "email", + "first_name", + "last_name", + "activated", + "role", + "profile_picture_id", + "background_image_id", + "socket_secret" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "username", + "password", + "email", + "first_name", + "last_name", + "activated", + "role", + "profile_picture_id", + "background_image_id", + "socket_secret" + FROM "v12_gamevault_user" + `); + await queryRunner.query(` + DROP TABLE "v12_gamevault_user" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_gamevault_user" + RENAME TO "v12_gamevault_user" + `); + await queryRunner.query(` + CREATE INDEX "IDX_59b393e6f4ed2f9a57e15835a9" ON "v12_gamevault_user" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_d8fa7fb9bde6aa79885c4eed33" ON "v12_gamevault_user" ("username") + `); + await queryRunner.query(` + DROP INDEX "IDX_8d759a72ce42e6444af6860181" + `); + await queryRunner.query(` + DROP INDEX "IDX_d99731b484bc5fec1cfee9e0fc" + `); + await queryRunner.query(` + DROP INDEX "IDX_179bcdd73ab43366d14defc706" + `); + await queryRunner.query(` + DROP INDEX "IDX_95628db340ba8b2c1ed6add021" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_game" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "title" varchar NOT NULL, + "rawg_title" varchar, + "version" varchar, + "release_date" datetime, + "rawg_release_date" datetime, + "cache_date" datetime, + "file_path" varchar NOT NULL, + "size" bigint NOT NULL DEFAULT (0), + "description" varchar, + "website_url" varchar, + "metacritic_rating" integer, + "average_playtime" integer, + "early_access" boolean NOT NULL, + "box_image_id" integer, + "background_image_id" integer, + "type" varchar CHECK( + "type" IN ( + 'UNDETECTABLE', + 'WINDOWS_SETUP', + 'WINDOWS_PORTABLE', + 'LINUX_PORTABLE' + ) + ) NOT NULL DEFAULT ('UNDETECTABLE'), + CONSTRAINT "UQ_95628db340ba8b2c1ed6add021c" UNIQUE ("file_path"), + CONSTRAINT "UQ_ec66cc0eac714939df6576d0121" UNIQUE ("box_image_id"), + CONSTRAINT "UQ_1ccf51eea9ed1b50a9e3f7a5db4" UNIQUE ("background_image_id"), + CONSTRAINT "FK_a61e492ac08b0b32d61ae9963c1" FOREIGN KEY ("box_image_id") REFERENCES "v12_image" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_58972db9052aa0dbc1815defd6a" FOREIGN KEY ("background_image_id") REFERENCES "v12_image" ("id") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_game"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "title", + "rawg_title", + "version", + "release_date", + "rawg_release_date", + "cache_date", + "file_path", + "size", + "description", + "website_url", + "metacritic_rating", + "average_playtime", + "early_access", + "box_image_id", + "background_image_id", + "type" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "title", + "rawg_title", + "version", + "release_date", + "rawg_release_date", + "cache_date", + "file_path", + "size", + "description", + "website_url", + "metacritic_rating", + "average_playtime", + "early_access", + "box_image_id", + "background_image_id", + "type" + FROM "v12_game" + `); + await queryRunner.query(` + DROP TABLE "v12_game" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_game" + RENAME TO "v12_game" + `); + await queryRunner.query(` + CREATE INDEX "IDX_8d759a72ce42e6444af6860181" ON "v12_game" ("title") + `); + await queryRunner.query(` + CREATE INDEX "IDX_d99731b484bc5fec1cfee9e0fc" ON "v12_game" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_179bcdd73ab43366d14defc706" ON "v12_game" ("release_date") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_95628db340ba8b2c1ed6add021" ON "v12_game" ("file_path") + `); + await queryRunner.query(` + DROP INDEX "IDX_178abeeb628ebcdb70239c08d4" + `); + await queryRunner.query(` + DROP INDEX "IDX_c5afe975cb06f9624d5f5aa8ff" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_gamevault_games_gamevault_game" ( + "game_metadata_id" integer NOT NULL, + "gamevault_game_id" integer NOT NULL, + CONSTRAINT "FK_178abeeb628ebcdb70239c08d46" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_c5afe975cb06f9624d5f5aa8ff7" FOREIGN KEY ("gamevault_game_id") REFERENCES "gamevault_game" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY ("game_metadata_id", "gamevault_game_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_gamevault_games_gamevault_game"("game_metadata_id", "gamevault_game_id") + SELECT "game_metadata_id", + "gamevault_game_id" + FROM "game_metadata_gamevault_games_gamevault_game" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_gamevault_games_gamevault_game" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_gamevault_games_gamevault_game" + RENAME TO "game_metadata_gamevault_games_gamevault_game" + `); + await queryRunner.query(` + CREATE INDEX "IDX_178abeeb628ebcdb70239c08d4" ON "game_metadata_gamevault_games_gamevault_game" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_c5afe975cb06f9624d5f5aa8ff" ON "game_metadata_gamevault_games_gamevault_game" ("gamevault_game_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_6d9f174cdbce41bb5b934271a9" + `); + await queryRunner.query(` + DROP INDEX "IDX_71ffc2cb90c863a5c225efa295" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_publishers_publisher_metadata" ( + "game_metadata_id" integer NOT NULL, + "publisher_metadata_id" integer NOT NULL, + CONSTRAINT "FK_6d9f174cdbce41bb5b934271a9b" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_71ffc2cb90c863a5c225efa2950" FOREIGN KEY ("publisher_metadata_id") REFERENCES "publisher_metadata" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY ("game_metadata_id", "publisher_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_publishers_publisher_metadata"("game_metadata_id", "publisher_metadata_id") + SELECT "game_metadata_id", + "publisher_metadata_id" + FROM "game_metadata_publishers_publisher_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_publishers_publisher_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_publishers_publisher_metadata" + RENAME TO "game_metadata_publishers_publisher_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_6d9f174cdbce41bb5b934271a9" ON "game_metadata_publishers_publisher_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_71ffc2cb90c863a5c225efa295" ON "game_metadata_publishers_publisher_metadata" ("publisher_metadata_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_2b99b13a4b75f1396c49990e6d" + `); + await queryRunner.query(` + DROP INDEX "IDX_3741d615695a161ffc5a41e748" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_developers_developer_metadata" ( + "game_metadata_id" integer NOT NULL, + "developer_metadata_id" integer NOT NULL, + CONSTRAINT "FK_2b99b13a4b75f1396c49990e6de" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_3741d615695a161ffc5a41e748c" FOREIGN KEY ("developer_metadata_id") REFERENCES "developer_metadata" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY ("game_metadata_id", "developer_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_developers_developer_metadata"("game_metadata_id", "developer_metadata_id") + SELECT "game_metadata_id", + "developer_metadata_id" + FROM "game_metadata_developers_developer_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_developers_developer_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_developers_developer_metadata" + RENAME TO "game_metadata_developers_developer_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_2b99b13a4b75f1396c49990e6d" ON "game_metadata_developers_developer_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_3741d615695a161ffc5a41e748" ON "game_metadata_developers_developer_metadata" ("developer_metadata_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_f6c8361e5e167251a06355c168" + `); + await queryRunner.query(` + DROP INDEX "IDX_a4f3fec63ccb14d466924a11ef" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_tags_tag_metadata" ( + "game_metadata_id" integer NOT NULL, + "tag_metadata_id" integer NOT NULL, + CONSTRAINT "FK_f6c8361e5e167251a06355c168a" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_a4f3fec63ccb14d466924a11efc" FOREIGN KEY ("tag_metadata_id") REFERENCES "tag_metadata" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY ("game_metadata_id", "tag_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_tags_tag_metadata"("game_metadata_id", "tag_metadata_id") + SELECT "game_metadata_id", + "tag_metadata_id" + FROM "game_metadata_tags_tag_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_tags_tag_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_tags_tag_metadata" + RENAME TO "game_metadata_tags_tag_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_f6c8361e5e167251a06355c168" ON "game_metadata_tags_tag_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_a4f3fec63ccb14d466924a11ef" ON "game_metadata_tags_tag_metadata" ("tag_metadata_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_c7d2d3ca1a28eab7d55e99ff24" + `); + await queryRunner.query(` + DROP INDEX "IDX_0482ce35adf40c9128eaa1ae89" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_genres_genre_metadata" ( + "game_metadata_id" integer NOT NULL, + "genre_metadata_id" integer NOT NULL, + CONSTRAINT "FK_c7d2d3ca1a28eab7d55e99ff24b" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_0482ce35adf40c9128eaa1ae894" FOREIGN KEY ("genre_metadata_id") REFERENCES "genre_metadata" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY ("game_metadata_id", "genre_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_genres_genre_metadata"("game_metadata_id", "genre_metadata_id") + SELECT "game_metadata_id", + "genre_metadata_id" + FROM "game_metadata_genres_genre_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_genres_genre_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_genres_genre_metadata" + RENAME TO "game_metadata_genres_genre_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_c7d2d3ca1a28eab7d55e99ff24" ON "game_metadata_genres_genre_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_0482ce35adf40c9128eaa1ae89" ON "game_metadata_genres_genre_metadata" ("genre_metadata_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_8602b8a76c7952d1155118933f" + `); + await queryRunner.query(` + DROP INDEX "IDX_0b9f583ebc16b0bb8cbfaf00f8" + `); + await queryRunner.query(` + CREATE TABLE "temporary_gamevault_game_provider_metadata_game_metadata" ( + "gamevault_game_id" integer NOT NULL, + "game_metadata_id" integer NOT NULL, + CONSTRAINT "FK_8602b8a76c7952d1155118933f4" FOREIGN KEY ("gamevault_game_id") REFERENCES "gamevault_game" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_0b9f583ebc16b0bb8cbfaf00f8f" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY ("gamevault_game_id", "game_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_gamevault_game_provider_metadata_game_metadata"("gamevault_game_id", "game_metadata_id") + SELECT "gamevault_game_id", + "game_metadata_id" + FROM "gamevault_game_provider_metadata_game_metadata" + `); + await queryRunner.query(` + DROP TABLE "gamevault_game_provider_metadata_game_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_gamevault_game_provider_metadata_game_metadata" + RENAME TO "gamevault_game_provider_metadata_game_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_8602b8a76c7952d1155118933f" ON "gamevault_game_provider_metadata_game_metadata" ("gamevault_game_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_0b9f583ebc16b0bb8cbfaf00f8" ON "gamevault_game_provider_metadata_game_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_6f00464edf85ddfedbd2580842" + `); + await queryRunner.query(` + DROP INDEX "IDX_3c8d93fdd9e34a97f5a5903129" + `); + await queryRunner.query(` + CREATE TABLE "temporary_bookmark" ( + "gamevault_user_id" integer NOT NULL, + "gamevault_game_id" integer NOT NULL, + CONSTRAINT "FK_6f00464edf85ddfedbd25808428" FOREIGN KEY ("gamevault_user_id") REFERENCES "gamevault_user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_3c8d93fdd9e34a97f5a5903129b" FOREIGN KEY ("gamevault_game_id") REFERENCES "gamevault_game" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY ("gamevault_user_id", "gamevault_game_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_bookmark"("gamevault_user_id", "gamevault_game_id") + SELECT "gamevault_user_id", + "gamevault_game_id" + FROM "bookmark" + `); + await queryRunner.query(` + DROP TABLE "bookmark" + `); + await queryRunner.query(` + ALTER TABLE "temporary_bookmark" + RENAME TO "bookmark" + `); + await queryRunner.query(` + CREATE INDEX "IDX_6f00464edf85ddfedbd2580842" ON "bookmark" ("gamevault_user_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_3c8d93fdd9e34a97f5a5903129" ON "bookmark" ("gamevault_game_id") + `); + } + + private async part3_migrate_data(queryRunner: QueryRunner) { + const totalSteps = 8; + let currentStep = 1; + + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating images - Running...`, + ); + await this.migrateImages(queryRunner); + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating images - Completed`, + ); + currentStep++; + + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating tags - Running...`, + ); + await this.migrateTags(queryRunner); + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating tags - Completed`, + ); + currentStep++; + + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating genres - Running...`, + ); + await this.migrateGenres(queryRunner); + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating genres - Completed`, + ); + currentStep++; + + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating developers - Running...`, + ); + await this.migrateDevelopers(queryRunner); + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating developers - Completed`, + ); + currentStep++; + + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating publishers - Running...`, + ); + await this.migratePublishers(queryRunner); + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating publishers - Completed`, + ); + currentStep++; + + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating games - Running...`, + ); + await this.migrateGames(queryRunner); + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating games - Completed`, + ); + currentStep++; + + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating users and bookmarks - Running...`, + ); + await this.migrateUsersAndBookmarks(queryRunner); + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating users and bookmarks - Completed`, + ); + currentStep++; + + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating progresses - Running...`, + ); + await this.migrateProgresses(queryRunner); + this.logger.log( + `Sub-Step ${currentStep}/${totalSteps}: Migrating progresses - Completed`, + ); + } + + private async part4_enable_auto_increment(queryRunner: QueryRunner) { + await queryRunner.query(` + DROP INDEX "IDX_907a95c00ab6d81140c1a1b4a3" + `); + await queryRunner.query(` + DROP INDEX "IDX_54a35803b834868362fa4c2629" + `); + await queryRunner.query(` + DROP INDEX "IDX_039ad5528f914321b2fc6b1fff" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_developer" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_039ad5528f914321b2fc6b1fffc" UNIQUE ("rawg_id"), + CONSTRAINT "UQ_54a35803b834868362fa4c26290" UNIQUE ("name") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_developer"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_developer" + `); + await queryRunner.query(` + DROP TABLE "v12_developer" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_developer" + RENAME TO "v12_developer" + `); + await queryRunner.query(` + CREATE INDEX "IDX_907a95c00ab6d81140c1a1b4a3" ON "v12_developer" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_54a35803b834868362fa4c2629" ON "v12_developer" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_039ad5528f914321b2fc6b1fff" ON "v12_developer" ("rawg_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_cf2ba84ceb90f80049fce15995" + `); + await queryRunner.query(` + DROP INDEX "IDX_8a0e8d0364e3637f00d655af94" + `); + await queryRunner.query(` + DROP INDEX "IDX_888c3736e64117aba956e90f65" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_genre" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_888c3736e64117aba956e90f658" UNIQUE ("rawg_id"), + CONSTRAINT "UQ_8a0e8d0364e3637f00d655af947" UNIQUE ("name") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_genre"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_genre" + `); + await queryRunner.query(` + DROP TABLE "v12_genre" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_genre" + RENAME TO "v12_genre" + `); + await queryRunner.query(` + CREATE INDEX "IDX_cf2ba84ceb90f80049fce15995" ON "v12_genre" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_8a0e8d0364e3637f00d655af94" ON "v12_genre" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_888c3736e64117aba956e90f65" ON "v12_genre" ("rawg_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_f2f05b756501810d84eea1d651" + `); + await queryRunner.query(` + DROP INDEX "IDX_2263bfd2f8ed59b0f54f6d3ae9" + `); + await queryRunner.query(` + DROP INDEX "IDX_ba10ea475597187820c3b4fd28" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_publisher" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_ba10ea475597187820c3b4fd281" UNIQUE ("rawg_id"), + CONSTRAINT "UQ_2263bfd2f8ed59b0f54f6d3ae99" UNIQUE ("name") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_publisher"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_publisher" + `); + await queryRunner.query(` + DROP TABLE "v12_publisher" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_publisher" + RENAME TO "v12_publisher" + `); + await queryRunner.query(` + CREATE INDEX "IDX_f2f05b756501810d84eea1d651" ON "v12_publisher" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_2263bfd2f8ed59b0f54f6d3ae9" ON "v12_publisher" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_ba10ea475597187820c3b4fd28" ON "v12_publisher" ("rawg_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_e2db9da8c8288f3ff795994d4d" + `); + await queryRunner.query(` + DROP INDEX "IDX_6695d0cc38a598edd65fcba0ee" + `); + await queryRunner.query(` + DROP INDEX "IDX_4a2e62473659b6263b17a5497c" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_store" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_4a2e62473659b6263b17a5497c3" UNIQUE ("rawg_id"), + CONSTRAINT "UQ_6695d0cc38a598edd65fcba0ee4" UNIQUE ("name") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_store"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_store" + `); + await queryRunner.query(` + DROP TABLE "v12_store" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_store" + RENAME TO "v12_store" + `); + await queryRunner.query(` + CREATE INDEX "IDX_e2db9da8c8288f3ff795994d4d" ON "v12_store" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_6695d0cc38a598edd65fcba0ee" ON "v12_store" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_4a2e62473659b6263b17a5497c" ON "v12_store" ("rawg_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_0e129f8ad40f587596e0f8d8ff" + `); + await queryRunner.query(` + DROP INDEX "IDX_636a93cb92150e4660bf07a3bc" + `); + await queryRunner.query(` + DROP INDEX "IDX_b60ff4525bb354df761a2eba44" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_tag" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_b60ff4525bb354df761a2eba441" UNIQUE ("rawg_id"), + CONSTRAINT "UQ_636a93cb92150e4660bf07a3bc1" UNIQUE ("name") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_tag"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_tag" + `); + await queryRunner.query(` + DROP TABLE "v12_tag" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_tag" + RENAME TO "v12_tag" + `); + await queryRunner.query(` + CREATE INDEX "IDX_0e129f8ad40f587596e0f8d8ff" ON "v12_tag" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_636a93cb92150e4660bf07a3bc" ON "v12_tag" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_b60ff4525bb354df761a2eba44" ON "v12_tag" ("rawg_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_3741d615695a161ffc5a41e748" + `); + await queryRunner.query(` + DROP INDEX "IDX_2b99b13a4b75f1396c49990e6d" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_developers_developer_metadata" ( + "game_metadata_id" integer NOT NULL, + "developer_metadata_id" integer NOT NULL, + PRIMARY KEY ("game_metadata_id", "developer_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_developers_developer_metadata"("game_metadata_id", "developer_metadata_id") + SELECT "game_metadata_id", + "developer_metadata_id" + FROM "game_metadata_developers_developer_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_developers_developer_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_developers_developer_metadata" + RENAME TO "game_metadata_developers_developer_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_3741d615695a161ffc5a41e748" ON "game_metadata_developers_developer_metadata" ("developer_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_2b99b13a4b75f1396c49990e6d" ON "game_metadata_developers_developer_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + DROP INDEX "UQ_DEVELOPER_METADATA" + `); + await queryRunner.query(` + DROP INDEX "IDX_16b10ff59b57ea2b920ccdec2d" + `); + await queryRunner.query(` + DROP INDEX "IDX_414ccae60b54eb1580bca0c28f" + `); + await queryRunner.query(` + DROP INDEX "IDX_8d642e3a72cb76d343639c3281" + `); + await queryRunner.query(` + DROP INDEX "IDX_3797936110f483ab684d700e48" + `); + await queryRunner.query(` + CREATE TABLE "temporary_developer_metadata" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "provider_slug" varchar NOT NULL, + "provider_data_id" varchar NOT NULL, + "name" varchar NOT NULL + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_developer_metadata"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "provider_slug", + "provider_data_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "provider_slug", + "provider_data_id", + "name" + FROM "developer_metadata" + `); + await queryRunner.query(` + DROP TABLE "developer_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_developer_metadata" + RENAME TO "developer_metadata" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_DEVELOPER_METADATA" ON "developer_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_16b10ff59b57ea2b920ccdec2d" ON "developer_metadata" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_414ccae60b54eb1580bca0c28f" ON "developer_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_8d642e3a72cb76d343639c3281" ON "developer_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_3797936110f483ab684d700e48" ON "developer_metadata" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_0482ce35adf40c9128eaa1ae89" + `); + await queryRunner.query(` + DROP INDEX "IDX_c7d2d3ca1a28eab7d55e99ff24" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_genres_genre_metadata" ( + "game_metadata_id" integer NOT NULL, + "genre_metadata_id" integer NOT NULL, + PRIMARY KEY ("game_metadata_id", "genre_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_genres_genre_metadata"("game_metadata_id", "genre_metadata_id") + SELECT "game_metadata_id", + "genre_metadata_id" + FROM "game_metadata_genres_genre_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_genres_genre_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_genres_genre_metadata" + RENAME TO "game_metadata_genres_genre_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_0482ce35adf40c9128eaa1ae89" ON "game_metadata_genres_genre_metadata" ("genre_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_c7d2d3ca1a28eab7d55e99ff24" ON "game_metadata_genres_genre_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + DROP INDEX "UQ_GENRE_METADATA" + `); + await queryRunner.query(` + DROP INDEX "IDX_bf40614141adff790cb659c902" + `); + await queryRunner.query(` + DROP INDEX "IDX_7258256a052ef3ff3e882fa471" + `); + await queryRunner.query(` + DROP INDEX "IDX_bcbc44cdfbf2977f55c52651aa" + `); + await queryRunner.query(` + DROP INDEX "IDX_ab9cd344970e9df47d3d6c8b5b" + `); + await queryRunner.query(` + CREATE TABLE "temporary_genre_metadata" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "provider_slug" varchar NOT NULL, + "provider_data_id" varchar NOT NULL, + "name" varchar NOT NULL + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_genre_metadata"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "provider_slug", + "provider_data_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "provider_slug", + "provider_data_id", + "name" + FROM "genre_metadata" + `); + await queryRunner.query(` + DROP TABLE "genre_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_genre_metadata" + RENAME TO "genre_metadata" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_GENRE_METADATA" ON "genre_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_bf40614141adff790cb659c902" ON "genre_metadata" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_7258256a052ef3ff3e882fa471" ON "genre_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_bcbc44cdfbf2977f55c52651aa" ON "genre_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_ab9cd344970e9df47d3d6c8b5b" ON "genre_metadata" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_71ffc2cb90c863a5c225efa295" + `); + await queryRunner.query(` + DROP INDEX "IDX_6d9f174cdbce41bb5b934271a9" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_publishers_publisher_metadata" ( + "game_metadata_id" integer NOT NULL, + "publisher_metadata_id" integer NOT NULL, + PRIMARY KEY ("game_metadata_id", "publisher_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_publishers_publisher_metadata"("game_metadata_id", "publisher_metadata_id") + SELECT "game_metadata_id", + "publisher_metadata_id" + FROM "game_metadata_publishers_publisher_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_publishers_publisher_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_publishers_publisher_metadata" + RENAME TO "game_metadata_publishers_publisher_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_71ffc2cb90c863a5c225efa295" ON "game_metadata_publishers_publisher_metadata" ("publisher_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_6d9f174cdbce41bb5b934271a9" ON "game_metadata_publishers_publisher_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + DROP INDEX "UQ_PUBLISHER_METADATA" + `); + await queryRunner.query(` + DROP INDEX "IDX_73c3afaa08bae7e58471e83c8e" + `); + await queryRunner.query(` + DROP INDEX "IDX_e9ec06cab4b92d64ba257b4eed" + `); + await queryRunner.query(` + DROP INDEX "IDX_16f6954549be1a71c53654c939" + `); + await queryRunner.query(` + DROP INDEX "IDX_73e957f8e68ba1111ac3b79adc" + `); + await queryRunner.query(` + CREATE TABLE "temporary_publisher_metadata" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "provider_slug" varchar NOT NULL, + "provider_data_id" varchar NOT NULL, + "name" varchar NOT NULL + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_publisher_metadata"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "provider_slug", + "provider_data_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "provider_slug", + "provider_data_id", + "name" + FROM "publisher_metadata" + `); + await queryRunner.query(` + DROP TABLE "publisher_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_publisher_metadata" + RENAME TO "publisher_metadata" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_PUBLISHER_METADATA" ON "publisher_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_73c3afaa08bae7e58471e83c8e" ON "publisher_metadata" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_e9ec06cab4b92d64ba257b4eed" ON "publisher_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_16f6954549be1a71c53654c939" ON "publisher_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_73e957f8e68ba1111ac3b79adc" ON "publisher_metadata" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_a4f3fec63ccb14d466924a11ef" + `); + await queryRunner.query(` + DROP INDEX "IDX_f6c8361e5e167251a06355c168" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_tags_tag_metadata" ( + "game_metadata_id" integer NOT NULL, + "tag_metadata_id" integer NOT NULL, + PRIMARY KEY ("game_metadata_id", "tag_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_tags_tag_metadata"("game_metadata_id", "tag_metadata_id") + SELECT "game_metadata_id", + "tag_metadata_id" + FROM "game_metadata_tags_tag_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_tags_tag_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_tags_tag_metadata" + RENAME TO "game_metadata_tags_tag_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_a4f3fec63ccb14d466924a11ef" ON "game_metadata_tags_tag_metadata" ("tag_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_f6c8361e5e167251a06355c168" ON "game_metadata_tags_tag_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + DROP INDEX "UQ_TAG_METADATA" + `); + await queryRunner.query(` + DROP INDEX "IDX_a5f8eb5e083ca5fb83cd152777" + `); + await queryRunner.query(` + DROP INDEX "IDX_a1b923a5cf28e468500e7e0b59" + `); + await queryRunner.query(` + DROP INDEX "IDX_d914734a79b8145479a748d0a5" + `); + await queryRunner.query(` + DROP INDEX "IDX_96d7cccf17f8cb2cfa25388cbd" + `); + await queryRunner.query(` + CREATE TABLE "temporary_tag_metadata" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "provider_slug" varchar NOT NULL, + "provider_data_id" varchar NOT NULL, + "name" varchar NOT NULL + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_tag_metadata"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "provider_slug", + "provider_data_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "provider_slug", + "provider_data_id", + "name" + FROM "tag_metadata" + `); + await queryRunner.query(` + DROP TABLE "tag_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_tag_metadata" + RENAME TO "tag_metadata" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_TAG_METADATA" ON "tag_metadata" ("provider_slug", "provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_a5f8eb5e083ca5fb83cd152777" ON "tag_metadata" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_a1b923a5cf28e468500e7e0b59" ON "tag_metadata" ("provider_data_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_d914734a79b8145479a748d0a5" ON "tag_metadata" ("provider_slug") + `); + await queryRunner.query(` + CREATE INDEX "IDX_96d7cccf17f8cb2cfa25388cbd" ON "tag_metadata" ("id") + `); + await queryRunner.query(` + DROP INDEX "IDX_907a95c00ab6d81140c1a1b4a3" + `); + await queryRunner.query(` + DROP INDEX "IDX_54a35803b834868362fa4c2629" + `); + await queryRunner.query(` + DROP INDEX "IDX_039ad5528f914321b2fc6b1fff" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_developer" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_039ad5528f914321b2fc6b1fffc" UNIQUE ("rawg_id"), + CONSTRAINT "UQ_54a35803b834868362fa4c26290" UNIQUE ("name") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_developer"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_developer" + `); + await queryRunner.query(` + DROP TABLE "v12_developer" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_developer" + RENAME TO "v12_developer" + `); + await queryRunner.query(` + CREATE INDEX "IDX_907a95c00ab6d81140c1a1b4a3" ON "v12_developer" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_54a35803b834868362fa4c2629" ON "v12_developer" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_039ad5528f914321b2fc6b1fff" ON "v12_developer" ("rawg_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_cf2ba84ceb90f80049fce15995" + `); + await queryRunner.query(` + DROP INDEX "IDX_8a0e8d0364e3637f00d655af94" + `); + await queryRunner.query(` + DROP INDEX "IDX_888c3736e64117aba956e90f65" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_genre" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_888c3736e64117aba956e90f658" UNIQUE ("rawg_id"), + CONSTRAINT "UQ_8a0e8d0364e3637f00d655af947" UNIQUE ("name") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_genre"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_genre" + `); + await queryRunner.query(` + DROP TABLE "v12_genre" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_genre" + RENAME TO "v12_genre" + `); + await queryRunner.query(` + CREATE INDEX "IDX_cf2ba84ceb90f80049fce15995" ON "v12_genre" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_8a0e8d0364e3637f00d655af94" ON "v12_genre" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_888c3736e64117aba956e90f65" ON "v12_genre" ("rawg_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_f2f05b756501810d84eea1d651" + `); + await queryRunner.query(` + DROP INDEX "IDX_2263bfd2f8ed59b0f54f6d3ae9" + `); + await queryRunner.query(` + DROP INDEX "IDX_ba10ea475597187820c3b4fd28" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_publisher" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_ba10ea475597187820c3b4fd281" UNIQUE ("rawg_id"), + CONSTRAINT "UQ_2263bfd2f8ed59b0f54f6d3ae99" UNIQUE ("name") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_publisher"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_publisher" + `); + await queryRunner.query(` + DROP TABLE "v12_publisher" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_publisher" + RENAME TO "v12_publisher" + `); + await queryRunner.query(` + CREATE INDEX "IDX_f2f05b756501810d84eea1d651" ON "v12_publisher" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_2263bfd2f8ed59b0f54f6d3ae9" ON "v12_publisher" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_ba10ea475597187820c3b4fd28" ON "v12_publisher" ("rawg_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_e2db9da8c8288f3ff795994d4d" + `); + await queryRunner.query(` + DROP INDEX "IDX_6695d0cc38a598edd65fcba0ee" + `); + await queryRunner.query(` + DROP INDEX "IDX_4a2e62473659b6263b17a5497c" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_store" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_4a2e62473659b6263b17a5497c3" UNIQUE ("rawg_id"), + CONSTRAINT "UQ_6695d0cc38a598edd65fcba0ee4" UNIQUE ("name") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_store"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_store" + `); + await queryRunner.query(` + DROP TABLE "v12_store" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_store" + RENAME TO "v12_store" + `); + await queryRunner.query(` + CREATE INDEX "IDX_e2db9da8c8288f3ff795994d4d" ON "v12_store" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_6695d0cc38a598edd65fcba0ee" ON "v12_store" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_4a2e62473659b6263b17a5497c" ON "v12_store" ("rawg_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_0e129f8ad40f587596e0f8d8ff" + `); + await queryRunner.query(` + DROP INDEX "IDX_636a93cb92150e4660bf07a3bc" + `); + await queryRunner.query(` + DROP INDEX "IDX_b60ff4525bb354df761a2eba44" + `); + await queryRunner.query(` + CREATE TABLE "temporary_v12_tag" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "created_at" datetime NOT NULL DEFAULT (datetime('now')), + "updated_at" datetime NOT NULL DEFAULT (datetime('now')), + "deleted_at" datetime, + "entity_version" integer NOT NULL, + "rawg_id" integer, + "name" varchar NOT NULL, + CONSTRAINT "UQ_b60ff4525bb354df761a2eba441" UNIQUE ("rawg_id"), + CONSTRAINT "UQ_636a93cb92150e4660bf07a3bc1" UNIQUE ("name") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_v12_tag"( + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + ) + SELECT "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "rawg_id", + "name" + FROM "v12_tag" + `); + await queryRunner.query(` + DROP TABLE "v12_tag" + `); + await queryRunner.query(` + ALTER TABLE "temporary_v12_tag" + RENAME TO "v12_tag" + `); + await queryRunner.query(` + CREATE INDEX "IDX_0e129f8ad40f587596e0f8d8ff" ON "v12_tag" ("id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_636a93cb92150e4660bf07a3bc" ON "v12_tag" ("name") + `); + await queryRunner.query(` + CREATE INDEX "IDX_b60ff4525bb354df761a2eba44" ON "v12_tag" ("rawg_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_71ffc2cb90c863a5c225efa295" + `); + await queryRunner.query(` + DROP INDEX "IDX_6d9f174cdbce41bb5b934271a9" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_publishers_publisher_metadata" ( + "game_metadata_id" integer NOT NULL, + "publisher_metadata_id" integer NOT NULL, + CONSTRAINT "FK_6d9f174cdbce41bb5b934271a9b" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_71ffc2cb90c863a5c225efa2950" FOREIGN KEY ("publisher_metadata_id") REFERENCES "publisher_metadata" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY ("game_metadata_id", "publisher_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_publishers_publisher_metadata"("game_metadata_id", "publisher_metadata_id") + SELECT "game_metadata_id", + "publisher_metadata_id" + FROM "game_metadata_publishers_publisher_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_publishers_publisher_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_publishers_publisher_metadata" + RENAME TO "game_metadata_publishers_publisher_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_71ffc2cb90c863a5c225efa295" ON "game_metadata_publishers_publisher_metadata" ("publisher_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_6d9f174cdbce41bb5b934271a9" ON "game_metadata_publishers_publisher_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_3741d615695a161ffc5a41e748" + `); + await queryRunner.query(` + DROP INDEX "IDX_2b99b13a4b75f1396c49990e6d" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_developers_developer_metadata" ( + "game_metadata_id" integer NOT NULL, + "developer_metadata_id" integer NOT NULL, + CONSTRAINT "FK_2b99b13a4b75f1396c49990e6de" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_3741d615695a161ffc5a41e748c" FOREIGN KEY ("developer_metadata_id") REFERENCES "developer_metadata" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY ("game_metadata_id", "developer_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_developers_developer_metadata"("game_metadata_id", "developer_metadata_id") + SELECT "game_metadata_id", + "developer_metadata_id" + FROM "game_metadata_developers_developer_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_developers_developer_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_developers_developer_metadata" + RENAME TO "game_metadata_developers_developer_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_3741d615695a161ffc5a41e748" ON "game_metadata_developers_developer_metadata" ("developer_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_2b99b13a4b75f1396c49990e6d" ON "game_metadata_developers_developer_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_a4f3fec63ccb14d466924a11ef" + `); + await queryRunner.query(` + DROP INDEX "IDX_f6c8361e5e167251a06355c168" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_tags_tag_metadata" ( + "game_metadata_id" integer NOT NULL, + "tag_metadata_id" integer NOT NULL, + CONSTRAINT "FK_f6c8361e5e167251a06355c168a" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_a4f3fec63ccb14d466924a11efc" FOREIGN KEY ("tag_metadata_id") REFERENCES "tag_metadata" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY ("game_metadata_id", "tag_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_tags_tag_metadata"("game_metadata_id", "tag_metadata_id") + SELECT "game_metadata_id", + "tag_metadata_id" + FROM "game_metadata_tags_tag_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_tags_tag_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_tags_tag_metadata" + RENAME TO "game_metadata_tags_tag_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_a4f3fec63ccb14d466924a11ef" ON "game_metadata_tags_tag_metadata" ("tag_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_f6c8361e5e167251a06355c168" ON "game_metadata_tags_tag_metadata" ("game_metadata_id") + `); + await queryRunner.query(` + DROP INDEX "IDX_0482ce35adf40c9128eaa1ae89" + `); + await queryRunner.query(` + DROP INDEX "IDX_c7d2d3ca1a28eab7d55e99ff24" + `); + await queryRunner.query(` + CREATE TABLE "temporary_game_metadata_genres_genre_metadata" ( + "game_metadata_id" integer NOT NULL, + "genre_metadata_id" integer NOT NULL, + CONSTRAINT "FK_c7d2d3ca1a28eab7d55e99ff24b" FOREIGN KEY ("game_metadata_id") REFERENCES "game_metadata" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "FK_0482ce35adf40c9128eaa1ae894" FOREIGN KEY ("genre_metadata_id") REFERENCES "genre_metadata" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, + PRIMARY KEY ("game_metadata_id", "genre_metadata_id") + ) + `); + await queryRunner.query(` + INSERT INTO "temporary_game_metadata_genres_genre_metadata"("game_metadata_id", "genre_metadata_id") + SELECT "game_metadata_id", + "genre_metadata_id" + FROM "game_metadata_genres_genre_metadata" + `); + await queryRunner.query(` + DROP TABLE "game_metadata_genres_genre_metadata" + `); + await queryRunner.query(` + ALTER TABLE "temporary_game_metadata_genres_genre_metadata" + RENAME TO "game_metadata_genres_genre_metadata" + `); + await queryRunner.query(` + CREATE INDEX "IDX_0482ce35adf40c9128eaa1ae89" ON "game_metadata_genres_genre_metadata" ("genre_metadata_id") + `); + await queryRunner.query(` + CREATE INDEX "IDX_c7d2d3ca1a28eab7d55e99ff24" ON "game_metadata_genres_genre_metadata" ("game_metadata_id") + `); + } + private async part5_delete_old_tables(queryRunner: QueryRunner) { + await queryRunner.query(`DROP TABLE IF EXISTS "v12_bookmark"`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_developer"`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_game"`); + await queryRunner.query( + `DROP TABLE IF EXISTS "v12_game_developers_v12_developer"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_game_genres_v12_genre"`); + await queryRunner.query( + `DROP TABLE IF EXISTS "v12_game_publishers_v12_publisher"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_game_stores_v12_store"`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_game_tags_v12_tag"`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_gamevault_user"`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_genre"`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_image"`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_progress"`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_publisher"`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_store"`); + await queryRunner.query(`DROP TABLE IF EXISTS "v12_tag"`); + } + + private async part6_sorting_title(queryRunner: QueryRunner) { + const gameRepository = queryRunner.manager.getRepository("gamevault_game"); + + // Fetch all games + const games = await gameRepository.find({ + withDeleted: true, + select: ["id", "title"], + }); + + // Update each game with the new sort_title + for (const game of games) { + const sortTitle = this.createSortTitle(game.title); // Apply the sorting function + await gameRepository.update(game.id, { sort_title: sortTitle }); + } + } + + private createSortTitle(title: string): string { + // List of leading articles to be removed + const articles: string[] = ["the", "a", "an"]; + + // Convert the title to lowercase + let sortTitle: string = toLower(title).trim(); + + // Remove any leading article + for (const article of articles) { + const articleWithSpace = `${article} `; + if (sortTitle.startsWith(articleWithSpace)) { + sortTitle = sortTitle.substring(articleWithSpace.length); + break; + } + } + + // Remove special characters except alphanumeric and spaces + // Replace multiple spaces with a single space and trim + return sortTitle + .replace(/[^a-z0-9\s]/g, "") + .replace(/\s+/g, " ") + .trim(); + } + + private async migrateImages(queryRunner: QueryRunner): Promise { + const images = await queryRunner.manager.find(ImageV12, { + withDeleted: true, + }); + this.logger.log({ + message: `Found ${images.length} images in the V12 database.`, + }); + + for (const image of images) { + this.logger.log({ + message: `Migrating image ID ${image.id}, Source: ${image.source}`, + }); + + const newImage = await queryRunner.manager.save(Media, { + id: image.id, + source_url: image.source, + file_path: image.path.replace("/images/", "/media/"), + type: image.mediaType ?? "application/octet-stream", + uploader: image.uploader, + created_at: image.created_at, + updated_at: image.updated_at, + deleted_at: image.deleted_at, + entity_version: image.entity_version, + }); + + this.logger.log({ message: "Migrated Image Successfully.", newImage }); + } + } + + private async migrateTags(queryRunner: QueryRunner): Promise { + const tags = uniqBy( + await queryRunner.manager.find(TagV12, { withDeleted: true }), + "rawg_id", + ); + this.logger.log({ + message: `Found ${tags.length} tags in the V12 database.`, + }); + + for (const tag of tags) { + this.logger.log({ + message: `Migrating tag ID ${tag.id}, Name: ${tag.name}`, + }); + + const newTag = await queryRunner.manager.save(TagMetadata, { + id: tag.id, + provider_slug: this.legacyProviderSlug, + provider_data_id: tag.rawg_id.toString(), + name: tag.name, + created_at: tag.created_at, + updated_at: tag.updated_at, + deleted_at: tag.deleted_at, + entity_version: tag.entity_version, + }); + + this.logger.log({ message: "Migrated Tag Successfully.", newTag }); + } + } + + private async migrateGenres(queryRunner: QueryRunner): Promise { + const genres = uniqBy( + await queryRunner.manager.find(GenreV12, { + withDeleted: true, + }), + "rawg_id", + ); + this.logger.log({ + message: `Found ${genres.length} genres in the V12 database.`, + }); + + for (const genre of genres) { + this.logger.log({ + message: `Migrating genre ID ${genre.id}, Name: ${genre.name}`, + }); + + const newGenre = await queryRunner.manager.save(GenreMetadata, { + id: genre.id, + provider_slug: this.legacyProviderSlug, + provider_data_id: genre.rawg_id.toString(), + name: genre.name, + created_at: genre.created_at, + updated_at: genre.updated_at, + deleted_at: genre.deleted_at, + entity_version: genre.entity_version, + }); + + this.logger.log({ message: "Migrated Genre Successfully.", newGenre }); + } + } + + private async migrateDevelopers(queryRunner: QueryRunner): Promise { + const developers = uniqBy( + await queryRunner.manager.find(DeveloperV12, { + withDeleted: true, + }), + "rawg_id", + ); + this.logger.log({ + message: `Found ${developers.length} developers in the V12 database.`, + }); + + for (const developer of developers) { + this.logger.log({ + message: `Migrating developer ID ${developer.id}, Name: ${developer.name}`, + }); + + const newDeveloper = await queryRunner.manager.save(DeveloperMetadata, { + id: developer.id, + provider_slug: this.legacyProviderSlug, + provider_data_id: developer.rawg_id.toString(), + name: developer.name, + created_at: developer.created_at, + updated_at: developer.updated_at, + deleted_at: developer.deleted_at, + entity_version: developer.entity_version, + }); + + this.logger.log({ + message: "Migrated Developer Successfully.", + newDeveloper, + }); + } + } + + private async migratePublishers(queryRunner: QueryRunner): Promise { + const publishers = uniqBy( + await queryRunner.manager.find(PublisherV12, { + withDeleted: true, + }), + "rawg_id", + ); + this.logger.log({ + message: `Found ${publishers.length} publishers in the V12 database.`, + }); + + for (const publisher of publishers) { + this.logger.log({ + message: `Migrating publisher ID ${publisher.id}, Name: ${publisher.name}`, + }); + + const newPublisher = await queryRunner.manager.save(PublisherMetadata, { + id: publisher.id, + provider_slug: this.legacyProviderSlug, + provider_data_id: publisher.rawg_id.toString(), + name: publisher.name, + created_at: publisher.created_at, + updated_at: publisher.updated_at, + deleted_at: publisher.deleted_at, + entity_version: publisher.entity_version, + }); + this.logger.log({ + message: "Migrated Publisher Successfully.", + newPublisher, + }); + } + } + + private async migrateGames(queryRunner: QueryRunner): Promise { + const games = await queryRunner.manager.find(GameV12, { + relations: [ + "box_image", + "background_image", + "publishers", + "developers", + "tags", + "genres", + ], + withDeleted: true, + relationLoadStrategy: "query", + }); + this.logger.log({ + message: `Found ${games.length} games in the V12 database.`, + }); + + for (const game of games) { + this.logger.log({ + message: `Migrating game ID ${game.id}, Title: ${game.title}`, + }); + + const migratedGame = await queryRunner.manager.save(GamevaultGame, { + id: game.id, + file_path: game.file_path, + size: game.size, + title: game.title, + version: game.version, + release_date: game.release_date, + early_access: game.early_access, + download_count: 0, + type: game.type, + created_at: game.created_at, + updated_at: game.updated_at, + deleted_at: game.deleted_at, + entity_version: game.entity_version, + }); + + const cover = game.box_image + ? await queryRunner.manager.findOne(Media, { + where: { id: game.box_image.id }, + withDeleted: true, + }) + : undefined; + if (cover) + this.logger.log({ message: `Linked cover image, ID: ${cover?.id}` }); + + const background = game.background_image + ? await queryRunner.manager.findOne(Media, { + where: { id: game.background_image.id }, + withDeleted: true, + }) + : undefined; + if (background) + this.logger.log({ + message: `Linked background image, ID: ${background?.id}`, + }); + + if (!game.rawg_id) { + if (cover || background) { + const userMetadata = await queryRunner.manager.save(GameMetadata, { + provider_slug: "user", + provider_data_id: game.id?.toString(), + cover, + background, + }); + migratedGame.user_metadata = userMetadata; + await queryRunner.manager.save(GamevaultGame, migratedGame); + this.logger.log({ + message: `User metadata saved successfully. Metadata ID: ${userMetadata.id}, Game ID: ${game.id}, Title: ${userMetadata.title}`, + }); + continue; + } + + this.logger.log({ + message: `No rawg_id or custom images found. Skipping metadata for game ID: ${game.id}.`, + }); + continue; + } + + const tags = game.tags?.length + ? await queryRunner.manager.findBy(TagMetadata, { + provider_slug: this.legacyProviderSlug, + provider_data_id: In(game.tags.map((t) => t.rawg_id)), + }) + : []; + this.logger.log({ + message: `Linked ${tags.length} tags for game ID: ${game.id}`, + }); + + const genres = game.genres?.length + ? await queryRunner.manager.findBy(GenreMetadata, { + provider_slug: this.legacyProviderSlug, + provider_data_id: In(game.genres.map((g) => g.rawg_id)), + }) + : []; + this.logger.log({ + message: `Linked ${genres.length} genres for game ID: ${game.id}`, + }); + + const developers = game.developers?.length + ? await queryRunner.manager.findBy(DeveloperMetadata, { + provider_slug: this.legacyProviderSlug, + provider_data_id: In(game.developers.map((d) => d.rawg_id)), + }) + : []; + this.logger.log({ + message: `Linked ${developers.length} developers for game ID: ${game.id}`, + }); + + const publishers = game.publishers?.length + ? await queryRunner.manager.findBy(PublisherMetadata, { + provider_slug: this.legacyProviderSlug, + provider_data_id: In(game.publishers.map((p) => p.rawg_id)), + }) + : []; + this.logger.log({ + message: `Linked ${publishers.length} publishers for game ID: ${game.id}`, + }); + + const existingMetadata = await queryRunner.manager.findOneBy( + GameMetadata, + { + provider_slug: this.legacyProviderSlug, + provider_data_id: game.rawg_id.toString(), + }, + ); + + if (existingMetadata) { + this.logger.log({ + message: `Rawg Metadata already exists for game ID ${game.id}. Linking...`, + }); + migratedGame.provider_metadata = [existingMetadata]; + } else { + const gameMetadata = await queryRunner.manager.save(GameMetadata, { + provider_slug: this.legacyProviderSlug, + provider_data_id: game.rawg_id.toString(), + title: game.rawg_title, + release_date: game.rawg_release_date, + description: game.description, + average_playtime: game.average_playtime, + cover, + background, + url_websites: [game.website_url], + rating: game.metacritic_rating, + early_access: game.early_access, + tags, + genres, + developers, + publishers, + }); + migratedGame.provider_metadata = [gameMetadata]; + this.logger.log({ + message: `New Game metadata saved successfully. Metadata ID: ${gameMetadata.id}, Title: ${gameMetadata.title}`, + }); + } + + const savedGame = await queryRunner.manager.save( + GamevaultGame, + migratedGame, + ); + this.logger.log({ message: "Migrated Game Successfully.", savedGame }); + } + } + + private async migrateUsersAndBookmarks( + queryRunner: QueryRunner, + ): Promise { + const users = await queryRunner.manager.find(GamevaultUserV12, { + withDeleted: true, + select: [ + "id", + "created_at", + "updated_at", + "deleted_at", + "entity_version", + "username", + "password", + "socket_secret", + "profile_picture", + "background_image", + "email", + "first_name", + "last_name", + "activated", + "role", + "bookmarked_games", + ], + relations: ["profile_picture", "background_image", "bookmarked_games"], + relationLoadStrategy: "query", + }); + this.logger.log({ + message: `Found ${users.length} users in the V12 database.`, + }); + + for (const user of users) { + this.logger.log({ + message: `Migrating user ID ${user.id}, Username: ${user.username}`, + }); + + const avatar = user.profile_picture + ? await queryRunner.manager.findOne(Media, { + where: { id: user.profile_picture.id }, + withDeleted: true, + }) + : undefined; + if (avatar) + this.logger.log({ + message: `Linked avatar image, ID: ${avatar?.id}`, + }); + + const background = user.background_image + ? await queryRunner.manager.findOne(Media, { + where: { id: user.background_image.id }, + withDeleted: true, + }) + : undefined; + if (background) + this.logger.log({ + message: `Linked background image, ID: ${background?.id}`, + }); + + const bookmarkedGames = user.bookmarked_games?.length + ? await queryRunner.manager.findBy(GamevaultGame, { + id: In(user.bookmarked_games.map((b) => b.id)), + }) + : []; + + const newUser = await queryRunner.manager.save(GamevaultUser, { + id: user.id, + username: user.username, + password: user.password, + socket_secret: user.socket_secret ?? randomBytes(32).toString("hex"), + avatar, + background, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + birth_date: undefined, + activated: user.activated, + role: user.role.valueOf(), + bookmarked_games: bookmarkedGames, + created_at: user.created_at, + updated_at: user.updated_at, + deleted_at: user.deleted_at, + entity_version: user.entity_version, + }); + + this.logger.log({ message: "Migrated User Successfully.", newUser }); + } + } + + private async migrateProgresses(queryRunner: QueryRunner): Promise { + const progresses = await queryRunner.manager.find(ProgressV12, { + withDeleted: true, + loadEagerRelations: true, + relations: ["user", "game"], + relationLoadStrategy: "query", + }); + this.logger.log({ + message: `Found ${progresses.length} progresses in the V12 database.`, + }); + + for (const progress of progresses) { + this.logger.log({ + message: `Migrating progress ID ${progress.id}, User: ${progress.user?.id}, Game: ${progress.game?.id}`, + }); + + const user = progress.user + ? await queryRunner.manager.findOne(GamevaultUser, { + where: { id: progress.user.id }, + withDeleted: true, + }) + : undefined; + + const game = progress.game + ? await queryRunner.manager.findOne(GamevaultGame, { + where: { id: progress.game.id }, + withDeleted: true, + }) + : undefined; + + const newProgress = await queryRunner.manager.save(Progress, { + id: progress.id, + user, + game, + minutes_played: progress.minutes_played, + state: State[progress.state.valueOf()], + last_played_at: progress.last_played_at, + created_at: progress.created_at, + updated_at: progress.updated_at, + deleted_at: progress.deleted_at, + entity_version: progress.entity_version, + }); + + this.logger.log({ + message: "Migrated User Successfully.", + newProgress, + }); + } + } + + public async down(): Promise { + throw new NotImplementedException( + "There is no way to undo this migration.", + ); + } +} diff --git a/src/modules/developers/developers.module.ts b/src/modules/developers/developers.module.ts deleted file mode 100644 index d49955f8..00000000 --- a/src/modules/developers/developers.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { Developer } from "./developer.entity"; -import { DevelopersService } from "./developers.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([Developer])], - controllers: [], - providers: [DevelopersService], - exports: [DevelopersService], -}) -export class DevelopersModule {} diff --git a/src/modules/developers/developers.service.ts b/src/modules/developers/developers.service.ts deleted file mode 100644 index 8e95b782..00000000 --- a/src/modules/developers/developers.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Builder } from "builder-pattern"; -import { Repository } from "typeorm"; - -import { Developer } from "./developer.entity"; - -@Injectable() -export class DevelopersService { - private readonly logger = new Logger(DevelopersService.name); - constructor( - @InjectRepository(Developer) - private developerRepository: Repository, - ) {} - - /** - * Returns the developer with the specified RAWG ID, creating a new developer - * if one does not already exist. - */ - async getOrCreate(name: string, rawg_id: number): Promise { - const existingDeveloper = await this.developerRepository.findOneBy({ - name, - }); - - if (existingDeveloper) return existingDeveloper; - - const developer = await this.developerRepository.save( - Builder(Developer).name(name).rawg_id(rawg_id).build(), - ); - this.logger.log({ - message: "Created new Developer.", - developer, - }); - return developer; - } -} diff --git a/src/modules/files/files.controller.ts b/src/modules/files/files.controller.ts deleted file mode 100644 index f0ef63e4..00000000 --- a/src/modules/files/files.controller.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Controller, Put } from "@nestjs/common"; -import { - ApiBasicAuth, - ApiOkResponse, - ApiOperation, - ApiTags, -} from "@nestjs/swagger"; - -import { MinimumRole } from "../../decorators/minimum-role.decorator"; -import { Game } from "../games/game.entity"; -import { Role } from "../users/models/role.enum"; -import { FilesService } from "./files.service"; - -@ApiBasicAuth() -@ApiTags("files") -@Controller("files") -export class FilesController { - constructor(private filesService: FilesService) {} - - @Put("reindex") - @ApiOperation({ - summary: "manually triggers an index of all games", - operationId: "putFilesReindex", - }) - @ApiOkResponse({ type: () => Game, isArray: true }) - @MinimumRole(Role.ADMIN) - async putFilesReindex() { - return await this.filesService.index("Reindex API was called"); - } -} diff --git a/src/modules/files/files.module.ts b/src/modules/files/files.module.ts deleted file mode 100644 index 5ce4b07b..00000000 --- a/src/modules/files/files.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { forwardRef, Module } from "@nestjs/common"; - -import { BoxartsModule } from "../boxarts/boxarts.module"; -import { GamesModule } from "../games/games.module"; -import { RawgModule } from "../providers/rawg/rawg.module"; -import { FilesController } from "./files.controller"; -import { FilesService } from "./files.service"; - -@Module({ - imports: [ - forwardRef(() => GamesModule), - forwardRef(() => RawgModule), - forwardRef(() => BoxartsModule), - ], - controllers: [FilesController], - providers: [FilesService], - exports: [FilesService], -}) -export class FilesModule {} diff --git a/src/modules/files/files.service.spec.ts b/src/modules/files/files.service.spec.ts deleted file mode 100644 index b4a9fe5c..00000000 --- a/src/modules/files/files.service.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* -https://docs.nestjs.com/fundamentals/testing#unit-testing -*/ -import { Test } from "@nestjs/testing"; - -import { BoxArtsService } from "../boxarts/boxarts.service"; -import { GamesService } from "../games/games.service"; -import { RawgService } from "../providers/rawg/rawg.service"; -import { FilesService } from "./files.service"; - -describe("FilesService", () => { - let filesService: FilesService; - - const mockGamesService = {}; - const mockRawgService = {}; - const mockBoxartService = {}; - - beforeEach(async () => { - const moduleRef = await Test.createTestingModule({ - providers: [FilesService, GamesService, RawgService, BoxArtsService], - }) - .overrideProvider(GamesService) - .useValue(mockGamesService) - .overrideProvider(RawgService) - .useValue(mockRawgService) - .overrideProvider(BoxArtsService) - .useValue(mockBoxartService) - .compile(); - - filesService = moduleRef.get(FilesService); - }); - - it("should be defined", () => { - expect(filesService).toBeDefined(); - }); - - describe("isValidFilename", () => { - let loggerDebugSpy, loggerWarnSpy; - - beforeEach(() => { - // Create spies for logger.debug and logger.warn methods - loggerDebugSpy = jest - .spyOn(filesService["logger"], "debug") - .mockImplementation(() => {}); - loggerWarnSpy = jest - .spyOn(filesService["logger"], "warn") - .mockImplementation(() => {}); - }); - - afterEach(() => { - // Restore the original logger.debug and logger.warn methods - loggerDebugSpy.mockRestore(); - loggerWarnSpy.mockRestore(); - }); - - it("should return false for unsupported file extensions", () => { - const unsupportedFilenames = [ - "Assassin's Creed Odyssey (v1.5.3) (2018).pig", - "Red Dead Redemption 2 (v1.2) (2020).rat", - "Cyberpunk 2077 (v1.3.1) (2021).giraffe", - ]; - - unsupportedFilenames.forEach((filename) => { - expect(filesService["isValidFilename"](filename)).toBe(false); - expect(loggerDebugSpy).toHaveBeenCalled(); - }); - }); - - it("should return false for filenames with invalid characters", () => { - const invalidFilenames = [ - "DOOM: Eternal (v2.1) (2020).zip", - "The Legend of Zelda: Breath of the Wild (v1.2) (2017).7z", - "Dishonored <2> (v1.11) (2016).tar", - ]; - - invalidFilenames.forEach((filename) => { - expect(filesService["isValidFilename"](filename)).toBe(false); - expect(loggerWarnSpy).toHaveBeenCalled(); - }); - }); - - it("should return true for valid filenames", () => { - const validFilenames = [ - "Star Wars Jedi - Fallen Order (v1.0.10.0) (2019).zip", - "HITMAN 3 (v3.10.1) (2021).7z", - "!mygames/!The Wandering Village (v0.1.32) (EA) (2022).iso", - "Saints Row (W_S) (2022).zip", - "My personal IndieGame (v1.2.9) (NC) (2018).zip", - "Stray (2022).7z", - "Captain of Industry (v0.4.12b) (EA) (2022).gz", - "Minecraft (2011).exe", - ]; - - validFilenames.forEach((filename) => { - expect(filesService["isValidFilename"](filename)).toBe(true); - }); - - // Ensure logger.debug and logger.warn were not called for valid filenames - expect(loggerDebugSpy).not.toHaveBeenCalled(); - expect(loggerWarnSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/modules/files/files.service.ts b/src/modules/games/files.service.ts similarity index 66% rename from src/modules/files/files.service.ts rename to src/modules/games/files.service.ts index 5199e71e..459b0aef 100644 --- a/src/modules/files/files.service.ts +++ b/src/modules/games/files.service.ts @@ -1,220 +1,256 @@ import { Injectable, - InternalServerErrorException, Logger, NotFoundException, OnApplicationBootstrap, StreamableFile, } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; -import { watch } from "chokidar"; import { randomBytes } from "crypto"; -import { createReadStream, existsSync, statSync } from "fs"; +import { Response } from "express"; +import { Stats, createReadStream, existsSync, statSync } from "fs"; import { readdir, stat } from "fs/promises"; -import { debounce } from "lodash"; +import { debounce, toLower } from "lodash"; import mime from "mime"; import { add, list } from "node-7z"; -import path, { basename, extname, join } from "path"; +import path, { basename, join } from "path"; import filenameSanitizer from "sanitize-filename"; import { Readable } from "stream"; import { Throttle } from "stream-throttle"; import unidecode from "unidecode"; +import { watch } from "chokidar"; import configuration from "../../configuration"; import globals from "../../globals"; -import { BoxArtsService } from "../boxarts/boxarts.service"; -import { Game } from "../games/game.entity"; -import mock from "../games/games.mock"; -import { GamesService } from "../games/games.service"; -import { GameExistence } from "../games/models/game-existence.enum"; -import { GameType } from "../games/models/game-type.enum"; -import { RawgService } from "../providers/rawg/rawg.service"; +import { logGamevaultGame } from "../../logging"; +import { MetadataService } from "../metadata/metadata.service"; +import mock from "./games.mock"; +import { GamesService } from "./games.service"; +import { GamevaultGame } from "./gamevault-game.entity"; import ByteRangeStream from "./models/byte-range-stream"; -import { IGameVaultFile } from "./models/file.model"; +import { File } from "./models/file.model"; +import { GameExistence } from "./models/game-existence.enum"; +import { GameType } from "./models/game-type.enum"; import { RangeHeader } from "./models/range-header.model"; @Injectable() export class FilesService implements OnApplicationBootstrap { - private logger = new Logger(FilesService.name); + private readonly logger = new Logger(this.constructor.name); + private readonly indexJobs = new Map(); + + private readonly runDebouncedIntegrityCheck = debounce(async () => { + await this.checkIntegrity(); + }, 5000); + + private readonly runDebouncedIndex = debounce(async () => { + await this.index(Array.from(this.indexJobs.values())); + }, 5000); constructor( - private gamesService: GamesService, - private rawgService: RawgService, - private boxartService: BoxArtsService, - ) {} + private readonly gamesService: GamesService, + private readonly metadataService: MetadataService, + ) { + if (configuration.TESTING.MOCK_FILES) { + this.logger.warn({ + message: "Skipping File Indexer.", + reason: "TESTING_MOCK_FILES is set to true.", + }); + } + } onApplicationBootstrap() { - this.index("Initial indexing on application start").catch((error) => { - this.logger.error({ message: "Error in initial file indexing", error }); - }); + this.bootstrapIndexer(); + } + private async bootstrapIndexer() { if (configuration.TESTING.MOCK_FILES) { return; } - watch(configuration.VOLUMES.FILES, { depth: configuration.GAMES.SEARCH_RECURSIVE ? undefined : 0, + ignorePermissionErrors: true, }) - .on( - "all", - debounce(() => { - this.index( - `Filewatcher detected changes in '${configuration.VOLUMES.FILES}'`, - ); - }, 5000), + .on("add", (path: string, stats: Stats) => this.addIndexJob(path, stats)) + .on("change", (path: string, stats: Stats) => + this.addIndexJob(path, stats), + ) + .on("unlink", (path: string, stats: Stats) => + this.addIndexJob(path, stats), ) .on("error", (error) => { - this.logger.error({ message: "Error in Filewatcher", error }); + this.logger.error({ message: "Error in FileWatcher.", error }); }); } - @Cron(`*/${configuration.GAMES.INDEX_INTERVAL_IN_MINUTES || 60} * * * *`, { - disabled: configuration.GAMES.INDEX_INTERVAL_IN_MINUTES === 0, + private addIndexJob(path: string, stats: Stats) { + const size = BigInt(stats?.size ?? 0); + if (!path || !stats?.size) { + this.logger.warn({ + message: "Ignoring Index Job due to missing path or size.", + path, + size, + }); + return; + } + this.logger.debug({ + messsage: "Adding Index Job.", + path, + size, + }); + this.indexJobs.set(path, { path, size }); + this.runDebouncedIndex(); + } + + @Cron(`*/${configuration.GAMES.INDEX_INTERVAL_IN_MINUTES} * * * *`, { + disabled: configuration.GAMES.INDEX_INTERVAL_IN_MINUTES <= 0, }) - public async index(reason: string): Promise { - this.logger.log({ message: "Indexing games.", reason }); - const gamesInFileSystem = await this.fetch(); - await this.ingest(gamesInFileSystem); - let games = await this.gamesService.getAll(); - games = await this.checkIntegrity(gamesInFileSystem, games); - games = await this.rawgService.checkCache(games); - games = await this.boxartService.checkMultiple(games); - return games; + public async index(files?: File[]): Promise { + this.logger.log({ + message: "Indexing game(s).", + jobs: this.indexJobs.size, + }); + this.indexJobs.clear(); + const unvalidatedFilesToIngest = files ?? (await this.readAllFiles()); + + const validatedFilesToIngest = unvalidatedFilesToIngest.filter((file) => + this.isValidFilePath(file.path), + ); + + if (validatedFilesToIngest.length === 0) { + this.logger.debug({ message: "No valid files to ingest." }); + return; + } + + this.ingest(validatedFilesToIngest); } - private async ingest(gamesInFileSystem: IGameVaultFile[]): Promise { + private async ingest(files: File[]): Promise { this.logger.log({ - message: "Started ingesting games.", - gamesCount: gamesInFileSystem.length, + message: "Ingesting games.", + count: files.length, }); - for (const file of gamesInFileSystem) { - const gameToIndex = new Game(); + const updatedGames: GamevaultGame[] = []; + for (const file of files) { + const gameToIndex = new GamevaultGame(); try { gameToIndex.size = file.size; gameToIndex.file_path = `${file.path}`; gameToIndex.title = this.extractTitle(file.path); + gameToIndex.sort_title = this.gamesService.generateSortTitle( + gameToIndex.title, + ); gameToIndex.release_date = this.extractReleaseYear(file.path); gameToIndex.version = this.extractVersion(file.path); gameToIndex.early_access = this.extractEarlyAccessFlag( basename(file.path), ); // For each file, check if it already exists in the database. - const existingGameTuple: [GameExistence, Game] = + const existingGameTuple: [GameExistence, GamevaultGame] = await this.gamesService.checkIfExistsInDatabase(gameToIndex); switch (existingGameTuple[0]) { case GameExistence.EXISTS: { this.logger.debug({ message: `Identical file is already indexed in the database. Skipping it.`, - game: { - id: gameToIndex.id, - file_path: gameToIndex.file_path, - }, - existingGame: { - id: existingGameTuple[1].id, - file_path: existingGameTuple[1].file_path, - }, + game: logGamevaultGame(gameToIndex), + existingGame: logGamevaultGame(existingGameTuple[1]), }); + updatedGames.push(existingGameTuple[1]); continue; } case GameExistence.DOES_NOT_EXIST: { this.logger.debug({ message: `Indexing new file.`, - game: { - id: gameToIndex.id, - file_path: gameToIndex.file_path, - }, + game: logGamevaultGame(gameToIndex), }); gameToIndex.type = await this.detectType(gameToIndex.file_path); - await this.gamesService.save(gameToIndex); + updatedGames.push(await this.gamesService.save(gameToIndex)); continue; } case GameExistence.EXISTS_BUT_DELETED_IN_DATABASE: { this.logger.debug({ message: `A Soft-deleted duplicate of the file has been found in the database. Restoring it and updating the information.`, - game: { - id: gameToIndex.id, - file_path: gameToIndex.file_path, - }, - existingGame: { - id: existingGameTuple[1].id, - file_path: existingGameTuple[1].file_path, - }, + game: logGamevaultGame(gameToIndex), + existingGame: logGamevaultGame(existingGameTuple[1]), }); const restoredGame = await this.gamesService.restore( existingGameTuple[1].id, ); gameToIndex.type = await this.detectType(gameToIndex.file_path); - await this.update(restoredGame, gameToIndex); + updatedGames.push( + await this.updateFileInfo(restoredGame.id, gameToIndex), + ); continue; } case GameExistence.EXISTS_BUT_ALTERED: { this.logger.debug({ message: `An altered duplicate of the file has been found in the database. Updating the information.`, - game: { - id: gameToIndex.id, - file_path: gameToIndex.file_path, - }, - existingGame: { - id: existingGameTuple[1].id, - file_path: existingGameTuple[1].file_path, - }, + game: logGamevaultGame(gameToIndex), + existingGame: logGamevaultGame(existingGameTuple[1]), }); gameToIndex.type = await this.detectType(gameToIndex.file_path); - await this.update(existingGameTuple[1], gameToIndex); + updatedGames.push( + await this.updateFileInfo(existingGameTuple[1].id, gameToIndex), + ); continue; } } } catch (error) { this.logger.error({ message: `Failed to index file "${gameToIndex.file_path}". Does this file really belong here and are you sure the format is correct?`, - game: { id: gameToIndex.id, file_path: file }, + game: { id: gameToIndex.id, path: file }, error, }); } } + + // Run metadata and integrity checks. + this.metadataService.checkAndUpdateMetadata(updatedGames); + this.runDebouncedIntegrityCheck(); this.logger.log({ message: "Finished ingesting games.", - gamesCount: gamesInFileSystem.length, + count: files.length, }); } - /** Updates the game information with the provided updates. */ - private async update( - gameToUpdate: Game, - updatesToApply: Game, - ): Promise { - const updatedGame = { - ...gameToUpdate, - file_path: updatesToApply.file_path, - title: updatesToApply.title, - release_date: updatesToApply.release_date, - size: updatesToApply.size, - version: updatesToApply.version, - early_access: updatesToApply.early_access, - type: updatesToApply.type, - }; + /** Updates the game information with the information provided by the file. */ + private async updateFileInfo( + id: number, + updatesToApply: GamevaultGame, + ): Promise { + const gameToUpdate = await this.gamesService.findOneByGameIdOrFail(id, { + loadDeletedEntities: false, + }); + + gameToUpdate.file_path = updatesToApply.file_path; + gameToUpdate.title = updatesToApply.title; + gameToUpdate.sort_title = this.gamesService.generateSortTitle( + updatesToApply.title, + ); + gameToUpdate.release_date = updatesToApply.release_date; + gameToUpdate.size = updatesToApply.size; + gameToUpdate.version = updatesToApply.version; + gameToUpdate.early_access = updatesToApply.early_access; + gameToUpdate.type = updatesToApply.type; - await this.gamesService.save(updatedGame); + const updatedGame = await this.gamesService.save(gameToUpdate); this.logger.log({ - message: `Updated new Game Information.`, - game: { - id: updatedGame.id, - file_path: updatedGame.file_path, - }, + message: `Updated new Game Information based on file changes.`, + game: logGamevaultGame(gameToUpdate), }); + return updatedGame; } - private isValidFilename(filename: string) { + private isValidFilePath(filename: string) { const invalidCharacters = /[/<>:"\\|?*]/; const actualFilename = basename(filename); if ( !configuration.GAMES.SUPPORTED_FILE_FORMATS.includes( - extname(actualFilename)?.toLowerCase(), + toLower(path.extname(actualFilename)), ) ) { this.logger.debug({ @@ -242,9 +278,11 @@ export class FilesService implements OnApplicationBootstrap { * regular expression. */ private extractTitle(filePath: string): string { - const basename = path.basename(filePath, path.extname(filePath)); - const parenthesesRemoved = basename.replace(/\([^)]*\)/g, ""); - return parenthesesRemoved.trim(); + return path + .basename(filePath, path.extname(filePath)) + .replace(/\([^)]*\)/g, "") + .replace(/\s+/g, " ") + .trim(); } /** @@ -266,7 +304,7 @@ export class FilesService implements OnApplicationBootstrap { private extractReleaseYear(filePath: string): Date { try { return new Date(RegExp(/\((\d{4})\)/).exec(basename(filePath))[1]); - } catch (error) { + } catch { return undefined; } } @@ -296,13 +334,13 @@ export class FilesService implements OnApplicationBootstrap { const detectedPatterns: string[] = []; for (const path of filepaths) { - const fileName = basename(path)?.toLowerCase(); + const fileName = toLower(basename(path)); for (const pattern of windowsInstallerPatterns) { if (pattern.regex.test(fileName)) { this.logger.debug({ message: `File matched Windows Installer Game Type pattern.`, - game: { id: undefined, file_path: path }, + game: { id: undefined, path: path }, pattern, }); detectedPatterns.push(pattern.description); @@ -319,7 +357,7 @@ export class FilesService implements OnApplicationBootstrap { this.logger.debug({ message: `Detected game type as ${GameType.WINDOWS_PORTABLE}.`, reason: "(W_P) override in filename.", - game: { id: undefined, file_path: path }, + game: { id: undefined, path: path }, }); return GameType.WINDOWS_PORTABLE; } @@ -328,7 +366,7 @@ export class FilesService implements OnApplicationBootstrap { this.logger.debug({ message: `Detected game type as ${GameType.WINDOWS_SETUP}.`, reason: "(W_S) override in filename.", - game: { id: undefined, file_path: path }, + game: { id: undefined, path: path }, }); return GameType.WINDOWS_SETUP; } @@ -337,7 +375,7 @@ export class FilesService implements OnApplicationBootstrap { this.logger.debug({ message: `Detected game type as ${GameType.LINUX_PORTABLE}.`, reason: "(L_P) override in filename.", - game: { id: undefined, file_path: path }, + game: { id: undefined, path }, }); return GameType.LINUX_PORTABLE; } @@ -347,33 +385,33 @@ export class FilesService implements OnApplicationBootstrap { this.logger.debug({ message: `Detected game type as ${GameType.WINDOWS_SETUP}.`, reason: "TESTING_MOCK_FILES is set to true.", - game: { id: undefined, file_path: path }, + game: { id: undefined, path }, }); return GameType.WINDOWS_SETUP; } // Detect single File executables - if (path?.toLowerCase().endsWith(".exe")) { + if (toLower(path).endsWith(".exe")) { this.logger.debug({ message: `Detected game type as ${GameType.WINDOWS_SETUP}.`, reason: "Filename ends with .exe .", - game: { id: undefined, file_path: path }, + game: { id: undefined, path }, }); return GameType.WINDOWS_SETUP; } - if (path?.toLowerCase().endsWith(".sh")) { + if (toLower(path).endsWith(".sh")) { this.logger.debug({ message: `Detected game type as ${GameType.LINUX_PORTABLE}.`, reason: "Filename ends with .sh .", - game: { id: undefined, file_path: path }, + game: { id: undefined, path }, }); return GameType.LINUX_PORTABLE; } // Detect Windows Executables in Archive const windowsExecutablesInArchive = - await this.getAllExecutablesFromArchive(path, ["*.exe", "*.msi"]); + await this.findAllExecutablesInArchive(path, ["*.exe", "*.msi"]); if (windowsExecutablesInArchive.length > 0) { if (this.detectWindowsSetupExecutable(windowsExecutablesInArchive)) { @@ -381,19 +419,19 @@ export class FilesService implements OnApplicationBootstrap { message: `Detected game type as ${GameType.WINDOWS_SETUP}.`, reason: "There are windows executables in the archive that look like installers.", - game: { id: undefined, file_path: path }, + game: { id: undefined, path }, }); return GameType.WINDOWS_SETUP; } this.logger.debug({ message: `Detected game type as ${GameType.WINDOWS_PORTABLE}.`, reason: "There are windows executables in the archive.", - game: { id: undefined, file_path: path }, + game: { id: undefined, path }, }); return GameType.WINDOWS_PORTABLE; } - const linuxExecutablesInArchive = await this.getAllExecutablesFromArchive( + const linuxExecutablesInArchive = await this.findAllExecutablesInArchive( path, ["*.sh"], ); @@ -401,7 +439,7 @@ export class FilesService implements OnApplicationBootstrap { this.logger.debug({ message: `Detected game type as ${GameType.LINUX_PORTABLE}.`, reason: "There are .sh files in the archive.", - game: { id: undefined, file_path: path }, + game: { id: undefined, path }, }); return GameType.WINDOWS_PORTABLE; } @@ -409,20 +447,20 @@ export class FilesService implements OnApplicationBootstrap { // More Platforms and Game Types can be added here. this.logger.debug({ message: `Could not detect game type.`, - game: { id: undefined, file_path: path }, + game: { id: undefined, path }, }); return GameType.UNDETECTABLE; } catch (error) { this.logger.warn({ message: `Error detecting game type.`, - game: { id: undefined, file_path: path }, + game: { id: undefined, path }, error, }); return GameType.UNDETECTABLE; } } - private async getAllExecutablesFromArchive( + private async findAllExecutablesInArchive( path: string, matchers: string[], ): Promise { @@ -449,13 +487,13 @@ export class FilesService implements OnApplicationBootstrap { if (executablesList.length) { this.logger.debug({ message: `Found ${executablesList.length} executable(s) in archive.`, - game: { id: undefined, file_path: path }, + game: { id: undefined, path: path }, executables: executablesList, }); } else { this.logger.warn({ message: `Could not detect any executables in archive. Please note that the Game Type Detection algorithm does not support nested archives.`, - game: { id: undefined, file_path: path }, + game: { id: undefined, path: path }, }); } resolve(executablesList); @@ -497,10 +535,13 @@ export class FilesService implements OnApplicationBootstrap { * system with the games in the database, marking the deleted games as deleted * in the database. Then returns the updated games in the database. */ - private async checkIntegrity( - gamesInFileSystem: IGameVaultFile[], - gamesInDatabase: Game[], - ): Promise { + private async checkIntegrity(): Promise { + const gamesInFileSystem = await this.readAllFiles(); + const gamesInDatabase = await this.gamesService.find({ + loadDeletedEntities: false, + loadRelations: true, + }); + if (configuration.TESTING.MOCK_FILES) { this.logger.log({ message: "Skipping Integrity Check.", @@ -509,10 +550,10 @@ export class FilesService implements OnApplicationBootstrap { return gamesInDatabase; } this.logger.log({ - message: "Started Integrity Check.", - gamesCount: gamesInDatabase.length, + message: "Started Game Integrity Check.", + count: gamesInDatabase.length, }); - const updatedGames: Game[] = []; + const checkedGames: GamevaultGame[] = []; for (const gameInDatabase of gamesInDatabase) { try { const gameInFileSystem = gamesInFileSystem.find( @@ -526,35 +567,35 @@ export class FilesService implements OnApplicationBootstrap { reason: "Game file not found in filesystem.", game: { id: gameInDatabase.id, - file_path: gameInDatabase.file_path, + path: gameInDatabase.file_path, }, }); continue; } - updatedGames.push(gameInDatabase); + checkedGames.push(gameInDatabase); } catch (error) { this.logger.error({ message: `Error checking integrity of file.`, game: { id: gameInDatabase.id, - file_path: gameInDatabase.file_path, + path: gameInDatabase.file_path, }, error, }); } } this.logger.log({ - message: "Finished Integrity Check.", - gamesCount: gamesInDatabase.length, + message: "Finished Game Integrity Check.", + count: gamesInDatabase.length, }); - return updatedGames; + return checkedGames; } /** * This method retrieves an array of objects representing game files in the * file system. */ - private async fetch(): Promise { + private async readAllFiles(): Promise { try { if (configuration.TESTING.MOCK_FILES) { return mock; @@ -567,19 +608,20 @@ export class FilesService implements OnApplicationBootstrap { withFileTypes: true, }) ) - .filter((file) => file.isFile() && this.isValidFilename(file.name)) + .filter((file) => file.isFile() && this.isValidFilePath(file.name)) .map( (file) => ({ path: join(file.path, file.name), size: BigInt(statSync(join(file.path, file.name)).size), - }) as IGameVaultFile, + }) as File, ); } catch (error) { - throw new InternalServerErrorException( - "Error reading /files directory!", - { cause: error }, - ); + this.logger.error({ + message: `Error reading files.`, + error, + }); + return []; } } @@ -593,9 +635,11 @@ export class FilesService implements OnApplicationBootstrap { * @throws NotFoundException if the game file could not be found. */ public async download( + response: Response, gameId: number, speedlimitHeader?: number, rangeHeader?: string, + filterByAge?: number, ): Promise { // Set the download speed limit if provided, otherwise use the default value from configuration. speedlimitHeader = @@ -603,7 +647,10 @@ export class FilesService implements OnApplicationBootstrap { speedlimitHeader *= 1024; // Find the game by ID. - const game = await this.gamesService.findByGameIdOrFail(gameId); + const game = await this.gamesService.findOneByGameIdOrFail(gameId, { + loadDeletedEntities: false, + filterByAge, + }); let fileDownloadPath = game.file_path; // If mocking files for testing, return a StreamableFile with random bytes. @@ -655,10 +702,16 @@ export class FilesService implements OnApplicationBootstrap { new ByteRangeStream(BigInt(range.start), BigInt(range.end)), ); + response.setHeader("X-Download-Size", range.size); + if (speedlimitHeader) { file = file.pipe(new Throttle({ rate: speedlimitHeader })); } + // Increment the download count. + game.download_count++; + this.gamesService.save(game); + return new StreamableFile(file, { disposition: `attachment; filename="${filenameSanitizer( unidecode(path.basename(fileDownloadPath)), diff --git a/src/modules/games/games.controller.ts b/src/modules/games/games.controller.ts index 8ed297e8..1e81219f 100644 --- a/src/modules/games/games.controller.ts +++ b/src/modules/games/games.controller.ts @@ -7,6 +7,8 @@ import { Logger, Param, Put, + Request, + Res, StreamableFile, } from "@nestjs/common"; import { @@ -18,79 +20,114 @@ import { ApiTags, } from "@nestjs/swagger"; import { InjectRepository } from "@nestjs/typeorm"; +import { Response } from "express"; import { - NO_PAGINATION, Paginate, - paginate, - Paginated, PaginateQuery, + Paginated, PaginationType, + paginate, } from "nestjs-paginate"; import { Repository } from "typeorm"; +import configuration from "../../configuration"; import { MinimumRole } from "../../decorators/minimum-role.decorator"; import { PaginateQueryOptions } from "../../decorators/pagination.decorator"; import { ApiOkResponsePaginated } from "../../globals"; -import { IdDto } from "../database/models/id.dto"; -import { FilesService } from "../files/files.service"; +import { GamevaultUser } from "../users/gamevault-user.entity"; import { Role } from "../users/models/role.enum"; -import { Game } from "./game.entity"; +import { UsersService } from "../users/users.service"; +import { FilesService } from "./files.service"; import { GamesService } from "./games.service"; +import { GamevaultGame } from "./gamevault-game.entity"; +import { GameIdDto } from "./models/game-id.dto"; import { UpdateGameDto } from "./models/update-game.dto"; @ApiBasicAuth() @ApiTags("game") @Controller("games") export class GamesController { - private readonly logger = new Logger(GamesController.name); + private readonly logger = new Logger(this.constructor.name); constructor( - private gamesService: GamesService, - private filesService: FilesService, - @InjectRepository(Game) - private readonly gamesRepository: Repository, + private readonly gamesService: GamesService, + private readonly filesService: FilesService, + @InjectRepository(GamevaultGame) + private readonly gamesRepository: Repository, + private readonly usersService: UsersService, ) {} + @Put("reindex") + @ApiOperation({ + summary: "manually triggers an index of all games", + operationId: "putFilesReindex", + }) + @ApiOkResponse({ type: () => GamevaultGame, isArray: true }) + @MinimumRole(Role.ADMIN) + async putFilesReindex() { + return this.filesService.index(); + } + /** Get paginated games list based on the given query parameters. */ @Get() @PaginateQueryOptions() - @ApiOkResponsePaginated(Game) + @ApiOkResponsePaginated(GamevaultGame) @ApiOperation({ summary: "get a list of games", operationId: "getGames", }) @MinimumRole(Role.GUEST) - async getGames(@Paginate() query: PaginateQuery): Promise> { - const relations = ["box_image", "background_image", "bookmarked_users"]; - if (query.filter) { - if (query.filter["genres.name"]) { - relations.push("genres"); - } - if (query.filter["tags.name"]) { - relations.push("tags"); - } + async findGames( + @Request() request: { gamevaultuser: GamevaultUser }, + @Paginate() query: PaginateQuery, + ): Promise> { + const relations = ["bookmarked_users", "metadata", "metadata.cover"]; + + if (query.filter?.["metadata.genres.name"]) { + relations.push("metadata.genres"); + } + + if (query.filter?.["metadata.tags.name"]) { + relations.push("metadata.tags"); + } + + if (configuration.PARENTAL.AGE_RESTRICTION_ENABLED) { + query.filter ??= {}; + query.filter["metadata.age_rating"] = + `$lte:${await this.usersService.findUserAgeByUsername(request.gamevaultuser.username)}`; } return paginate(query, this.gamesRepository, { paginationType: PaginationType.TAKE_AND_SKIP, defaultLimit: 100, - maxLimit: NO_PAGINATION, + defaultSortBy: [["sort_title", "ASC"]], + maxLimit: -1, nullSort: "last", relations, sortableColumns: [ "id", "title", + "sort_title", "release_date", - "rawg_release_date", "created_at", "size", - "metacritic_rating", - "average_playtime", "early_access", "type", + "download_count", "bookmarked_users.id", + "metadata.title", + "metadata.early_access", + "metadata.release_date", + "metadata.average_playtime", + "metadata.rating", + ], + loadEagerRelations: false, + searchableColumns: [ + "id", + "title", + "metadata.title", + "metadata.description", ], - searchableColumns: ["title", "description"], filterableColumns: { id: true, title: true, @@ -101,9 +138,11 @@ export class GamesController { average_playtime: true, early_access: true, type: true, + download_count: true, "bookmarked_users.id": true, - "genres.name": true, - "tags.name": true, + "metadata.genres.name": true, + "metadata.tags.name": true, + "metadata.age_rating": true, }, withDeleted: false, }); @@ -115,29 +154,42 @@ export class GamesController { summary: "get a random game", operationId: "getGameRandom", }) - @ApiOkResponse({ type: () => Game }) + @ApiOkResponse({ type: () => GamevaultGame }) @MinimumRole(Role.GUEST) - async getGameRandom(): Promise { - return await this.gamesService.getRandom(); + async getGameRandom( + @Request() request: { gamevaultuser: GamevaultUser }, + ): Promise { + return this.gamesService.findRandom({ + loadDeletedEntities: false, + loadRelations: true, + filterByAge: await this.usersService.findUserAgeByUsername( + request.gamevaultuser.username, + ), + }); } /** Retrieves details for a game with the specified ID. */ - @Get(":id") + @Get(":game_id") @ApiOperation({ summary: "get details on a game", operationId: "getGameByGameId", }) - @ApiOkResponse({ type: () => Game }) + @ApiOkResponse({ type: () => GamevaultGame }) @MinimumRole(Role.GUEST) - async getGameByGameId(@Param() params: IdDto): Promise { - return await this.gamesService.findByGameIdOrFail(Number(params.id), { - loadRelations: true, + async getGameByGameId( + @Request() request: { gamevaultuser: GamevaultUser }, + @Param() params: GameIdDto, + ): Promise { + return this.gamesService.findOneByGameIdOrFail(Number(params.game_id), { loadDeletedEntities: true, + filterByAge: await this.usersService.findUserAgeByUsername( + request.gamevaultuser.username, + ), }); } /** Download a game by its ID. */ - @Get(":id/download") + @Get(":game_id/download") @ApiHeader({ name: "X-Download-Speed-Limit", required: false, @@ -173,18 +225,24 @@ export class GamesController { @ApiOkResponse({ type: () => StreamableFile }) @Header("Accept-Ranges", "bytes") async getGameDownload( - @Param() params: IdDto, + @Request() request: { gamevaultuser: GamevaultUser }, + @Param() params: GameIdDto, + @Res({ passthrough: true }) response: Response, @Headers("X-Download-Speed-Limit") speedlimit?: string, @Headers("Range") range?: string, ): Promise { - return await this.filesService.download( - Number(params.id), + return this.filesService.download( + response, + Number(params.game_id), Number(speedlimit), range, + await this.usersService.findUserAgeByUsername( + request.gamevaultuser.username, + ), ); } - @Put(":id") + @Put(":game_id") @ApiOperation({ summary: "updates the details of a game", operationId: "putGameUpdate", @@ -192,9 +250,9 @@ export class GamesController { @ApiBody({ type: () => UpdateGameDto }) @MinimumRole(Role.EDITOR) async putGameUpdate( - @Param() params: IdDto, + @Param() params: GameIdDto, @Body() dto: UpdateGameDto, - ): Promise { - return await this.gamesService.update(Number(params.id), dto); + ): Promise { + return this.gamesService.update(Number(params.game_id), dto); } } diff --git a/src/modules/games/games.e2e.spec.ts b/src/modules/games/games.e2e.spec.ts deleted file mode 100644 index b6833c2c..00000000 --- a/src/modules/games/games.e2e.spec.ts +++ /dev/null @@ -1,296 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { HttpService } from "@nestjs/axios"; -import { Test } from "@nestjs/testing"; -import { getRepositoryToken } from "@nestjs/typeorm"; -import gis, { Result } from "async-g-i-s"; -import { Builder } from "builder-pattern"; -import { of } from "rxjs"; -import { Repository } from "typeorm"; - -import { AppModule } from "../../app.module"; -import { Game } from "../games/game.entity"; -import { GamesController } from "./games.controller"; - -jest.mock("async-g-i-s"); - -describe("/api/games", () => { - let gamesController: GamesController; - let gameRepository: Repository; - - const mockHttpService = { - get: jest.fn(), - }; - - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [AppModule], - }) - .overrideProvider(HttpService) - .useValue(mockHttpService) - .compile(); - gamesController = moduleRef.get(GamesController); - gameRepository = moduleRef.get>(getRepositoryToken(Game)); - }); - - afterEach(() => { - jest.clearAllMocks(); - gameRepository.clear(); - }); - - describe("GET /api/games", () => { - it("should get all games", async () => { - //Mock some games - await gameRepository.save( - Builder(Game) - .title("Grand Theft Auto V") - .file_path("filepath.zip") - .early_access(false) - .build(), - ); - - await gameRepository.save( - Builder(Game) - .title("Assassin's Creed 2") - .file_path("filepath2.zip") - .early_access(false) - .build(), - ); - - const result = await gamesController.getGames({ path: "/" }); - - expect(result.data.length).toBe(2); - }); - - it("should search games by name and description case-insensitive", async () => { - await gameRepository.save( - Builder(Game) - .title("Grand Theft Keyword V") - .file_path("filepath.zip") - .early_access(false) - .build(), - ); - - await gameRepository.save( - Builder(Game) - .title("Assassin's Creed 2") - .description("This Description contains the Keyword") - .file_path("filepath2.zip") - .early_access(false) - .build(), - ); - - await gameRepository.save( - Builder(Game) - .title("Grand Theft Motorcycle V") - .file_path("filepath3.zip") - .early_access(false) - .build(), - ); - - await gameRepository.save( - Builder(Game) - .title("Assassin's Barn 2") - .description("This Description does not contain the relevant word") - .file_path("filepath4.zip") - .early_access(false) - .build(), - ); - - const result = await gamesController.getGames({ - path: "/", - search: "keyword", - }); - - expect(result.data.length).toBe(2); - }); - - it("should filter games by some criteria", async () => { - await gameRepository.save( - Builder(Game) - .title("Grand Theft Auto V") - .file_path("filepath.zip") - .release_date(new Date("2015")) - .early_access(false) - .build(), - ); - - await gameRepository.save( - Builder(Game) - .title("Assassin's Broship") - .release_date(new Date("2001")) - .file_path("filepath2.zip") - .early_access(false) - .build(), - ); - - const result = await gamesController.getGames({ - path: "/", - filter: { release_date: "$lt:2011-01-01" }, - }); - - expect(result.data.length).toBe(1); - }); - it("should sort games by some criteria", async () => { - const game1 = await gameRepository.save( - Builder(Game) - .title("Assassin's Broship 3") - .file_path("filepath.zip") - .release_date(new Date("2015")) - .early_access(false) - .build(), - ); - - const game2 = await gameRepository.save( - Builder(Game) - .title("Assassin's Broship 1") - .release_date(new Date("2001")) - .file_path("filepath2.zip") - .early_access(false) - .build(), - ); - - const game3 = await gameRepository.save( - Builder(Game) - .title("Assassin's Broship 2") - .release_date(new Date("2004")) - .file_path("filepath3.zip") - .early_access(false) - .build(), - ); - const game4 = await gameRepository.save( - Builder(Game) - .title("Assassin's Broship 4") - .release_date(new Date("2110")) - .file_path("filepath4.zip") - .early_access(false) - .build(), - ); - - const result = await gamesController.getGames({ - path: "/", - sortBy: [["release_date", "ASC"]], - }); - - expect(result.data.length).toBe(4); - expect(result.data[0].id).toBe(game2.id); - expect(result.data[1].id).toBe(game3.id); - expect(result.data[2].id).toBe(game1.id); - expect(result.data[3].id).toBe(game4.id); - }); - }); - - describe("GET /api/games/random", () => { - it("should get a game", async () => { - const game1 = await gameRepository.save( - Builder(Game) - .title("Grand Theft Auto V") - .file_path("filepath.zip") - .early_access(false) - .build(), - ); - - await gameRepository.save( - Builder(Game) - .title("Assassin's Creed 2") - .file_path("filepath2.zip") - .early_access(false) - .build(), - ); - - const result = await gamesController.getGameRandom(); - - expect(result.id).toBeGreaterThanOrEqual(game1.id); - }); - }); - - describe("GET /api/games/{id}", () => { - it("should get a game by id", async () => { - const game1 = await gameRepository.save( - Builder(Game) - .title("Grand Theft Auto V") - .file_path("filepath.zip") - .early_access(false) - .build(), - ); - - await gameRepository.save( - Builder(Game) - .title("Assassin's Creed 2") - .file_path("filepath2.zip") - .early_access(false) - .build(), - ); - - const result = await gamesController.getGameByGameId({ - id: game1.id.toString(), - }); - - expect(result.id).toBe(game1.id); - }); - }); - - describe("PUT /api/games/{id}", () => { - it("should update the details of a game by id", async () => { - mockHttpService.get.mockReturnValue( - of({ - data: {}, - }), - ); - //Mock Google-Image-Search - (gis as jest.Mock).mockResolvedValue([ - { - url: "https://example.com/example.png", - height: 900, - width: 600, - } as Result, - ]); - - const game = await gameRepository.save( - Builder(Game) - .title("Grand Theft Auto V") - .file_path("filepath.zip") - .early_access(false) - .build(), - ); - - const response = await gamesController.putGameUpdate( - { - id: game.id.toString(), - }, - { rawg_id: 1000 }, - ); - - const result = await gameRepository.findOneByOrFail({ rawg_id: 1000 }); - - expect(response.rawg_id).toBe(1000); - expect(result.id).toBe(game.id); - //verify a remap occured (boxart and rawg recache) - expect(gis).toHaveBeenCalled(); - expect(mockHttpService.get).toHaveBeenCalled(); - }); - }); - - describe("GET /api/games/{id}/download", () => { - it("should download a game by its id", async () => { - const game1 = await gameRepository.save( - Builder(Game) - .title("Assassin's Creed 1") - .file_path( - "Assassin's Cr~eed 1 Extreme Emoji Edition 🤡🤡🤡🤡🤡🤡🤡 (EA) (NC) (W_S) (2006).zip", - ) - .early_access(false) - .build(), - ); - - const result = await gamesController.getGameDownload({ - id: game1.id.toString(), - }); - - expect(result.getHeaders()).toStrictEqual({ - disposition: `attachment; filename="Assassin's Cr~eed 1 Extreme Emoji Edition (EA) (NC) (W_S) (2006).zip"`, - length: 1000, - type: "application/x-zip", - }); - }); - }); -}); diff --git a/src/modules/games/games.mock.ts b/src/modules/games/games.mock.ts index 0bb012b1..9e69eca9 100644 --- a/src/modules/games/games.mock.ts +++ b/src/modules/games/games.mock.ts @@ -1,4 +1,4 @@ -import { IGameVaultFile } from "../files/models/file.model"; +import { File } from "./models/file.model"; export default [ { @@ -1241,4 +1241,4 @@ export default [ path: "art of rally (2020).zip", size: 933198750n, }, -] as IGameVaultFile[]; +] as File[]; diff --git a/src/modules/games/games.module.ts b/src/modules/games/games.module.ts index 1a48e857..03fa3ec9 100644 --- a/src/modules/games/games.module.ts +++ b/src/modules/games/games.module.ts @@ -1,24 +1,25 @@ -import { forwardRef, Module } from "@nestjs/common"; +import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; -import { BoxartsModule } from "../boxarts/boxarts.module"; -import { FilesModule } from "../files/files.module"; -import { ImagesModule } from "../images/images.module"; -import { RawgModule } from "../providers/rawg/rawg.module"; -import { Game } from "./game.entity"; +import { MediaModule } from "../media/media.module"; +import { MetadataModule } from "../metadata/metadata.module"; +import { ProgressModule } from "../progresses/progress.module"; +import { UsersModule } from "../users/users.module"; +import { FilesService } from "./files.service"; import { GamesController } from "./games.controller"; import { GamesService } from "./games.service"; +import { GamevaultGame } from "./gamevault-game.entity"; @Module({ imports: [ - TypeOrmModule.forFeature([Game]), - forwardRef(() => RawgModule), - forwardRef(() => FilesModule), - forwardRef(() => BoxartsModule), - ImagesModule, + TypeOrmModule.forFeature([GamevaultGame]), + MediaModule, + MetadataModule, + ProgressModule, + UsersModule, ], controllers: [GamesController], - providers: [GamesService], - exports: [GamesService], + providers: [GamesService, FilesService], + exports: [GamesService, FilesService], }) export class GamesModule {} diff --git a/src/modules/games/games.service.ts b/src/modules/games/games.service.ts index 92c4f21b..b8bc97e4 100644 --- a/src/modules/games/games.service.ts +++ b/src/modules/games/games.service.ts @@ -7,62 +7,73 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { + FindManyOptions, + FindOneOptions, + FindOptionsSelect, + LessThanOrEqual, + Repository, +} from "typeorm"; +import { isEmpty, kebabCase, toLower } from "lodash"; import { FindOptions } from "../../globals"; -import { BoxArtsService } from "../boxarts/boxarts.service"; -import { ImagesService } from "../images/images.service"; -import { RawgService } from "../providers/rawg/rawg.service"; -import { Game } from "./game.entity"; +import { logGamevaultGame } from "../../logging"; +import { DeveloperMetadata } from "../metadata/developers/developer.metadata.entity"; +import { GameMetadata } from "../metadata/games/game.metadata.entity"; +import { GameMetadataService } from "../metadata/games/game.metadata.service"; +import { GenreMetadata } from "../metadata/genres/genre.metadata.entity"; +import { MetadataService } from "../metadata/metadata.service"; +import { PublisherMetadata } from "../metadata/publishers/publisher.metadata.entity"; +import { TagMetadata } from "../metadata/tags/tag.metadata.entity"; +import { GamevaultGame } from "./gamevault-game.entity"; import { GameExistence } from "./models/game-existence.enum"; import { UpdateGameDto } from "./models/update-game.dto"; @Injectable() export class GamesService { - private readonly logger = new Logger(GamesService.name); + private readonly logger = new Logger(this.constructor.name); + private readonly defaultRelations = [ + "progresses", + "progresses.user", + "bookmarked_users", + "metadata", + "provider_metadata", + "user_metadata", + ]; constructor( - @InjectRepository(Game) - private gamesRepository: Repository, - @Inject(forwardRef(() => RawgService)) - private rawgService: RawgService, - @Inject(forwardRef(() => BoxArtsService)) - private boxartService: BoxArtsService, - private imagesService: ImagesService, + @InjectRepository(GamevaultGame) + private readonly gamesRepository: Repository, + @Inject(forwardRef(() => MetadataService)) + private readonly metadataService: MetadataService, + @Inject(forwardRef(() => GameMetadataService)) + private readonly gameMetadataService: GameMetadataService, ) {} - public async findByGameIdOrFail( + public async findOneByGameIdOrFail( id: number, - options: FindOptions = { loadDeletedEntities: true, loadRelations: false }, - ): Promise { + options: FindOptions, + ): Promise { try { - let relations = []; - - if (options.loadRelations) { - if (options.loadRelations === true) { - relations = [ - "developers", - "publishers", - "genres", - "stores", - "tags", - "progresses", - "progresses.user", - "box_image", - "background_image", - "bookmarked_users", - ]; - } else if (Array.isArray(options.loadRelations)) - relations = options.loadRelations; - } - - const games = await this.gamesRepository.findOneOrFail({ + const findParameters: FindOneOptions = { where: { id }, - relations, - withDeleted: options.loadDeletedEntities, relationLoadStrategy: "query", - }); - return this.filterDeletedSubEntities(games); + loadEagerRelations: true, + relations: this.defaultRelations, + }; + + if (options.loadDeletedEntities) { + findParameters.withDeleted = true; + } + + if (options.filterByAge) { + findParameters.where = { + id, + metadata: { age_rating: LessThanOrEqual(options.filterByAge) }, + }; + } + + return await this.gamesRepository.findOneOrFail(findParameters); } catch (error) { throw new NotFoundException( `Game with id ${id} was not found on the server.`, @@ -71,31 +82,303 @@ export class GamesService { } } + /** Retrieves all games from the database. */ + public async find(options: FindOptions): Promise { + const findParameters: FindManyOptions = { + relationLoadStrategy: "query", + }; + + if (options.select) { + findParameters.select = + options.select as FindOptionsSelect; + } + + if (options.loadRelations) { + if (options.loadRelations === true) { + findParameters.relations = this.defaultRelations; + } else if (Array.isArray(options.loadRelations)) + findParameters.relations = options.loadRelations; + } + + if (options.loadDeletedEntities) { + findParameters.withDeleted = true; + } + + if (options.filterByAge) { + if (!options.loadRelations) { + findParameters.relations = ["metadata"]; + } + findParameters.where = { + metadata: { age_rating: LessThanOrEqual(options.filterByAge) }, + }; + } + + return this.gamesRepository.find(findParameters); + } + + public async findRandom(options: FindOptions): Promise { + const findParameters: FindManyOptions = { + relationLoadStrategy: "query", + loadEagerRelations: false, + select: ["id"], + }; + + if (options.loadDeletedEntities) { + findParameters.withDeleted = true; + } + + if (options.filterByAge) { + findParameters.relations = ["metadata"]; + findParameters.where = { + metadata: { age_rating: LessThanOrEqual(options.filterByAge) }, + }; + } + + const game = await this.gamesRepository + .createQueryBuilder() + .setFindOptions(findParameters) + .orderBy("RANDOM()") + .limit(1) + .getOneOrFail(); + + return this.findOneByGameIdOrFail(game.id, { + loadDeletedEntities: options.loadDeletedEntities, + select: options.select, + loadRelations: options.loadRelations, + }); + } + + /** Save a game to the database. */ + public async save(game: GamevaultGame): Promise { + return this.gamesRepository.save(game); + } + + /** Soft delete a game from the database. */ + public delete(id: number): Promise { + return this.gamesRepository.softRemove({ id }); + } + + public async update(id: number, dto: UpdateGameDto) { + // Finds the game by ID + const game = await this.findOneByGameIdOrFail(id, { + loadDeletedEntities: true, + }); + + if (dto.mapping_requests != null) { + for (const request of dto.mapping_requests) { + this.logger.log({ + message: "Handling Mapping Request", + game: logGamevaultGame(game), + details: request, + }); + if (request.provider_data_id) { + await this.metadataService.map( + id, + request.provider_slug, + request.provider_data_id, + request.provider_priority, + ); + } else { + await this.metadataService.unmap(id, request.provider_slug); + } + } + } + + if (dto.user_metadata) { + this.logger.debug({ + message: "Updating User Metadata", + game: logGamevaultGame(game), + user_metadata: dto.user_metadata, + }); + const updatedUserMetadata = game.user_metadata || new GameMetadata(); + + updatedUserMetadata.id = updatedUserMetadata.id || undefined; + updatedUserMetadata.provider_slug = "user"; + updatedUserMetadata.provider_data_id = game.id.toString(); + updatedUserMetadata.created_at = + updatedUserMetadata.created_at || undefined; + updatedUserMetadata.updated_at = + updatedUserMetadata.updated_at || undefined; + updatedUserMetadata.entity_version = + updatedUserMetadata.entity_version || undefined; + updatedUserMetadata.gamevault_games = + game.metadata?.gamevault_games || undefined; + + if (dto.user_metadata.age_rating != null) { + updatedUserMetadata.age_rating = dto.user_metadata.age_rating; + } + + if (dto.user_metadata.title != null) { + updatedUserMetadata.title = dto.user_metadata.title; + } + + if (dto.user_metadata.sort_title != null) { + // Allow user to override sort title + game.sort_title = dto.user_metadata.sort_title; + } + + if (dto.user_metadata.release_date != null) { + updatedUserMetadata.release_date = new Date( + dto.user_metadata.release_date, + ); + } + + if (dto.user_metadata.description != null) { + updatedUserMetadata.description = dto.user_metadata.description; + } + + if (dto.user_metadata.notes != null) { + updatedUserMetadata.notes = dto.user_metadata.notes; + } + + if (dto.user_metadata.average_playtime != null) { + updatedUserMetadata.average_playtime = + dto.user_metadata.average_playtime; + } + + if (dto.user_metadata.cover != null) { + updatedUserMetadata.cover = dto.user_metadata.cover; + } + + if (dto.user_metadata.background != null) { + updatedUserMetadata.background = dto.user_metadata.background; + } + + if (dto.user_metadata.url_websites != null) { + updatedUserMetadata.url_websites = dto.user_metadata.url_websites; + } + + if (dto.user_metadata.rating != null) { + updatedUserMetadata.rating = dto.user_metadata.rating; + } + + if (dto.user_metadata.early_access != null) { + updatedUserMetadata.early_access = dto.user_metadata.early_access; + } + + if (dto.user_metadata.launch_parameters != null) { + updatedUserMetadata.launch_parameters = + dto.user_metadata.launch_parameters; + } + + if (dto.user_metadata.launch_executable != null) { + updatedUserMetadata.launch_executable = + dto.user_metadata.launch_executable; + } + + if (dto.user_metadata.installer_executable != null) { + updatedUserMetadata.installer_executable = + dto.user_metadata.installer_executable; + } + + if (dto.user_metadata.url_screenshots != null) { + updatedUserMetadata.url_screenshots = dto.user_metadata.url_screenshots; + } + + if (dto.user_metadata.url_trailers != null) { + updatedUserMetadata.url_trailers = dto.user_metadata.url_trailers; + } + + if (dto.user_metadata.url_gameplays != null) { + updatedUserMetadata.url_gameplays = dto.user_metadata.url_gameplays; + } + + if (!isEmpty(dto.user_metadata.tags)) { + updatedUserMetadata.tags = dto.user_metadata.tags.map((tag) => { + return { + provider_slug: "user", + provider_data_id: kebabCase(tag), + name: tag, + } as TagMetadata; + }); + } + + if (!isEmpty(dto.user_metadata.genres)) { + updatedUserMetadata.genres = dto.user_metadata.genres.map((genre) => { + return { + provider_slug: "user", + provider_data_id: kebabCase(genre), + name: genre, + } as GenreMetadata; + }); + } + + if (!isEmpty(dto.user_metadata.developers)) { + updatedUserMetadata.developers = dto.user_metadata.developers.map( + (developer) => { + return { + provider_slug: "user", + provider_data_id: kebabCase(developer), + name: developer, + } as DeveloperMetadata; + }, + ); + } + + if (!isEmpty(dto.user_metadata.publishers)) { + updatedUserMetadata.publishers = dto.user_metadata.publishers.map( + (publisher) => { + return { + provider_slug: "user", + provider_data_id: kebabCase(publisher), + name: publisher, + } as PublisherMetadata; + }, + ); + } + + game.user_metadata = + await this.gameMetadataService.save(updatedUserMetadata); + const updatedGame = await this.save(game); + this.logger.debug({ + message: "Game User Metadata updated", + game: logGamevaultGame(game), + user_metadata: updatedGame.user_metadata, + }); + this.logger.log({ + message: "Game User Metadata updated", + game: logGamevaultGame(game), + }); + } + + return this.metadataService.merge(game.id); + } + + /** Restore a game that has been soft deleted. */ + public async restore(id: number): Promise { + await this.gamesRepository.recover({ id }); + return this.findOneByGameIdOrFail(id, { + loadDeletedEntities: false, + }); + } + /** Checks if a game exists in the database. */ public async checkIfExistsInDatabase( - game: Game, - ): Promise<[GameExistence, Game]> { + game: GamevaultGame, + ): Promise<[GameExistence, GamevaultGame]> { if (!game.file_path || (!game.title && !game.release_date)) { throw new InternalServerErrorException( game, "Dupe-Checking Data not available in indexed game!", ); } - const existingGameByPath = await this.gamesRepository.findOne({ - where: { file_path: game.file_path }, - withDeleted: true, - }); - const existingGameByTitleAndReleaseDate = - await this.gamesRepository.findOne({ + const foundGame = + (await this.gamesRepository.findOne({ + relationLoadStrategy: "query", + where: { file_path: game.file_path }, + relations: this.defaultRelations, + withDeleted: true, + })) ?? + (await this.gamesRepository.findOne({ + relationLoadStrategy: "query", where: { title: game.title, release_date: game.release_date, }, + relations: this.defaultRelations, withDeleted: true, - }); - - const foundGame = existingGameByPath ?? existingGameByTitleAndReleaseDate; + })); if (!foundGame) { return [GameExistence.DOES_NOT_EXIST, undefined]; @@ -108,22 +391,11 @@ export class GamesService { const differences: string[] = []; if (foundGame.file_path != game.file_path) { - differences.push( - `file_path: ${foundGame.file_path} -> ${game.file_path}`, - ); + differences.push(`path: ${foundGame.file_path} -> ${game.file_path}`); } if (foundGame.title != game.title) { differences.push(`title: ${foundGame.title} -> ${game.title}`); } - if ( - +foundGame.release_date != +game.release_date && - foundGame.rawg_release_date && - +foundGame.release_date != +foundGame.rawg_release_date - ) { - differences.push( - `release_date: ${foundGame.release_date} -> ${game.release_date}`, - ); - } if (foundGame.early_access != game.early_access) { differences.push( `early_access: ${foundGame.early_access} -> ${game.early_access}`, @@ -155,124 +427,25 @@ export class GamesService { return [GameExistence.EXISTS, foundGame]; } - /** Retrieves all games from the database. */ - public async getAll(): Promise { - return this.gamesRepository.find(); - } - - public async getRandom(): Promise { - const game = await this.gamesRepository - .createQueryBuilder("game") - .select("game.id") - .orderBy("RANDOM()") - .limit(1) - .getOne(); - - return this.findByGameIdOrFail(game.id, { - loadDeletedEntities: true, - loadRelations: true, - }); - } - - /** Unmaps the Rawg Metadata of a game then saves it. */ - public async unmap(id: number): Promise { - const game = await this.findByGameIdOrFail(id); - game.rawg_id = null; - game.rawg_title = null; - game.rawg_release_date = null; - game.cache_date = null; - game.description = null; - game.box_image = null; - game.background_image = null; - game.website_url = null; - game.metacritic_rating = null; - game.average_playtime = null; - game.publishers = null; - game.developers = null; - game.stores = null; - game.tags = null; - game.genres = null; - this.logger.log({ message: "Unmapped Game", game }); - return await this.gamesRepository.save(game); - } - - /** - * Remaps the Rawg ID of a game then recaches the game. - */ - public async remap(id: number, new_rawg_id: number): Promise { - let game = await this.unmap(id); - game.rawg_id = new_rawg_id; - await this.gamesRepository.save(game); - game = (await this.rawgService.checkCache([game]))[0]; - - // Refetch the boxart and return - return await this.boxartService.check(game); - } - - /** Save a game to the database. */ - public async save(game: Game): Promise { - return this.gamesRepository.save(game); - } - - /** Soft delete a game from the database. */ - public delete(id: number): Promise { - return this.gamesRepository.softRemove({ id }); - } + public generateSortTitle(title: string): string { + // List of leading articles to be removed + const articles: string[] = ["the", "a", "an"]; - /** - * Updates a game with the provided ID using the information in the DTO. If - * the DTO contains a rawg_id, the game will be remapped. Otherwise, the game - * will be found by the provided ID. If the DTO contains a box_image_id, the - * game's box_image will be updated. If the DTO contains a - * background_image_id, the game's background_image will be updated. Finally, - * the updated game will be saved and returned. - * - * @param id - The ID of the game to update. - * @param dto - The DTO containing the updated game information. - * @returns The updated game. - */ - public async update(id: number, dto: UpdateGameDto) { - // Finds the game by ID - let game = await this.findByGameIdOrFail(id, { - loadDeletedEntities: true, - loadRelations: true, - }); + // Convert the title to lowercase + let sortTitle: string = toLower(title).trim(); - // Remaps the Game - if (dto.rawg_id != null) { - game = await this.remap(game.id, dto.rawg_id); + // Remove any leading article + for (const article of articles) { + const articleWithSpace = `${article} `; + if (sortTitle.startsWith(articleWithSpace)) { + sortTitle = sortTitle.substring(articleWithSpace.length); + break; + } } - - // Updates BoxArt - if (dto.box_image_id != null) - game.box_image = await this.imagesService.findByImageIdOrFail( - dto.box_image_id, - ); - - // Updates Background Image - if (dto.background_image_id != null) - game.background_image = await this.imagesService.findByImageIdOrFail( - dto.background_image_id, - ); - - return this.gamesRepository.save(game); - } - - /** Restore a game that has been soft deleted. */ - public async restore(id: number): Promise { - await this.gamesRepository.recover({ id }); - return this.findByGameIdOrFail(id); - } - - private filterDeletedSubEntities(game?: Game): Game { - return { - ...game, - genres: game?.genres?.filter((entity) => !entity.deleted_at), - tags: game?.tags?.filter((entity) => !entity.deleted_at), - developers: game?.developers?.filter((entity) => !entity.deleted_at), - publishers: game?.publishers?.filter((entity) => !entity.deleted_at), - stores: game?.stores?.filter((entity) => !entity.deleted_at), - progresses: game?.progresses?.filter((entity) => !entity.deleted_at), - }; + // Remove special characters except alphanumeric and spaces and Replace multiple spaces with a single space and trim + return sortTitle + .replace(/[^a-z0-9\s]/g, "") + .replace(/\s+/g, " ") + .trim(); } } diff --git a/src/modules/games/gamevault-game.entity.ts b/src/modules/games/gamevault-game.entity.ts new file mode 100644 index 00000000..afbd3456 --- /dev/null +++ b/src/modules/games/gamevault-game.entity.ts @@ -0,0 +1,195 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + AfterLoad, + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + OneToMany, + OneToOne, +} from "typeorm"; + +import { DatabaseEntity } from "../database/database.entity"; +import { GameMetadata } from "../metadata/games/game.metadata.entity"; +import { Progress } from "../progresses/progress.entity"; +import { GamevaultUser } from "../users/gamevault-user.entity"; +import { GameType } from "./models/game-type.enum"; + +@Entity() +export class GamevaultGame extends DatabaseEntity { + @Index({ unique: true }) + @Column({ unique: true }) + @ApiProperty({ + description: + "file path to the game or the game manifest (relative to root)", + example: "/files/Action/Grand Theft Auto V (v1.0.0).zip", + }) + file_path: string; + + @Column({ + type: "bigint", + default: 0, + transformer: { + to: (value) => value, + from: (value) => { + if (value) return BigInt(value).toString(); + return value; + }, + }, + }) + @ApiProperty({ + description: "size of the game file in bytes", + example: "1234567890", + type: () => String, + }) + size: bigint; + + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: "title of the game (extracted from the filename')", + example: "Grand Theft Auto V", + }) + title?: string; + + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: + "sort title of the game, generated and used to optimize sorting.", + example: "grand theft auto 5", + }) + sort_title?: string; + + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: "version tag (extracted from the filename e.g. '(v1.0.0)')", + example: "v1.0.0", + }) + version?: string; + + @Index() + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: + "release date of the game (extracted from filename e.g. '(2013)')", + example: "2013-01-01T00:00:00.000Z", + }) + release_date?: Date; + + @Column({ default: false }) + @ApiProperty({ + description: + "indicates if the game is an early access title (extracted from filename e.g. '(EA)')", + example: true, + default: false, + }) + early_access: boolean = false; + + @Column({ default: 0 }) + @ApiProperty({ + description: + "Indicates how many times the game has been downloaded on this server.", + example: 10, + default: 0, + }) + download_count: number = 0; + + @Column({ + type: "simple-enum", + enum: GameType, + default: GameType.UNDETECTABLE, + }) + @ApiProperty({ + description: + "type of the game, see https://gamevau.lt/docs/server-docs/game-types for all possible values", + type: "enum", + enum: GameType, + example: GameType.WINDOWS_PORTABLE, + }) + type: GameType; + + @JoinTable() + @ManyToMany(() => GameMetadata, (metadata) => metadata.gamevault_games) + @ApiPropertyOptional({ + description: "metadata of various providers associated to the game", + type: () => GameMetadata, + isArray: true, + }) + provider_metadata?: GameMetadata[]; + + @OneToOne(() => GameMetadata, { + nullable: true, + cascade: true, + onDelete: "SET NULL", + orphanedRowAction: "delete", + }) + @JoinColumn() + @ApiPropertyOptional({ + description: "user-defined metadata of the game", + type: () => GameMetadata, + }) + user_metadata?: GameMetadata; + + @OneToOne(() => GameMetadata, { + eager: true, + nullable: true, + cascade: true, + onDelete: "SET NULL", + orphanedRowAction: "delete", + }) + @JoinColumn() + @ApiPropertyOptional({ + description: "effective and merged metadata of the game", + type: () => GameMetadata, + }) + metadata?: GameMetadata; + + @OneToMany(() => Progress, (progress) => progress.game) + @ApiPropertyOptional({ + description: "progresses associated to the game", + type: () => Progress, + isArray: true, + }) + progresses?: Progress[]; + + @ManyToMany(() => GamevaultUser, (user) => user.bookmarked_games) + @ApiProperty({ + description: "users that bookmarked this game", + type: () => GamevaultGame, + isArray: true, + }) + bookmarked_users?: GamevaultUser[]; + + private createSortTitle(title: string): string { + // List of leading articles to be removed + const articles: string[] = ["the", "a", "an"]; + + // Convert the title to lowercase + let sortTitle: string = title.toLowerCase().trim(); + + // Remove any leading article + for (const article of articles) { + const articleWithSpace = article + " "; + if (sortTitle.startsWith(articleWithSpace)) { + sortTitle = sortTitle.substring(articleWithSpace.length); + break; + } + } + + // Remove special characters except alphanumeric and spaces + sortTitle = sortTitle.replace(/[^a-z0-9\s]/g, ""); + + // Replace multiple spaces with a single space and trim + sortTitle = sortTitle.replace(/\s+/g, " ").trim(); + + return sortTitle; + } + + @AfterLoad() + async nullChecks() { + if (!this.provider_metadata) { + this.provider_metadata = []; + } + } +} diff --git a/src/modules/files/models/byte-range-stream.ts b/src/modules/games/models/byte-range-stream.ts similarity index 100% rename from src/modules/files/models/byte-range-stream.ts rename to src/modules/games/models/byte-range-stream.ts diff --git a/src/modules/files/models/file.model.ts b/src/modules/games/models/file.model.ts similarity index 50% rename from src/modules/files/models/file.model.ts rename to src/modules/games/models/file.model.ts index af5af014..786d21c5 100644 --- a/src/modules/files/models/file.model.ts +++ b/src/modules/games/models/file.model.ts @@ -1,4 +1,4 @@ -export interface IGameVaultFile { +export interface File { path: string; size: bigint; } diff --git a/src/modules/database/models/id.dto.ts b/src/modules/games/models/game-id.dto.ts similarity index 57% rename from src/modules/database/models/id.dto.ts rename to src/modules/games/models/game-id.dto.ts index a6581026..214169d5 100644 --- a/src/modules/database/models/id.dto.ts +++ b/src/modules/games/models/game-id.dto.ts @@ -1,9 +1,9 @@ import { ApiProperty } from "@nestjs/swagger"; import { IsNotEmpty, IsNumberString } from "class-validator"; -export class IdDto { +export class GameIdDto { @IsNumberString() @IsNotEmpty() - @ApiProperty({ example: "1", description: "id of the entity" }) - id: string; + @ApiProperty({ example: "1", description: "id of the game" }) + game_id: number; } diff --git a/src/modules/games/models/map-game.dto.ts b/src/modules/games/models/map-game.dto.ts new file mode 100644 index 00000000..ceb11f4c --- /dev/null +++ b/src/modules/games/models/map-game.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsInt, + IsNotEmpty, + IsNotIn, + IsOptional, + Matches, +} from "class-validator"; + +import globals from "../../../globals"; + +export class MapGameDto { + @IsNotEmpty() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: + "Invalid slug: Only lowercase letters, numbers, and single hyphens inbetween them are allowed.", + }) + @IsNotIn(globals.RESERVED_PROVIDER_SLUGS, { + message: + "Invalid slug: The terms 'gamevault' and 'user' are reserved slugs.", + }) + @ApiProperty({ + description: + "slug (url-friendly name) of the provider. This is the primary identifier. Must be formatted like a valid slug.", + example: "igdb", + }) + provider_slug: string; + + @IsOptional() + @ApiPropertyOptional({ + description: + "id of the target game from the provider. If not provided, the metadata for the specified provider will be unmapped.", + example: "1234", + }) + provider_data_id?: string; + + @IsInt() + @IsNotEmpty() + @ApiProperty({ + type: Number, + description: + "used to override the priority of usage for this provider. Lower priority providers are tried first, while higher priority providers fill in gaps.", + }) + provider_priority?: number; +} diff --git a/src/modules/games/models/minimal-game.ts b/src/modules/games/models/minimal-game.ts deleted file mode 100644 index b76a297f..00000000 --- a/src/modules/games/models/minimal-game.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; - -import { Image } from "../../images/image.entity"; - -export class MinimalGame { - @ApiPropertyOptional({ - description: "unique gamevault-identifier of the game", - example: 1212, - }) - id?: number; - - @ApiPropertyOptional({ - description: "unique rawg-api-identifier of the game", - example: 1212, - }) - rawg_id?: number; - - @ApiPropertyOptional({ - description: "title of the game", - example: "Grand Theft Auto V", - }) - title?: string; - - @ApiPropertyOptional({ - description: "release date of the game", - example: "2013-09-17T00:00:00.000Z", - }) - release_date?: Date; - - @ApiPropertyOptional({ - description: "box image of the game", - type: () => Image, - }) - box_image?: Image; - - @ApiPropertyOptional({ - description: "box image url of the game", - example: "example.com/example.jpg", - }) - box_image_url?: string; -} diff --git a/src/modules/files/models/range-header.model.ts b/src/modules/games/models/range-header.model.ts similarity index 100% rename from src/modules/files/models/range-header.model.ts rename to src/modules/games/models/range-header.model.ts diff --git a/src/modules/games/models/rawg_id.dto.ts b/src/modules/games/models/rawg_id.dto.ts deleted file mode 100644 index 1c76043c..00000000 --- a/src/modules/games/models/rawg_id.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsNotEmpty, IsNumber, IsPositive } from "class-validator"; - -export class RawgIdDto { - @IsNumber() - @IsPositive() - @IsNotEmpty() - @ApiProperty({ - example: 1000, - description: "unique rawg-api-identifier of the game", - }) - rawg_id: number; -} diff --git a/src/modules/games/models/update-game.dto.ts b/src/modules/games/models/update-game.dto.ts index a4ac3e58..fd5320ad 100644 --- a/src/modules/games/models/update-game.dto.ts +++ b/src/modules/games/models/update-game.dto.ts @@ -1,30 +1,27 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsNotEmpty, IsNumber, IsOptional, IsPositive } from "class-validator"; +import { IsArray, IsOptional, ValidateNested } from "class-validator"; -export class UpdateGameDto { - @IsOptional() - @IsNumber() - @IsPositive() - @IsNotEmpty() - @ApiPropertyOptional({ - example: 1000, - description: "unique rawg-api-identifier of the game", - }) - rawg_id?: number; +import { UpdateGameUserMetadataDto } from "../../metadata/models/user-game-metadata.dto"; +import { MapGameDto } from "./map-game.dto"; +export class UpdateGameDto { + @IsArray() @IsOptional() - @IsNumber() + @ValidateNested({ each: true }) @ApiPropertyOptional({ - example: 69_420, - description: "id of the image", + description: + "The mapping requests. If not provided, the game will not be remapped or unmapped.", + type: MapGameDto, + isArray: true, }) - box_image_id?: number; + mapping_requests?: MapGameDto[]; @IsOptional() - @IsNumber() + @ValidateNested() @ApiPropertyOptional({ - example: 69_420, - description: "id of the image", + description: + "The updated user metadata. If not provided, the games user_metadata will not be updated.", + type: () => UpdateGameUserMetadataDto, }) - background_image_id?: number; + user_metadata?: UpdateGameUserMetadataDto; } diff --git a/src/modules/garbage-collection/garbage-collection.module.ts b/src/modules/garbage-collection/garbage-collection.module.ts index c3604f81..8d9e0392 100644 --- a/src/modules/garbage-collection/garbage-collection.module.ts +++ b/src/modules/garbage-collection/garbage-collection.module.ts @@ -1,21 +1,21 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; -import { Game } from "../games/game.entity"; -import { Image } from "../images/image.entity"; -import { ImagesModule } from "../images/images.module"; +import { Media } from "../media/media.entity"; +import { MediaModule } from "../media/media.module"; +import { GameMetadata } from "../metadata/games/game.metadata.entity"; import { GamevaultUser } from "../users/gamevault-user.entity"; -import { ImageGarbageCollectionService } from "./image-garbage-collection.service"; +import { MediaGarbageCollectionService } from "./media-garbage-collection.service"; @Module({ imports: [ - ImagesModule, - TypeOrmModule.forFeature([Image]), - TypeOrmModule.forFeature([Game]), + MediaModule, + TypeOrmModule.forFeature([Media]), + TypeOrmModule.forFeature([GameMetadata]), TypeOrmModule.forFeature([GamevaultUser]), ], controllers: [], - providers: [ImageGarbageCollectionService], - exports: [ImageGarbageCollectionService], + providers: [MediaGarbageCollectionService], + exports: [MediaGarbageCollectionService], }) export class GarbageCollectionModule {} diff --git a/src/modules/garbage-collection/image-garbage-collection.service.ts b/src/modules/garbage-collection/image-garbage-collection.service.ts deleted file mode 100644 index 2c63b3ec..00000000 --- a/src/modules/garbage-collection/image-garbage-collection.service.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { Cron } from "@nestjs/schedule"; -import { InjectRepository } from "@nestjs/typeorm"; -import { isUUID } from "class-validator"; -import { readdir, unlink } from "fs/promises"; -import { join } from "path"; -import { Repository } from "typeorm"; - -import configuration from "../../configuration"; -import { Game } from "../games/game.entity"; -import { Image } from "../images/image.entity"; -import { ImagesService } from "../images/images.service"; -import { GamevaultUser } from "../users/gamevault-user.entity"; - -@Injectable() -export class ImageGarbageCollectionService { - private readonly logger = new Logger(ImageGarbageCollectionService.name); - - constructor( - @InjectRepository(Image) - private imageRepository: Repository, - @InjectRepository(Game) - private gameRepository: Repository, - @InjectRepository(GamevaultUser) - private userRepository: Repository, - private imagesService: ImagesService, - ) { - this.garbageCollectUnusedImages(); - } - - /** - * Garbage collects unused images. - * - * This function checks if image garbage collection is disabled before - * proceeding. It retrieves all images from the image repository and collects - * the paths of used images dynamically. Then, it removes unused images from - * the database and cleans up the file system. Finally, it logs the number of - * deleted images from the database and file system. - */ - @Cron(`*/${configuration.IMAGE.GC_INTERVAL_IN_MINUTES} * * * *`) - async garbageCollectUnusedImages() { - // Check if image garbage collection is disabled - if (configuration.IMAGE.GC_DISABLED) { - // Log warning and skip garbage collection - this.logger.warn({ - message: "Skipping image garbage collection.", - reason: "IMAGE_GC_DISABLED is set to true.", - }); - return; - } - - // Retrieve all images from the image repository - const allImages = await this.imageRepository.find(); - - // Collect paths of used images dynamically - const usedImagePaths = new Set(); - await this.collectUsedImagePathsDynamically(usedImagePaths); - - // Remove unused images from the database - const dbRemovedCount = await this.removeUnusedImagesFromDB( - allImages, - usedImagePaths, - ); - - // Clean up the file system - const fsRemovedCount = await this.cleanupFileSystem(usedImagePaths); - - // Log the number of deleted images from the database and file system (if any) - if (dbRemovedCount) { - this.logger.log( - `Deleted ${dbRemovedCount} unused images from the database.`, - ); - } - - if (fsRemovedCount) { - this.logger.log( - `Deleted ${fsRemovedCount} unused image files from ${configuration.VOLUMES.IMAGES}.`, - ); - } - } - - /** - * Collects the used image paths dynamically from various repositories and - * properties. - * - * @param usedImagePaths - Set that will store the used image paths. - */ - private async collectUsedImagePathsDynamically( - usedImagePaths: Set, - ): Promise { - // Define an array of objects, each containing a repository and the properties to check for images - const entityImageProperties = [ - { - repository: this.gameRepository, - properties: ["background_image", "box_image"], - }, - { - repository: this.userRepository, - properties: ["background_image", "profile_picture"], - }, - // Add more repositories and image properties as needed - ]; - - // Iterate over each object in the entityImageProperties array - for (const { repository, properties } of entityImageProperties) { - // Fetch all entities from the repository - const entities = await repository.find({ withDeleted: true }); - - // Iterate over each entity - for (const entity of entities) { - // Iterate over each property in the properties array - for (const property of properties) { - // Get the image from the entity's property - const image = entity[property]; - // If the image has a path, add it to the usedImagePaths set - if (image?.path) { - usedImagePaths.add(image.path); - } - } - } - } - } - - /** - * Removes unused images from the database. - * - * @param allImages - An array of all images in the database. - * @param usedImagePaths - A set of image paths that are currently being used. - * @returns The number of images deleted. - */ - private async removeUnusedImagesFromDB( - allImages: Image[], - usedImagePaths: Set, - ): Promise { - // Filter out images that are not being used - const unusedImages = allImages.filter( - (image) => !usedImagePaths.has(image.path), - ); - - // Create an array of promises to delete the unused images - const deletePromises = unusedImages.map((image) => - this.imagesService.delete(image), - ); - - // Wait for all the delete promises to resolve - await Promise.all(deletePromises); - - // Return the number of images deleted - return deletePromises.length; - } - - /** - * Cleans up the file system by removing unused files. - * - * @param usedImagePaths - A Set of paths to used image files. - * @returns The number of files removed. - */ - private async cleanupFileSystem( - usedImagePaths: Set, - ): Promise { - // Skip garbage collection if TESTING_MOCK_FILES is true - if (configuration.TESTING.MOCK_FILES) { - this.logger.warn({ - message: "Skipping image garbage collection.", - reason: "TESTING_MOCK_FILES is true.", - }); - return 0; - } - - // Get the directory where the image files are stored - const imagesDirectory = configuration.VOLUMES.IMAGES; - - // Get a list of all image files in the directory - const allImageFilePaths = ( - await readdir(imagesDirectory, { - encoding: "utf8", - withFileTypes: true, - recursive: false, - }) - ) - .filter((file) => file.isFile() && isUUID(file.name.substring(0, 35), 4)) - .map((file) => join(file.path, file.name)); - - let removedCount = 0; - - // Create an array of unlink promises for each file - const unlinkPromises = allImageFilePaths.map((path) => { - // If the file path is not in the usedImagePaths set, delete the file - if (!usedImagePaths.has(path)) { - return unlink(path) - .then(() => { - this.logger.debug({ - message: "Garbage collected unused image.", - path, - }); - removedCount++; - }) - .catch((error) => { - this.logger.error({ - message: "Error garbage collecting unused image.", - path, - error, - }); - }); - } - - return Promise.resolve(); - }); - - // Wait for all unlink promises to resolve - await Promise.all(unlinkPromises); - - return removedCount; - } -} diff --git a/src/modules/garbage-collection/media-garbage-collection.service.ts b/src/modules/garbage-collection/media-garbage-collection.service.ts new file mode 100644 index 00000000..e56bcb43 --- /dev/null +++ b/src/modules/garbage-collection/media-garbage-collection.service.ts @@ -0,0 +1,267 @@ +import { Injectable, Logger, OnApplicationBootstrap } from "@nestjs/common"; +import { Cron } from "@nestjs/schedule"; +import { InjectRepository } from "@nestjs/typeorm"; +import { isUUID } from "class-validator"; +import { readdir, unlink } from "fs/promises"; +import { join } from "path"; +import { Repository } from "typeorm"; + +import { uniq } from "lodash"; +import configuration from "../../configuration"; +import { Media } from "../media/media.entity"; +import { MediaService } from "../media/media.service"; +import { GameMetadata } from "../metadata/games/game.metadata.entity"; +import { GamevaultUser } from "../users/gamevault-user.entity"; + +@Injectable() +export class MediaGarbageCollectionService implements OnApplicationBootstrap { + private readonly logger = new Logger(this.constructor.name); + + constructor( + @InjectRepository(Media) + private readonly mediaRepository: Repository, + @InjectRepository(GameMetadata) + private readonly gameMetadataRepository: Repository, + @InjectRepository(GamevaultUser) + private readonly userRepository: Repository, + private readonly mediaService: MediaService, + ) {} + + onApplicationBootstrap() { + this.garbageCollectUnusedMedia(); + } + + /** + * Garbage collects unused medias. + * + * This function checks if media garbage collection is disabled before + * proceeding. It retrieves all media from the media repository and collects + * the paths of used media dynamically. Then, it removes unused media from + * the database and cleans up the file system. Finally, it logs the number of + * deleted media from the database and file system. + */ + @Cron(`*/${configuration.MEDIA.GC_INTERVAL_IN_MINUTES} * * * *`) + async garbageCollectUnusedMedia() { + // Check if media garbage collection is disabled + if (configuration.MEDIA.GC_DISABLED) { + // Log warning and skip garbage collection + this.logger.warn({ + message: "Skipping media garbage collection.", + reason: "MEDIA_GC_DISABLED is set to true.", + }); + return; + } + + try { + // Retrieve all media from the media repository + const allMedia = await this.mediaRepository.find(); + + // Collect paths of used media dynamically + const usedMediaPaths = await this.collectUsedMediaPaths(); + + // Remove unused media from the database + const dbRemovedCount = await this.removeUnusedMediaFromDB( + allMedia, + usedMediaPaths, + ); + if (dbRemovedCount) { + this.logger.log( + `Deleted ${dbRemovedCount} unused media entities from the database.`, + ); + } + + // Clean up the file system + const fsRemovedCount = + await this.removeUnusedMediaFromFileSystem(usedMediaPaths); + if (fsRemovedCount) { + this.logger.log( + `Deleted ${fsRemovedCount} unused media files from ${configuration.VOLUMES.MEDIA}.`, + ); + } + } catch (error) { + this.logger.error({ + message: "Error garbage collecting unused media.", + error, + }); + } + } + + /** + * Collects paths of used media dynamically. + * + * @returns An array of media paths that are currently being used. + */ + private async collectUsedMediaPaths(): Promise { + const mediaPaths: string[] = []; + /** + * The entities and properties that are checked for media usage. + * Each element in the array is an object with a `repository` property and a + * `properties` property. The `repository` property is the TypeORM repository + * instance for the entity. The `relations` property is an array of strings + * representing the relations of the entity that may contain media. + */ + const entityMediaProperties = [ + { + repository: this.userRepository, + relations: ["background", "avatar"], + }, + { + repository: this.gameMetadataRepository, + relations: ["background", "cover"], + }, + ]; + /** + * Loop through the entities and their properties and find the media that is + * being used. + */ + for (const { repository, relations } of entityMediaProperties) { + const entities = await repository.find({ + withDeleted: true, + relations, + loadEagerRelations: false, + relationLoadStrategy: "query", + }); + for (const entity of entities) { + const foundMedia: Media[] = []; + /** + * Loop through each property of the entity and check if it contains + * media. + */ + for (const relation of relations) { + if (Array.isArray(entity[relation])) { + this.logger.debug({ + message: `Found ${entity[relation].length} media entities in relation.`, + entity: entity.constructor.name, + entity_id: entity.id, + entity_relation: relation, + media_ids: entity[relation].map((media: Media) => media.id), + media_paths: entity[relation].map( + (media: Media) => media.file_path, + ), + }); + foundMedia.push(...entity[relation]); + } else if (entity[relation]) { + this.logger.debug({ + message: `Found 1 media entity in relation.`, + entity: entity.constructor.name, + entity_id: entity.id, + entity_relation: relation, + media_id: entity[relation].id, + media_path: entity[relation].file_path, + }); + foundMedia.push(entity[relation]); + } + } + /** + * Add the media paths to the `mediaPaths` array. + */ + mediaPaths.push( + ...foundMedia + .filter((media) => media.file_path) + .map((media) => media.file_path), + ); + } + } + return mediaPaths; + } + + /** + * Removes unused media from the database. + * + * @param allMedia - An array of all media in the database. + * @param usedMediaPaths - A set of media paths that are currently being used. + * @returns The number of media deleted. + */ + private async removeUnusedMediaFromDB( + allMedia: Media[], + usedMediaPaths: string[], + ): Promise { + const uniqueAllMedia = uniq(allMedia); + const uniqueUsedMediaPaths = uniq(usedMediaPaths); + this.logger.log({ + message: "Calculated difference of all media paths and used media paths.", + all_count: uniqueAllMedia.length, + used_count: uniqueUsedMediaPaths.length, + delta: uniqueAllMedia.length - uniqueUsedMediaPaths.length, + }); + + // Filter out media that are not being used + const uniqueUnusedMedia = uniq( + uniqueAllMedia.filter( + (media) => !uniqueUsedMediaPaths.includes(media.file_path), + ), + ); + + // Create an array of promises to delete the unused media + const deletePromises = uniqueUnusedMedia.map((media) => + this.mediaService.delete(media), + ); + + // Wait for all the delete promises to resolve + await Promise.all(deletePromises); + + // Return the number of media deleted + return deletePromises.length; + } + + /** + * Cleans up the file system by removing unused files. + * + * @param usedMediaPaths - A Set of paths to used media files. + * @returns The number of files removed. + */ + private async removeUnusedMediaFromFileSystem( + usedMediaPaths: string[], + ): Promise { + // Skip garbage collection if TESTING_MOCK_FILES is true + if (configuration.TESTING.MOCK_FILES) { + this.logger.warn({ + message: "Skipping media garbage collection.", + reason: "TESTING_MOCK_FILES is true.", + }); + return 0; + } + + // Get a list of all media files in the directory + const allMediaFilePaths = ( + await readdir(configuration.VOLUMES.MEDIA, { + encoding: "utf8", + withFileTypes: true, + recursive: false, + }) + ) + .filter((file) => file.isFile() && isUUID(file.name.substring(0, 35), 4)) + .map((file) => join(file.path, file.name)); + + let removedCount = 0; + + // Create an array of unlink promises for each file + const unlinkPromises = allMediaFilePaths.map((path) => { + // If the file path is not in the usedMediaPaths set, delete the file + if (!usedMediaPaths.includes(path)) { + return unlink(path) + .then(() => { + this.logger.debug({ + message: "Garbage collected unused media.", + path, + }); + removedCount++; + }) + .catch((error) => { + this.logger.error({ + message: "Error garbage collecting unused media.", + path, + error, + }); + }); + } + + return Promise.resolve(); + }); + + // Wait for all unlink promises to resolve + await Promise.all(unlinkPromises); + + return removedCount; + } +} diff --git a/src/modules/genres/genres.controller.ts b/src/modules/genres/genres.controller.ts deleted file mode 100644 index 43a477e0..00000000 --- a/src/modules/genres/genres.controller.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Controller, Get } from "@nestjs/common"; -import { - ApiBasicAuth, - ApiOkResponse, - ApiOperation, - ApiTags, -} from "@nestjs/swagger"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; - -import { MinimumRole } from "../../decorators/minimum-role.decorator"; -import { Role } from "../users/models/role.enum"; -import { Genre } from "./genre.entity"; - -@Controller("genres") -@ApiTags("genres") -@ApiBasicAuth() -export class GenresController { - constructor( - @InjectRepository(Genre) - private readonly genreRepository: Repository, - ) {} - - /** - * Get a list of genres sorted by the amount of games that are associated with - * each genre. - */ - @Get() - @ApiOperation({ - summary: "get a list of genres", - description: - "the list is sorted by the amount of games that are associated with each genre.", - operationId: "getGenres", - }) - @MinimumRole(Role.GUEST) - @ApiOkResponse({ type: () => Genre, isArray: true }) - async getGenres(): Promise { - const genres = await this.genreRepository.find({ - relations: ["games"], - loadEagerRelations: false, - }); - genres.sort((a, b) => b.games?.length - a.games?.length); - return genres; - } -} diff --git a/src/modules/genres/genres.e2e.spec.ts b/src/modules/genres/genres.e2e.spec.ts deleted file mode 100644 index 2500adfe..00000000 --- a/src/modules/genres/genres.e2e.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Test } from "@nestjs/testing"; -import { getRepositoryToken } from "@nestjs/typeorm"; -import { Builder } from "builder-pattern"; -import { Repository } from "typeorm/repository/Repository"; - -import { AppModule } from "../../app.module"; -import { Game } from "../games/game.entity"; -import { Genre } from "./genre.entity"; -import { GenresController } from "./genres.controller"; - -describe("GenresController", () => { - let genresController: GenresController; - let genreRepository: Repository; - let gameRepository: Repository; - - beforeEach(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [AppModule], - controllers: [], - }).compile(); - - genresController = moduleRef.get(GenresController); - genreRepository = moduleRef.get>( - getRepositoryToken(Genre), - ); - gameRepository = moduleRef.get>(getRepositoryToken(Game)); - }); - - afterEach(async () => { - gameRepository.clear(); - genreRepository.clear(); - }); - - it("should be defined", () => { - expect(genresController).toBeDefined(); - expect(genreRepository).toBeDefined(); - expect(gameRepository).toBeDefined(); - }); - - it("should get genre", async () => { - const testingGenre: Genre = new Genre(); - testingGenre.name = "Action"; - testingGenre.rawg_id = 1337; - await genreRepository.save(testingGenre); - - const results = await genresController.getGenres(); - expect(results.length).toBe(1); - expect(results[0].rawg_id).toBe(1337); - expect(results[0].name).toBe("Action"); - }); - - it("should sort genres by the amount of games associated with them", async () => { - const genre1: Genre = Builder(Genre).name("Racing").rawg_id(1111).build(); - const genre2: Genre = Builder(Genre) - .name("Platformer") - .rawg_id(2222) - .build(); - await genreRepository.save([genre1, genre2]); - - await gameRepository.save( - Builder(Game) - .title("Testgame2") - .file_path("filepath.zip") - .early_access(false) - .genres([genre2]) - .build(), - ); - - const results = await genresController.getGenres(); - expect(results.length).toBe(2); - expect(results[0].rawg_id).toBe(2222); - expect(results[0].name).toBe("Platformer"); - expect(results[1].rawg_id).toBe(1111); - expect(results[1].name).toBe("Racing"); - }); -}); diff --git a/src/modules/genres/genres.module.ts b/src/modules/genres/genres.module.ts deleted file mode 100644 index 557605b6..00000000 --- a/src/modules/genres/genres.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { forwardRef, Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { FilesModule } from "../files/files.module"; -import { Genre } from "./genre.entity"; -import { GenresController } from "./genres.controller"; -import { GenresService } from "./genres.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([Genre]), forwardRef(() => FilesModule)], - controllers: [GenresController], - providers: [GenresService], - exports: [GenresService], -}) -export class GenresModule {} diff --git a/src/modules/genres/genres.service.ts b/src/modules/genres/genres.service.ts deleted file mode 100644 index e75c41f6..00000000 --- a/src/modules/genres/genres.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Builder } from "builder-pattern"; -import { Repository } from "typeorm"; - -import { Genre } from "./genre.entity"; - -@Injectable() -export class GenresService { - private readonly logger = new Logger(GenresService.name); - constructor( - @InjectRepository(Genre) - private tagRepository: Repository, - ) {} - - /** - * Returns the genre with the specified RAWG ID, creating a new genre if one - * does not already exist. - */ - async getOrCreate(name: string, rawg_id: number): Promise { - const existingGenre = await this.tagRepository.findOneBy({ name }); - - if (existingGenre) return existingGenre; - - const genre = this.tagRepository.save( - Builder(Genre).name(name).rawg_id(rawg_id).build(), - ); - this.logger.log({ - message: "Created new Genre.", - genre, - }); - return genre; - } -} diff --git a/src/modules/guards/authentication.guard.ts b/src/modules/guards/authentication.guard.ts index 75c15592..e9274b1b 100644 --- a/src/modules/guards/authentication.guard.ts +++ b/src/modules/guards/authentication.guard.ts @@ -6,7 +6,7 @@ import configuration from "../../configuration"; @Injectable() export class AuthenticationGuard extends AuthGuard("basic") { - private readonly logger = new Logger(AuthenticationGuard.name); + private readonly logger = new Logger(this.constructor.name); constructor(private readonly reflector: Reflector) { super(); @@ -29,10 +29,6 @@ export class AuthenticationGuard extends AuthGuard("basic") { return true; } - if (configuration.TESTING.AUTHENTICATION_DISABLED) { - return true; - } - return super.canActivate(context); } } diff --git a/src/modules/guards/authorization.guard.ts b/src/modules/guards/authorization.guard.ts index 7ef78856..3c84175c 100644 --- a/src/modules/guards/authorization.guard.ts +++ b/src/modules/guards/authorization.guard.ts @@ -13,7 +13,7 @@ import { Role } from "../users/models/role.enum"; @Injectable() export class AuthorizationGuard implements CanActivate { - private readonly logger = new Logger(AuthorizationGuard.name); + private readonly logger = new Logger(this.constructor.name); constructor(private readonly reflector: Reflector) { if (configuration.TESTING.AUTHENTICATION_DISABLED) { this.logger.warn({ diff --git a/src/modules/guards/basic-auth.strategy.ts b/src/modules/guards/basic-auth.strategy.ts index 1a75239e..4de8e1d6 100644 --- a/src/modules/guards/basic-auth.strategy.ts +++ b/src/modules/guards/basic-auth.strategy.ts @@ -2,13 +2,14 @@ import { Injectable, Logger } from "@nestjs/common"; import { PassportStrategy } from "@nestjs/passport"; import { BasicStrategy } from "passport-http"; +import configuration from "../../configuration"; import { GamevaultUser } from "../users/gamevault-user.entity"; import { UsersService } from "../users/users.service"; @Injectable() export class DefaultStrategy extends PassportStrategy(BasicStrategy) { - private readonly logger = new Logger(DefaultStrategy.name); - constructor(private usersService: UsersService) { + private readonly logger = new Logger(this.constructor.name); + constructor(private readonly usersService: UsersService) { super({ passReqToCallback: true, }); @@ -20,6 +21,13 @@ export class DefaultStrategy extends PassportStrategy(BasicStrategy) { username: string, password: string, ) { + if ( + configuration.TESTING.AUTHENTICATION_DISABLED && + (!username || !password) + ) { + return true; + } + const user = await this.usersService.login(username, password); req.gamevaultuser = user; return !!user; diff --git a/src/modules/guards/socket-secret.guard.ts b/src/modules/guards/socket-secret.guard.ts index ef7cf006..4167816f 100644 --- a/src/modules/guards/socket-secret.guard.ts +++ b/src/modules/guards/socket-secret.guard.ts @@ -10,9 +10,9 @@ import { SocketSecretService } from "../users/socket-secret.service"; @Injectable() export class SocketSecretGuard implements CanActivate { - private readonly logger = new Logger(SocketSecretGuard.name); + private readonly logger = new Logger(this.constructor.name); - constructor(private socketSecretService: SocketSecretService) {} + constructor(private readonly socketSecretService: SocketSecretService) {} async canActivate(context: ExecutionContext): Promise { const client = context.switchToWs().getClient(); @@ -28,7 +28,7 @@ export class SocketSecretGuard implements CanActivate { } try { - const user = await this.socketSecretService.getUserBySocketSecretOrFail( + const user = await this.socketSecretService.findUserBySocketSecretOrFail( socketSecret.toString(), ); this.logger.debug({ diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index 067c5b00..2c91239a 100644 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -8,7 +8,7 @@ import { Health } from "./models/health.model"; @Controller("health") @ApiTags("health") export class HealthController { - constructor(private healthService: HealthService) {} + constructor(private readonly healthService: HealthService) {} @Get() @ApiOkResponse({ type: () => Health }) diff --git a/src/modules/health/health.e2e.spec.ts b/src/modules/health/health.e2e.spec.ts index 90dd2659..6a01542b 100644 --- a/src/modules/health/health.e2e.spec.ts +++ b/src/modules/health/health.e2e.spec.ts @@ -1,6 +1,7 @@ import { Test } from "@nestjs/testing"; import { AppModule } from "../../app.module"; +import configuration from "../../configuration"; import { HealthController } from "./health.controller"; describe("/api/health", () => { @@ -18,6 +19,7 @@ describe("/api/health", () => { const result = await healthController.getHealth(); expect(result).toStrictEqual({ status: "HEALTHY", + version: configuration.SERVER.VERSION, }); }); }); diff --git a/src/modules/health/health.service.ts b/src/modules/health/health.service.ts index 57384158..335a6e68 100644 --- a/src/modules/health/health.service.ts +++ b/src/modules/health/health.service.ts @@ -1,12 +1,12 @@ import { Injectable } from "@nestjs/common"; import configuration from "../../configuration"; -import { Health, HealthProtocolEntry } from "./models/health.model"; import { HealthStatus } from "./models/health-status.enum"; +import { Health, HealthProtocolEntry } from "./models/health.model"; @Injectable() export class HealthService { - private epoch: Date = new Date(); + private readonly epoch: Date = new Date(); private currentHealth: Health = new Health(); constructor() { @@ -31,7 +31,6 @@ export class HealthService { const healthCopy = { ...health }; delete healthCopy.protocol; delete healthCopy.uptime; - delete healthCopy.version; return healthCopy; } diff --git a/src/modules/images/images.module.ts b/src/modules/images/images.module.ts deleted file mode 100644 index 4400d932..00000000 --- a/src/modules/images/images.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { HttpModule } from "@nestjs/axios"; -import { forwardRef, Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { UsersModule } from "../users/users.module"; -import { Image } from "./image.entity"; -import { ImagesController } from "./images.controller"; -import { ImagesService } from "./images.service"; - -@Module({ - imports: [ - HttpModule, - TypeOrmModule.forFeature([Image]), - forwardRef(() => UsersModule), - ], - controllers: [ImagesController], - providers: [ImagesService], - exports: [ImagesService], -}) -export class ImagesModule {} diff --git a/src/modules/images/images.service.ts b/src/modules/images/images.service.ts deleted file mode 100644 index 021e505b..00000000 --- a/src/modules/images/images.service.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { HttpService } from "@nestjs/axios"; -import { - BadRequestException, - forwardRef, - Inject, - Injectable, - InternalServerErrorException, - Logger, - NotFoundException, -} from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { AxiosError, AxiosResponse } from "axios"; -import { randomUUID } from "crypto"; -import fileTypeChecker from "file-type-checker"; -import { existsSync } from "fs"; -import { unlink, writeFile } from "fs/promises"; -import { catchError, firstValueFrom } from "rxjs"; -import sharp from "sharp"; -import { Repository } from "typeorm"; - -import configuration from "../../configuration"; -import { UsersService } from "../users/users.service"; -import { Image } from "./image.entity"; - -@Injectable() -export class ImagesService { - private readonly logger = new Logger(ImagesService.name); - - constructor( - private readonly httpService: HttpService, - @InjectRepository(Image) - private imageRepository: Repository, - @Inject(forwardRef(() => UsersService)) - private usersService: UsersService, - ) {} - - public async isAvailable(id: number): Promise { - try { - if (!id) { - throw new NotFoundException("No image id given!"); - } - await this.findByImageIdOrFail(id); - return true; - } catch (error) { - return false; - } - } - - public async findByImageIdOrFail(id: number): Promise { - try { - const image = await this.imageRepository.findOneByOrFail({ id }); - if (!existsSync(image.path) || configuration.TESTING.MOCK_FILES) { - await this.delete(image); - throw new NotFoundException("Image not found on filesystem."); - } - return image; - } catch (error) { - throw new NotFoundException(`Image with id ${id} was not found.`, { - cause: error, - }); - } - } - - /** - * Downloads an image from a given URL and saves it to the file system. - * - * @param {string} sourceUrl - The URL of the image to be downloaded. - * @param {string} uploaderUsername - (optional) The username of the uploader. - * @returns {Promise} The saved Image object. - */ - async downloadByUrl( - sourceUrl: string, - uploaderUsername?: string, - ): Promise { - const image = new Image(); - image.source = sourceUrl; - - try { - if (uploaderUsername) { - image.uploader = - await this.usersService.findByUsernameOrFail(uploaderUsername); - } - const response = await this.fetchFromUrl(image.source); - this.logger.debug({ - message: `Downloaded image.`, - url: sourceUrl, - uploader: uploaderUsername, - }); - const imageBuffer = Buffer.from(response.data); - const fileType = this.checkFileType(imageBuffer); - - image.path = `${configuration.VOLUMES.IMAGES}/${randomUUID()}.${ - fileType.extension - }`; - - await this.saveToFileSystem(image.path, imageBuffer); - return await this.imageRepository.save(image); - } catch (error) { - if (image.id) { - await this.delete(image); - } - throw new InternalServerErrorException( - `Failed to download image from '${sourceUrl}'.`, - { cause: error }, - ); - } - } - - private async fetchFromUrl(sourceUrl: string): Promise { - return await firstValueFrom( - this.httpService - .get(sourceUrl, { - responseType: "arraybuffer", - timeout: 30_000, - }) - .pipe( - catchError((error: AxiosError) => { - throw new Error( - `Failed to download image from ${sourceUrl}: ${error.status} ${error.message}`, - { cause: error.toJSON() }, - ); - }), - ), - ); - } - - private checkFileType(imageBuffer: Buffer) { - const fileType = fileTypeChecker.detectFile(imageBuffer); - if ( - !configuration.IMAGE.SUPPORTED_IMAGE_FORMATS.includes(fileType.mimeType) - ) { - throw new BadRequestException( - `Content Type "${fileType.mimeType}" is not supported. Please select a different image or convert it.`, - ); - } - return fileType; - } - - private async saveToFileSystem( - path: string, - imageBuffer: Buffer, - ): Promise { - if (configuration.TESTING.MOCK_FILES) { - this.logger.warn({ - message: "Not saving image to the filesystem.", - reason: "TESTING_MOCK_FILES is set to true.", - }); - return; - } - this.logger.debug(`Compressing image...`); - const compressedImageBuffer = await sharp(imageBuffer, { - animated: true, - }).toBuffer(); - await writeFile(path, compressedImageBuffer); - this.logger.debug({ - message: "Image successfully saved to filesystem.", - path, - }); - } - - async delete(image: Image): Promise { - if (configuration.TESTING.MOCK_FILES) { - this.logger.warn({ - message: "Not deleting image from filesystem.", - reason: "TESTING_MOCK_FILES is set to true.", - }); - return; - } - try { - await this.imageRepository.remove(image); - await unlink(image.path); - this.logger.debug({ - message: "Image successfully deleted from filesystem and database.", - image, - }); - } catch (error) { - this.logger.error({ - message: "Error deleting image.", - image, - error, - }); - } - } - - public async upload( - file: Express.Multer.File, - username: string, - ): Promise { - const image = await this.createFromUpload(file, username); - - try { - await this.saveToFileSystem(image.path, file.buffer); - const uploadedImage = await this.imageRepository.save(image); - this.logger.log({ - message: "Image successfully uploaded.", - image: uploadedImage, - uploader: username, - }); - return uploadedImage; - } catch (error) { - await this.delete(image); - throw new InternalServerErrorException( - "Error uploading image. Please retry or try another one.", - { cause: error }, - ); - } - } - - private async validate(imageBuffer: Buffer) { - const type = fileTypeChecker.detectFile(imageBuffer); - const errorContextObject = { - type, - bufferLength: imageBuffer.length, - bufferStart: imageBuffer - .toString("hex", 0, 32) - .match(/.{1,2}/g) - .join(" "), - }; - if (!type?.extension || !type?.mimeType) { - throw new BadRequestException( - errorContextObject, - "File type could not be detected. Please try another image.", - ); - } - if (!configuration.IMAGE.SUPPORTED_IMAGE_FORMATS.includes(type.mimeType)) { - throw new BadRequestException( - errorContextObject, - `This file is a "${type.mimeType}", which is not supported.`, - ); - } - return type; - } - - private async createFromUpload( - file: Express.Multer.File, - username?: string, - ): Promise { - const fileType = await this.validate(file.buffer); - const image = new Image(); - - if (username) { - image.uploader = await this.usersService.findByUsernameOrFail(username); - } - - image.path = `${configuration.VOLUMES.IMAGES}/${randomUUID()}.${ - fileType.extension - }`; - image.mediaType = fileType.mimeType; - return image; - } -} diff --git a/src/modules/images/images.controller.ts b/src/modules/media/media.controller.ts similarity index 52% rename from src/modules/images/images.controller.ts rename to src/modules/media/media.controller.ts index 69ae08fb..3f0aa790 100644 --- a/src/modules/images/images.controller.ts +++ b/src/modules/media/media.controller.ts @@ -22,6 +22,7 @@ import { ApiProduces, ApiTags, } from "@nestjs/swagger"; +import bytes from "bytes"; import { Response } from "express"; import fs from "fs"; @@ -30,44 +31,44 @@ import { DisableApiIf } from "../../decorators/disable-api-if.decorator"; import { MinimumRole } from "../../decorators/minimum-role.decorator"; import { GamevaultUser } from "../users/gamevault-user.entity"; import { Role } from "../users/models/role.enum"; -import { Image } from "./image.entity"; -import { ImagesService } from "./images.service"; +import { Media } from "./media.entity"; +import { MediaService } from "./media.service"; -@ApiTags("images") -@Controller("images") +@ApiTags("media") +@Controller("media") @ApiBasicAuth() -export class ImagesController { - private readonly logger = new Logger(ImagesService.name); +export class MediaController { + private readonly logger = new Logger(this.constructor.name); - constructor(private imagesService: ImagesService) {} + constructor(private readonly mediaService: MediaService) {} - /** Retrieve an image by its ID and send it as the response. */ + /** Retrieve media by its ID and send it as the response. */ @Get(":id") @ApiOperation({ - summary: "Retrieve an image using its id", - operationId: "getImageByImageId", + summary: "Retrieve media using its id", + operationId: "getMediaByMediaId", }) @ApiOkResponse({ type: () => Buffer, - description: "The requested image", + description: "The requested media", }) - @ApiProduces("image/*") + @ApiProduces("image/*", "video/*", "audio/*") @MinimumRole(Role.GUEST) - async getImageByImageId( + async getMediaByMediaId( @Param("id") id: string, @Res() res: Response, ): Promise { - const image = await this.imagesService.findByImageIdOrFail(Number(id)); - res.set("Content-Type", image.mediaType); - fs.createReadStream(image.path).pipe(res); + const media = await this.mediaService.findOneByMediaIdOrFail(Number(id)); + res.set("Content-Type", media.type); + fs.createReadStream(media.file_path).pipe(res); } @Post() @ApiOperation({ - summary: "Upload an image", + summary: "Upload a media file to the server", description: - "You can use the id of the uploaded image in subsequent requests.", - operationId: "postImage", + "You can use the id of the uploaded media in subsequent requests.", + operationId: "postMedia", }) @ApiConsumes("multipart/form-data") @ApiBody({ @@ -76,32 +77,33 @@ export class ImagesController { file: { type: "string", format: "binary", - description: "The image to upload", + description: "The media file to upload", }, }, }, }) @ApiOkResponse({ - type: () => Image, - description: "The uploaded image", + type: () => Media, + description: "The uploaded media", }) @UseInterceptors(FileInterceptor("file")) @MinimumRole(Role.USER) @DisableApiIf(configuration.SERVER.DEMO_MODE_ENABLED) - postImage( + postMedia( @Request() req: { gamevaultuser: GamevaultUser }, @UploadedFile( new ParseFilePipe({ validators: [ new MaxFileSizeValidator({ - maxSize: configuration.IMAGE.MAX_SIZE_IN_KB, + maxSize: configuration.MEDIA.MAX_SIZE, + message: `File exceeds maximum allowed size of ${bytes(configuration.MEDIA.MAX_SIZE, { unit: "MB", thousandsSeparator: "." })}.`, }), - new FileTypeValidator({ fileType: /image\/.*/ }), + new FileTypeValidator({ fileType: /^(image|video|audio)\/.*/ }), ], }), ) file: Express.Multer.File, ) { - return this.imagesService.upload(file, req.gamevaultuser.username); + return this.mediaService.upload(file, req.gamevaultuser.username); } } diff --git a/src/modules/media/media.entity.ts b/src/modules/media/media.entity.ts new file mode 100644 index 00000000..671e83d7 --- /dev/null +++ b/src/modules/media/media.entity.ts @@ -0,0 +1,41 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Column, Entity, Index, ManyToOne } from "typeorm"; + +import { DatabaseEntity } from "../database/database.entity"; +import { GamevaultUser } from "../users/gamevault-user.entity"; + +@Entity() +export class Media extends DatabaseEntity { + @Column({ nullable: true }) + @ApiPropertyOptional({ + example: + "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Grand_Theft_Auto_logo_series.svg", + description: "the original source URL of the media", + pattern: "url", + }) + source_url?: string; + + @Column({ unique: true, nullable: true }) + @Index({ unique: true }) + @ApiPropertyOptional({ + example: "/media/6e6ae60b-7102-4501-ba69-62bd6419b2e0.jpg", + description: "the path of the media on the filesystem", + }) + file_path?: string; + + @Column() + @ApiPropertyOptional({ + example: "image/jpeg", + description: "the media type of the media on the filesystem", + }) + type: string; + + @ManyToOne(() => GamevaultUser, (user) => user.uploaded_media, { + nullable: true, + }) + @ApiPropertyOptional({ + description: "the uploader of the media", + type: () => GamevaultUser, + }) + uploader?: GamevaultUser; +} diff --git a/src/modules/media/media.module.ts b/src/modules/media/media.module.ts new file mode 100644 index 00000000..aab83f99 --- /dev/null +++ b/src/modules/media/media.module.ts @@ -0,0 +1,15 @@ +import { forwardRef, Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { UsersModule } from "../users/users.module"; +import { MediaController } from "./media.controller"; +import { Media } from "./media.entity"; +import { MediaService } from "./media.service"; + +@Module({ + imports: [TypeOrmModule.forFeature([Media]), forwardRef(() => UsersModule)], + controllers: [MediaController], + providers: [MediaService], + exports: [MediaService], +}) +export class MediaModule {} diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts new file mode 100644 index 00000000..d5add1d7 --- /dev/null +++ b/src/modules/media/media.service.ts @@ -0,0 +1,237 @@ +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { randomUUID } from "crypto"; +import fileTypeChecker from "file-type-checker"; +import { existsSync } from "fs"; +import { unlink, writeFile } from "fs/promises"; +import { Repository } from "typeorm"; + +import configuration from "../../configuration"; +import { logMedia } from "../../logging"; +import { UsersService } from "../users/users.service"; +import { Media } from "./media.entity"; + +@Injectable() +export class MediaService { + private readonly logger = new Logger(this.constructor.name); + + constructor( + @InjectRepository(Media) + private readonly mediaRepository: Repository, + @Inject(forwardRef(() => UsersService)) + private readonly usersService: UsersService, + ) {} + + public async isAvailable(id: number): Promise { + try { + if (!id) { + throw new NotFoundException("No media id given!"); + } + await this.findOneByMediaIdOrFail(id); + return true; + } catch { + return false; + } + } + + public async findOneByMediaIdOrFail(id: number): Promise { + try { + const media = await this.mediaRepository.findOneByOrFail({ id }); + if (!existsSync(media.file_path) || configuration.TESTING.MOCK_FILES) { + await this.delete(media); + throw new NotFoundException("Media not found on filesystem."); + } + return media; + } catch (error) { + throw new NotFoundException(`Media with id ${id} was not found.`, { + cause: error, + }); + } + } + + /** + * Downloads media from a given URL and saves it to the file system. + * + * @param {string} sourceUrl - The URL of the media file to be downloaded. + * @param {string} uploaderUsername - (optional) The username of the uploader. + * @returns {Promise} The saved Media object. + */ + async downloadByUrl( + sourceUrl: string, + uploaderUsername?: string, + ): Promise { + const media = new Media(); + media.source_url = sourceUrl; + + try { + if (uploaderUsername) { + media.uploader = + await this.usersService.findOneByUsernameOrFail(uploaderUsername); + } + const mediaBuffer = await this.fetchFromUrl(media.source_url); + this.logger.debug({ + message: `Downloaded media.`, + media: logMedia(media), + }); + const validatedMediaBuffer = await this.validate(mediaBuffer); + + media.type = validatedMediaBuffer.mimeType; + media.file_path = `${configuration.VOLUMES.MEDIA}/${randomUUID()}.${ + validatedMediaBuffer.extension + }`; + + await this.saveToFileSystem(media.file_path, mediaBuffer); + return await this.mediaRepository.save(media); + } catch (error) { + if (media.id) { + await this.delete(media); + } + throw new InternalServerErrorException( + `Failed to download media from '${sourceUrl}'.`, + { cause: error }, + ); + } + } + + private async fetchFromUrl(sourceUrl: string): Promise { + try { + const response = await fetch(sourceUrl, { + method: "GET", + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + }, + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + throw new Error( + `Failed to download media from ${sourceUrl}: ${response.status} ${response.statusText}`, + ); + } + return Buffer.from(await response.arrayBuffer()); + } catch (error) { + this.logger.error(`Error downloading media from ${sourceUrl}`, error); + throw new Error(`Failed to download media from ${sourceUrl}`, { + cause: error, + }); + } + } + + private async saveToFileSystem( + path: string, + mediaBuffer: Buffer, + ): Promise { + if (configuration.TESTING.MOCK_FILES) { + this.logger.warn({ + message: "Not saving media to the filesystem.", + reason: "TESTING_MOCK_FILES is set to true.", + }); + return; + } + await writeFile(path, mediaBuffer); + this.logger.debug({ + message: "Media successfully saved to filesystem.", + path, + }); + } + + async delete(media: Media): Promise { + if (configuration.TESTING.MOCK_FILES) { + this.logger.warn({ + message: "Not deleting media from filesystem.", + reason: "TESTING_MOCK_FILES is set to true.", + }); + return; + } + try { + await this.mediaRepository.remove(media); + await unlink(media.file_path); + this.logger.debug({ + message: "Media successfully deleted from filesystem and database.", + media, + }); + } catch (error) { + this.logger.error({ + message: "Error deleting media.", + media: logMedia(media), + error, + }); + } + } + + public async upload( + file: Express.Multer.File, + username: string, + ): Promise { + const media = await this.createFromUpload(file, username); + + try { + await this.saveToFileSystem(media.file_path, file.buffer); + const uploadedMedia = await this.mediaRepository.save(media); + this.logger.log({ + message: "Media successfully uploaded.", + media: logMedia(uploadedMedia), + }); + return uploadedMedia; + } catch (error) { + await this.delete(media); + throw new InternalServerErrorException( + "Error uploading media. Please retry or use a different file.", + { cause: error }, + ); + } + } + + private async validate(mediaBuffer: Buffer) { + const type = fileTypeChecker.detectFile(mediaBuffer); + const errorContextObject = { + type, + bufferLength: mediaBuffer.length, + bufferStart: mediaBuffer + .toString("hex", 0, 32) + .match(/.{1,2}/g) + .join(" "), + }; + if (!type?.extension || !type?.mimeType) { + throw new BadRequestException( + errorContextObject, + "File type could not be detected. Please use a different file.", + ); + } + if (!configuration.MEDIA.SUPPORTED_FORMATS.includes(type.mimeType)) { + throw new BadRequestException( + errorContextObject, + `This file is a "${type.mimeType}", which is not supported.`, + ); + } + return type; + } + + private async createFromUpload( + file: Express.Multer.File, + username?: string, + ): Promise { + const fileType = await this.validate(file.buffer); + const media = new Media(); + media.type = fileType.mimeType; + + if (username) { + media.uploader = + await this.usersService.findOneByUsernameOrFail(username); + } + + media.file_path = `${configuration.VOLUMES.MEDIA}/${randomUUID()}.${ + fileType.extension + }`; + return media; + } +} diff --git a/src/modules/metadata/developers/developer.metadata.entity.ts b/src/modules/metadata/developers/developer.metadata.entity.ts new file mode 100644 index 00000000..d040724f --- /dev/null +++ b/src/modules/metadata/developers/developer.metadata.entity.ts @@ -0,0 +1,55 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotIn, Matches } from "class-validator"; +import { Column, Entity, Index, ManyToMany } from "typeorm"; + +import globals from "../../../globals"; +import { DatabaseEntity } from "../../database/database.entity"; +import { GameMetadata } from "../games/game.metadata.entity"; +import { Metadata } from "../models/metadata.interface"; + +@Entity() +@Index("UQ_DEVELOPER_METADATA", ["provider_slug", "provider_data_id"], { + unique: true, +}) +export class DeveloperMetadata extends DatabaseEntity implements Metadata { + @Column() + @Index() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: + "Invalid slug: Only lowercase letters, numbers, and single hyphens inbetween them are allowed.", + }) + @IsNotIn(globals.RESERVED_PROVIDER_SLUGS, { + message: + "Invalid slug: The terms 'gamevault' and 'user' are reserved slugs.", + }) + @ApiProperty({ + description: + "slug (url-friendly name) of the provider. This is the primary identifier. Must be formatted like a valid slug.", + example: "igdb", + }) + provider_slug: string; + + @Column() + @Index() + @ApiProperty({ + description: "id of the developer from the provider", + example: "1190", + }) + provider_data_id: string; + + @Index() + @Column() + @ApiProperty({ + example: "Rockstar North", + description: "name of the developer", + }) + name: string; + + @ManyToMany(() => GameMetadata, (game) => game.developers) + @ApiProperty({ + description: "games developed by the developer", + type: () => GameMetadata, + isArray: true, + }) + games: GameMetadata[]; +} diff --git a/src/modules/metadata/developers/developer.metadata.service.ts b/src/modules/metadata/developers/developer.metadata.service.ts new file mode 100644 index 00000000..27578945 --- /dev/null +++ b/src/modules/metadata/developers/developer.metadata.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { FindOptions } from "../../../globals"; +import { DeveloperMetadata } from "./developer.metadata.entity"; + +@Injectable() +export class DeveloperMetadataService { + private readonly logger = new Logger(this.constructor.name); + constructor( + @InjectRepository(DeveloperMetadata) + private readonly developerRepository: Repository, + ) {} + + async findByProviderSlug( + provider_slug: string = "gamevault", + options: FindOptions = { loadDeletedEntities: false, loadRelations: false }, + ): Promise { + let relations = []; + + if (options.loadRelations) { + if (options.loadRelations === true) { + relations = ["games"]; + } else if (Array.isArray(options.loadRelations)) + relations = options.loadRelations; + } + + return this.developerRepository.find({ + where: { provider_slug }, + relations, + withDeleted: options.loadDeletedEntities, + relationLoadStrategy: "query", + }); + } + + async save(developer: DeveloperMetadata): Promise { + const existingDeveloper = await this.developerRepository.findOneBy({ + provider_slug: developer.provider_slug, + provider_data_id: developer.provider_data_id, + }); + this.logger.debug({ + message: "Saving developer metadata", + developer, + already_exists: !!existingDeveloper, + }); + return this.developerRepository.save({ + ...existingDeveloper, + ...{ + provider_data_id: developer.provider_data_id, + provider_slug: developer.provider_slug, + name: developer.name, + }, + }); + } +} diff --git a/src/modules/metadata/developers/developers.metadata.controller.ts b/src/modules/metadata/developers/developers.metadata.controller.ts new file mode 100644 index 00000000..f26f3c8f --- /dev/null +++ b/src/modules/metadata/developers/developers.metadata.controller.ts @@ -0,0 +1,80 @@ +import { Controller, Get } from "@nestjs/common"; +import { ApiBasicAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + Paginate, + paginate, + Paginated, + PaginateQuery, + PaginationType, +} from "nestjs-paginate"; +import { Repository } from "typeorm"; + +import { MinimumRole } from "../../../decorators/minimum-role.decorator"; +import { PaginateQueryOptions } from "../../../decorators/pagination.decorator"; +import { ApiOkResponsePaginated } from "../../../globals"; +import { Role } from "../../users/models/role.enum"; +import { DeveloperMetadata } from "./developer.metadata.entity"; + +@Controller("developers") +@ApiTags("developers") +@ApiBasicAuth() +export class DeveloperController { + constructor( + @InjectRepository(DeveloperMetadata) + private readonly developerRepository: Repository, + ) {} + + /** + * Get a paginated list of developers, sorted by the number of games released by + * each developer (by default). + */ + @Get() + @ApiOperation({ + summary: "get a list of developers", + description: + "by default the list is sorted by the amount of games that are developed by the developer.", + operationId: "getDevelopers", + }) + @MinimumRole(Role.GUEST) + @ApiOkResponsePaginated(DeveloperMetadata) + @PaginateQueryOptions() + async getDevelopers( + @Paginate() query: PaginateQuery, + ): Promise> { + const queryBuilder = this.developerRepository + .createQueryBuilder("developer") + .leftJoinAndSelect("developer.games", "games") + .where("developer.provider_slug = :provider_slug", { + provider_slug: "gamevault", + }) + .groupBy("developer.id") + .addGroupBy("games.id") + .having("COUNT(games.id) > 0"); + + // If no specific sort is provided, sort by the number of games in descending order + if (query.sortBy?.length === 0) { + queryBuilder + .addSelect("COUNT(games.id)", "games_count") + .orderBy("games_count", "DESC"); + } + + const paginatedResults = await paginate(query, queryBuilder, { + paginationType: PaginationType.TAKE_AND_SKIP, + defaultLimit: 100, + maxLimit: -1, + nullSort: "last", + loadEagerRelations: false, + sortableColumns: ["id", "name", "created_at", "provider_slug"], + searchableColumns: ["name"], + filterableColumns: { + id: true, + created_at: true, + name: true, + }, + withDeleted: false, + }); + + return paginatedResults; + } +} diff --git a/src/modules/metadata/games/game.metadata.entity.ts b/src/modules/metadata/games/game.metadata.entity.ts new file mode 100644 index 00000000..d73b9584 --- /dev/null +++ b/src/modules/metadata/games/game.metadata.entity.ts @@ -0,0 +1,259 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNotIn, Matches } from "class-validator"; +import { + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, +} from "typeorm"; + +import globals from "../../../globals"; +import { MediaValidator } from "../../../validators/media.validator"; +import { DatabaseEntity } from "../../database/database.entity"; +import { GamevaultGame } from "../../games/gamevault-game.entity"; +import { Media } from "../../media/media.entity"; +import { DeveloperMetadata } from "../developers/developer.metadata.entity"; +import { GenreMetadata } from "../genres/genre.metadata.entity"; +import { Metadata } from "../models/metadata.interface"; +import { PublisherMetadata } from "../publishers/publisher.metadata.entity"; +import { TagMetadata } from "../tags/tag.metadata.entity"; + +@Entity() +@Index("UQ_GAME_METADATA", ["provider_slug", "provider_data_id"], { + unique: true, +}) +export class GameMetadata extends DatabaseEntity implements Metadata { + @JoinTable() + @ManyToMany(() => GamevaultGame, (game) => game.provider_metadata) + @ApiPropertyOptional({ + description: "games the metadata belongs to", + type: () => GamevaultGame, + isArray: true, + }) + gamevault_games?: GamevaultGame[]; + + //#region Provider Metadata Properties + @Column({ nullable: true }) + @Index() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: + "Invalid slug: Only lowercase letters, numbers, and single hyphens inbetween them are allowed.", + }) + @IsNotIn(globals.RESERVED_PROVIDER_SLUGS, { + message: + "Invalid slug: The terms 'gamevault' and 'user' are reserved slugs.", + }) + @ApiProperty({ + description: + "slug (url-friendly name) of the provider. This is the primary identifier. Must be formatted like a valid slug.", + example: "igdb", + }) + provider_slug?: string; + + @Column({ nullable: true }) + @Index() + @ApiPropertyOptional({ + description: "id of the game from the provider", + example: "Grand Theft Auto V", + }) + provider_data_id?: string; + + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: "url of the game from the provider", + example: "https://www.igdb.com/games/grand-theft-auto-v", + pattern: "url", + }) + provider_data_url?: string; + + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: "optional priority override for this metadata", + example: 1, + }) + provider_priority?: number; + + //#endregion + + @Column({ type: "int", nullable: true }) + @ApiPropertyOptional({ + description: "the minimum age required to play the game", + example: 18, + default: 0, + }) + age_rating?: number; + + @Column({ nullable: true }) + @Index() + @ApiProperty({ + description: "title of the game", + example: "Grand Theft Auto V", + }) + title?: string; + + @Index() + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: "release date of the game", + example: "2013-09-17T00:00:00.000Z", + }) + release_date?: Date; + + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: "description of the game. markdown supported.", + example: + "An open world action-adventure video game developed by **Rockstar North** and published by **Rockstar Games**.", + }) + description?: string; + + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: + "public notes from the admin for the game. markdown supported.", + example: "# README \n Install other game first!", + }) + notes?: string; + + @Column({ type: "int", nullable: true }) + @ApiPropertyOptional({ + description: "average playtime of other people in the game in minutes", + example: 180, + }) + average_playtime?: number; + + @MediaValidator("image") + @ManyToOne(() => Media, { + nullable: true, + eager: true, + }) + @JoinColumn() + @ApiPropertyOptional({ + description: "cover/boxart image of the game", + type: () => Media, + }) + cover?: Media; + + @MediaValidator("image") + @ManyToOne(() => Media, { + nullable: true, + eager: true, + }) + @JoinColumn() + @ApiPropertyOptional({ + description: "background image of the game", + type: () => Media, + }) + background?: Media; + + @Column({ type: "simple-array", nullable: true }) + @ApiPropertyOptional({ + description: "URLs of externally hosted screenshots of the game", + isArray: true, + }) + url_screenshots?: string[]; + + @Column({ type: "simple-array", nullable: true }) + @ApiPropertyOptional({ + description: "URLs of externally hosted trailer videos of the game", + isArray: true, + }) + url_trailers?: string[]; + + @Column({ type: "simple-array", nullable: true }) + @ApiPropertyOptional({ + description: "URLs of externally hosted gameplay videos of the game", + isArray: true, + }) + url_gameplays?: string[]; + + @Column({ type: "simple-array", nullable: true }) + @ApiPropertyOptional({ + description: "URLs of websites of the game", + example: "https://escapefromtarkov.com", + isArray: true, + }) + url_websites?: string[]; + + @Column({ type: "float", nullable: true }) + @ApiPropertyOptional({ + description: "rating of the provider", + example: 90, + }) + rating?: number; + + @Column({ nullable: true }) + @ApiProperty({ + description: "indicates if the game is in early access", + example: true, + }) + early_access?: boolean; + + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: "Predefined launch parameters for the game.", + example: "-fullscreen -dx11", + }) + launch_parameters?: string; + + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: "Predefined launch executable for the game.", + example: "ShooterGame.exe", + }) + launch_executable?: string; + + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: "Predefined installer executable for the game.", + example: "setup.exe", + }) + installer_executable?: string; + + @JoinTable() + @ManyToMany(() => PublisherMetadata, (publisher) => publisher.games, { + eager: true, + }) + @ApiPropertyOptional({ + description: "publishers of the game", + type: () => PublisherMetadata, + isArray: true, + }) + publishers?: PublisherMetadata[]; + + @JoinTable() + @ManyToMany(() => DeveloperMetadata, (developer) => developer.games, { + eager: true, + }) + @ApiPropertyOptional({ + description: "developers of the game", + type: () => DeveloperMetadata, + isArray: true, + }) + developers?: DeveloperMetadata[]; + + @JoinTable() + @ManyToMany(() => TagMetadata, (tag) => tag.games, { + eager: true, + }) + @ApiPropertyOptional({ + description: "tags of the game", + type: () => TagMetadata, + isArray: true, + }) + tags?: TagMetadata[]; + + @JoinTable() + @ManyToMany(() => GenreMetadata, (genre) => genre.games, { + eager: true, + }) + @ApiPropertyOptional({ + description: "genres of the game", + type: () => GenreMetadata, + isArray: true, + }) + genres?: GenreMetadata[]; +} diff --git a/src/modules/metadata/games/game.metadata.service.ts b/src/modules/metadata/games/game.metadata.service.ts new file mode 100644 index 00000000..e4ddf483 --- /dev/null +++ b/src/modules/metadata/games/game.metadata.service.ts @@ -0,0 +1,273 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, Repository } from "typeorm"; + +import { FindOptions } from "../../../globals"; +import logger from "../../../logging"; +import { DeveloperMetadata } from "../developers/developer.metadata.entity"; +import { DeveloperMetadataService } from "../developers/developer.metadata.service"; +import { GenreMetadata } from "../genres/genre.metadata.entity"; +import { GenreMetadataService } from "../genres/genre.metadata.service"; +import { PublisherMetadata } from "../publishers/publisher.metadata.entity"; +import { PublisherMetadataService } from "../publishers/publisher.metadata.service"; +import { TagMetadata } from "../tags/tag.metadata.entity"; +import { TagMetadataService } from "../tags/tag.metadata.service"; +import { GameMetadata } from "./game.metadata.entity"; + +@Injectable() +export class GameMetadataService { + constructor( + @InjectRepository(GameMetadata) + private readonly gameMetadataRepository: Repository, + private readonly developerMetadataService: DeveloperMetadataService, + private readonly publisherMetadataService: PublisherMetadataService, + private readonly tagMetadataService: TagMetadataService, + private readonly genreMetadataService: GenreMetadataService, + ) {} + + async findByProviderSlug( + provider_slug: string = "gamevault", + options: FindOptions = { loadDeletedEntities: false, loadRelations: false }, + ): Promise { + let relations = []; + + if (options.loadRelations) { + if (options.loadRelations === true) { + relations = ["developers", "publishers", "genres", "tags"]; + } else if (Array.isArray(options.loadRelations)) + relations = options.loadRelations; + } + return this.gameMetadataRepository.find({ + where: { provider_slug }, + relations, + withDeleted: options.loadDeletedEntities, + relationLoadStrategy: "query", + }); + } + + async findByGameId( + gameId: number, + options: FindOptions = { loadDeletedEntities: false, loadRelations: true }, + ): Promise { + let relations = []; + if (options.loadRelations) { + if (options.loadRelations === true) { + relations = [ + "gamevault_games", + "developers", + "publishers", + "genres", + "tags", + ]; + } else if (Array.isArray(options.loadRelations)) { + relations = options.loadRelations; + } + } + + return this.gameMetadataRepository.find({ + where: { + gamevault_games: { + id: In([gameId]), + }, + }, + relations, + withDeleted: options.loadDeletedEntities, + relationLoadStrategy: "query", + }); + } + + async findOneByGameMetadataIdOrFail( + id: number, + options: FindOptions = { loadDeletedEntities: false, loadRelations: false }, + ): Promise { + try { + let relations = []; + + if (options.loadRelations) { + if (options.loadRelations === true) { + relations = ["developers", "publishers", "genres", "tags"]; + } else if (Array.isArray(options.loadRelations)) + relations = options.loadRelations; + } + + return await this.gameMetadataRepository.findOneOrFail({ + where: { id }, + relations, + withDeleted: options.loadDeletedEntities, + relationLoadStrategy: "query", + }); + } catch (error) { + throw new NotFoundException( + `GameMetadata with id ${id} was not found on the server.`, + { cause: error }, + ); + } + } + + async deleteByGameMetadataIdOrFail(id: number) { + return this.gameMetadataRepository.remove( + await this.findOneByGameMetadataIdOrFail(id), + ); + } + + /** + * Upserts a GameMetadata entity. + * + * If a GameMetadata with the same provider_slug and provider_data_id + * exists, it updates its properties with the ones from the provided + * metadata. Otherwise, it creates a new GameMetadata entity. + */ + async save(game: GameMetadata): Promise { + const existingGame = await this.gameMetadataRepository.findOne({ + where: { + provider_slug: game.provider_slug, + provider_data_id: game.provider_data_id, + }, + relationLoadStrategy: "query", + }); + + const upsertedGame: Required = { + id: existingGame?.id, + created_at: undefined, + updated_at: undefined, + deleted_at: undefined, + entity_version: undefined, + provider_slug: game.provider_slug, + provider_data_id: game.provider_data_id, + provider_data_url: game.provider_data_url, + provider_priority: game.provider_priority, + age_rating: game.age_rating, + title: game.title, + release_date: game.release_date, + description: game.description, + notes: game.notes, + average_playtime: game.average_playtime, + cover: game.cover, + background: game.background, + url_screenshots: game.url_screenshots, + url_trailers: game.url_trailers, + url_gameplays: game.url_gameplays, + url_websites: game.url_websites, + rating: game.rating, + early_access: game.early_access, + launch_parameters: game.launch_parameters, + launch_executable: game.launch_executable, + installer_executable: game.installer_executable, + gamevault_games: undefined, + publishers: null, + developers: null, + tags: null, + genres: null, + }; + + if (game.developers?.length) { + const upsertedDevelopers: DeveloperMetadata[] = []; + for (const developer of game.developers) { + try { + if ( + !upsertedDevelopers.find( + (upsertedDeveloper) => + upsertedDeveloper.provider_slug === developer.provider_slug && + upsertedDeveloper.provider_data_id === + developer.provider_data_id, + ) + ) { + upsertedDevelopers.push( + await this.developerMetadataService.save(developer), + ); + } + } catch (error) { + logger.error({ + message: `Error upserting developer metadata`, + error, + developer, + }); + } + } + upsertedGame.developers = upsertedDevelopers; + } + + if (game.publishers?.length) { + const upsertedPublishers: PublisherMetadata[] = []; + for (const publisher of game.publishers) { + try { + if ( + !upsertedPublishers.find( + (upsertedPublisher) => + upsertedPublisher.provider_slug === publisher.provider_slug && + upsertedPublisher.provider_data_id === + publisher.provider_data_id, + ) + ) { + upsertedPublishers.push( + await this.publisherMetadataService.save(publisher), + ); + } + } catch (error) { + logger.error({ + message: `Error upserting publisher metadata`, + error, + publisher, + }); + } + } + upsertedGame.publishers = upsertedPublishers; + } + + if (game.tags?.length) { + const upsertedTags: TagMetadata[] = []; + for (const tag of game.tags) { + try { + if ( + !upsertedTags.find( + (upsertedTag) => + upsertedTag.provider_slug === tag.provider_slug && + upsertedTag.provider_data_id === tag.provider_data_id, + ) + ) { + upsertedTags.push(await this.tagMetadataService.save(tag)); + } + } catch (error) { + logger.error({ + message: `Error upserting tag metadata`, + error, + tag, + }); + } + } + upsertedGame.tags = upsertedTags; + } + + if (game.genres?.length) { + const upsertedGenres: GenreMetadata[] = []; + for (const genre of game.genres) { + try { + if ( + !upsertedGenres.find( + (upsertedGenre) => + upsertedGenre.provider_slug === genre.provider_slug && + upsertedGenre.provider_data_id === genre.provider_data_id, + ) + ) { + upsertedGenres.push(await this.genreMetadataService.save(genre)); + } + } catch (error) { + logger.error({ + message: `Error upserting genre metadata`, + error, + genre, + }); + } + } + upsertedGame.genres = upsertedGenres; + } + + logger.debug({ + message: `Saving game metadata`, + game: upsertedGame, + already_exists: !!existingGame, + }); + + return this.gameMetadataRepository.save(upsertedGame); + } +} diff --git a/src/modules/metadata/games/minimal-game.metadata.dto.ts b/src/modules/metadata/games/minimal-game.metadata.dto.ts new file mode 100644 index 00000000..5bf8b0af --- /dev/null +++ b/src/modules/metadata/games/minimal-game.metadata.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsNotIn, Matches } from "class-validator"; + +import globals from "../../../globals"; + +export class MinimalGameMetadataDto { + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: + "Invalid slug: Only lowercase letters, numbers, and single hyphens inbetween them are allowed.", + }) + @IsNotIn(globals.RESERVED_PROVIDER_SLUGS, { + message: + "Invalid slug: The terms 'gamevault' and 'user' are reserved slugs.", + }) + @ApiProperty({ + description: + "slug (url-friendly name) of the provider. This is the primary identifier. Must be formatted like a valid slug.", + example: "igdb", + }) + provider_slug: string; + + @ApiPropertyOptional({ + description: "id of the game from the provider", + example: "Grand Theft Auto V", + }) + provider_data_id?: string; + + @ApiProperty({ + description: "title of the game", + example: "Grand Theft Auto V", + }) + title: string; + + @ApiPropertyOptional({ + description: "release date of the game", + example: "2013-09-17T00:00:00.000Z", + }) + release_date?: Date; + + @ApiPropertyOptional({ + description: "box image url of the game", + example: "example.com/example.jpg", + }) + cover_url?: string; + + @ApiPropertyOptional({ + description: "description of the game. markdown supported.", + example: + "An open world action-adventure video game developed by **Rockstar North** and published by **Rockstar Games**.", + }) + description?: string; +} diff --git a/src/modules/metadata/genres/genre.metadata.entity.ts b/src/modules/metadata/genres/genre.metadata.entity.ts new file mode 100644 index 00000000..9864f107 --- /dev/null +++ b/src/modules/metadata/genres/genre.metadata.entity.ts @@ -0,0 +1,54 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotIn, Matches } from "class-validator"; +import { Column, Entity, Index, ManyToMany } from "typeorm"; + +import globals from "../../../globals"; +import { DatabaseEntity } from "../../database/database.entity"; +import { GameMetadata } from "../games/game.metadata.entity"; +import { Metadata } from "../models/metadata.interface"; + +@Entity() +@Index("UQ_GENRE_METADATA", ["provider_slug", "provider_data_id"], { + unique: true, +}) +export class GenreMetadata extends DatabaseEntity implements Metadata { + @Column() + @Index() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: + "Invalid slug: Only lowercase letters, numbers, and single hyphens inbetween them are allowed.", + }) + @IsNotIn(globals.RESERVED_PROVIDER_SLUGS, { + message: + "Invalid slug: The terms 'gamevault' and 'user' are reserved slugs.", + }) + @ApiProperty({ + description: + "slug (url-friendly name) of the provider. This is the primary identifier. Must be formatted like a valid slug.", + example: "igdb", + }) + provider_slug: string; + @Column() + @Index() + @ApiProperty({ + description: "id of the developer from the provider", + example: "1190", + }) + provider_data_id: string; + + @Index() + @Column() + @ApiProperty({ + example: "Platformer", + description: "name of the genre", + }) + name: string; + + @ManyToMany(() => GameMetadata, (game) => game.genres) + @ApiProperty({ + description: "games of the genre", + type: () => GameMetadata, + isArray: true, + }) + games: GameMetadata[]; +} diff --git a/src/modules/metadata/genres/genre.metadata.service.ts b/src/modules/metadata/genres/genre.metadata.service.ts new file mode 100644 index 00000000..96cf4214 --- /dev/null +++ b/src/modules/metadata/genres/genre.metadata.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { FindOptions } from "../../../globals"; +import { GenreMetadata } from "./genre.metadata.entity"; + +@Injectable() +export class GenreMetadataService { + private readonly logger = new Logger(this.constructor.name); + constructor( + @InjectRepository(GenreMetadata) + private readonly genreRepository: Repository, + ) {} + + async findByProviderSlug( + provider_slug: string = "gamevault", + options: FindOptions = { loadDeletedEntities: false, loadRelations: false }, + ): Promise { + let relations = []; + + if (options.loadRelations) { + if (options.loadRelations === true) { + relations = ["games"]; + } else if (Array.isArray(options.loadRelations)) + relations = options.loadRelations; + } + + return this.genreRepository.find({ + where: { provider_slug }, + relations, + withDeleted: options.loadDeletedEntities, + relationLoadStrategy: "query", + }); + } + + async save(genre: GenreMetadata): Promise { + const existingGenre = await this.genreRepository.findOneBy({ + provider_slug: genre.provider_slug, + provider_data_id: genre.provider_data_id, + }); + this.logger.debug({ + message: "Saving genre metadata", + genre, + already_exists: !!genre, + }); + return this.genreRepository.save({ + ...existingGenre, + ...{ + provider_data_id: genre.provider_data_id, + provider_slug: genre.provider_slug, + name: genre.name, + }, + }); + } +} diff --git a/src/modules/metadata/genres/genres.metadata.controller.ts b/src/modules/metadata/genres/genres.metadata.controller.ts new file mode 100644 index 00000000..caaf5015 --- /dev/null +++ b/src/modules/metadata/genres/genres.metadata.controller.ts @@ -0,0 +1,80 @@ +import { Controller, Get } from "@nestjs/common"; +import { ApiBasicAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + Paginate, + paginate, + Paginated, + PaginateQuery, + PaginationType, +} from "nestjs-paginate"; +import { Repository } from "typeorm"; + +import { MinimumRole } from "../../../decorators/minimum-role.decorator"; +import { PaginateQueryOptions } from "../../../decorators/pagination.decorator"; +import { ApiOkResponsePaginated } from "../../../globals"; +import { Role } from "../../users/models/role.enum"; +import { GenreMetadata } from "./genre.metadata.entity"; + +@Controller("genres") +@ApiTags("genres") +@ApiBasicAuth() +export class GenreController { + constructor( + @InjectRepository(GenreMetadata) + private readonly genreRepository: Repository, + ) {} + + /** + * Get a paginated list of genres, sorted by the number of games released by + * each genre (by default). + */ + @Get() + @ApiOperation({ + summary: "get a list of genres", + description: + "by default the list is sorted by the amount of games that are in each genre.", + operationId: "getGenres", + }) + @MinimumRole(Role.GUEST) + @ApiOkResponsePaginated(GenreMetadata) + @PaginateQueryOptions() + async getGenres( + @Paginate() query: PaginateQuery, + ): Promise> { + const queryBuilder = this.genreRepository + .createQueryBuilder("genre") + .leftJoinAndSelect("genre.games", "games") + .where("genre.provider_slug = :provider_slug", { + provider_slug: "gamevault", + }) + .groupBy("genre.id") + .addGroupBy("games.id") + .having("COUNT(games.id) > 0"); + + // If no specific sort is provided, sort by the number of games in descending order + if (query.sortBy?.length === 0) { + queryBuilder + .addSelect("COUNT(games.id)", "games_count") + .orderBy("games_count", "DESC"); + } + + const paginatedResults = await paginate(query, queryBuilder, { + paginationType: PaginationType.TAKE_AND_SKIP, + defaultLimit: 100, + maxLimit: -1, + nullSort: "last", + loadEagerRelations: false, + sortableColumns: ["id", "name", "created_at", "provider_slug"], + searchableColumns: ["name"], + filterableColumns: { + id: true, + created_at: true, + name: true, + }, + withDeleted: false, + }); + + return paginatedResults; + } +} diff --git a/src/modules/metadata/metadata.controller.ts b/src/modules/metadata/metadata.controller.ts new file mode 100644 index 00000000..d201232d --- /dev/null +++ b/src/modules/metadata/metadata.controller.ts @@ -0,0 +1,62 @@ +import { Controller, Get, Param, Query } from "@nestjs/common"; +import { + ApiBasicAuth, + ApiOkResponse, + ApiOperation, + ApiQuery, + ApiTags, +} from "@nestjs/swagger"; + +import { MinimumRole } from "../../decorators/minimum-role.decorator"; +import { Role } from "../users/models/role.enum"; +import { MinimalGameMetadataDto } from "./games/minimal-game.metadata.dto"; +import { MetadataService } from "./metadata.service"; +import { MetadataProviderDto } from "./providers/models/metadata-provider.dto"; +import { ProviderSlugDto } from "./providers/models/provider-slug.dto"; + +@Controller("metadata") +@ApiTags("metadata") +@ApiBasicAuth() +export class MetadataController { + constructor(private readonly metadataService: MetadataService) {} + + @Get("/providers") + @ApiOperation({ + summary: "Get a list of all registered metadata providers.", + operationId: "getProviders", + }) + @MinimumRole(Role.EDITOR) + @ApiOkResponse({ type: () => MetadataProviderDto, isArray: true }) + async getProviders(): Promise { + return this.metadataService.providers.map( + (provider) => + ({ + slug: provider.slug, + name: provider.name, + priority: provider.priority, + enabled: provider.enabled, + }) as MetadataProviderDto, + ); + } + + @Get("/providers/:provider_slug/search") + @ApiOperation({ + summary: "Search for games using a metadata provider.", + operationId: "getSearchResultsByProvider", + }) + @ApiQuery({ + name: "query", + description: + "Search Query. Usually it is the title of the game but specific providers may have their own syntax.", + }) + @MinimumRole(Role.EDITOR) + @ApiOkResponse({ type: () => MinimalGameMetadataDto, isArray: true }) + async getSearchResultsByProvider( + @Param() params: ProviderSlugDto, + @Query("query") query: string, + ): Promise { + return this.metadataService + .getProviderBySlugOrFail(params.provider_slug) + .search(query); + } +} diff --git a/src/modules/metadata/metadata.module.ts b/src/modules/metadata/metadata.module.ts new file mode 100644 index 00000000..ac6bb661 --- /dev/null +++ b/src/modules/metadata/metadata.module.ts @@ -0,0 +1,67 @@ +import { forwardRef, Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { GamesModule } from "../games/games.module"; +import { MediaModule } from "../media/media.module"; +import { DeveloperMetadata } from "./developers/developer.metadata.entity"; +import { DeveloperMetadataService } from "./developers/developer.metadata.service"; +import { DeveloperController as DevelopersController } from "./developers/developers.metadata.controller"; +import { GameMetadata } from "./games/game.metadata.entity"; +import { GameMetadataService } from "./games/game.metadata.service"; +import { GenreMetadata } from "./genres/genre.metadata.entity"; +import { GenreMetadataService } from "./genres/genre.metadata.service"; +import { GenreController as GenresController } from "./genres/genres.metadata.controller"; +import { MetadataController } from "./metadata.controller"; +import { MetadataService } from "./metadata.service"; +import { IgdbMetadataProviderService } from "./providers/igdb/igdb.metadata-provider.service"; +import { RawgLegacyMetadataProviderService } from "./providers/rawg-legacy/rawg-legacy.metadata-provider.service"; +import { TestHighPriorityProviderService } from "./providers/testing/test-high-priority.metadata-provider.service"; +import { TestLowPriorityProviderService } from "./providers/testing/test-low-priority.metadata-provider.service"; +import { PublisherMetadata } from "./publishers/publisher.metadata.entity"; +import { PublisherMetadataService } from "./publishers/publisher.metadata.service"; +import { PublisherController as PublishersController } from "./publishers/publishers.metadata.controller"; +import { TagMetadata } from "./tags/tag.metadata.entity"; +import { TagMetadataService } from "./tags/tag.metadata.service"; +import { TagsController } from "./tags/tags.metadata.controller"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + DeveloperMetadata, + GameMetadata, + GenreMetadata, + PublisherMetadata, + TagMetadata, + ]), + forwardRef(() => GamesModule), + MediaModule, + ], + providers: [ + MetadataService, + DeveloperMetadataService, + GameMetadataService, + GenreMetadataService, + PublisherMetadataService, + TagMetadataService, + RawgLegacyMetadataProviderService, + IgdbMetadataProviderService, + TestLowPriorityProviderService, + TestHighPriorityProviderService, + ], + exports: [ + MetadataService, + DeveloperMetadataService, + GameMetadataService, + GenreMetadataService, + PublisherMetadataService, + TagMetadataService, + ], + controllers: [ + MetadataController, + TagsController, + GenresController, + PublishersController, + DevelopersController, + ], +}) +export class MetadataModule {} diff --git a/src/modules/metadata/metadata.service.ts b/src/modules/metadata/metadata.service.ts new file mode 100644 index 00000000..cfa89b93 --- /dev/null +++ b/src/modules/metadata/metadata.service.ts @@ -0,0 +1,530 @@ +import { + ConflictException, + forwardRef, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { validateOrReject } from "class-validator"; + +import { kebabCase } from "lodash"; +import { setTimeout } from "timers/promises"; +import configuration from "../../configuration"; +import { logGamevaultGame, logMetadataProvider } from "../../logging"; +import { GamesService } from "../games/games.service"; +import { GamevaultGame } from "../games/gamevault-game.entity"; +import { GameMetadata } from "./games/game.metadata.entity"; +import { GameMetadataService } from "./games/game.metadata.service"; +import { MinimalGameMetadataDto } from "./games/minimal-game.metadata.dto"; +import { MetadataProvider } from "./providers/abstract.metadata-provider.service"; +import { ProviderNotFoundException } from "./providers/models/provider-not-found.exception"; + +@Injectable() +export class MetadataService { + private readonly logger = new Logger(this.constructor.name); + private readonly metadataJobs = new Map(); + providers: MetadataProvider[] = []; + + constructor( + @Inject(forwardRef(() => GamesService)) + private readonly gamesService: GamesService, + private readonly gameMetadataService: GameMetadataService, + ) {} + + /** + * Registers a metadata provider. + * If a provider with the same slug or priority already exists, throws a ConflictException. + * Validates the provider using class-validator and throws an InternalServerErrorException if validation fails. + * Sorts the providers by priority in ascending order. + */ + registerProvider(provider: MetadataProvider) { + // Check if a provider with the same slug or priority already exists + const existingProvider = this.providers?.find( + (existingProvider) => + existingProvider.slug === provider.slug || + existingProvider.priority === provider.priority, + ); + + if (existingProvider) { + const errorMessage = + `There is already a provider (${existingProvider.slug}) with the ` + + (provider.slug === existingProvider.slug + ? `same slug (${provider.slug})` + : `same priority (${provider.priority})`); + throw new ConflictException(errorMessage); + } + + // Validate the provider using class-validator + validateOrReject(provider).catch((errors) => { + this.logger.error({ + message: `Failed to register metadata provider due to validation errors.`, + provider: logMetadataProvider(provider), + }); + console.error(errors); + }); + + // Add the provider to the list of providers + this.providers.push(provider); + + // Sort the providers by priority in descending order + this.providers.sort((a, b) => b.priority - a.priority); + + // Log the registration of the metadata provider + this.logger.log({ + message: `Registered metadata provider.`, + provider: logMetadataProvider(provider), + }); + } + + /** + * Retrieves a metadata provider by its slug. + * If no provider is found, it throws a NotFoundException. + */ + getProviderBySlugOrFail(slug: string): MetadataProvider { + if (!slug) { + throw new NotFoundException(`No slug provided.`); + } + + // Find the provider with the given slug. + const provider = this.providers.find((provider) => provider.slug === slug); + + // If no provider is found, throw a NotFoundException. + if (!provider) { + throw new ProviderNotFoundException( + `There is no registered provider with slug "${slug}".`, + ); + } + + // Return the found provider. + return provider; + } + + /** + * Checks the metadata of games and updates them if necessary. + */ + async checkAndUpdateMetadata(games: GamevaultGame[]): Promise { + for (const game of games) { + const alreadyEnqueued = this.metadataJobs.has(game.id); + this.metadataJobs.set(game.id, game); + + if (alreadyEnqueued) { + this.logger.debug({ + message: + "Skipping metadata job, because it is already enqueued, but updated game details accordingly.", + game: logGamevaultGame(game), + }); + continue; + } + + try { + await this.runUpdateMetadataJob(game.id); + } catch (error) { + this.logger.warn({ + message: "Error checking and updating metadata for game.", + game: logGamevaultGame(game), + error, + }); + } finally { + this.metadataJobs.delete(game.id); + } + } + } + + /** + * Updates the metadata of a game if necessary. + * If the game's file path contains "(NC)", the metadata update is skipped. + * If the game's metadata is already up to date (i.e. the TTL has not been exceeded), + * the metadata update is skipped. + * If the metadata update fails for a provider, the error is logged and the update is skipped. + * @param game The game to update the metadata for. + * @returns The updated game. + */ + private async runUpdateMetadataJob(gameId: number): Promise { + const game = this.metadataJobs.get(gameId); + if (!game) { + this.logger.error({ + message: "Corresponing metadata-job was not found", + game: { id: gameId }, + }); + throw new NotFoundException("Corresponing metadata-job was not found"); + } + + this.logger.log({ + message: "Updating metadata.", + game: logGamevaultGame(game), + }); + + // If the game's file path contains "(NC)", skip the metadata update. + if (game.file_path.includes("(NC)")) { + this.logger.debug({ + message: "Skipping metadata update for (NC) game.", + game: logGamevaultGame(game), + }); + return; + } + + for (const provider of this.providers.filter( + (provider) => provider.enabled, + )) { + try { + // Find the existing provider metadata for the game and provider. + const existingProviderMetadata = game.provider_metadata?.find( + (metadata) => metadata.provider_slug === provider.slug, + ); + + // If the existing provider metadata is already up to date, skip the update. + if ( + existingProviderMetadata && + (existingProviderMetadata.updated_at ?? + existingProviderMetadata.created_at) > + new Date( + Date.now() - + configuration.METADATA.TTL_IN_DAYS * 24 * 60 * 60 * 1000, + ) + ) { + this.logger.debug({ + message: "Metadata is already up to date. Skipping.", + game: logGamevaultGame(game), + provider: logMetadataProvider(provider), + }); + continue; + } + + if (provider.request_interval_ms) { + // Waiting the specified request interval to prevent hitting rate limits before making requests to the provider + this.logger.debug({ + message: `Delaying requests by ${provider.request_interval_ms} ms to avoid rate limits.`, + game: logGamevaultGame(game), + provider: logMetadataProvider(provider), + }); + await setTimeout(provider.request_interval_ms); + } + + // If the existing provider metadata is not up to date, update it. + if (existingProviderMetadata) { + await this.map( + game.id, + provider.slug, + existingProviderMetadata.provider_data_id, + undefined, + ); + } else { + // If the existing provider metadata is not found, find the metadata. + await this.findMetadata(game, provider); + } + } catch (error) { + // If the metadata update fails, log the error and skip the update. + this.logger.error({ + message: "Failed updating metadata for game and provider. Skipping.", + game: logGamevaultGame(game), + provider: logMetadataProvider(provider), + error, + }); + } + } + + // If no metadata changes were made, return the game without merging the metadata. + if ( + !game.metadata || + game.provider_metadata.some( + (provider_metadata) => + provider_metadata?.updated_at > game.metadata?.updated_at, + ) || + game.user_metadata?.updated_at > game.metadata?.updated_at + ) { + this.merge(game.id).catch((error) => { + this.logger.warn({ + message: "Error merging metadata for game.", + game: logGamevaultGame(game), + error, + }); + }); + } else { + this.logger.debug({ + message: "No metadata changes. Skipping merge.", + game: logGamevaultGame(game), + }); + } + } + + /** + * Checks the metadata of a single provider and updates it if necessary. + */ + private async findMetadata( + game: GamevaultGame, + provider: MetadataProvider, + ): Promise { + this.logger.log({ + message: "Searching for metadata.", + game: logGamevaultGame(game), + provider: logMetadataProvider(provider), + }); + try { + const bestMatchingGame = await provider.getBestMatch(game); + await this.map( + game.id, + provider.slug, + bestMatchingGame.provider_data_id, + undefined, + ); + } catch (error) { + if (error instanceof NotFoundException) { + this.logger.debug({ + message: "No matching game found.", + game: logGamevaultGame(game), + provider: logMetadataProvider(provider), + }); + return; + } + throw error; + } + } + + /** + * Searches for metadata of a game using a specific provider. + */ + async search( + query: string, + providerSlug: string, + ): Promise { + const provider = this.getProviderBySlugOrFail(providerSlug); + try { + const results = provider.search(query); + this.logger.debug({ + message: "Searched for metadata.", + provider: logMetadataProvider(provider), + query, + results, + }); + return results; + } catch (error) { + this.logger.error({ + message: "Error searching provider.", + provider: logMetadataProvider(provider), + query, + error, + }); + throw new InternalServerErrorException( + error, + "Error searching provider. Please check the server logs for details.", + ); + } + } + + async merge(gameId: number): Promise { + const game = await this.gamesService.findOneByGameIdOrFail(gameId, { + loadDeletedEntities: false, + }); + + if (!game.provider_metadata.length && !game.user_metadata) { + this.logger.warn({ + message: "No metadata found to merge.", + game: gameId, + }); + return game; + } + + // Sort the provider metadata by priority in ascending order + const providerMetadata = game.provider_metadata.toSorted((a, b) => { + return ( + (a.provider_priority ?? + this.getProviderBySlugOrFail(a.provider_slug).priority) - + (b.provider_priority ?? + this.getProviderBySlugOrFail(b.provider_slug).priority) + ); + }); + + const userMetadata = JSON.parse( + JSON.stringify(game.user_metadata), + ) as GameMetadata; + + let mergedMetadata = new GameMetadata(); + + // Create New Effective Metadata by applying the priorotized metadata one by one + for (const metadata of providerMetadata) { + // Delete all empty fields of provider so only delta is overwritten + for (const key of Object.keys(metadata)) { + if (metadata[key] == null) { + delete metadata[key]; + } + if (Array.isArray(metadata[key]) && metadata[key].length === 0) { + delete metadata[key]; + } + } + + mergedMetadata = { + ...mergedMetadata, + ...metadata, + } as GameMetadata; + } + + // Apply the users changes on top + if (userMetadata) { + // Delete all empty fields of dto.user_metadata so only delta is overwritten + for (const key of Object.keys(userMetadata)) { + if (userMetadata[key] == null) { + delete userMetadata[key]; + } + if ( + Array.isArray(userMetadata[key]) && + userMetadata[key].length === 0 + ) { + delete userMetadata[key]; + } + } + + mergedMetadata = { + ...mergedMetadata, + ...userMetadata, + } as GameMetadata; + } + + // Apply the merged metadata to the game + mergedMetadata = { + ...mergedMetadata, + ...{ + id: game.metadata?.id || undefined, + provider_slug: "gamevault", + provider_data_id: gameId.toString(), + provider_priority: null, + }, + } as GameMetadata; + + if (mergedMetadata.genres?.length) { + for (const genre of mergedMetadata.genres) { + genre.id = undefined; + genre.provider_slug = "gamevault"; + genre.provider_data_id = kebabCase(genre.name); + } + } + + if (mergedMetadata.tags?.length) { + for (const tag of mergedMetadata.tags) { + tag.id = undefined; + tag.provider_slug = "gamevault"; + tag.provider_data_id = kebabCase(tag.name); + } + } + + if (mergedMetadata.developers?.length) { + for (const developer of mergedMetadata.developers) { + developer.id = undefined; + developer.provider_slug = "gamevault"; + developer.provider_data_id = kebabCase(developer.name); + } + } + + if (mergedMetadata.publishers?.length) { + for (const publisher of mergedMetadata.publishers) { + publisher.id = undefined; + publisher.provider_slug = "gamevault"; + publisher.provider_data_id = kebabCase(publisher.name); + } + } + + // Save the merged metadata + game.metadata = await this.gameMetadataService.save(mergedMetadata); + const mergedGame = await this.gamesService.save(game); + this.logger.debug({ + message: "Merged metadata.", + game: logGamevaultGame(mergedGame), + details: mergedGame, + }); + return mergedGame; + } + + /** + * Removes metadata from the game. Does not remove user provided metadata. + + */ + async unmap(gameId: number, providerSlug: string) { + // Find the game by gameId. + const game = await this.gamesService.findOneByGameIdOrFail(gameId, { + loadDeletedEntities: false, + }); + + // Clear the effective metadata. + game.provider_metadata = game.provider_metadata.filter( + (metadata) => metadata.provider_slug !== providerSlug, + ); + this.logger.log({ + message: "Unmapped metadata provider from a game.", + game: logGamevaultGame(game), + providerSlug, + }); + + if (game.metadata) { + // Clear the merged metadata. + await this.gameMetadataService.deleteByGameMetadataIdOrFail( + game.metadata.id, + ); + game.metadata = null; + this.logger.debug({ + message: "Deleted merged metadata for a game.", + game: logGamevaultGame(game), + providerSlug, + }); + } + + // Clear the user metadata if necessary. + if (providerSlug === "user" && game.user_metadata?.id) { + await this.gameMetadataService.deleteByGameMetadataIdOrFail( + game.user_metadata.id, + ); + game.user_metadata = null; + game.sort_title = this.gamesService.generateSortTitle(game.title); + this.logger.log({ + message: "Deleted user metadata from a game.", + game: logGamevaultGame(game), + providerSlug, + }); + } + + return this.gamesService.save(game); + } + + /** + * Maps the metadata of a game provider to a game, overwriting the existing one if necessary. + * Metadata usually needs to be merged after to be effective. + */ + async map( + gameId: number, + providerSlug: string, + providerGameId: string, + providerPriority: number, + ) { + const provider = this.getProviderBySlugOrFail(providerSlug); + try { + const metadata = await provider.getByProviderDataIdOrFail(providerGameId); + + if (providerPriority != null) { + metadata.provider_priority = providerPriority; + } + + const game = await this.unmap(gameId, providerSlug); + game.provider_metadata.push( + await this.gameMetadataService.save(metadata), + ); + const mappedGame = await this.gamesService.save(game); + this.logger.log({ + message: "Mapped metadata provider to a game.", + game: logGamevaultGame(game), + providerSlug, + }); + return mappedGame; + } catch (error) { + this.logger.error({ + message: "Error mapping game to provider.", + provider: logMetadataProvider(provider), + game: logGamevaultGame({ id: gameId } as GamevaultGame), + error, + }); + throw new InternalServerErrorException( + error, + "Error mapping game to provider. Please check the server logs for details.", + ); + } + } +} diff --git a/src/modules/metadata/models/map-game-body.dto.ts b/src/modules/metadata/models/map-game-body.dto.ts new file mode 100644 index 00000000..67ae4347 --- /dev/null +++ b/src/modules/metadata/models/map-game-body.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty } from "class-validator"; + +export class MapGameBodyDto { + @IsNotEmpty() + @ApiProperty({ + description: + "Target id of the game this game should be mapped to from the metadata provider. Can be found in the provider's API or website.", + example: "12345", + }) + provider_game_id: string; +} diff --git a/src/modules/metadata/models/map-game-params.dto.ts b/src/modules/metadata/models/map-game-params.dto.ts new file mode 100644 index 00000000..c0cf86c8 --- /dev/null +++ b/src/modules/metadata/models/map-game-params.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsNumberString, Matches } from "class-validator"; + +import { GameIdDto } from "../../games/models/game-id.dto"; +import { ProviderSlugDto } from "../providers/models/provider-slug.dto"; + +export class MapGameParamsDto implements ProviderSlugDto, GameIdDto { + @IsNotEmpty() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: + "Invalid slug: Only lowercase letters, numbers, and single hyphens inbetween them are allowed.", + }) + @ApiProperty({ + description: + "slug (url-friendly name) of the provider. This is the primary identifier. Must be formatted like a valid slug.", + example: "igdb", + }) + provider_slug: string; + + @IsNumberString() + @IsNotEmpty() + @ApiProperty({ example: "1", description: "id of the game" }) + game_id: number; +} diff --git a/src/modules/metadata/models/metadata.interface.ts b/src/modules/metadata/models/metadata.interface.ts new file mode 100644 index 00000000..8f751a6c --- /dev/null +++ b/src/modules/metadata/models/metadata.interface.ts @@ -0,0 +1,9 @@ +export interface Metadata { + id: number; + provider_slug?: string; + provider_data_id?: string; + provider_priority?: number; + name?: string; + created_at?: Date; + updated_at?: Date; +} diff --git a/src/modules/metadata/models/user-game-metadata.dto.ts b/src/modules/metadata/models/user-game-metadata.dto.ts new file mode 100644 index 00000000..83f79333 --- /dev/null +++ b/src/modules/metadata/models/user-game-metadata.dto.ts @@ -0,0 +1,240 @@ +import { Optional } from "@nestjs/common"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsArray, + IsBoolean, + IsDateString, + IsInt, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + IsUrl, + Max, + Min, + NotContains, +} from "class-validator"; + +import { MediaValidator } from "../../../validators/media.validator"; +import { Media } from "../../media/media.entity"; + +export class UpdateGameUserMetadataDto { + @ApiPropertyOptional({ + description: "the minimum age required to play the game", + example: 18, + default: 0, + }) + @IsOptional() + @IsNotEmpty() + @IsInt() + @Min(0) + age_rating?: number = 0; + + @IsOptional() + @IsString() + @IsNotEmpty() + @ApiPropertyOptional({ + description: "title of the game", + example: "Grand Theft Auto V", + }) + title?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @ApiPropertyOptional({ + description: + "sort title of the game, generated and used to optimize sorting.", + example: "grand theft auto 5", + }) + sort_title?: string; + + @ApiPropertyOptional({ + description: "release date of the game as ISO8601 string", + example: "2013-09-17T00:00:00.000Z", + }) + @IsOptional() + @IsDateString() + @IsNotEmpty() + release_date?: string; + + @ApiPropertyOptional({ + description: "description of the game. markdown supported.", + example: + "An open world action-adventure video game developed by **Rockstar North** and published by **Rockstar Games**.", + }) + @IsOptional() + @IsString() + @IsNotEmpty() + description?: string; + + @ApiPropertyOptional({ + description: + "public notes from the admin for the game. markdown supported.", + example: "# README \n Install other game first!", + }) + @IsOptional() + @IsString() + @IsNotEmpty() + notes?: string; + + @ApiPropertyOptional({ + description: "average playtime of other people in the game in minutes", + example: 180, + }) + @IsInt() + @Min(0) + @Optional() + @IsNotEmpty() + average_playtime?: number; + + @MediaValidator("image") + @Optional() + @IsNotEmpty() + @ApiPropertyOptional({ + description: "cover/boxart image of the game", + type: () => Media, + }) + cover?: Media; + + @MediaValidator("image") + @Optional() + @IsNotEmpty() + @ApiPropertyOptional({ + description: "background image of the game", + type: () => Media, + }) + background?: Media; + + @ApiPropertyOptional({ + description: "rating of the provider", + example: 90, + }) + @IsOptional() + @IsNotEmpty() + @IsNumber() + @Min(0) + @Max(100) + rating?: number; + + @ApiProperty({ + description: "indicates if the game is in early access", + example: true, + }) + @IsBoolean() + @IsOptional() + @IsNotEmpty() + early_access?: boolean; + + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiPropertyOptional({ + description: "Predefined launch parameters for the game.", + example: "-fullscreen -dx11", + }) + launch_parameters?: string; + + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiPropertyOptional({ + description: "Predefined launch executable for the game.", + example: "ShooterGame.exe", + }) + launch_executable?: string; + + @IsOptional() + @IsNotEmpty() + @IsString() + @ApiPropertyOptional({ + description: "Predefined installer executable for the game.", + example: "setup.exe", + }) + installer_executable?: string; + + @IsArray() + @IsOptional() + @IsUrl(undefined, { each: true }) + @NotContains(",", { each: true }) + @IsNotEmpty({ each: true }) + @ApiPropertyOptional({ + description: "URLs of externally hosted screenshots of the game", + isArray: true, + }) + url_screenshots?: string[]; + + @IsArray() + @IsOptional() + @IsUrl(undefined, { each: true }) + @NotContains(",", { each: true }) + @IsNotEmpty({ each: true }) + @ApiPropertyOptional({ + description: "URLs of externally hosted trailer videos of the game", + isArray: true, + }) + url_trailers?: string[]; + + @IsArray() + @IsOptional() + @IsUrl(undefined, { each: true }) + @NotContains(",", { each: true }) + @IsNotEmpty({ each: true }) + @ApiPropertyOptional({ + description: "URLs of externally hosted gameplay videos of the game", + isArray: true, + }) + url_gameplays?: string[]; + + @IsArray() + @IsOptional() + @IsUrl(undefined, { each: true }) + @NotContains(",", { each: true }) + @IsNotEmpty({ each: true }) + @ApiPropertyOptional({ + description: "URLs of websites of the game", + example: "https://www.escapefromtarkov.com/", + isArray: true, + }) + url_websites?: string[]; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @ApiPropertyOptional({ + description: "publishers of the game", + isArray: true, + }) + publishers?: string[]; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @ApiPropertyOptional({ + description: "developers of the game", + isArray: true, + }) + developers?: string[]; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @ApiPropertyOptional({ + description: "tags of the game", + isArray: true, + }) + tags?: string[]; + + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @IsOptional() + @ApiPropertyOptional({ + description: "genres of the game", + isArray: true, + }) + genres?: string[]; +} diff --git a/src/modules/metadata/providers/abstract.metadata-provider.service.ts b/src/modules/metadata/providers/abstract.metadata-provider.service.ts new file mode 100644 index 00000000..aff98a20 --- /dev/null +++ b/src/modules/metadata/providers/abstract.metadata-provider.service.ts @@ -0,0 +1,230 @@ +import { + Injectable, + Logger, + NotFoundException, + OnModuleInit, +} from "@nestjs/common"; +import { ApiProperty } from "@nestjs/swagger"; +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsNotIn, + Matches, + Min, +} from "class-validator"; +import { stringSimilarity } from "string-similarity-js"; + +import { kebabCase } from "lodash"; +import globals from "../../../globals"; +import { logGamevaultGame } from "../../../logging"; +import { GamevaultGame } from "../../games/gamevault-game.entity"; +import { MediaService } from "../../media/media.service"; +import { DeveloperMetadata } from "../developers/developer.metadata.entity"; +import { DeveloperMetadataService } from "../developers/developer.metadata.service"; +import { GameMetadata } from "../games/game.metadata.entity"; +import { GameMetadataService } from "../games/game.metadata.service"; +import { MinimalGameMetadataDto } from "../games/minimal-game.metadata.dto"; +import { GenreMetadata } from "../genres/genre.metadata.entity"; +import { GenreMetadataService } from "../genres/genre.metadata.service"; +import { MetadataService } from "../metadata.service"; +import { PublisherMetadata } from "../publishers/publisher.metadata.entity"; +import { PublisherMetadataService } from "../publishers/publisher.metadata.service"; +import { TagMetadata } from "../tags/tag.metadata.entity"; +import { TagMetadataService } from "../tags/tag.metadata.service"; + +@Injectable() +export abstract class MetadataProvider implements OnModuleInit { + protected readonly logger = new Logger(this.constructor.name); + constructor( + protected readonly metadataService: MetadataService, + private readonly gameMetadataService: GameMetadataService, + private readonly developerMetadataService: DeveloperMetadataService, + private readonly publisherMetadataService: PublisherMetadataService, + private readonly tagMetadataService: TagMetadataService, + private readonly genreMetadataService: GenreMetadataService, + protected readonly mediaService: MediaService, + ) {} + + async onModuleInit() { + await this.register(); + } + + @IsNotEmpty() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: + "Invalid slug: Only lowercase letters, numbers, and single hyphens inbetween them are allowed.", + }) + @IsNotIn(globals.RESERVED_PROVIDER_SLUGS, { + message: + "Invalid slug: The terms 'gamevault' and 'user' are reserved slugs.", + }) + @ApiProperty({ + description: + "slug (url-friendly name) of the provider. This is the primary identifier. Must be formatted like a valid slug.", + example: "igdb", + }) + public slug: string; + + @IsNotEmpty() + @ApiProperty({ + description: "display name of the provider.", + example: "IGDB", + }) + public name: string; + + @IsInt() + @IsNotEmpty() + @ApiProperty({ + type: Number, + description: + "priority of usage for this provider. Lower priority providers are tried first, while higher priority providers fill in gaps.", + }) + public priority: number; + + @IsBoolean() + @ApiProperty({ + type: Boolean, + description: "whether this provider is enabled or not.", + default: true, + }) + public enabled = true; + + @IsInt() + @Min(0) + @ApiProperty({ + type: Number, + description: + "the interval, in milliseconds, to wait between consecutive requests to prevent exceeding rate limits. this delay will be applied before each call to the provider.", + }) + public request_interval_ms = 0; + + /** + * Searches for a game using the provider. Only returns the minimal info of a game. + */ + public abstract search(query: string): Promise; + + /** + * Returns a game metadata object using the id. + * + * **CAUTION: Data needs to be upserted before using it.** + * @param provider_data_id - The provider data id of the game. + * @returns A promise that resolves to the game metadata object. + * @throws NotFoundException if the game is not found. + */ + public abstract getByProviderDataIdOrFail( + provider_data_id: string, + ): Promise; + + /** + * Searches for the best match for a given game using all available + * metadata providers. + * + * @param game - The game to find a match for. + * @returns A promise that resolves to the best match + * @throws NotFoundException if no matching games are found. + */ + public async getBestMatch( + game: GamevaultGame, + ): Promise { + // Search for the game using all available metadata providers. + const gameResults = await this.search(game.title); + + // If no matching games are found, throw an exception. + if (gameResults.length === 0) { + throw new NotFoundException("No matching games found."); + } + + // Map of game index (key) to probability (value). + const probabilityMap = new Map(); + + // Calculate the probability of a game result being a match for the input game. + for (const gameResult of gameResults) { + // Clean casing + const cleanedGameTitle = kebabCase(game.title); + const cleanedGameResultTitle = kebabCase(gameResult.title); + + if (!cleanedGameTitle || !cleanedGameResultTitle) { + this.logger.warn({ + message: "Could not clean game title.", + game: logGamevaultGame(game), + gameResult, + }); + continue; + } + + // Calculate the similarity between the two titles and assign it to the game result. + probabilityMap.set( + gameResults.indexOf(gameResult), + stringSimilarity(cleanedGameTitle, cleanedGameResultTitle), + ); + + // If both games have a release date, subtract the absolute difference in years divided by 10 from the match probability. + if (game.release_date && gameResult.release_date) { + const gameReleaseYear = new Date( + gameResult.release_date, + ).getUTCFullYear(); + const gameResultReleaseYear = new Date( + gameResult.release_date, + ).getUTCFullYear(); + + probabilityMap.set( + gameResults.indexOf(gameResult), + probabilityMap.get(gameResults.indexOf(gameResult)) - + Math.abs(gameResultReleaseYear - gameReleaseYear) / 10, + ); + } + } + + // Sort the game results by the match probability in descending order. + gameResults.sort( + (a, b) => + probabilityMap.get(gameResults.indexOf(b)) - + probabilityMap.get(gameResults.indexOf(a)), + ); + + this.logger.debug({ + message: "Found matching games.", + game: logGamevaultGame(game), + gameResults: gameResults.map((gameResult) => ({ + probability: probabilityMap.get(gameResults.indexOf(gameResult)), + title: gameResult.title, + release_date: gameResult.release_date, + provider_data_id: gameResult.provider_data_id, + })), + }); + + // Return the game result with the highest match probability. + return gameResults.shift(); + } + + public async register() { + if (!this.enabled) { + this.logger.debug({ + message: `Metadata provider "${this.slug}" is disabled.`, + }); + return; + } + this.metadataService.registerProvider(this); + } + + public async findGames(): Promise { + return this.gameMetadataService.findByProviderSlug(this.slug); + } + + public async findPublishers(): Promise { + return this.publisherMetadataService.findByProviderSlug(this.slug); + } + + public async findDevelopers(): Promise { + return this.developerMetadataService.findByProviderSlug(this.slug); + } + + public async findTags(): Promise { + return this.tagMetadataService.findByProviderSlug(this.slug); + } + + public async findGenres(): Promise { + return this.genreMetadataService.findByProviderSlug(this.slug); + } +} diff --git a/src/modules/metadata/providers/igdb/igdb.metadata-provider.service.ts b/src/modules/metadata/providers/igdb/igdb.metadata-provider.service.ts new file mode 100644 index 00000000..ee6b02bf --- /dev/null +++ b/src/modules/metadata/providers/igdb/igdb.metadata-provider.service.ts @@ -0,0 +1,324 @@ +import { Injectable } from "@nestjs/common"; +import { + fields, + igdb, + search, + twitchAccessToken, + where, + whereIn, +} from "ts-igdb-client"; + +import { isNumberString } from "class-validator"; +import { isEmpty, toLower } from "lodash"; +import configuration from "../../../../configuration"; +import { DeveloperMetadata } from "../../developers/developer.metadata.entity"; +import { GameMetadata } from "../../games/game.metadata.entity"; +import { MinimalGameMetadataDto } from "../../games/minimal-game.metadata.dto"; +import { GenreMetadata } from "../../genres/genre.metadata.entity"; +import { PublisherMetadata } from "../../publishers/publisher.metadata.entity"; +import { TagMetadata } from "../../tags/tag.metadata.entity"; +import { MetadataProvider } from "../abstract.metadata-provider.service"; +import { + GameVaultIgdbAgeRatingMap, + IgdbAgeRating, +} from "./models/igdb-age-rating.interface"; +import { IgdbGameCategory } from "./models/igdb-game-category.enum"; +import { IgdbGameStatus } from "./models/igdb-game-status.enum"; +import { IgdbGame } from "./models/igdb-game.interface"; + +@Injectable() +export class IgdbMetadataProviderService extends MetadataProvider { + enabled = configuration.METADATA.IGDB.ENABLED; + request_interval_ms = configuration.METADATA.IGDB.REQUEST_INTERVAL_MS; + readonly slug = "igdb"; + readonly name = "IGDB"; + readonly priority = configuration.METADATA.IGDB.PRIORITY; + readonly fieldsToInclude = [ + "*", + "age_ratings.*", + "cover.*", + "genres.*", + "involved_companies.*", + "involved_companies.company.*", + "keywords.*", + "screenshots.*", + "artworks.*", + "videos.*", + "themes.*", + "websites.*", + ]; + readonly categoriesToInclude = [ + IgdbGameCategory.main_game, + IgdbGameCategory.standalone_expansion, + IgdbGameCategory.episode, + IgdbGameCategory.season, + IgdbGameCategory.remake, + IgdbGameCategory.remaster, + IgdbGameCategory.expanded_game, + IgdbGameCategory.port, + IgdbGameCategory.fork, + ]; + + override async onModuleInit(): Promise { + if ( + !configuration.METADATA.IGDB.CLIENT_ID || + !configuration.METADATA.IGDB.CLIENT_SECRET + ) { + this.enabled = false; + this.logger.warn({ + message: + "IGDB Metadata Provider is disabled because METADATA_IGDB_CLIENT_ID or METADATA_IGDB_CLIENT_SECRET is not set.", + }); + return; + } + super.onModuleInit(); + } + + public override async search( + query: string, + ): Promise { + const client = await this.getClient(); + + const found_games = []; + + const searchByName = await client + .request("games") + .pipe( + fields(["id", "name", "first_release_date", "cover.*"]), + search(query), + whereIn("category", this.categoriesToInclude), + ) + .execute(); + + found_games.push(...searchByName.data); + + if (isNumberString(query)) { + const searchById = await client + .request("games") + .pipe( + fields(["id", "name", "first_release_date", "cover.*"]), + where("id", "=", Number(query)), + ) + .execute(); + found_games.push(...searchById.data); + } + + this.logger.debug({ + message: `Found ${found_games.length} games on IGDB`, + query, + count: found_games.length, + games: found_games, + }); + + const minimalGameMetadata = []; + for (const game of found_games) { + minimalGameMetadata.push( + await this.mapMinimalGameMetadata(game as IgdbGame), + ); + } + return minimalGameMetadata; + } + + public override async getByProviderDataIdOrFail( + provider_data_id: string, + ): Promise { + const update = await ( + await this.getClient() + ) + .request("games") + .pipe( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fields(this.fieldsToInclude as any), + where("id", "=", Number(provider_data_id)), + ) + .execute(); + return this.mapGameMetadata(update.data[0] as IgdbGame); + } + + private async mapGameMetadata(game: IgdbGame): Promise { + return { + age_rating: this.calculateAverageAgeRating(game.name, game.age_ratings), + provider_slug: this.slug, + provider_data_id: game.id?.toString(), + provider_data_url: game.url, + title: game.name, + release_date: isNaN(new Date(game.first_release_date * 1000).getTime()) + ? undefined + : new Date(game.first_release_date * 1000), + description: + game.summary && game.storyline + ? `${game.summary}\n\n${game.storyline}` + : game.summary || game.storyline || null, + rating: game.total_rating, + url_websites: game.websites?.map((website) => website.url), + early_access: [ + IgdbGameStatus.alpha, + IgdbGameStatus.beta, + IgdbGameStatus.early_access, + ].includes(game.status), + url_screenshots: [ + ...(game.screenshots || []), + ...(game.artworks || []), + ].map((image) => this.replaceUrl(image.url, "t_thumb", "t_1080p_2x")), + url_trailers: game.videos + ?.filter((video) => + ["trailer", "teaser", "intro", "showcase", "preview"].some((word) => + toLower(video.name).includes(word), + ), + ) + .map((video) => `https://www.youtube.com/watch?v=${video.video_id}`), + url_gameplays: game.videos + ?.filter((video) => + ["gameplay", "playthrough", "demo"].some((word) => + toLower(video.name).includes(word), + ), + ) + .map((video) => `https://www.youtube.com/watch?v=${video.video_id}`), + developers: (game.involved_companies || []) + .filter((company) => company.developer) + .map( + (company) => + ({ + provider_slug: "igdb", + provider_data_id: company.company.id.toString(), + name: company.company.name, + }) as DeveloperMetadata, + ), + publishers: (game.involved_companies || []) + .filter((company) => company.publisher) + .map( + (company) => + ({ + provider_slug: "igdb", + provider_data_id: company.company.id.toString(), + name: company.company.name, + }) as PublisherMetadata, + ), + genres: (game.genres || []).map( + (genre) => + ({ + provider_slug: "igdb", + provider_data_id: genre.id.toString(), + name: genre.name, + }) as GenreMetadata, + ), + tags: [ + ...(game.keywords || []).map( + (keyword) => + ({ + provider_slug: "igdb", + provider_data_id: keyword.id.toString(), + name: keyword.name, + }) as TagMetadata, + ), + ...(game.themes || []).map( + (theme) => + ({ + provider_slug: "igdb", + provider_data_id: theme.id.toString(), + name: theme.name, + }) as TagMetadata, + ), + ], + cover: await this.downloadImage( + game.cover?.url, + "t_thumb", + "t_cover_big_2x", + ), + background: await this.downloadImage( + game.artworks?.[0]?.url, + "t_thumb", + "t_1080p_2x", + ), + } as GameMetadata; + } + + private async mapMinimalGameMetadata( + game: IgdbGame, + ): Promise { + return { + provider_slug: "igdb", + provider_data_id: game.id?.toString(), + title: game.name, + description: + game.summary && game.storyline + ? `${game.summary}\n\n${game.storyline}` + : game.summary || game.storyline || null, + release_date: new Date(game.first_release_date * 1000), + cover_url: this.replaceUrl(game.cover?.url, "t_thumb", "t_cover_big_2x"), + } as MinimalGameMetadataDto; + } + + private async getClient() { + const token = await twitchAccessToken({ + client_id: configuration.METADATA.IGDB.CLIENT_ID, + client_secret: configuration.METADATA.IGDB.CLIENT_SECRET, + }); + return igdb(configuration.METADATA.IGDB.CLIENT_ID, token); + } + private replaceUrl(url: string, from: string, to: string) { + if (!url) return undefined; + return url.replace("//", "https://").replace(from, to); + } + + private async downloadImage(url?: string, from?: string, to?: string) { + if (!url) return undefined; + try { + return await this.mediaService.downloadByUrl( + this.replaceUrl(url, from, to), + ); + } catch (error) { + this.logger.error(`Failed to download image from ${url}:`, error); + return undefined; + } + } + + private calculateAverageAgeRating( + gameTitle: string, + ageRatings: IgdbAgeRating[], + ): number { + if (isEmpty(ageRatings)) { + this.logger.debug({ + message: `No age ratings found.`, + gameTitle, + }); + return undefined; + } + + const ages = ageRatings + .map((rating) => + GameVaultIgdbAgeRatingMap.find( + (entry) => entry.igdbEnumValue === rating.rating, + ), + ) + .filter((entry) => entry != null) + .map((entry) => { + this.logger.debug({ + message: `Determined age rating.`, + gameTitle, + ageRating: entry, + }); + return entry.minAge; + }); + + if (ages?.length === 0) { + this.logger.debug({ + message: `No age ratings found.`, + gameTitle, + }); + return undefined; + } + + const averageAge = Math.round( + ages.reduce((sum, age) => sum + age, 0) / ages.length, + ); + this.logger.debug({ + message: `Calculated average age rating.`, + gameTitle, + ages, + averageAge, + }); + + return averageAge; + } +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-age-rating.interface.ts b/src/modules/metadata/providers/igdb/models/igdb-age-rating.interface.ts new file mode 100644 index 00000000..73d79dc0 --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-age-rating.interface.ts @@ -0,0 +1,74 @@ +export type AgeRatingMapEntry = { + system: string; + name: string; + minAge: number; + igdbEnumValue: number; +}; + +export interface IgdbAgeRating { + id: number; + category: number; + content_descriptions: number[]; + rating: number; + synopsis: string; + checksum: string; +} + +export const GameVaultIgdbAgeRatingMap: AgeRatingMapEntry[] = [ + { system: "PEGI", name: "Three", minAge: 3, igdbEnumValue: 1 }, + { system: "PEGI", name: "Seven", minAge: 7, igdbEnumValue: 2 }, + { system: "PEGI", name: "Twelve", minAge: 12, igdbEnumValue: 3 }, + { system: "PEGI", name: "Sixteen", minAge: 16, igdbEnumValue: 4 }, + { system: "PEGI", name: "Eighteen", minAge: 18, igdbEnumValue: 5 }, + { system: "ESRB", name: "EC", minAge: 3, igdbEnumValue: 7 }, + { system: "ESRB", name: "E", minAge: 6, igdbEnumValue: 8 }, + { system: "ESRB", name: "E10", minAge: 10, igdbEnumValue: 9 }, + { system: "ESRB", name: "T", minAge: 13, igdbEnumValue: 10 }, + { system: "ESRB", name: "M", minAge: 17, igdbEnumValue: 11 }, + { system: "ESRB", name: "AO", minAge: 18, igdbEnumValue: 12 }, + { system: "CERO", name: "CERO_A", minAge: 0, igdbEnumValue: 13 }, + { system: "CERO", name: "CERO_B", minAge: 12, igdbEnumValue: 14 }, + { system: "CERO", name: "CERO_C", minAge: 15, igdbEnumValue: 15 }, + { system: "CERO", name: "CERO_D", minAge: 17, igdbEnumValue: 16 }, + { system: "CERO", name: "CERO_Z", minAge: 18, igdbEnumValue: 17 }, + { system: "USK", name: "USK_0", minAge: 0, igdbEnumValue: 18 }, + { system: "USK", name: "USK_6", minAge: 6, igdbEnumValue: 19 }, + { system: "USK", name: "USK_12", minAge: 12, igdbEnumValue: 20 }, + { system: "USK", name: "USK_16", minAge: 16, igdbEnumValue: 21 }, + { system: "USK", name: "USK_18", minAge: 18, igdbEnumValue: 22 }, + { system: "GRAC", name: "GRAC_ALL", minAge: 0, igdbEnumValue: 23 }, + { system: "GRAC", name: "GRAC_Twelve", minAge: 12, igdbEnumValue: 24 }, + { system: "GRAC", name: "GRAC_Fifteen", minAge: 15, igdbEnumValue: 25 }, + { system: "GRAC", name: "GRAC_Eighteen", minAge: 18, igdbEnumValue: 26 }, + { system: "CLASS_IND", name: "CLASS_IND_L", minAge: 0, igdbEnumValue: 28 }, + { system: "CLASS_IND", name: "CLASS_IND_Ten", minAge: 10, igdbEnumValue: 29 }, + { + system: "CLASS_IND", + name: "CLASS_IND_Twelve", + minAge: 12, + igdbEnumValue: 30, + }, + { + system: "CLASS_IND", + name: "CLASS_IND_Fourteen", + minAge: 14, + igdbEnumValue: 31, + }, + { + system: "CLASS_IND", + name: "CLASS_IND_Sixteen", + minAge: 16, + igdbEnumValue: 32, + }, + { + system: "CLASS_IND", + name: "CLASS_IND_Eighteen", + minAge: 18, + igdbEnumValue: 33, + }, + { system: "ACB", name: "ACB_G", minAge: 0, igdbEnumValue: 34 }, + { system: "ACB", name: "ACB_PG", minAge: 8, igdbEnumValue: 35 }, + { system: "ACB", name: "ACB_M", minAge: 15, igdbEnumValue: 36 }, + { system: "ACB", name: "ACB_MA15", minAge: 15, igdbEnumValue: 37 }, + { system: "ACB", name: "ACB_R18", minAge: 18, igdbEnumValue: 38 }, +]; diff --git a/src/modules/metadata/providers/igdb/models/igdb-artwork.interface.ts b/src/modules/metadata/providers/igdb/models/igdb-artwork.interface.ts new file mode 100644 index 00000000..4b4e83fb --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-artwork.interface.ts @@ -0,0 +1,11 @@ +export interface IgdbArtwork { + id: number; + alpha_channel: boolean; + animated: boolean; + game: number; + height: number; + image_id: string; + url: string; + width: number; + checksum: string; +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-company.interface.ts b/src/modules/metadata/providers/igdb/models/igdb-company.interface.ts new file mode 100644 index 00000000..d75b7ad0 --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-company.interface.ts @@ -0,0 +1,20 @@ +export interface IgdbCompany { + id: number; + change_date?: number; + change_date_category: number; + country: number; + created_at: number; + description: string; + developed: number[]; + logo: number; + name: string; + published: number[]; + slug: string; + start_date: number; + start_date_category: number; + updated_at: number; + url: string; + checksum: string; + websites?: number[]; + parent?: number; +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-cover.interface.ts b/src/modules/metadata/providers/igdb/models/igdb-cover.interface.ts new file mode 100644 index 00000000..a37d5761 --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-cover.interface.ts @@ -0,0 +1,11 @@ +export interface IgdbCover { + id: number; + alpha_channel: boolean; + animated: boolean; + game: number; + height: number; + image_id: string; + url: string; + width: number; + checksum: string; +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-external-game.interface.ts b/src/modules/metadata/providers/igdb/models/igdb-external-game.interface.ts new file mode 100644 index 00000000..3b85c966 --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-external-game.interface.ts @@ -0,0 +1,12 @@ +export interface IgdbExternalGame { + id: number; + category: number; + created_at: number; + game: number; + name?: string; + uid: string; + updated_at: number; + checksum: string; + url?: string; + year?: number; +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-game-category.enum.ts b/src/modules/metadata/providers/igdb/models/igdb-game-category.enum.ts new file mode 100644 index 00000000..9bd05980 --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-game-category.enum.ts @@ -0,0 +1,17 @@ +export enum IgdbGameCategory { + main_game = 0, + dlc_addon = 1, + expansion = 2, + bundle = 3, + standalone_expansion = 4, + mod = 5, + episode = 6, + season = 7, + remake = 8, + remaster = 9, + expanded_game = 10, + port = 11, + fork = 12, + pack = 13, + update = 14, +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-game-status.enum.ts b/src/modules/metadata/providers/igdb/models/igdb-game-status.enum.ts new file mode 100644 index 00000000..71398834 --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-game-status.enum.ts @@ -0,0 +1,10 @@ +export enum IgdbGameStatus { + released = 0, + alpha = 2, + beta = 3, + early_access = 4, + offline = 5, + cancelled = 6, + rumored = 7, + delisted = 8, +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-game.interface.ts b/src/modules/metadata/providers/igdb/models/igdb-game.interface.ts new file mode 100644 index 00000000..aebc64b0 --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-game.interface.ts @@ -0,0 +1,61 @@ +import { IgdbAgeRating } from "./igdb-age-rating.interface"; +import { IgdbArtwork } from "./igdb-artwork.interface"; +import { IgdbCover } from "./igdb-cover.interface"; +import { IgdbExternalGame } from "./igdb-external-game.interface"; +import { IgdbGameCategory } from "./igdb-game-category.enum"; +import { IgdbGameStatus } from "./igdb-game-status.enum"; +import { IgdbGenre } from "./igdb-genre.interface"; +import { IgdbInvolvedCompany } from "./igdb-involved-company.interface"; +import { IgdbKeyword } from "./igdb-keyword.interace"; +import { IgdbScreenshot } from "./igdb-screenshot.interface"; +import { IgdbTheme } from "./igdb-theme.interface"; +import { IgdbVideo } from "./igdb-video.interface"; +import { IgdbWebsite } from "./igdb-website.interface"; + +export interface IgdbGame { + id: number; + age_ratings: IgdbAgeRating[]; + aggregated_rating: number; + aggregated_rating_count: number; + alternative_names: number[]; + artworks: IgdbArtwork[]; + bundles: number[]; + category: IgdbGameCategory; + collection: number; + cover: IgdbCover; + created_at: number; + dlcs: number[]; + external_games: IgdbExternalGame[]; + first_release_date: number; + franchises: number[]; + game_engines: number[]; + game_modes: number[]; + genres: IgdbGenre[]; + involved_companies: IgdbInvolvedCompany[]; + keywords: IgdbKeyword[]; + multiplayer_modes: number[]; + name: string; + platforms: number[]; + player_perspectives: number[]; + rating: number; + rating_count: number; + release_dates: number[]; + screenshots: IgdbScreenshot[]; + similar_games: number[]; + slug: string; + status: IgdbGameStatus; + storyline: string; + summary: string; + tags: number[]; + themes: IgdbTheme[]; + total_rating: number; + total_rating_count: number; + updated_at: number; + url: string; + videos: IgdbVideo[]; + websites: IgdbWebsite[]; + checksum: string; + language_supports: number[]; + game_localizations: number[]; + collections: number[]; +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-genre.interface.ts b/src/modules/metadata/providers/igdb/models/igdb-genre.interface.ts new file mode 100644 index 00000000..26f4dd39 --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-genre.interface.ts @@ -0,0 +1,9 @@ +export interface IgdbGenre { + id: number; + created_at: number; + name: string; + slug: string; + updated_at: number; + url: string; + checksum: string; +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-involved-company.interface.ts b/src/modules/metadata/providers/igdb/models/igdb-involved-company.interface.ts new file mode 100644 index 00000000..00b6c7bd --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-involved-company.interface.ts @@ -0,0 +1,14 @@ +import { IgdbCompany } from "./igdb-company.interface"; + +export interface IgdbInvolvedCompany { + id: number; + company: IgdbCompany; + created_at: number; + developer: boolean; + game: number; + porting: boolean; + publisher: boolean; + supporting: boolean; + updated_at: number; + checksum: string; +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-keyword.interace.ts b/src/modules/metadata/providers/igdb/models/igdb-keyword.interace.ts new file mode 100644 index 00000000..3c759de0 --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-keyword.interace.ts @@ -0,0 +1,9 @@ +export interface IgdbKeyword { + id: number; + created_at: number; + name: string; + slug: string; + updated_at: number; + url: string; + checksum: string; +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-screenshot.interface.ts b/src/modules/metadata/providers/igdb/models/igdb-screenshot.interface.ts new file mode 100644 index 00000000..48cb91be --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-screenshot.interface.ts @@ -0,0 +1,9 @@ +export interface IgdbScreenshot { + id: number; + game: number; + height: number; + image_id: string; + url: string; + width: number; + checksum: string; +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-theme.interface.ts b/src/modules/metadata/providers/igdb/models/igdb-theme.interface.ts new file mode 100644 index 00000000..ae81c3b9 --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-theme.interface.ts @@ -0,0 +1,9 @@ +export interface IgdbTheme { + id: number; + created_at: number; + name: string; + slug: string; + updated_at: number; + url: string; + checksum: string; +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-video.interface.ts b/src/modules/metadata/providers/igdb/models/igdb-video.interface.ts new file mode 100644 index 00000000..5ace26d3 --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-video.interface.ts @@ -0,0 +1,7 @@ +export interface IgdbVideo { + id: number; + game: number; + name: string; + video_id: string; + checksum: string; +} diff --git a/src/modules/metadata/providers/igdb/models/igdb-website.interface.ts b/src/modules/metadata/providers/igdb/models/igdb-website.interface.ts new file mode 100644 index 00000000..b09af34f --- /dev/null +++ b/src/modules/metadata/providers/igdb/models/igdb-website.interface.ts @@ -0,0 +1,8 @@ +export interface IgdbWebsite { + id: number; + category: number; + game: number; + trusted: boolean; + url: string; + checksum: string; +} diff --git a/src/modules/metadata/providers/models/metadata-provider.dto.ts b/src/modules/metadata/providers/models/metadata-provider.dto.ts new file mode 100644 index 00000000..1be043f3 --- /dev/null +++ b/src/modules/metadata/providers/models/metadata-provider.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsNotIn, + Matches, +} from "class-validator"; + +import globals from "../../../../globals"; + +export class MetadataProviderDto { + @IsNotEmpty() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: + "Invalid slug: Only lowercase letters, numbers, and single hyphens inbetween them are allowed.", + }) + @IsNotIn(globals.RESERVED_PROVIDER_SLUGS, { + message: + "Invalid slug: The terms 'gamevault' and 'user' are reserved slugs.", + }) + @ApiProperty({ + description: + "slug (url-friendly name) of the provider. This is the primary identifier. Must be formatted like a valid slug.", + example: "igdb", + }) + public slug: string; + + @IsInt() + @IsNotEmpty() + @ApiProperty({ + type: Number, + description: + "priority of usage for this provider. Lower priority providers are tried first, while higher priority providers fill in gaps.", + }) + public priority: number; + + @IsBoolean() + @ApiProperty({ + type: Boolean, + description: "whether this provider is enabled or not.", + default: true, + }) + public enabled = true; +} diff --git a/src/modules/metadata/providers/models/provider-not-found.exception.ts b/src/modules/metadata/providers/models/provider-not-found.exception.ts new file mode 100644 index 00000000..63b4af32 --- /dev/null +++ b/src/modules/metadata/providers/models/provider-not-found.exception.ts @@ -0,0 +1,7 @@ +import { HttpException, HttpStatus } from "@nestjs/common"; + +export class ProviderNotFoundException extends HttpException { + constructor(message: string) { + super(message, HttpStatus.NOT_FOUND); + } +} diff --git a/src/modules/metadata/providers/models/provider-slug.dto.ts b/src/modules/metadata/providers/models/provider-slug.dto.ts new file mode 100644 index 00000000..6d5709c1 --- /dev/null +++ b/src/modules/metadata/providers/models/provider-slug.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, Matches } from "class-validator"; + +export class ProviderSlugDto { + @IsNotEmpty() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: + "Invalid slug: Only lowercase letters, numbers, and single hyphens inbetween them are allowed.", + }) + @ApiProperty({ + description: + "slug (url-friendly name) of the provider. This is the primary identifier. Must be formatted like a valid slug.", + example: "igdb", + }) + provider_slug: string; +} diff --git a/src/modules/metadata/providers/rawg-legacy/rawg-legacy.metadata-provider.service.ts b/src/modules/metadata/providers/rawg-legacy/rawg-legacy.metadata-provider.service.ts new file mode 100644 index 00000000..bd422548 --- /dev/null +++ b/src/modules/metadata/providers/rawg-legacy/rawg-legacy.metadata-provider.service.ts @@ -0,0 +1,43 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; + +import { GameMetadata } from "../../games/game.metadata.entity"; +import { MinimalGameMetadataDto } from "../../games/minimal-game.metadata.dto"; +import { MetadataProvider } from "../abstract.metadata-provider.service"; + +@Injectable() +export class RawgLegacyMetadataProviderService extends MetadataProvider { + readonly enabled = false; + readonly priority = -10; + readonly slug = "rawg-legacy"; + readonly name = "RAWG (Legacy)"; + readonly noopMessage = + "The RAWG (Legacy) Metadata Provider does not support this functionality. It is designed solely for compatibility."; + + public override async register() { + this.metadataService.registerProvider(this); + } + + public override async search( + query: string, + ): Promise { + this.logger.debug({ + message: this.noopMessage, + operation: "search", + query, + }); + return []; + } + + public override async getByProviderDataIdOrFail( + provider_data_id: string, + ): Promise { + this.logger.debug({ + message: this.noopMessage, + operation: "getByProviderDataIdOrFail", + provider_data_id, + }); + throw new NotFoundException({ + message: this.noopMessage, + }); + } +} diff --git a/src/modules/metadata/providers/testing/test-high-priority.metadata-provider.service.ts b/src/modules/metadata/providers/testing/test-high-priority.metadata-provider.service.ts new file mode 100644 index 00000000..4a6bfbfa --- /dev/null +++ b/src/modules/metadata/providers/testing/test-high-priority.metadata-provider.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from "@nestjs/common"; +import { randomUUID } from "crypto"; + +import configuration from "../../../../configuration"; +import { DeveloperMetadata } from "../../developers/developer.metadata.entity"; +import { GameMetadata } from "../../games/game.metadata.entity"; +import { MinimalGameMetadataDto } from "../../games/minimal-game.metadata.dto"; +import { GenreMetadata } from "../../genres/genre.metadata.entity"; +import { PublisherMetadata } from "../../publishers/publisher.metadata.entity"; +import { TagMetadata } from "../../tags/tag.metadata.entity"; +import { MetadataProvider } from "../abstract.metadata-provider.service"; + +@Injectable() +export class TestHighPriorityProviderService extends MetadataProvider { + readonly enabled = configuration.TESTING.MOCK_PROVIDERS; + readonly slug = "test-high-priority"; + readonly name = "Test High Priority"; + readonly priority = 9999; + + public override async search(): Promise { + const minimalGameMetadata = []; + for (let i = 0; i < 10; i++) { + minimalGameMetadata.push(this.fakeMinimalGame()); + } + return minimalGameMetadata; + } + + public override async getByProviderDataIdOrFail(): Promise { + return this.fakeGame(); + } + + private fakeGame(): GameMetadata { + return { + provider_slug: this.slug, + provider_data_id: randomUUID(), + title: this.name, + description: this.name, + developers: [ + { + provider_slug: this.slug, + provider_data_id: randomUUID(), + name: this.name, + } as DeveloperMetadata, + ], + publishers: [ + { + provider_slug: this.slug, + provider_data_id: randomUUID(), + name: this.name, + } as PublisherMetadata, + ], + genres: [ + { + provider_slug: this.slug, + provider_data_id: randomUUID(), + name: this.name, + } as GenreMetadata, + ], + tags: [ + { + provider_slug: this.slug, + provider_data_id: randomUUID(), + name: this.name, + } as TagMetadata, + ], + } as GameMetadata; + } + + private fakeMinimalGame(): MinimalGameMetadataDto { + return { + provider_slug: this.slug, + provider_data_id: randomUUID(), + release_date: new Date(), + description: this.name, + title: this.name, + } as MinimalGameMetadataDto; + } +} diff --git a/src/modules/metadata/providers/testing/test-low-priority.metadata-provider.service.ts b/src/modules/metadata/providers/testing/test-low-priority.metadata-provider.service.ts new file mode 100644 index 00000000..41e61e73 --- /dev/null +++ b/src/modules/metadata/providers/testing/test-low-priority.metadata-provider.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from "@nestjs/common"; +import { randomUUID } from "crypto"; + +import configuration from "../../../../configuration"; +import { DeveloperMetadata } from "../../developers/developer.metadata.entity"; +import { GameMetadata } from "../../games/game.metadata.entity"; +import { MinimalGameMetadataDto } from "../../games/minimal-game.metadata.dto"; +import { GenreMetadata } from "../../genres/genre.metadata.entity"; +import { PublisherMetadata } from "../../publishers/publisher.metadata.entity"; +import { TagMetadata } from "../../tags/tag.metadata.entity"; +import { MetadataProvider } from "../abstract.metadata-provider.service"; + +@Injectable() +export class TestLowPriorityProviderService extends MetadataProvider { + readonly enabled = configuration.TESTING.MOCK_PROVIDERS; + readonly slug = "test-low-priority"; + readonly name = "Test Low Priority"; + readonly priority = -9999; + + public override async search(): Promise { + const minimalGameMetadata = []; + for (let i = 0; i < 10; i++) { + minimalGameMetadata.push(this.fakeMinimalGame()); + } + return minimalGameMetadata; + } + + public override async getByProviderDataIdOrFail(): Promise { + return this.fakeGame(); + } + + private fakeGame(): GameMetadata { + return { + provider_slug: this.slug, + provider_data_id: randomUUID(), + title: this.name, + description: this.name, + developers: [ + { + provider_slug: this.slug, + provider_data_id: randomUUID(), + name: this.name, + } as DeveloperMetadata, + ], + publishers: [ + { + provider_slug: this.slug, + provider_data_id: randomUUID(), + name: this.name, + } as PublisherMetadata, + ], + genres: [ + { + provider_slug: this.slug, + provider_data_id: randomUUID(), + name: this.name, + } as GenreMetadata, + ], + tags: [ + { + provider_slug: this.slug, + provider_data_id: randomUUID(), + name: this.name, + } as TagMetadata, + ], + } as GameMetadata; + } + + private fakeMinimalGame(): MinimalGameMetadataDto { + return { + provider_slug: this.slug, + provider_data_id: randomUUID(), + release_date: new Date(), + description: this.name, + title: this.name, + } as MinimalGameMetadataDto; + } +} diff --git a/src/modules/metadata/publishers/publisher.metadata.entity.ts b/src/modules/metadata/publishers/publisher.metadata.entity.ts new file mode 100644 index 00000000..eb3fd445 --- /dev/null +++ b/src/modules/metadata/publishers/publisher.metadata.entity.ts @@ -0,0 +1,55 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotIn, Matches } from "class-validator"; +import { Column, Entity, Index, ManyToMany } from "typeorm"; + +import globals from "../../../globals"; +import { DatabaseEntity } from "../../database/database.entity"; +import { GameMetadata } from "../games/game.metadata.entity"; +import { Metadata } from "../models/metadata.interface"; + +@Entity() +@Index("UQ_PUBLISHER_METADATA", ["provider_slug", "provider_data_id"], { + unique: true, +}) +export class PublisherMetadata extends DatabaseEntity implements Metadata { + @Column() + @Index() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: + "Invalid slug: Only lowercase letters, numbers, and single hyphens inbetween them are allowed.", + }) + @IsNotIn(globals.RESERVED_PROVIDER_SLUGS, { + message: + "Invalid slug: The terms 'gamevault' and 'user' are reserved slugs.", + }) + @ApiProperty({ + description: + "slug (url-friendly name) of the provider. This is the primary identifier. Must be formatted like a valid slug.", + example: "igdb", + }) + provider_slug: string; + + @Column() + @Index() + @ApiProperty({ + description: "id of the developer from the provider", + example: "1190", + }) + provider_data_id: string; + + @Index() + @Column() + @ApiProperty({ + example: "Rockstar Games", + description: "name of the publisher", + }) + name: string; + + @ManyToMany(() => GameMetadata, (game) => game.publishers) + @ApiProperty({ + description: "games published by the publisher", + type: () => GameMetadata, + isArray: true, + }) + games: GameMetadata[]; +} diff --git a/src/modules/metadata/publishers/publisher.metadata.service.ts b/src/modules/metadata/publishers/publisher.metadata.service.ts new file mode 100644 index 00000000..d1674d12 --- /dev/null +++ b/src/modules/metadata/publishers/publisher.metadata.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { FindOptions } from "../../../globals"; +import { PublisherMetadata } from "./publisher.metadata.entity"; + +@Injectable() +export class PublisherMetadataService { + private readonly logger = new Logger(this.constructor.name); + constructor( + @InjectRepository(PublisherMetadata) + private readonly publisherRepository: Repository, + ) {} + + async findByProviderSlug( + provider_slug: string = "gamevault", + options: FindOptions = { loadDeletedEntities: false, loadRelations: false }, + ): Promise { + let relations = []; + + if (options.loadRelations) { + if (options.loadRelations === true) { + relations = ["games"]; + } else if (Array.isArray(options.loadRelations)) + relations = options.loadRelations; + } + + return this.publisherRepository.find({ + where: { provider_slug }, + relations, + withDeleted: options.loadDeletedEntities, + relationLoadStrategy: "query", + }); + } + + async save(publisher: PublisherMetadata): Promise { + const existingPublisher = await this.publisherRepository.findOneBy({ + provider_slug: publisher.provider_slug, + provider_data_id: publisher.provider_data_id, + }); + this.logger.debug({ + message: "Saving publisher metadata", + publisher, + already_exists: !!publisher, + }); + return this.publisherRepository.save({ + ...existingPublisher, + ...{ + provider_data_id: publisher.provider_data_id, + provider_slug: publisher.provider_slug, + name: publisher.name, + }, + }); + } +} diff --git a/src/modules/metadata/publishers/publishers.metadata.controller.ts b/src/modules/metadata/publishers/publishers.metadata.controller.ts new file mode 100644 index 00000000..1666bad3 --- /dev/null +++ b/src/modules/metadata/publishers/publishers.metadata.controller.ts @@ -0,0 +1,80 @@ +import { Controller, Get } from "@nestjs/common"; +import { ApiBasicAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + Paginate, + paginate, + Paginated, + PaginateQuery, + PaginationType, +} from "nestjs-paginate"; +import { Repository } from "typeorm"; + +import { MinimumRole } from "../../../decorators/minimum-role.decorator"; +import { PaginateQueryOptions } from "../../../decorators/pagination.decorator"; +import { ApiOkResponsePaginated } from "../../../globals"; +import { Role } from "../../users/models/role.enum"; +import { PublisherMetadata } from "./publisher.metadata.entity"; + +@Controller("publishers") +@ApiTags("publishers") +@ApiBasicAuth() +export class PublisherController { + constructor( + @InjectRepository(PublisherMetadata) + private readonly publisherRepository: Repository, + ) {} + + /** + * Get a paginated list of publishers, sorted by the number of games released by + * each publisher (by default). + */ + @Get() + @ApiOperation({ + summary: "get a list of publishers", + description: + "by default the list is sorted by the amount of games that are published by the publisher.", + operationId: "getPublishers", + }) + @MinimumRole(Role.GUEST) + @ApiOkResponsePaginated(PublisherMetadata) + @PaginateQueryOptions() + async getPublishers( + @Paginate() query: PaginateQuery, + ): Promise> { + const queryBuilder = this.publisherRepository + .createQueryBuilder("publisher") + .leftJoinAndSelect("publisher.games", "games") + .where("publisher.provider_slug = :provider_slug", { + provider_slug: "gamevault", + }) + .groupBy("publisher.id") + .addGroupBy("games.id") + .having("COUNT(games.id) > 0"); + + // If no specific sort is provided, sort by the number of games in descending order + if (query.sortBy?.length === 0) { + queryBuilder + .addSelect("COUNT(games.id)", "games_count") + .orderBy("games_count", "DESC"); + } + + const paginatedResults = await paginate(query, queryBuilder, { + paginationType: PaginationType.TAKE_AND_SKIP, + defaultLimit: 100, + maxLimit: -1, + nullSort: "last", + loadEagerRelations: false, + sortableColumns: ["id", "name", "created_at", "provider_slug"], + searchableColumns: ["name"], + filterableColumns: { + id: true, + created_at: true, + name: true, + }, + withDeleted: false, + }); + + return paginatedResults; + } +} diff --git a/src/modules/metadata/tags/tag.metadata.entity.ts b/src/modules/metadata/tags/tag.metadata.entity.ts new file mode 100644 index 00000000..8ce54ab7 --- /dev/null +++ b/src/modules/metadata/tags/tag.metadata.entity.ts @@ -0,0 +1,55 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotIn, Matches } from "class-validator"; +import { Column, Entity, Index, ManyToMany } from "typeorm"; + +import globals from "../../../globals"; +import { DatabaseEntity } from "../../database/database.entity"; +import { GameMetadata } from "../games/game.metadata.entity"; +import { Metadata } from "../models/metadata.interface"; + +@Entity() +@Index("UQ_TAG_METADATA", ["provider_slug", "provider_data_id"], { + unique: true, +}) +export class TagMetadata extends DatabaseEntity implements Metadata { + @Column() + @Index() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: + "Invalid slug: Only lowercase letters, numbers, and single hyphens inbetween them are allowed.", + }) + @IsNotIn(globals.RESERVED_PROVIDER_SLUGS, { + message: + "Invalid slug: The terms 'gamevault' and 'user' are reserved slugs.", + }) + @ApiProperty({ + description: + "slug (url-friendly name) of the provider. This is the primary identifier. Must be formatted like a valid slug.", + example: "igdb", + }) + provider_slug: string; + + @Column() + @Index() + @ApiProperty({ + description: "id of the developer from the provider", + example: "1190", + }) + provider_data_id: string; + + @Index() + @Column() + @ApiProperty({ + example: "battle-royale", + description: "name of the tag", + }) + name: string; + + @ManyToMany(() => GameMetadata, (game) => game.tags) + @ApiProperty({ + description: "games tagged with the tag", + type: () => GameMetadata, + isArray: true, + }) + games: GameMetadata[]; +} diff --git a/src/modules/metadata/tags/tag.metadata.service.ts b/src/modules/metadata/tags/tag.metadata.service.ts new file mode 100644 index 00000000..d243e14c --- /dev/null +++ b/src/modules/metadata/tags/tag.metadata.service.ts @@ -0,0 +1,57 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { FindOptions } from "../../../globals"; +import { TagMetadata } from "./tag.metadata.entity"; + +@Injectable() +export class TagMetadataService { + private readonly logger = new Logger(this.constructor.name); + + constructor( + @InjectRepository(TagMetadata) + private readonly tagRepository: Repository, + ) {} + + async findByProviderSlug( + provider_slug: string = "gamevault", + options: FindOptions = { loadDeletedEntities: false, loadRelations: false }, + ): Promise { + let relations = []; + + if (options.loadRelations) { + if (options.loadRelations === true) { + relations = ["games"]; + } else if (Array.isArray(options.loadRelations)) + relations = options.loadRelations; + } + + return this.tagRepository.find({ + where: { provider_slug }, + relations, + withDeleted: options.loadDeletedEntities, + relationLoadStrategy: "query", + }); + } + + async save(tag: TagMetadata): Promise { + const existingTag = await this.tagRepository.findOneBy({ + provider_slug: tag.provider_slug, + provider_data_id: tag.provider_data_id, + }); + this.logger.debug({ + message: "Saving tag metadata", + tag, + already_exists: !!tag, + }); + return this.tagRepository.save({ + ...existingTag, + ...{ + provider_data_id: tag.provider_data_id, + provider_slug: tag.provider_slug, + name: tag.name, + }, + }); + } +} diff --git a/src/modules/metadata/tags/tags.metadata.controller.ts b/src/modules/metadata/tags/tags.metadata.controller.ts new file mode 100644 index 00000000..a2c60659 --- /dev/null +++ b/src/modules/metadata/tags/tags.metadata.controller.ts @@ -0,0 +1,80 @@ +import { Controller, Get } from "@nestjs/common"; +import { ApiBasicAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + Paginate, + paginate, + Paginated, + PaginateQuery, + PaginationType, +} from "nestjs-paginate"; +import { Repository } from "typeorm"; + +import { MinimumRole } from "../../../decorators/minimum-role.decorator"; +import { PaginateQueryOptions } from "../../../decorators/pagination.decorator"; +import { ApiOkResponsePaginated } from "../../../globals"; +import { Role } from "../../users/models/role.enum"; +import { TagMetadata } from "./tag.metadata.entity"; + +@Controller("tags") +@ApiTags("tags") +@ApiBasicAuth() +export class TagsController { + constructor( + @InjectRepository(TagMetadata) + private readonly tagRepository: Repository, + ) {} + + /** + * Get a paginated list of tags, sorted by the number of games tagged with + * each tag (by default). + */ + @Get() + @ApiOperation({ + summary: "get a list of tags", + description: + "by default the list is sorted by the amount of games that are tagged with each tag.", + operationId: "getTags", + }) + @MinimumRole(Role.GUEST) + @ApiOkResponsePaginated(TagMetadata) + @PaginateQueryOptions() + async getTags( + @Paginate() query: PaginateQuery, + ): Promise> { + const queryBuilder = this.tagRepository + .createQueryBuilder("tag") + .leftJoinAndSelect("tag.games", "games") + .where("tag.provider_slug = :provider_slug", { + provider_slug: "gamevault", + }) + .groupBy("tag.id") + .addGroupBy("games.id") + .having("COUNT(games.id) > 0"); + + // If no specific sort is provided, sort by the number of games in descending order + if (query.sortBy?.length === 0) { + queryBuilder + .addSelect("COUNT(games.id)", "games_count") + .orderBy("games_count", "DESC"); + } + + const paginatedResults = await paginate(query, queryBuilder, { + paginationType: PaginationType.TAKE_AND_SKIP, + defaultLimit: 100, + maxLimit: -1, + nullSort: "last", + loadEagerRelations: false, + sortableColumns: ["id", "name", "created_at", "provider_slug"], + searchableColumns: ["name"], + filterableColumns: { + id: true, + created_at: true, + name: true, + }, + withDeleted: false, + }); + + return paginatedResults; + } +} diff --git a/src/modules/plugins/plugin.module.ts b/src/modules/plugins/plugin.module.ts deleted file mode 100644 index 3e1ff5a8..00000000 --- a/src/modules/plugins/plugin.module.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Module } from "@nestjs/common"; - -import { AdminModule } from "../admin/admin.module"; -import { BoxartsModule } from "../boxarts/boxarts.module"; -import { DatabaseModule } from "../database/database.module"; -import { DevelopersModule } from "../developers/developers.module"; -import { FilesModule } from "../files/files.module"; -import { GamesModule } from "../games/games.module"; -import { GarbageCollectionModule } from "../garbage-collection/garbage-collection.module"; -import { GenresModule } from "../genres/genres.module"; -import { HealthModule } from "../health/health.module"; -import { ImagesModule } from "../images/images.module"; -import { ProgressModule } from "../progresses/progress.module"; -import { RawgModule } from "../providers/rawg/rawg.module"; -import { PublishersModule } from "../publishers/publishers.module"; -import { StoresModule } from "../stores/stores.module"; -import { TagsModule } from "../tags/tags.module"; -import { UsersModule } from "../users/users.module"; -import { PluginService } from "./plugin.service"; - -@Module({ - providers: [PluginService], - exports: [PluginService], - imports: [ - GarbageCollectionModule, - AdminModule, - DatabaseModule, - BoxartsModule, - DevelopersModule, - PublishersModule, - StoresModule, - FilesModule, - UsersModule, - ImagesModule, - TagsModule, - RawgModule, - ProgressModule, - HealthModule, - GenresModule, - GamesModule, - ], -}) -export class PluginModule {} diff --git a/src/modules/plugins/plugin.service.ts b/src/modules/plugins/plugin.service.ts deleted file mode 100644 index 402a3d4c..00000000 --- a/src/modules/plugins/plugin.service.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Injectable, Logger, OnApplicationBootstrap } from "@nestjs/common"; -import axios from "axios"; -import path from "path"; -import * as vm from "vm"; - -import configuration from "../../configuration"; -import { BoxArtsService } from "../boxarts/boxarts.service"; -import { DatabaseService } from "../database/database.service"; -import { DevelopersService } from "../developers/developers.service"; -import { FilesService } from "../files/files.service"; -import { GamesService } from "../games/games.service"; -import { ImageGarbageCollectionService } from "../garbage-collection/image-garbage-collection.service"; -import { GenresService } from "../genres/genres.service"; -import { HealthService } from "../health/health.service"; -import { ImagesService } from "../images/images.service"; -import { ProgressService } from "../progresses/progress.service"; -import { RawgService } from "../providers/rawg/rawg.service"; -import { PublishersService } from "../publishers/publishers.service"; -import { StoresService } from "../stores/stores.service"; -import { TagsService } from "../tags/tags.service"; -import { UsersService } from "../users/users.service"; - -@Injectable() -export class PluginService implements OnApplicationBootstrap { - private readonly logger = new Logger(PluginService.name); - public loadedPlugins = []; - - constructor( - private boxartService: BoxArtsService, - private databaseService: DatabaseService, - private developersService: DevelopersService, - private filesService: FilesService, - private gamesService: GamesService, - private imageGarbageCollectionService: ImageGarbageCollectionService, - private genresService: GenresService, - private healthService: HealthService, - private imagesService: ImagesService, - private progressService: ProgressService, - private rawgService: RawgService, - private publishersService: PublishersService, - private storesService: StoresService, - private tagsService: TagsService, - private usersService: UsersService, - ) {} - - onApplicationBootstrap() { - if (!configuration.PLUGIN.ENABLED) { - return; - } - // TODO: Fully work out experimental plugin loader - this.logger.warn({ - message: `Experimental Plugin Loader Activated: ${configuration.PLUGIN.SOURCES.length} plugin(s) discovered.`, - reason: "PLUGIN_ENABLED is set to true.", - sources: configuration.PLUGIN.SOURCES, - }); - - this.loadPlugins(); - } - - private async loadPlugins(): Promise { - for (const source of configuration.PLUGIN.SOURCES) { - try { - const response = await axios.get(source); - const filename = path.basename(source); - const pluginContext = { - module: { exports: { meta: {} } }, - fetch, - logger: new Logger(filename), - configuration: process.env, - boxartService: this.boxartService, - databaseService: this.databaseService, - developersService: this.developersService, - filesService: this.filesService, - gamesService: this.gamesService, - imageGarbageCollectionService: this.imageGarbageCollectionService, - genresService: this.genresService, - healthService: this.healthService, - pluginService: this, - imagesService: this.imagesService, - progressService: this.progressService, - rawgService: this.rawgService, - publishersService: this.publishersService, - storesService: this.storesService, - tagsService: this.tagsService, - usersService: this.usersService, - }; - vm.createContext(pluginContext); - const script = new vm.Script(response.data, { filename }); - script.runInContext(pluginContext); - this.loadedPlugins.push(pluginContext.module.exports); - } catch (error) { - this.logger.error({ message: "Error loading plugin.", error, source }); - } - } - - if (this.loadedPlugins.length) { - const loadedPluginMetas = this.loadedPlugins.map((plugin) => plugin.meta); - this.logger.log({ - message: `Successfully loaded ${this.loadedPlugins.length} Plugin(s).`, - plugins: loadedPluginMetas, - }); - } - } -} diff --git a/src/modules/progresses/models/increment-progress-by-minutes.dto.ts b/src/modules/progresses/models/increment-progress-by-minutes.dto.ts index 31ae1407..73e6b915 100644 --- a/src/modules/progresses/models/increment-progress-by-minutes.dto.ts +++ b/src/modules/progresses/models/increment-progress-by-minutes.dto.ts @@ -1,22 +1,19 @@ import { ApiProperty } from "@nestjs/swagger"; import { IsNotEmpty, IsNumberString } from "class-validator"; -export class IncrementProgressByMinutesDto { +import { GameIdDto } from "../../games/models/game-id.dto"; +import { UserIdDto } from "../../users/models/user-id.dto"; + +export class IncrementProgressByMinutesDto implements UserIdDto, GameIdDto { @IsNumberString() @IsNotEmpty() - @ApiProperty({ - example: "1", - description: "Unique gamevault-identifier of the user", - }) - userId: string; + @ApiProperty({ example: "1", description: "id of the user" }) + user_id: number; @IsNumberString() @IsNotEmpty() - @ApiProperty({ - example: "1", - description: "Unique gamevault-identifier of the game", - }) - gameId: string; + @ApiProperty({ example: "1", description: "id of the game" }) + game_id: number; @IsNumberString() @IsNotEmpty() diff --git a/src/modules/progresses/models/progress-id.dto.ts b/src/modules/progresses/models/progress-id.dto.ts new file mode 100644 index 00000000..bd62e97d --- /dev/null +++ b/src/modules/progresses/models/progress-id.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsNumberString } from "class-validator"; + +export class ProgressIdDto { + @IsNumberString() + @IsNotEmpty() + @ApiProperty({ example: "1", description: "id of the progress" }) + progress_id: number; +} diff --git a/src/modules/progresses/models/user-id-game-id.dto.ts b/src/modules/progresses/models/user-id-game-id.dto.ts index 7289b95a..a3d01c1c 100644 --- a/src/modules/progresses/models/user-id-game-id.dto.ts +++ b/src/modules/progresses/models/user-id-game-id.dto.ts @@ -1,20 +1,17 @@ import { ApiProperty } from "@nestjs/swagger"; import { IsNotEmpty, IsNumberString } from "class-validator"; -export class UserIdGameIdDto { +import { GameIdDto } from "../../games/models/game-id.dto"; +import { UserIdDto } from "../../users/models/user-id.dto"; + +export class UserIdGameIdDto implements UserIdDto, GameIdDto { @IsNumberString() @IsNotEmpty() - @ApiProperty({ - example: "1", - description: "Unique gamevault-identifier of the user", - }) - userId: string; + @ApiProperty({ example: "1", description: "id of the user" }) + user_id: number; @IsNumberString() @IsNotEmpty() - @ApiProperty({ - example: "1", - description: "Unique gamevault-identifier of the game", - }) - gameId: string; + @ApiProperty({ example: "1", description: "id of the game" }) + game_id: number; } diff --git a/src/modules/progresses/progress.controller.ts b/src/modules/progresses/progress.controller.ts index 5b20c833..f2471311 100644 --- a/src/modules/progresses/progress.controller.ts +++ b/src/modules/progresses/progress.controller.ts @@ -15,14 +15,26 @@ import { ApiOperation, ApiTags, } from "@nestjs/swagger"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + Paginate, + paginate, + Paginated, + PaginateQuery, + PaginationType, +} from "nestjs-paginate"; +import { Repository } from "typeorm"; import configuration from "../../configuration"; import { DisableApiIf } from "../../decorators/disable-api-if.decorator"; import { MinimumRole } from "../../decorators/minimum-role.decorator"; -import { IdDto } from "../database/models/id.dto"; +import { PaginateQueryOptions } from "../../decorators/pagination.decorator"; +import { ApiOkResponsePaginated } from "../../globals"; import { GamevaultUser } from "../users/gamevault-user.entity"; import { Role } from "../users/models/role.enum"; +import { UsersService } from "../users/users.service"; import { IncrementProgressByMinutesDto } from "./models/increment-progress-by-minutes.dto"; +import { ProgressIdDto } from "./models/progress-id.dto"; import { UpdateProgressDto } from "./models/update-progress.dto"; import { UserIdGameIdDto } from "./models/user-id-game-id.dto"; import { Progress } from "./progress.entity"; @@ -32,9 +44,14 @@ import { ProgressService } from "./progress.service"; @ApiTags("progress") @ApiBasicAuth() export class ProgressController { - private readonly logger = new Logger(ProgressController.name); + private readonly logger = new Logger(this.constructor.name); - constructor(private progressService: ProgressService) {} + constructor( + private readonly progressService: ProgressService, + private readonly usersService: UsersService, + @InjectRepository(Progress) + private readonly progressRepository: Repository, + ) {} /** Get an array of files to ignore for progress-tracking. */ @Get("ignorefile") @@ -48,32 +65,75 @@ export class ProgressController { return this.progressService.ignoreList; } - /** Get all progresses for all users and games. */ - @Get("") + /** Get paginated progress list based on the given query parameters. */ + @Get() + @PaginateQueryOptions() + @ApiOkResponsePaginated(Progress) @ApiOperation({ - summary: "get all progresses for all users and games", + summary: "get a list of progresses", operationId: "getProgresses", }) @MinimumRole(Role.GUEST) - @ApiOkResponse({ type: () => Progress, isArray: true }) - async getProgresses(): Promise { - return await this.progressService.getAll(); + async findProgresses( + @Request() request: { gamevaultuser: GamevaultUser }, + @Paginate() query: PaginateQuery, + ): Promise> { + const relations = ["user", "game"]; + + if (configuration.PARENTAL.AGE_RESTRICTION_ENABLED) { + query.filter ??= {}; + query.filter["game.metadata.age_rating"] = + `$lte:${await this.usersService.findUserAgeByUsername(request.gamevaultuser.username)}`; + } + + return paginate(query, this.progressRepository, { + paginationType: PaginationType.TAKE_AND_SKIP, + defaultLimit: 100, + maxLimit: -1, + nullSort: "last", + relations, + sortableColumns: ["id", "created_at", "updated_at", "minutes_played"], + searchableColumns: ["user.username", "game.title"], + filterableColumns: { + id: true, + created_at: true, + updated_at: true, + minutes_played: true, + "user.id": true, + "user.username": true, + "game.id": true, + "game.metadata.age_rating": true, + }, + withDeleted: false, + }); } /** Retrieves a specific progress by its ID. */ - @Get(":id") + @Get(":progress_id") @ApiOperation({ summary: "get a specific progress by progress id", operationId: "getProgressByProgressId", }) @MinimumRole(Role.GUEST) @ApiOkResponse({ type: () => Progress, isArray: true }) - async getProgressByProgressId(@Param() params: IdDto): Promise { - return await this.progressService.findByProgressId(Number(params.id)); + async getProgressByProgressId( + @Param() params: ProgressIdDto, + @Request() request: { gamevaultuser: GamevaultUser }, + ): Promise { + return this.progressService.findOneByProgressId( + Number(params.progress_id), + { + loadDeletedEntities: true, + loadRelations: true, + filterByAge: await this.usersService.findUserAgeByUsername( + request.gamevaultuser.username, + ), + }, + ); } /** Deletes a progress by its ID. */ - @Delete(":id") + @Delete(":progress_id") @ApiOperation({ summary: "delete a progress by progress id.", description: @@ -84,41 +144,17 @@ export class ProgressController { @MinimumRole(Role.USER) @DisableApiIf(configuration.SERVER.DEMO_MODE_ENABLED) async deleteProgressByProgressId( - @Param() params: IdDto, + @Param() params: ProgressIdDto, @Request() req: { gamevaultuser: GamevaultUser }, ): Promise { - return await this.progressService.delete( - Number(params.id), + return this.progressService.delete( + Number(params.progress_id), req.gamevaultuser.username, ); } - /** Retrieves all progresses for a user by their ID. */ - @Get("/user/:id") - @ApiOperation({ - summary: "get all progresses for a user", - operationId: "getProgressesByUserId", - }) - @MinimumRole(Role.GUEST) - @ApiOkResponse({ type: () => Progress, isArray: true }) - async getProgressesByUserId(@Param() params: IdDto) { - return await this.progressService.findByUserId(Number(params.id)); - } - - /** Returns an array of progresses for a game with the given ID. */ - @Get("/game/:id") - @ApiOperation({ - summary: "get all progresses for a game", - operationId: "getProgressesByGameId", - }) - @MinimumRole(Role.GUEST) - @ApiOkResponse({ type: () => Progress, isArray: true }) - async getProgressesByGameId(@Param() params: IdDto): Promise { - return await this.progressService.findByGameId(Number(params.id)); - } - /** Get the progress of a specific game for a user. */ - @Get("/user/:userId/game/:gameId") + @Get("/user/:user_id/game/:game_id") @ApiOperation({ summary: "get a specific game progress for a user", operationId: "getProgressByUserIdAndGameId", @@ -127,15 +163,23 @@ export class ProgressController { @ApiOkResponse({ type: () => Progress }) async getProgressByUserIdAndGameId( @Param() params: UserIdGameIdDto, + @Request() request: { gamevaultuser: GamevaultUser }, ): Promise { - return await this.progressService.findOrCreateByUserIdAndGameId( - Number(params.userId), - Number(params.gameId), + return this.progressService.findOneByUserIdAndGameIdOrReturnEmptyProgress( + Number(params.user_id), + Number(params.game_id), + { + loadDeletedEntities: true, + loadRelations: true, + filterByAge: await this.usersService.findUserAgeByUsername( + request.gamevaultuser.username, + ), + }, ); } /** Set progress for a user and game. */ - @Put("/user/:userId/game/:gameId") + @Put("/user/:user_id/game/:game_id") @ApiBody({ type: () => UpdateProgressDto }) @ApiOperation({ summary: "create or update a progress", @@ -148,9 +192,9 @@ export class ProgressController { @Body() progress: UpdateProgressDto, @Request() req: { gamevaultuser: GamevaultUser }, ): Promise { - return await this.progressService.set( - Number(params.userId), - Number(params.gameId), + return this.progressService.set( + Number(params.user_id), + Number(params.game_id), progress, req.gamevaultuser.username, ); @@ -160,7 +204,7 @@ export class ProgressController { * Endpoint to increment the progress for a specific game by one minute for a * given user. */ - @Put("/user/:userId/game/:gameId/increment") + @Put("/user/:user_id/game/:game_id/increment") @ApiOperation({ summary: "Increment a specific game progress for a user by a minute", operationId: "putProgressByUserIdAndGameIdIncrementByOne", @@ -171,9 +215,9 @@ export class ProgressController { @Param() params: UserIdGameIdDto, @Request() req: { gamevaultuser: GamevaultUser }, ): Promise { - return await this.progressService.increment( - Number(params.userId), - Number(params.gameId), + return this.progressService.increment( + Number(params.user_id), + Number(params.game_id), req.gamevaultuser.username, ); } @@ -182,7 +226,7 @@ export class ProgressController { * Increment a specific game progress for a user by a certain number of * minutes. */ - @Put("/user/:userId/game/:gameId/increment/:minutes") + @Put("/user/:user_id/game/:game_id/increment/:minutes") @ApiOperation({ summary: "Increment a specific game progress for a user by x minutes", operationId: "putProgressByUserIdAndGameIdIncrementByMinutes", @@ -193,9 +237,9 @@ export class ProgressController { @Param() params: IncrementProgressByMinutesDto, @Request() req: { gamevaultuser: GamevaultUser }, ): Promise { - return await this.progressService.increment( - Number(params.userId), - Number(params.gameId), + return this.progressService.increment( + Number(params.user_id), + Number(params.game_id), req.gamevaultuser.username, Number(params.minutes), ); diff --git a/src/modules/progresses/progress.entity.ts b/src/modules/progresses/progress.entity.ts index 4d8b5390..a3188a7e 100644 --- a/src/modules/progresses/progress.entity.ts +++ b/src/modules/progresses/progress.entity.ts @@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Column, Entity, Index, ManyToOne } from "typeorm"; import { DatabaseEntity } from "../database/database.entity"; -import { Game } from "../games/game.entity"; +import { GamevaultGame } from "../games/gamevault-game.entity"; import { GamevaultUser } from "../users/gamevault-user.entity"; import { State } from "./models/state.enum"; @@ -17,14 +17,14 @@ export class Progress extends DatabaseEntity { user?: GamevaultUser; @Index() - @ManyToOne(() => Game, (game) => game.progresses) + @ManyToOne(() => GamevaultGame, (game) => game.progresses) @ApiPropertyOptional({ description: "game the progress belongs to", - type: () => Game, + type: () => GamevaultGame, }) - game?: Game; + game?: GamevaultGame; - @Column({ default: 0 }) + @Column({ type: "int", default: 0 }) @ApiProperty({ description: "playtime in minutes", example: 25, diff --git a/src/modules/progresses/progress.module.ts b/src/modules/progresses/progress.module.ts index 215c93b7..4427a8c7 100644 --- a/src/modules/progresses/progress.module.ts +++ b/src/modules/progresses/progress.module.ts @@ -1,4 +1,4 @@ -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { GamesModule } from "../games/games.module"; @@ -8,7 +8,11 @@ import { Progress } from "./progress.entity"; import { ProgressService } from "./progress.service"; @Module({ - imports: [TypeOrmModule.forFeature([Progress]), UsersModule, GamesModule], + imports: [ + TypeOrmModule.forFeature([Progress]), + forwardRef(() => UsersModule), + forwardRef(() => GamesModule), + ], controllers: [ProgressController], providers: [ProgressService], exports: [ProgressService], diff --git a/src/modules/progresses/progress.service.ts b/src/modules/progresses/progress.service.ts index 066e3c6b..23330ba0 100644 --- a/src/modules/progresses/progress.service.ts +++ b/src/modules/progresses/progress.service.ts @@ -4,12 +4,15 @@ import { InternalServerErrorException, Logger, NotFoundException, + OnApplicationBootstrap, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { readFile } from "fs/promises"; import path from "path"; -import { IsNull, Repository } from "typeorm"; +import { FindOneOptions, IsNull, LessThanOrEqual, Repository } from "typeorm"; +import { FindOptions } from "../../globals"; +import { logProgress } from "../../logging"; import { GamesService } from "../games/games.service"; import { UsersService } from "../users/users.service"; import { State } from "./models/state.enum"; @@ -17,16 +20,18 @@ import { UpdateProgressDto } from "./models/update-progress.dto"; import { Progress } from "./progress.entity"; @Injectable() -export class ProgressService { - private readonly logger = new Logger(ProgressService.name); +export class ProgressService implements OnApplicationBootstrap { + private readonly logger = new Logger(this.constructor.name); public ignoreList: string[] = []; constructor( @InjectRepository(Progress) - private progressRepository: Repository, - private usersService: UsersService, - private gamesService: GamesService, - ) { + private readonly progressRepository: Repository, + private readonly usersService: UsersService, + private readonly gamesService: GamesService, + ) {} + + onApplicationBootstrap() { this.readIgnoreFile(); } @@ -46,30 +51,39 @@ export class ProgressService { } } - public async getAll() { - return await this.progressRepository.find({ - where: { - deleted_at: IsNull(), - }, - relations: ["game", "user"], - order: { minutes_played: "DESC" }, - withDeleted: true, - }); - } - - public async findByProgressId(progressId: number) { + public async findOneByProgressId(id: number, options: FindOptions) { try { - return await this.progressRepository.findOneOrFail({ - where: { - id: progressId, - }, - relations: ["game", "user"], - order: { minutes_played: "DESC" }, - withDeleted: true, - }); + const findParameters: FindOneOptions = { + where: { id }, + relationLoadStrategy: "query", + }; + + if (options.loadRelations) { + if (options.loadRelations === true) { + findParameters.relations = ["user", "game"]; + } else if (Array.isArray(options.loadRelations)) + findParameters.relations = options.loadRelations; + } + + if (options.loadDeletedEntities) { + findParameters.withDeleted = true; + } + + if (options.filterByAge) { + if (!options.loadRelations) { + findParameters.relations = ["game"]; + } + findParameters.where = { + id, + game: { + metadata: { age_rating: LessThanOrEqual(options.filterByAge) }, + }, + }; + } + return await this.progressRepository.findOneOrFail(findParameters); } catch (error) { throw new NotFoundException( - `Progress with id ${progressId} was not found on the server.`, + `Progress with id ${id} was not found on the server.`, { cause: error }, ); } @@ -79,7 +93,10 @@ export class ProgressService { progressId: number, executorUsername: string, ): Promise { - const progress = await this.findByProgressId(progressId); + const progress = await this.findOneByProgressId(progressId, { + loadDeletedEntities: false, + loadRelations: false, + }); await this.usersService.checkIfUsernameMatchesIdOrIsAdmin( progress.user.id, @@ -94,52 +111,54 @@ export class ProgressService { return softRemoveResult; } - public async findByUserId(userId: number) { - return await this.progressRepository.find({ - order: { minutes_played: "DESC" }, - where: { - user: { id: userId }, - deleted_at: IsNull(), - }, - relations: ["game"], - withDeleted: true, - }); - } - - public async findByGameId(gameId: number): Promise { - return await this.progressRepository.find({ - where: { - game: { id: gameId }, - deleted_at: IsNull(), - }, - relations: ["user"], - withDeleted: true, - order: { minutes_played: "DESC" }, - }); - } - - public async findOrCreateByUserIdAndGameId( + public async findOneByUserIdAndGameIdOrReturnEmptyProgress( userId: number, gameId: number, + options: FindOptions, ): Promise { try { - return await this.progressRepository.findOneOrFail({ + const findParameters: FindOneOptions = { where: { user: { id: userId }, game: { id: gameId }, deleted_at: IsNull(), }, - withDeleted: true, - }); - } catch (error) { + relationLoadStrategy: "query", + }; + + if (options.loadRelations) { + if (options.loadRelations === true) { + findParameters.relations = ["user", "game"]; + } else if (Array.isArray(options.loadRelations)) + findParameters.relations = options.loadRelations; + } + + if (options.loadDeletedEntities) { + findParameters.withDeleted = true; + } + + if (options.filterByAge) { + if (!options.loadRelations) { + findParameters.relations = ["game"]; + } + findParameters.where = { + user: { id: userId }, + game: { + id: gameId, + metadata: { age_rating: LessThanOrEqual(options.filterByAge) }, + }, + }; + } + + return await this.progressRepository.findOneOrFail(findParameters); + } catch { const newProgress = new Progress(); - newProgress.user = await this.usersService.findByUserIdOrFail(userId, { + newProgress.user = await this.usersService.findOneByUserIdOrFail(userId, { loadDeletedEntities: true, loadRelations: false, }); - newProgress.game = await this.gamesService.findByGameIdOrFail(gameId, { + newProgress.game = await this.gamesService.findOneByGameIdOrFail(gameId, { loadDeletedEntities: true, - loadRelations: false, }); newProgress.minutes_played = 0; newProgress.state = State.UNPLAYED; @@ -158,7 +177,11 @@ export class ProgressService { executorUsername, ); - const progress = await this.findOrCreateByUserIdAndGameId(userId, gameId); + const progress = await this.findOneByUserIdAndGameIdOrReturnEmptyProgress( + userId, + gameId, + { loadDeletedEntities: true, loadRelations: true }, + ); if (updateProgressDto.state != null) { progress.state = updateProgressDto.state; @@ -171,7 +194,7 @@ export class ProgressService { const deleteResult = await this.progressRepository.remove(progress); this.logger.log({ message: `Deleted empty progress.`, - progress, + progress: logProgress(progress), }); return deleteResult; } @@ -194,7 +217,10 @@ export class ProgressService { progress.last_played_at = new Date(); } } - this.logger.log({ message: `Updating progress.`, progress }); + this.logger.log({ + message: `Updating progress.`, + progress: logProgress(progress), + }); return this.progressRepository.save(progress); } @@ -208,7 +234,11 @@ export class ProgressService { userId, executorUsername, ); - const progress = await this.findOrCreateByUserIdAndGameId(userId, gameId); + const progress = await this.findOneByUserIdAndGameIdOrReturnEmptyProgress( + userId, + gameId, + { loadDeletedEntities: true, loadRelations: true }, + ); if ( progress.state !== State.INFINITE && progress.state !== State.COMPLETED @@ -216,13 +246,13 @@ export class ProgressService { this.logger.debug({ message: `Automatically setting progress state to "${State.PLAYING}".`, reason: "Current state is not 'INFINITE' or 'COMPLETED'", - progress, + progress: logProgress(progress), }); progress.state = State.PLAYING; } this.logger.log({ message: `Incrementing progress by ${incrementBy} minute(s).`, - progress, + progress: logProgress(progress), }); progress.last_played_at = new Date(); progress.minutes_played += incrementBy; diff --git a/src/modules/providers/rawg/mapper.service.ts b/src/modules/providers/rawg/mapper.service.ts deleted file mode 100644 index 3641ed5b..00000000 --- a/src/modules/providers/rawg/mapper.service.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; - -import { DevelopersService } from "../../developers/developers.service"; -import { Game } from "../../games/game.entity"; -import { GenresService } from "../../genres/genres.service"; -import { ImagesService } from "../../images/images.service"; -import { PublishersService } from "../../publishers/publishers.service"; -import { StoresService } from "../../stores/stores.service"; -import { TagsService } from "../../tags/tags.service"; -import { RawgGame } from "./models/game.interface"; - -@Injectable() -export class RawgMapperService { - private readonly logger = new Logger(RawgMapperService.name); - - constructor( - private tagService: TagsService, - private genreService: GenresService, - private publishersService: PublishersService, - private developersService: DevelopersService, - private storesService: StoresService, - private imagesService: ImagesService, - ) {} - - /** - * Maps a RawgGame to a Game entity, filling missing information in the entity - * using the RawgGame. - */ - public async mapRawgGameToGame(game: RawgGame, entity: Game): Promise { - this.logger.debug({ - message: `Mapping RAWG Game...`, - gameTitle: entity.title, - rawgGameTitle: game.name, - }); - entity = await this.mapRawgStoresToGame(game, entity); - entity = await this.mapRawgDevelopersToGame(game, entity); - entity = await this.mapRawgPublishersToGame(game, entity); - entity = await this.mapRawgTagsToGame(game, entity); - entity = await this.mapRawgGenresToGame(game, entity); - entity = await this.mapRawgGameDetailsToGame(game, entity); - entity.cache_date = new Date(); - return entity; - } - - /** Maps stores from RawgGame to Game entity. */ - private async mapRawgStoresToGame( - game: RawgGame, - entity: Game, - ): Promise { - try { - entity.stores = []; - if (!game.stores) return entity; - for (const storeContainer of game.stores) { - const store = storeContainer.store; - entity.stores.push( - await this.storesService.getOrCreate(store.name, store.id), - ); - } - } catch (error) { - this.logger.error({ - message: "Error mapping stores to game entity.", - error, - }); - } - return entity; - } - - /** Maps developers from RawgGame to Game entity. */ - private async mapRawgDevelopersToGame( - game: RawgGame, - entity: Game, - ): Promise { - try { - entity.developers = []; - if (!game.developers) return entity; - for (const developer of game.developers) { - entity.developers.push( - await this.developersService.getOrCreate( - developer.name, - developer.id, - ), - ); - } - } catch (error) { - this.logger.error({ - message: "Error mapping developers to game entity.", - error, - }); - } - return entity; - } - - /** Maps publishers from RawgGame to Game entity. */ - private async mapRawgPublishersToGame( - game: RawgGame, - entity: Game, - ): Promise { - try { - entity.publishers = []; - if (!game.publishers) return entity; - for (const publisher of game.publishers) { - entity.publishers.push( - await this.publishersService.getOrCreate( - publisher.name, - publisher.id, - ), - ); - } - } catch (error) { - this.logger.error({ - message: "Error mapping publishers to game entity.", - error, - }); - } - return entity; - } - - /** Maps tags from RawgGame to Game entity. Only English tags are mapped. */ - private async mapRawgTagsToGame(game: RawgGame, entity: Game): Promise { - try { - entity.tags = []; - - if (!game.tags) { - return entity; - } - - for (const tag of game.tags) { - const isEnglish = tag.language === "eng"; - const isAlphanumeric = /^[a-zA-Z0-9\s&.,-]+$/.test(tag.name); - - if (!isEnglish) { - this.logger.debug({ - message: "Skipping tag.", - reason: `Tag has invalid language. Only english tags are supported.`, - tag, - }); - } else if (!isAlphanumeric) { - this.logger.debug({ - message: "Skipping tag.", - reason: `Tag contains invalid characters.`, - tag, - }); - } else { - entity.tags.push(await this.tagService.getOrCreate(tag.name, tag.id)); - } - } - } catch (error) { - this.logger.error({ - message: "Error mapping tags to game entity.", - error, - }); - } - - return entity; - } - - /** Maps genres from RawgGame to Game entity. */ - private async mapRawgGenresToGame( - game: RawgGame, - entity: Game, - ): Promise { - try { - entity.genres = []; - if (!game.genres) return entity; - for (const genre of game.genres) { - entity.genres.push( - await this.genreService.getOrCreate(genre.name, genre.id), - ); - } - } catch (error) { - this.logger.error({ - message: "Error mapping genres to game entity.", - error, - }); - } - return entity; - } - - /** Maps a RawgGame object to a Game entity. */ - private async mapRawgGameDetailsToGame( - rawgGame: RawgGame, - game: Game, - ): Promise { - try { - if ( - rawgGame.background_image && - (!game.background_image?.id || - !(await this.imagesService.isAvailable(game.background_image.id))) - ) { - try { - game.background_image = await this.imagesService.downloadByUrl( - rawgGame.background_image, - ); - } catch (error) { - this.logger.error({ - message: "Error downloading background image.", - game: { id: game.id, file_path: game.file_path }, - error, - }); - } - } - - if ( - rawgGame.box_image && - (!game.box_image?.id || - !(await this.imagesService.isAvailable(game.box_image.id))) - ) { - try { - game.box_image = await this.imagesService.downloadByUrl( - rawgGame.box_image, - ); - } catch (error) { - this.logger.error({ - message: "Error downloading box image.", - game: { id: game.id, file_path: game.file_path }, - error, - }); - } - } - - game.rawg_title = rawgGame.name ?? game.rawg_title; - game.rawg_id = rawgGame.id ?? game.rawg_id; - game.description = rawgGame.description_raw ?? game.description; - game.website_url = rawgGame.website ?? game.website_url; - game.metacritic_rating = rawgGame.metacritic ?? game.metacritic_rating; - - game.rawg_release_date = - this.getReleaseDate(rawgGame) ?? game.rawg_release_date; - - if (!game.release_date && game.rawg_release_date) { - game.release_date = game.rawg_release_date; - } - - if (rawgGame.playtime) { - game.average_playtime = rawgGame.playtime * 60; - } - - return game; - } catch (error) { - this.logger.error({ - message: "Error mapping rawg game to game entity.", - error, - }); - throw error; - } - } - - /** - * Returns the release date for a RawgGame object or null if it is not - * available. - */ - private getReleaseDate(game: RawgGame): Date | null { - const pcReleaseDate = game.platforms?.find( - (p) => p.platform.id === 4, - )?.released_at; - const generalReleaseDate = game.released; - - if (pcReleaseDate) { - return new Date(pcReleaseDate); - } else if (generalReleaseDate) { - return new Date(generalReleaseDate); - } else { - return null; - } - } -} diff --git a/src/modules/providers/rawg/models/game.interface.ts b/src/modules/providers/rawg/models/game.interface.ts deleted file mode 100644 index 7f93a303..00000000 --- a/src/modules/providers/rawg/models/game.interface.ts +++ /dev/null @@ -1,170 +0,0 @@ -export interface Platform { - platform: number; - name: string; - slug: string; -} - -export interface MetacriticPlatform { - metascore: number; - url: string; - platform: Platform; -} - -export interface Rating { - id: number; - title: string; - count: number; - percent: number; -} - -export interface AddedByStatus { - yet: number; - owned: number; - beaten: number; - toplay: number; - dropped: number; -} - -export interface Platform2 { - id: number; - name: string; - slug: string; -} - -export interface ParentPlatform { - platform: Platform2; -} - -export interface Platform4 { - id: number; - name: string; - slug: string; - image?: unknown; - year_end?: unknown; - year_start?: unknown; - games_count: number; - image_background: string; -} - -export interface Requirements { - minimum: string; - recommended: string; -} - -export interface Platform3 { - platform: Platform4; - released_at: string; - requirements: Requirements; -} - -export interface Store2 { - id: number; - name: string; - slug: string; - domain: string; - games_count: number; - image_background: string; -} - -export interface Store { - id: number; - url: string; - store: Store2; -} - -export interface Developer { - id: number; - name: string; - slug: string; - games_count: number; - image_background: string; -} - -export interface Genre { - id: number; - name: string; - slug: string; - games_count: number; - image_background: string; -} - -export interface Tag { - id: number; - name: string; - slug: string; - language: string; - games_count: number; - image_background: string; -} - -export interface Publisher { - id: number; - name: string; - slug: string; - games_count: number; - image_background: string; -} - -export interface EsrbRating { - id: number; - name: string; - slug: string; -} - -export interface RawgGame { - id: number; - slug: string; - name: string; - name_original: string; - description: string; - metacritic: number; - metacritic_platforms: MetacriticPlatform[]; - released: string; - tba: boolean; - updated: Date; - box_image?: string; - background_image?: string; - background_image_additional: string; - website: string; - rating: number; - rating_top: number; - ratings: Rating[]; - reactions: unknown; - added: number; - added_by_status: AddedByStatus; - playtime: number; - screenshots_count: number; - movies_count: number; - creators_count: number; - achievements_count: number; - parent_achievements_count: number; - reddit_url: string; - reddit_name: string; - reddit_description: string; - reddit_logo: string; - reddit_count: number; - twitch_count: number; - youtube_count: number; - reviews_text_count: number; - ratings_count: number; - suggestions_count: number; - alternative_names: string[]; - metacritic_url: string; - parents_count: number; - additions_count: number; - game_series_count: number; - user_game?: unknown; - reviews_count: number; - saturated_color: string; - dominant_color: string; - parent_platforms: ParentPlatform[]; - platforms: Platform3[]; - stores: Store[]; - developers: Developer[]; - genres: Genre[]; - tags: Tag[]; - publishers: Publisher[]; - esrb_rating: EsrbRating; - clip?: unknown; - description_raw: string; -} diff --git a/src/modules/providers/rawg/models/games.interface.ts b/src/modules/providers/rawg/models/games.interface.ts deleted file mode 100644 index c14dddfc..00000000 --- a/src/modules/providers/rawg/models/games.interface.ts +++ /dev/null @@ -1,115 +0,0 @@ -export interface Platform2 { - id: number; - name: string; - slug: string; -} - -export interface Platform { - platform: Platform2; -} - -export interface Store2 { - id: number; - name: string; - slug: string; -} - -export interface Store { - store: Store2; -} - -export interface Rating { - id: number; - title: string; - count: number; - percent: number; -} - -export interface AddedByStatus { - yet: number; - owned: number; - beaten: number; - toplay: number; - dropped: number; - playing: number; -} - -export interface Tag { - id: number; - name: string; - slug: string; - language: string; - games_count: number; - image_background: string; -} - -export interface EsrbRating { - id: number; - name: string; - slug: string; - name_en: string; - name_ru: string; -} - -export interface ShortScreenshot { - id: number; - image: string; -} - -export interface Platform3 { - id: number; - name: string; - slug: string; -} - -export interface ParentPlatform { - platform: Platform3; -} - -export interface Genre { - id: number; - name: string; - slug: string; -} - -export interface Result { - slug: string; - name: string; - playtime: number; - platforms: Platform[]; - stores: Store[]; - released: string; - tba: boolean; - background_image: string; - rating: number; - rating_top: number; - ratings: Rating[]; - ratings_count: number; - reviews_text_count: number; - added: number; - added_by_status: AddedByStatus; - metacritic?: number; - suggestions_count: number; - updated: Date; - id: number; - score?: unknown; - clip?: unknown; - tags: Tag[]; - esrb_rating: EsrbRating; - user_game?: unknown; - reviews_count: number; - saturated_color: string; - dominant_color: string; - short_screenshots: ShortScreenshot[]; - parent_platforms: ParentPlatform[]; - genres: Genre[]; - probability?: number; -} - -export interface SearchResult { - count: number; - next: string; - previous?: unknown; - results: Result[]; - user_platforms: boolean; -} diff --git a/src/modules/providers/rawg/models/platforms.ts b/src/modules/providers/rawg/models/platforms.ts deleted file mode 100644 index 00f72096..00000000 --- a/src/modules/providers/rawg/models/platforms.ts +++ /dev/null @@ -1,57 +0,0 @@ -// prettier-ignore -export const RawgPlatform = { - "All Platforms": 0, - "Xbox One": 1, - "iOS": 3, - "PC": 4, - "macOS": 5, - "Linux": 6, - "Nintendo Switch": 7, - "Nintendo 3DS": 8, - "Nintendo DS": 9, - "Wii U": 10, - "Wii": 11, - "Neo Geo": 12, - "Nintendo DSi": 13, - "Xbox 360": 14, - "PlayStation 2": 15, - "PlayStation 3": 16, - "PSP": 17, - "PlayStation 4": 18, - "PS Vita": 19, - "Android": 21, - "Atari Flashback": 22, - "Atari 2600": 23, - "Game Boy Advance": 24, - "Atari 8-bit": 25, - "Game Boy": 26, - "PlayStation": 27, - "Atari 7800": 28, - "Atari 5200": 31, - "Atari ST": 34, - "Apple II": 41, - "Game Boy Color": 43, - "Atari Lynx": 46, - "NES": 49, - "Atari XEGS": 50, - "Classic Macintosh": 55, - "SEGA Master System": 74, - "Game Gear": 77, - "SNES": 79, - "Xbox": 80, - "Nintendo 64": 83, - "GameCube": 105, - "Dreamcast": 106, - "SEGA Saturn": 107, - "3DO": 111, - "Jaguar": 112, - "SEGA 32X": 117, - "SEGA CD": 119, - "Commodore / Amiga": 166, - "Genesis": 167, - "Web": 171, - "Xbox Series S/X": 186, - "PlayStation 5": 187, -} as const; - -export type RawgPlatform = (typeof RawgPlatform)[keyof typeof RawgPlatform]; diff --git a/src/modules/providers/rawg/models/stores.ts b/src/modules/providers/rawg/models/stores.ts deleted file mode 100644 index c5e7c76d..00000000 --- a/src/modules/providers/rawg/models/stores.ts +++ /dev/null @@ -1,16 +0,0 @@ -// prettier-ignore -export const RawgStore = { - "All Stores": 0, - "Steam": 1, - "Xbox Store": 2, - "PlayStation Store": 3, - "App Store": 4, - "GOG": 5, - "Nintendo Store": 6, - "Xbox 360 Store": 7, - "Google Play": 8, - "Itch.io": 9, - "EPIC Games": 11, -} as const; - -export type RawgStore = (typeof RawgStore)[keyof typeof RawgStore]; diff --git a/src/modules/providers/rawg/rawg.controller.ts b/src/modules/providers/rawg/rawg.controller.ts deleted file mode 100644 index b2f9fab4..00000000 --- a/src/modules/providers/rawg/rawg.controller.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Controller, Get, Param, Put, Query } from "@nestjs/common"; -import { - ApiBasicAuth, - ApiOkResponse, - ApiOperation, - ApiQuery, - ApiTags, -} from "@nestjs/swagger"; - -import { MinimumRole } from "../../../decorators/minimum-role.decorator"; -import { BoxArtsService } from "../../boxarts/boxarts.service"; -import { IdDto } from "../../database/models/id.dto"; -import { Game } from "../../games/game.entity"; -import { GamesService } from "../../games/games.service"; -import { MinimalGame } from "../../games/models/minimal-game"; -import { Role } from "../../users/models/role.enum"; -import { RawgService } from "./rawg.service"; - -@ApiTags("rawg") -@Controller("rawg") -@ApiBasicAuth() -export class RawgController { - constructor( - private gamesService: GamesService, - private rawgService: RawgService, - private boxartService: BoxArtsService, - ) {} - - /** Searches the Rawg API manually. */ - @Get("search") - @ApiOperation({ - summary: "search the rawg-api manually.", - operationId: "getRawgSearch", - }) - @ApiQuery({ name: "query", description: "search query" }) - @ApiOkResponse({ - type: () => MinimalGame, - isArray: true, - description: - "These are minimal game objects, without a lot of information.", - }) - @MinimumRole(Role.EDITOR) - async getRawgSearch(@Query("query") query: string): Promise { - const rawgGames = await this.rawgService.fetchMatching(query); - // for each search result return a minimal gamevault game - const games: MinimalGame[] = []; - for (const rawgGame of rawgGames) { - const newGame = new MinimalGame(); - newGame.rawg_id = rawgGame.id; - newGame.title = rawgGame.name; - newGame.box_image_url = rawgGame.background_image; - if (rawgGame.released) { - newGame.release_date = new Date(rawgGame.released); - } - games.push(newGame); - } - return games; - } - - /** - * Manually triggers a recache from rawg-api for a specific game, also updates - * boxart. - */ - @Put(":id/recache") - @ApiOperation({ - summary: - "manually triggers a recache from rawg-api for a specific game, also updates boxart", - operationId: "putRawgRecacheGameByGameId", - }) - @ApiOkResponse({ type: () => Game }) - @MinimumRole(Role.EDITOR) - async putRawgRecacheGameByGameId(@Param() params: IdDto): Promise { - let game = await this.gamesService.findByGameIdOrFail(Number(params.id)); - game.cache_date = null; - game = (await this.rawgService.checkCache([game]))[0]; - game = await this.boxartService.check(game); - return game; - } - - /** Manually triggers a recache from rawg-api for all games. */ - @Put("recache-all") - @ApiOperation({ - summary: "manually triggers a recache from rawg-api for all games", - description: - "DANGER: This is a very expensive operation and should be used sparingly", - operationId: "putRawgRecacheAll", - }) - @ApiOkResponse({ type: () => Game, isArray: true }) - @MinimumRole(Role.ADMIN) - async putRawgRecacheAll(): Promise { - let games = (await this.gamesService.getAll()).map((game) => { - game.cache_date = null; - return game; - }); - games = await this.rawgService.checkCache(games); - games = await this.boxartService.checkMultiple(games); - return `Successfully recached ${games.length} games`; - } -} diff --git a/src/modules/providers/rawg/rawg.e2e.spec.ts b/src/modules/providers/rawg/rawg.e2e.spec.ts deleted file mode 100644 index d242c467..00000000 --- a/src/modules/providers/rawg/rawg.e2e.spec.ts +++ /dev/null @@ -1,253 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { HttpService } from "@nestjs/axios"; -import { Test } from "@nestjs/testing"; -import { getRepositoryToken } from "@nestjs/typeorm"; -import gis, { Result } from "async-g-i-s"; -import { Builder } from "builder-pattern"; -import { of } from "rxjs"; -import { Repository } from "typeorm"; - -import { AppModule } from "../../../app.module"; -import configuration from "../../../configuration"; -import { Game } from "../../games/game.entity"; -import { RawgController } from "./rawg.controller"; - -jest.mock("async-g-i-s"); - -describe("/api/rawg", () => { - let rawgController: RawgController; - let gameRepository: Repository; - - const mockHttpService = { - get: jest.fn(), - }; - - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [AppModule], - }) - .overrideProvider(HttpService) - .useValue(mockHttpService) - .compile(); - rawgController = moduleRef.get(RawgController); - gameRepository = moduleRef.get>(getRepositoryToken(Game)); - }); - - afterEach(() => { - jest.clearAllMocks(); - gameRepository.clear(); - }); - - describe("GET /api/rawg/search", () => { - it("should search the rawg api", async () => { - mockHttpService.get.mockReturnValue( - of({ - data: { - results: [ - { name: "Totally diferrent game" }, - { name: "Grand Theft Auto V" }, - ], - }, - }), - ); - await rawgController.getRawgSearch("Grand Theft Auto V"); - expect(mockHttpService.get).toHaveBeenCalledWith( - `${configuration.RAWG_API.URL}/games`, - { - params: { - search: "Grand Theft Auto V", - key: configuration.RAWG_API.KEY, - dates: undefined, - stores: configuration.RAWG_API.INCLUDED_STORES.join(), - }, - }, - ); - }); - - it("should remove unnecessary game information", async () => { - mockHttpService.get.mockReturnValue( - of({ - data: { - results: [{ name: "Grand Theft Auto V", address: "Wall Street" }], - }, - }), - ); - const result = await rawgController.getRawgSearch("Grand Theft Auto V"); - expect(result[0].title).toBe("Grand Theft Auto V"); - expect((result[0] as any).address).toBeUndefined(); - }); - - it("should sort the games rawg returns by probabilty", async () => { - mockHttpService.get.mockReturnValue( - of({ - data: { - results: [ - { name: "Totally diferrent game" }, - { name: "Grand Theft Auto A" }, - { name: "Grand Theft Auto B" }, - { name: "Grand Theft Auto C" }, - { name: "Grand Theft Auto V" }, - { name: "Grand Theft Auto D" }, - { name: "Grand Theft Auto E" }, - { name: "Grand Theft Auto F" }, - ], - }, - }), - ); - const result = await rawgController.getRawgSearch("Grand Theft Auto V"); - //Expect the first game is the exact match - expect(result[0].title).toBe("Grand Theft Auto V"); - //Expect the last game is the worst match - expect(result[result.length - 1].title).toBe("Totally diferrent game"); - }); - }); - - describe("PUT /api/rawg/{id}/recache", () => { - it("should not recache '(NC)' flagged games", async () => { - mockHttpService.get.mockReturnValue( - of({ - data: {}, - }), - ); - //Mock Google-Image-Search - (gis as jest.Mock).mockResolvedValue([ - { - url: "https://example.com/example.png", - height: 900, - width: 600, - } as Result, - ]); - - //Mock a game - const mockGame = await gameRepository.save( - Builder(Game) - .title("Minecraft") - .cache_date(new Date("2022-07-04")) - .file_path("filepath (NC).zip") - .early_access(false) - .build(), - ); - - const result = await rawgController.putRawgRecacheGameByGameId({ - id: mockGame.id.toString(), - }); - - expect(result.title).toBe("Minecraft"); - expect(result.cache_date).toBeNull(); - expect(mockHttpService.get).toHaveBeenCalledTimes(1); - expect(gis).toHaveBeenCalled(); - }); - - it("should recache a game by its id", async () => { - //Mock Google-Image-Search - (gis as jest.Mock).mockResolvedValue([ - { - url: "https://example.com/example.png", - height: 900, - width: 600, - } as Result, - ]); - - //Mock a game - const mockGame = await gameRepository.save( - Builder(Game) - .title("Grand Theft Auto V") - .description("wrong description") - .cache_date(new Date("2022-07-04")) - .file_path("filepath.zip") - .early_access(false) - .build(), - ); - - mockHttpService.get - //mock search - .mockReturnValueOnce( - of({ - data: { - results: [ - { name: "Totally diferrent game" }, - { name: "Grand Theft Auto A" }, - { name: "Grand Theft Auto B" }, - { name: "Grand Theft Auto C" }, - { name: "Grand Theft Auto V" }, - { name: "Grand Theft Auto D" }, - ], - }, - }), - ) - //mock get details - .mockReturnValueOnce( - of({ - data: { - name: "Grand Theft Auto V", - description_raw: "correct description", - }, - }), - ) - //mock rest like boxarts - .mockReturnValue( - of({ - data: {}, - }), - ); - - const result = await rawgController.putRawgRecacheGameByGameId({ - id: mockGame.id.toString(), - }); - - //Expect the first game is the exact match - expect(result.title).toBe("Grand Theft Auto V"); - expect(result.description).toBe("correct description"); - expect(result.cache_date).not.toEqual(mockGame.cache_date); - expect(gis).toHaveBeenCalled(); - expect(mockHttpService.get).toHaveBeenCalledTimes(3); - }); - }); - - describe("PUT /api/rawg/recache-all", () => { - it("should recache all games", async () => { - mockHttpService.get.mockReturnValue( - of({ - data: {}, - }), - ); - //Mock Google-Image-Search - (gis as jest.Mock).mockResolvedValue([ - { - url: "https://example.com/example.png", - height: 900, - width: 600, - } as Result, - ]); - - //Mock a game - await gameRepository.save( - Builder(Game) - .title("Grand Theft Auto V") - .rawg_id(1000) - .cache_date(new Date("2021-07-04")) - .file_path("filepath.zip") - .early_access(false) - .build(), - ); - - await gameRepository.save( - Builder(Game) - .title("Grand Theft Auto IV") - .rawg_id(1000) - .cache_date(new Date("2021-07-04")) - .file_path("filepath2.zip") - .early_access(false) - .build(), - ); - const result = await rawgController.putRawgRecacheAll(); - expect(result).toBe("Successfully recached 2 games"); - const data = await gameRepository.find(); - // Only check if both are null, the rest is tested in the singular recache test - expect(data[0].cache_date).not.toEqual(new Date("2021-07-04")); - expect(data[1].cache_date).not.toEqual(new Date("2021-07-04")); - expect(mockHttpService.get).toHaveBeenCalledTimes(4); - expect(gis).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/src/modules/providers/rawg/rawg.module.ts b/src/modules/providers/rawg/rawg.module.ts deleted file mode 100644 index cae6abc8..00000000 --- a/src/modules/providers/rawg/rawg.module.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { HttpModule } from "@nestjs/axios"; -import { forwardRef, Module } from "@nestjs/common"; - -import { BoxartsModule } from "../../boxarts/boxarts.module"; -import { DevelopersModule } from "../../developers/developers.module"; -import { GamesModule } from "../../games/games.module"; -import { GenresModule } from "../../genres/genres.module"; -import { ImagesModule } from "../../images/images.module"; -import { PublishersModule } from "../../publishers/publishers.module"; -import { StoresModule } from "../../stores/stores.module"; -import { TagsModule } from "../../tags/tags.module"; -import { RawgMapperService } from "./mapper.service"; -import { RawgController } from "./rawg.controller"; -import { RawgService } from "./rawg.service"; - -@Module({ - imports: [ - forwardRef(() => GamesModule), - forwardRef(() => BoxartsModule), - HttpModule, - TagsModule, - GenresModule, - PublishersModule, - DevelopersModule, - StoresModule, - ImagesModule, - ], - controllers: [RawgController], - providers: [RawgService, RawgMapperService], - exports: [RawgService], -}) -export class RawgModule {} diff --git a/src/modules/providers/rawg/rawg.service.ts b/src/modules/providers/rawg/rawg.service.ts deleted file mode 100644 index 8d3bab96..00000000 --- a/src/modules/providers/rawg/rawg.service.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { HttpService } from "@nestjs/axios"; -import { - forwardRef, - Inject, - Injectable, - InternalServerErrorException, - Logger, - NotFoundException, -} from "@nestjs/common"; -import { AxiosError } from "axios"; -import { ClientRequest } from "http"; -import { catchError, firstValueFrom } from "rxjs"; -import stringSimilarity from "string-similarity-js"; - -import configuration from "../../../configuration"; -import { Game } from "../../games/game.entity"; -import { GamesService } from "../../games/games.service"; -import { RawgMapperService } from "./mapper.service"; -import { RawgGame } from "./models/game.interface"; -import { Result as RawgResult, SearchResult } from "./models/games.interface"; -import { RawgPlatform } from "./models/platforms"; -import { RawgStore } from "./models/stores"; - -@Injectable() -export class RawgService { - private readonly logger = new Logger(RawgService.name); - - constructor( - @Inject(forwardRef(() => GamesService)) - private gamesService: GamesService, - private mapper: RawgMapperService, - private readonly httpService: HttpService, - ) {} - - /** - * Check the cache for each game in the provided list. If the RAWG API is - * disabled or the API key is not set, the cache check will be skipped. - * Otherwise, each game will be cached using the cacheGame method. Any errors - * that occur during caching will be logged. Finally, the updated game list - * will be returned. - * - * @param games - The list of games to check the cache for - * @returns The updated list of games after caching - */ - public async checkCache(games: Game[]): Promise { - // Skip cache check if RAWG API is disabled - if (configuration.TESTING.RAWG_API_DISABLED) { - this.logger.warn({ - message: "Skipping RAWG Cache Check.", - reason: "TESTING_RAWG_API_DISABLED is set to true.", - }); - return games; - } - - // Skip cache check if RAWG API key is not set - if ( - !configuration.RAWG_API.KEY && - configuration.RAWG_API.URL === "https://api.rawg.io/api" - ) { - this.logger.warn({ - message: "Skipping RAWG Cache Check.", - reason: "RAWG_API_KEY is not set.", - }); - return games; - } - - this.logger.log({ - message: "Starting RAWG Cache Check.", - gamesCount: games.length, - }); - - // Cache each game in the list - for (let i = 0; i < games.length; i++) { - try { - games[i] = await this.cacheGame(games[i]); - this.logger.debug({ - message: "Game Cached Successfully.", - game: { - id: games[i].id, - file_path: games[i].file_path, - }, - }); - } catch (error) { - this.logger.error({ - message: "Error Caching Game.", - game: { - id: games[i].id, - file_path: games[i].file_path, - }, - error, - }); - } - } - - this.logger.log({ - message: "Finished RAWG Cache Check.", - gamesCount: games.length, - }); - return games; - } - - /** - * Caches a Game object. - * - * @param game - The Game object to be cached. - * @returns The cached Game object. - */ - private async cacheGame(game: Game): Promise { - // Skip caching if the file path contains (NC) flag - if (game.file_path.includes("(NC)")) { - this.logger.debug({ - message: "Skipping Caching Game.", - reason: "File path contains (NC) flag.", - game: { - id: game.id, - file_path: game.file_path, - }, - }); - return game; - } - - // Skip caching if the game is not outdated - if (!this.isOutdated(game)) { - this.logger.debug({ - message: "Skipping Caching Game.", - reason: "Cached data is still fresh.", - game: { - id: game.id, - file_path: game.file_path, - }, - cachedAt: game.cache_date, - }); - return game; - } - - this.logger.debug({ - message: "Caching Game.", - game: { - id: game.id, - file_path: game.file_path, - }, - }); - - // Fetch the game data from external API using Rawg ID or title and release date - const rawgEntry: RawgGame = game.rawg_id - ? await this.fetchByRawgId(game.rawg_id) - : await this.getBestMatch( - game.title, - game.release_date?.getUTCFullYear() || undefined, - ); - - // Map the RawgGame to a Game object - const mappedGame = await this.mapper.mapRawgGameToGame(rawgEntry, game); - // Save the mapped Game object - await this.gamesService.save(mappedGame); - - return mappedGame; - } - - private async getBestMatch( - title: string, - releaseYear?: number, - ): Promise { - const sortedResults = await this.fetchMatching(title, releaseYear); - - this.logger.log({ - message: `Found ${sortedResults.length} matches on RAWG.`, - title, - releaseYear, - sortedResults, - }); - - return this.fetchByRawgId(sortedResults[0].id); - } - - /** - * Fetches matching game titles from the RAWG API. If a release year is - * provided, it fetches games with the given title and release year. If no - * release year is provided or no matching game is found with the release - * year, it fetches games with the given title only. - * - * @param title - The title of the game. - * @param releaseYear - The release year of the game (optional). - * @returns An array of RawgResult objects representing the matching game - * titles. - * @throws NotFoundException if no matching game is found. - */ - public async fetchMatching( - title: string, - releaseYear?: number, - ): Promise { - // Array to store the search results - const searchResults: RawgResult[] = []; - - // If releaseYear is provided, fetch games with the given title and release year - if (releaseYear) { - const search = await this.fetch(title, releaseYear); - this.logger.debug({ - message: `Fetched ${search.results.length} RAWG game(s) matching title and release year.`, - title, - releaseYear, - temporarySearchResults: search, - }); - searchResults.push(...search.results); - } - - // If no search results are found with the release year, fetch games with the given title only - if (searchResults.length === 0) { - const search = await this.fetch(title); - this.logger.debug({ - message: `Fetched ${search.results.length} RAWG game(s) matching title.`, - title, - temporarySearchResults: search, - }); - searchResults.push(...search.results); - } - - // If no search results are found, throw a NotFoundException - if (searchResults.length === 0) { - this.logger.log({ - message: "No matching RAWG game found.", - title, - releaseYear, - }); - throw new NotFoundException("No matching RAWG game found"); - } - - // Calculate the probability of matching for each game - searchResults.forEach((searchResult) => { - const cleanedGameTitle = title?.toLowerCase().replace(/[^\w\s]/g, ""); - const cleanedSearchResultTitle = searchResult.name - ?.toLowerCase() - .replace(/[^\w\s]/g, ""); - - // Calculate string similarity between the title and game name - searchResult.probability = stringSimilarity( - cleanedGameTitle, - cleanedSearchResultTitle, - ); - - // If releaseYear is provided, adjust the probability based on the difference between the release year of the game and the provided release year - if (releaseYear !== undefined) { - const gameReleaseYear = new Date( - searchResult.released, - )?.getUTCFullYear(); - searchResult.probability -= - Math.abs(releaseYear - gameReleaseYear) / 10; - } - }); - - // Sort the search results by probability in descending order - searchResults.sort((a, b) => b.probability - a.probability); - - // Return the search results - return searchResults; - } - - /** Determines whether a game's cache is outdated. */ - private isOutdated(game: Game) { - if (!game.cache_date) { - this.logger.debug({ - message: "Game is not cached.", - game: { - id: game.id, - file_path: game.file_path, - }, - }); - return true; - } - - if ( - new Date().getTime() - game.cache_date.getTime() > - configuration.RAWG_API.CACHE_DAYS * 24 * 60 * 60 * 1000 - ) { - this.logger.debug({ - message: "Game Cache is outdated.", - game: { - id: game.id, - file_path: game.file_path, - }, - }); - return true; - } - return false; - } - - /** Returns the RawgGame object associated with the specified ID. */ - private async fetchByRawgId(id: number): Promise { - const response = await firstValueFrom( - this.httpService - .get(`${configuration.RAWG_API.URL}/games/${id}`, { - params: { key: configuration.RAWG_API.KEY }, - }) - .pipe( - catchError((error: AxiosError) => { - throw new InternalServerErrorException( - `Serverside RAWG Request Error: ${error.status} ${error.message}`, - { cause: error.toJSON() }, - ); - }), - ), - ); - return response.data; - } - - /** - * Retrieves a list of games from the RAWG API based on optional search - * criteria. - */ - private async fetch( - search?: string, - releaseYear?: number, - ): Promise { - const searchDates = releaseYear - ? `${releaseYear}-01-01,${releaseYear}-12-31` - : undefined; - - const requestParameters = { - search, - key: configuration.RAWG_API.KEY, - dates: searchDates, - stores: configuration.RAWG_API.INCLUDED_STORES.includes( - RawgStore["All Stores"], - ) - ? undefined - : configuration.RAWG_API.INCLUDED_STORES.join(), - platforms: configuration.RAWG_API.INCLUDED_PLATFORMS.includes( - RawgPlatform["All Platforms"], - ) - ? undefined - : configuration.RAWG_API.INCLUDED_PLATFORMS.join(), - }; - - const response = await firstValueFrom( - this.httpService - .get(`${configuration.RAWG_API.URL}/games`, { - params: requestParameters, - }) - .pipe( - catchError((error: AxiosError) => { - throw new InternalServerErrorException( - `Serverside RAWG Request Error: ${error.status} ${error.message}`, - { cause: error.toJSON() }, - ); - }), - ), - ); - - //Log the full request url and response data from RAWG - this.logger.debug({ - message: "RAWG Request", - url: - (response.request as ClientRequest)?.host + - (response.request as ClientRequest)?.path, - data: response.data, - }); - return response.data as SearchResult; - } -} diff --git a/src/modules/publishers/publishers.module.ts b/src/modules/publishers/publishers.module.ts deleted file mode 100644 index fcdfcfdf..00000000 --- a/src/modules/publishers/publishers.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { Publisher } from "./publisher.entity"; -import { PublishersService } from "./publishers.service"; - -@Module({ - imports: [TypeOrmModule, TypeOrmModule.forFeature([Publisher])], - controllers: [], - providers: [PublishersService], - exports: [PublishersService], -}) -export class PublishersModule {} diff --git a/src/modules/publishers/publishers.service.ts b/src/modules/publishers/publishers.service.ts deleted file mode 100644 index 5c953e81..00000000 --- a/src/modules/publishers/publishers.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Builder } from "builder-pattern"; -import { Repository } from "typeorm"; - -import { Publisher } from "./publisher.entity"; - -@Injectable() -export class PublishersService { - private readonly logger = new Logger(PublishersService.name); - constructor( - @InjectRepository(Publisher) - private publisherRepository: Repository, - ) {} - - /** - * Returns the publisher with the specified RAWG ID, creating a new publisher - * if one does not already exist. - */ - async getOrCreate(name: string, rawg_id: number): Promise { - const existingPublisher = await this.publisherRepository.findOneBy({ - name, - }); - - if (existingPublisher) return existingPublisher; - - const publisher = await this.publisherRepository.save( - Builder(Publisher).name(name).rawg_id(rawg_id).build(), - ); - this.logger.log({ - message: "Created new Publisher.", - publisher, - }); - return publisher; - } -} diff --git a/src/modules/stores/stores.module.ts b/src/modules/stores/stores.module.ts deleted file mode 100644 index 2c751b8c..00000000 --- a/src/modules/stores/stores.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { Store } from "./store.entity"; -import { StoresService } from "./stores.service"; - -@Module({ - imports: [TypeOrmModule, TypeOrmModule.forFeature([Store])], - controllers: [], - providers: [StoresService], - exports: [StoresService], -}) -export class StoresModule {} diff --git a/src/modules/stores/stores.service.ts b/src/modules/stores/stores.service.ts deleted file mode 100644 index 8ca00200..00000000 --- a/src/modules/stores/stores.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Builder } from "builder-pattern"; -import { Repository } from "typeorm"; - -import { Store } from "./store.entity"; - -@Injectable() -export class StoresService { - private readonly logger = new Logger(StoresService.name); - constructor( - @InjectRepository(Store) - private storeRepository: Repository, - ) {} - - /** - * Returns the store with the specified RAWG ID, creating a new store if one - * does not already exist. - */ - async getOrCreate(name: string, rawg_id: number): Promise { - const existingStore = await this.storeRepository.findOneBy({ name }); - - if (existingStore) return existingStore; - - const store = await this.storeRepository.save( - Builder(Store).name(name).rawg_id(rawg_id).build(), - ); - this.logger.log({ - message: "Created new Store.", - store, - }); - return store; - } -} diff --git a/src/modules/tags/tags.controller.ts b/src/modules/tags/tags.controller.ts deleted file mode 100644 index 731751d9..00000000 --- a/src/modules/tags/tags.controller.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Controller, Get } from "@nestjs/common"; -import { ApiBasicAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; -import { InjectRepository } from "@nestjs/typeorm"; -import { - NO_PAGINATION, - Paginate, - paginate, - Paginated, - PaginateQuery, - PaginationType, -} from "nestjs-paginate"; -import { Repository } from "typeorm"; - -import { MinimumRole } from "../../decorators/minimum-role.decorator"; -import { PaginateQueryOptions } from "../../decorators/pagination.decorator"; -import { all_filters } from "../../filters/all-filters.filter"; -import { ApiOkResponsePaginated } from "../../globals"; -import { Role } from "../users/models/role.enum"; -import { Tag } from "./tag.entity"; - -@Controller("tags") -@ApiTags("tags") -@ApiBasicAuth() -export class TagsController { - constructor( - @InjectRepository(Tag) private readonly tagRepository: Repository, - ) {} - - /** - * Get a paginated list of tags, sorted by the number of games tagged with - * each tag (by default). - */ - @Get() - @ApiOperation({ - summary: "get a list of tags", - description: - "by default the list is sorted by the amount of games that are tagged with each tag.", - operationId: "getTags", - }) - @MinimumRole(Role.GUEST) - @ApiOkResponsePaginated(Tag) - @PaginateQueryOptions() - async getTags(@Paginate() query: PaginateQuery): Promise> { - const paginatedResults = await paginate(query, this.tagRepository, { - paginationType: PaginationType.TAKE_AND_SKIP, - defaultLimit: 100, - maxLimit: NO_PAGINATION, - nullSort: "last", - relations: ["games"], - loadEagerRelations: false, - sortableColumns: ["id", "name"], - searchableColumns: ["name", "games.title"], - filterableColumns: { - id: all_filters, - name: all_filters, - "games.title": all_filters, - "games.(genres.name)": all_filters, - }, - withDeleted: false, - }); - - if (!query.sortBy || query.sortBy.length === 0) { - paginatedResults.data.sort((a, b) => b.games.length - a.games.length); - } - - return paginatedResults; - } -} diff --git a/src/modules/tags/tags.e2e.spec.ts b/src/modules/tags/tags.e2e.spec.ts deleted file mode 100644 index 9974354a..00000000 --- a/src/modules/tags/tags.e2e.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Test } from "@nestjs/testing"; -import { getRepositoryToken } from "@nestjs/typeorm"; -import { Builder } from "builder-pattern"; -import { Repository } from "typeorm/repository/Repository"; - -import { AppModule } from "../../app.module"; -import { Game } from "../games/game.entity"; -import { Tag } from "./tag.entity"; -import { TagsController } from "./tags.controller"; - -describe("/api/tags", () => { - let tagsController: TagsController; - let tagRepository: Repository; - let gameRepository: Repository; - - beforeEach(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - tagsController = moduleRef.get(TagsController); - tagRepository = moduleRef.get>(getRepositoryToken(Tag)); - gameRepository = moduleRef.get>(getRepositoryToken(Game)); - }); - - afterEach(async () => { - gameRepository.clear(); - tagRepository.clear(); - }); - - it("GET /api/tags/", async () => { - const testingTag: Tag = new Tag(); - testingTag.name = "stealth"; - testingTag.rawg_id = 1337; - await tagRepository.save(testingTag); - - const results = await tagsController.getTags({ - path: "", - }); - expect(results.data.length).toBe(1); - expect(results.data[0].rawg_id).toBe(1337); - expect(results.data[0].name).toBe("stealth"); - }); - - it("should sort tags by the amount of games tagged with them", async () => { - const tag1: Tag = Builder(Tag).name("stealth").rawg_id(1111).build(); - const tag2: Tag = Builder(Tag).name("action").rawg_id(2222).build(); - await tagRepository.save([tag1, tag2]); - - await gameRepository.save( - Builder(Game) - .title("Testgame") - .file_path("filepath.zip") - .early_access(false) - .tags([tag2]) - .build(), - ); - - const results = await tagsController.getTags({ - path: "", - }); - expect(results.data.length).toBe(2); - expect(results.data[0].rawg_id).toBe(2222); - expect(results.data[0].name).toBe("action"); - expect(results.data[1].rawg_id).toBe(1111); - expect(results.data[1].name).toBe("stealth"); - }); -}); diff --git a/src/modules/tags/tags.module.ts b/src/modules/tags/tags.module.ts deleted file mode 100644 index 0c81f5ce..00000000 --- a/src/modules/tags/tags.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { Tag } from "./tag.entity"; -import { TagsController } from "./tags.controller"; -import { TagsService } from "./tags.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([Tag])], - controllers: [TagsController], - providers: [TagsService], - exports: [TagsService], -}) -export class TagsModule {} diff --git a/src/modules/tags/tags.service.ts b/src/modules/tags/tags.service.ts deleted file mode 100644 index 84cd6ae7..00000000 --- a/src/modules/tags/tags.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Builder } from "builder-pattern"; -import { Repository } from "typeorm"; - -import { Tag } from "./tag.entity"; - -@Injectable() -export class TagsService { - private readonly logger = new Logger(TagsService.name); - - constructor( - @InjectRepository(Tag) - private tagRepository: Repository, - ) {} - - /** - * Returns the tag with the specified RAWG ID, creating a new tag if one does - * not already exist. - */ - async getOrCreate(name: string, rawg_id: number): Promise { - const existingTag = await this.tagRepository.findOneBy({ name }); - - if (existingTag) return existingTag; - - const tag = await this.tagRepository.save( - Builder(Tag).name(name).rawg_id(rawg_id).build(), - ); - - this.logger.log({ - message: "Created new Tag.", - tag, - }); - return tag; - } -} diff --git a/src/modules/users/activity.gateway.ts b/src/modules/users/activity.gateway.ts index d5077ed4..c5d76067 100644 --- a/src/modules/users/activity.gateway.ts +++ b/src/modules/users/activity.gateway.ts @@ -10,21 +10,20 @@ import { WebSocketServer, } from "@nestjs/websockets"; import { AsyncApiPub, AsyncApiSub } from "nestjs-asyncapi"; -import { noop } from "rxjs"; import { Server, Socket } from "socket.io"; import configuration from "../../configuration"; import { WebsocketExceptionsFilter } from "../../filters/websocket-exceptions.filter"; import { SocketSecretGuard } from "../guards/socket-secret.guard"; import { GamevaultUser } from "./gamevault-user.entity"; -import { Activity } from "./models/activity.dto"; import { ActivityState } from "./models/activity-state.enum"; +import { Activity } from "./models/activity.dto"; import { UsersService } from "./users.service"; // Conditionally decorate the WebSocket gateway class. const ConditionalWebSocketGateway = configuration.SERVER .ONLINE_ACTIVITIES_DISABLED - ? noop + ? () => {} : WebSocketGateway({ cors: true }); @UseGuards(SocketSecretGuard) @@ -34,14 +33,17 @@ const ConditionalWebSocketGateway = configuration.SERVER export class ActivityGateway implements OnGatewayConnection, OnGatewayDisconnect { - private readonly logger = new Logger(ActivityGateway.name); + private readonly logger = new Logger(this.constructor.name); - private activities: Map = new Map(); + private readonly activities: Map = new Map< + number, + Activity + >(); @WebSocketServer() server: Server; - constructor(private usersService: UsersService) {} + constructor(private readonly usersService: UsersService) {} @AsyncApiSub({ channel: "set-activity", @@ -69,7 +71,7 @@ export class ActivityGateway const requestingUser = client as unknown as { gamevaultuser: GamevaultUser; }; - const user = await this.usersService.findByUserIdOrFail( + const user = await this.usersService.findOneByUserIdOrFail( requestingUser.gamevaultuser.id, ); dto.user_id = user.id; diff --git a/src/modules/users/gamevault-user.entity.ts b/src/modules/users/gamevault-user.entity.ts index b107add1..47ca9266 100644 --- a/src/modules/users/gamevault-user.entity.ts +++ b/src/modules/users/gamevault-user.entity.ts @@ -11,14 +11,14 @@ import { } from "typeorm"; import { DatabaseEntity } from "../database/database.entity"; -import { Game } from "../games/game.entity"; -import { Image } from "../images/image.entity"; +import { GamevaultGame } from "../games/gamevault-game.entity"; +import { Media } from "../media/media.entity"; import { Progress } from "../progresses/progress.entity"; import { Role } from "./models/role.enum"; @Entity() export class GamevaultUser extends DatabaseEntity { - @Index() + @Index({ unique: true }) @Column({ unique: true }) @ApiProperty({ example: "JohnDoe", description: "username of the user" }) username: string; @@ -30,6 +30,7 @@ export class GamevaultUser extends DatabaseEntity { }) password: string; + @Index({ unique: true }) @Column({ select: false, unique: true, length: 64 }) @ApiProperty({ description: @@ -38,7 +39,7 @@ export class GamevaultUser extends DatabaseEntity { }) socket_secret: string; - @OneToOne(() => Image, { + @OneToOne(() => Media, { nullable: true, eager: true, onDelete: "CASCADE", @@ -46,12 +47,12 @@ export class GamevaultUser extends DatabaseEntity { }) @JoinColumn() @ApiPropertyOptional({ - type: () => Image, - description: "the user's profile picture", + type: () => Media, + description: "the user's avatar image", }) - profile_picture?: Image; + avatar?: Media; - @OneToOne(() => Image, { + @OneToOne(() => Media, { nullable: true, eager: true, onDelete: "CASCADE", @@ -59,10 +60,10 @@ export class GamevaultUser extends DatabaseEntity { }) @JoinColumn() @ApiPropertyOptional({ - type: () => Image, - description: "the user's profile art (background-picture)", + type: () => Media, + description: "the user's profile background image", }) - background_image?: Image; + background?: Media; @Column({ unique: true, nullable: true }) @ApiProperty({ @@ -79,6 +80,14 @@ export class GamevaultUser extends DatabaseEntity { @ApiProperty({ example: "Doe", description: "last name of the user" }) last_name: string; + @Index() + @Column({ nullable: true }) + @ApiPropertyOptional({ + description: "birthday of the user", + example: "2013-09-17T00:00:00.000Z", + }) + birth_date?: Date; + @Column({ default: false }) @ApiProperty({ description: "indicates if the user is activated", @@ -108,20 +117,20 @@ export class GamevaultUser extends DatabaseEntity { }) role: Role; - @OneToMany(() => Image, (image) => image.uploader) + @OneToMany(() => Media, (media) => media.uploader) @ApiPropertyOptional({ - description: "images uploaded by this user", - type: () => Image, + description: "media uploaded by this user", + type: () => Media, isArray: true, }) - uploaded_images?: Image[]; + uploaded_media?: Media[]; - @ManyToMany(() => Game, (game) => game.bookmarked_users) + @ManyToMany(() => GamevaultGame, (game) => game.bookmarked_users) @JoinTable({ name: "bookmark" }) @ApiProperty({ description: "games bookmarked by this user", - type: () => Game, + type: () => GamevaultGame, isArray: true, }) - bookmarked_games?: Game[]; + bookmarked_games?: GamevaultGame[]; } diff --git a/src/modules/users/models/activity.dto.ts b/src/modules/users/models/activity.dto.ts index 0dc33a54..efbbd74a 100644 --- a/src/modules/users/models/activity.dto.ts +++ b/src/modules/users/models/activity.dto.ts @@ -5,9 +5,15 @@ import { ActivityState } from "./activity-state.enum"; export class Activity { @IsEmpty() + @ApiPropertyOptional({ + description: "The id of the user this activity belongs to.", + }) user_id?: number; @IsEmpty() + @ApiPropertyOptional({ + description: "The socket id of the user this activity belongs to.", + }) socket_id?: string; @ApiProperty({ diff --git a/src/modules/users/models/register-user.dto.ts b/src/modules/users/models/register-user.dto.ts index a767e03f..9d19862f 100644 --- a/src/modules/users/models/register-user.dto.ts +++ b/src/modules/users/models/register-user.dto.ts @@ -1,15 +1,17 @@ import { ApiProperty } from "@nestjs/swagger"; import { IsAlpha, + IsDateString, IsEmail, IsNotEmpty, Length, Matches, MinLength, - ValidateIf, } from "class-validator"; import configuration from "../../../configuration"; +import { IsDateStringBeforeNow } from "../../../validators/is-date-string-before-now.validator"; +import { IsOptionalIf } from "../../../validators/is-optional-if.validator"; export class RegisterUserDto { @Matches(/^\w+$/, { @@ -30,7 +32,7 @@ export class RegisterUserDto { }) password: string; - @ValidateIf(() => configuration.USERS.REQUIRE_EMAIL) + @IsOptionalIf(configuration.USERS.REQUIRE_EMAIL === false) @IsEmail() @IsNotEmpty() @ApiProperty({ @@ -40,7 +42,7 @@ export class RegisterUserDto { }) email?: string; - @ValidateIf(() => configuration.USERS.REQUIRE_FIRST_NAME) + @IsOptionalIf(configuration.USERS.REQUIRE_FIRST_NAME === false) @IsAlpha("de-DE") @IsNotEmpty() @ApiProperty({ @@ -50,7 +52,7 @@ export class RegisterUserDto { }) first_name?: string; - @ValidateIf(() => configuration.USERS.REQUIRE_LAST_NAME) + @IsOptionalIf(configuration.USERS.REQUIRE_LAST_NAME === false) @IsAlpha("de-DE") @IsNotEmpty() @ApiProperty({ @@ -59,4 +61,17 @@ export class RegisterUserDto { required: configuration.USERS.REQUIRE_LAST_NAME, }) last_name?: string; + + @IsOptionalIf( + !configuration.USERS.REQUIRE_BIRTH_DATE && + !configuration.PARENTAL.AGE_RESTRICTION_ENABLED, + ) + @IsDateString() + @IsDateStringBeforeNow() + @IsNotEmpty() + @ApiProperty({ + description: "date of birth of the user in ISO8601 format", + required: configuration.PARENTAL.AGE_RESTRICTION_ENABLED, + }) + birth_date?: string; } diff --git a/src/modules/users/models/update-user.dto.ts b/src/modules/users/models/update-user.dto.ts index dbe44958..6c5073f4 100644 --- a/src/modules/users/models/update-user.dto.ts +++ b/src/modules/users/models/update-user.dto.ts @@ -2,6 +2,7 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; import { IsAlpha, IsBoolean, + IsDateString, IsEmail, IsEnum, IsNotEmpty, @@ -12,6 +13,7 @@ import { MinLength, } from "class-validator"; +import { IsDateStringBeforeNow } from "../../../validators/is-date-string-before-now.validator"; import { Role } from "./role.enum"; export class UpdateUserDto { @@ -65,21 +67,30 @@ export class UpdateUserDto { }) last_name?: string; + @IsOptional() + @IsNotEmpty() + @IsDateString() + @IsDateStringBeforeNow() + @ApiPropertyOptional({ + description: "date of birth of the user in ISO8601 format", + }) + birth_date?: string; + @IsNumber() @IsOptional() @ApiPropertyOptional({ example: 69_420, - description: "id of the profile picture of the user", + description: "id of the avatar image of the user", }) - profile_picture_id?: number; + avatar_id?: number; @IsNumber() @IsOptional() @ApiPropertyOptional({ example: 69_420, - description: "id of the profile art (background-image) of the User", + description: "id of the background image of the User", }) - background_image_id?: number; + background_id?: number; @IsBoolean() @IsOptional() diff --git a/src/modules/users/models/user-id.dto.ts b/src/modules/users/models/user-id.dto.ts new file mode 100644 index 00000000..3360c495 --- /dev/null +++ b/src/modules/users/models/user-id.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsNumberString } from "class-validator"; + +export class UserIdDto { + @IsNumberString() + @IsNotEmpty() + @ApiProperty({ example: "1", description: "id of the user" }) + user_id: number; +} diff --git a/src/modules/users/socket-secret.service.ts b/src/modules/users/socket-secret.service.ts index 176f7d34..e7584506 100644 --- a/src/modules/users/socket-secret.service.ts +++ b/src/modules/users/socket-secret.service.ts @@ -8,19 +8,23 @@ import { GamevaultUser } from "./gamevault-user.entity"; export class SocketSecretService { constructor( @InjectRepository(GamevaultUser) - private userRepository: Repository, + private readonly userRepository: Repository, ) {} - async getUserBySocketSecretOrFail(socketSecret: string) { - return await this.userRepository.findOneByOrFail({ - socket_secret: socketSecret, + async findUserBySocketSecretOrFail(socketSecret: string) { + return this.userRepository.findOneOrFail({ + where: { + socket_secret: socketSecret, + }, + relationLoadStrategy: "query", }); } - async getSocketSecretOrFail(userId: number): Promise { + async findSocketSecretOrFail(userId: number): Promise { const user = await this.userRepository.findOneOrFail({ select: ["id", "socket_secret"], where: { id: userId }, + relationLoadStrategy: "query", }); return user.socket_secret; diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index e4a57d62..a7a8d668 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -21,11 +21,12 @@ import configuration from "../../configuration"; import { ConditionalRegistration } from "../../decorators/conditional-registration.decorator"; import { DisableApiIf } from "../../decorators/disable-api-if.decorator"; import { MinimumRole } from "../../decorators/minimum-role.decorator"; -import { IdDto } from "../database/models/id.dto"; +import { GameIdDto } from "../games/models/game-id.dto"; import { GamevaultUser } from "./gamevault-user.entity"; import { RegisterUserDto } from "./models/register-user.dto"; import { Role } from "./models/role.enum"; import { UpdateUserDto } from "./models/update-user.dto"; +import { UserIdDto } from "./models/user-id.dto"; import { SocketSecretService } from "./socket-secret.service"; import { UsersService } from "./users.service"; @@ -34,8 +35,8 @@ import { UsersService } from "./users.service"; @Controller("users") export class UsersController { constructor( - private usersService: UsersService, - private socketSecretService: SocketSecretService, + private readonly usersService: UsersService, + private readonly socketSecretService: SocketSecretService, ) {} @Get() @@ -50,7 +51,7 @@ export class UsersController { @Request() req: { gamevaultuser: GamevaultUser }, ): Promise { const includeHidden = req.gamevaultuser.role >= Role.ADMIN; - return await this.usersService.getAll(includeHidden); + return this.usersService.find(includeHidden); } /** Retrieve user information based on the provided request object. */ @@ -64,10 +65,10 @@ export class UsersController { async getUsersMe( @Request() request: { gamevaultuser: GamevaultUser }, ): Promise { - const user = await this.usersService.findByUsernameOrFail( + const user = await this.usersService.findOneByUsernameOrFail( request.gamevaultuser.username, ); - user.socket_secret = await this.socketSecretService.getSocketSecretOrFail( + user.socket_secret = await this.socketSecretService.findSocketSecretOrFail( user.id, ); return user; @@ -87,10 +88,10 @@ export class UsersController { @Body() dto: UpdateUserDto, @Request() request: { gamevaultuser: GamevaultUser }, ): Promise { - const user = await this.usersService.findByUsernameOrFail( + const user = await this.usersService.findOneByUsernameOrFail( request.gamevaultuser.username, ); - return await this.usersService.update(user.id, dto, false); + return this.usersService.update(user.id, dto, false); } /** Deletes your own user. */ @@ -103,13 +104,13 @@ export class UsersController { @MinimumRole(Role.USER) @DisableApiIf(configuration.SERVER.DEMO_MODE_ENABLED) async deleteUsersMe(@Request() request): Promise { - const user = await this.usersService.findByUsernameOrFail( + const user = await this.usersService.findOneByUsernameOrFail( request.gamevaultuser.username, ); - return await this.usersService.delete(user.id); + return this.usersService.delete(user.id); } - @Post("me/bookmark/:id") + @Post("me/bookmark/:game_id") @ApiOperation({ summary: "bookmark a game", operationId: "postUsersMeBookmark", @@ -117,16 +118,16 @@ export class UsersController { @MinimumRole(Role.GUEST) async postUsersMeBookmark( @Request() request: { gamevaultuser: GamevaultUser }, - @Param() params: IdDto, + @Param() params: GameIdDto, ): Promise { - const user = await this.usersService.findByUsernameOrFail( + const user = await this.usersService.findOneByUsernameOrFail( request.gamevaultuser.username, { loadDeletedEntities: false, loadRelations: ["bookmarked_games"] }, ); - return this.usersService.bookmarkGame(user.id, Number(params.id)); + return this.usersService.bookmarkGame(user.id, Number(params.game_id)); } - @Delete("me/bookmark/:id") + @Delete("me/bookmark/:game_id") @ApiOperation({ summary: "unbookmark a game", operationId: "deleteUsersMeBookmark", @@ -134,29 +135,29 @@ export class UsersController { @MinimumRole(Role.GUEST) async deleteUsersMeBookmark( @Request() request: { gamevaultuser: GamevaultUser }, - @Param() params: IdDto, + @Param() params: GameIdDto, ): Promise { - const user = await this.usersService.findByUsernameOrFail( + const user = await this.usersService.findOneByUsernameOrFail( request.gamevaultuser.username, { loadDeletedEntities: false, loadRelations: ["bookmarked_games"] }, ); - return this.usersService.unbookmarkGame(user.id, Number(params.id)); + return this.usersService.unbookmarkGame(user.id, Number(params.game_id)); } /** Get details on a user. */ - @Get(":id") + @Get(":user_id") @ApiOperation({ summary: "get details on a user", operationId: "getUserByUserId", }) @MinimumRole(Role.GUEST) @ApiOkResponse({ type: () => GamevaultUser }) - async getUserByUserId(@Param() params: IdDto): Promise { - return await this.usersService.findByUserIdOrFail(Number(params.id)); + async getUserByUserId(@Param() params: UserIdDto): Promise { + return this.usersService.findOneByUserIdOrFail(Number(params.user_id)); } /** Updates details of any user. */ - @Put(":id") + @Put(":user_id") @ApiBody({ type: () => UpdateUserDto }) @ApiOperation({ summary: "update details of any user", @@ -165,26 +166,26 @@ export class UsersController { @MinimumRole(Role.ADMIN) @ApiOkResponse({ type: () => GamevaultUser }) async putUserByUserId( - @Param() params: IdDto, + @Param() params: UserIdDto, @Body() dto: UpdateUserDto, ): Promise { - return await this.usersService.update(Number(params.id), dto, true); + return this.usersService.update(Number(params.user_id), dto, true); } /** Deletes any user with the specified ID. */ - @Delete(":id") + @Delete(":user_id") @ApiOperation({ summary: "delete any user", operationId: "deleteUserByUserId", }) @ApiOkResponse({ type: () => GamevaultUser }) @MinimumRole(Role.ADMIN) - async deleteUserByUserId(@Param() params: IdDto): Promise { - return await this.usersService.delete(Number(params.id)); + async deleteUserByUserId(@Param() params: UserIdDto): Promise { + return this.usersService.delete(Number(params.user_id)); } /** Recover a deleted user. */ - @Post(":id/recover") + @Post(":user_id/recover") @MinimumRole(Role.ADMIN) @ApiOperation({ summary: "recover a deleted user", @@ -192,9 +193,9 @@ export class UsersController { }) @ApiOkResponse({ type: () => GamevaultUser }) async postUserRecoverByUserId( - @Param() params: IdDto, + @Param() params: UserIdDto, ): Promise { - return await this.usersService.recover(Number(params.id)); + return this.usersService.recover(Number(params.user_id)); } /** Register a new user. */ @@ -220,6 +221,6 @@ export class UsersController { "Registration is disabled on this server.", ); } - return await this.usersService.register(dto); + return this.usersService.register(dto); } } diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index a5228b59..ff808a8d 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -3,7 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { GamesModule } from "../games/games.module"; import { SocketSecretGuard } from "../guards/socket-secret.guard"; -import { ImagesModule } from "../images/images.module"; +import { MediaModule } from "../media/media.module"; import { ActivityGateway } from "./activity.gateway"; import { GamevaultUser } from "./gamevault-user.entity"; import { SocketSecretService } from "./socket-secret.service"; @@ -13,7 +13,7 @@ import { UsersService } from "./users.service"; @Module({ imports: [ TypeOrmModule.forFeature([GamevaultUser]), - forwardRef(() => ImagesModule), + forwardRef(() => MediaModule), forwardRef(() => GamesModule), ], controllers: [UsersController], diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 7818a1e1..db2fcb33 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -14,10 +14,11 @@ import { compareSync, hashSync } from "bcrypt"; import { randomBytes } from "crypto"; import { FindManyOptions, ILike, IsNull, Not, Repository } from "typeorm"; +import { toLower } from "lodash"; import configuration from "../../configuration"; import { FindOptions } from "../../globals"; import { GamesService } from "../games/games.service"; -import { ImagesService } from "../images/images.service"; +import { MediaService } from "../media/media.service"; import { GamevaultUser } from "./gamevault-user.entity"; import { RegisterUserDto } from "./models/register-user.dto"; import { Role } from "./models/role.enum"; @@ -25,19 +26,19 @@ import { UpdateUserDto } from "./models/update-user.dto"; @Injectable() export class UsersService implements OnApplicationBootstrap { - private readonly logger = new Logger(UsersService.name); + private readonly logger = new Logger(this.constructor.name); constructor( @InjectRepository(GamevaultUser) - private userRepository: Repository, - @Inject(forwardRef(() => ImagesService)) - private imagesService: ImagesService, + private readonly userRepository: Repository, + @Inject(forwardRef(() => MediaService)) + private readonly mediaService: MediaService, @Inject(forwardRef(() => GamesService)) - private gamesService: GamesService, + private readonly gamesService: GamesService, ) {} async onApplicationBootstrap() { - await this.recoverAdmin(); + this.recoverAdmin(); } private async recoverAdmin() { @@ -46,7 +47,7 @@ export class UsersService implements OnApplicationBootstrap { return; } - const user = await this.findByUsernameOrFail( + const user = await this.findOneByUsernameOrFail( configuration.SERVER.ADMIN_USERNAME, ); @@ -76,7 +77,7 @@ export class UsersService implements OnApplicationBootstrap { * Retrieves a user by their ID or throws an exception if the user is not * found. */ - public async findByUserIdOrFail( + public async findOneByUserIdOrFail( id: number, options: FindOptions = { loadRelations: true, loadDeletedEntities: true }, ): Promise { @@ -84,7 +85,13 @@ export class UsersService implements OnApplicationBootstrap { if (options.loadRelations) { if (options.loadRelations === true) { - relations = ["progresses", "progresses.game", "bookmarked_games"]; + relations = [ + "progresses", + "progresses.game", + "progresses.game.metadata", + "progresses.game.metadata.cover", + "bookmarked_games", + ]; } else if (Array.isArray(options.loadRelations)) relations = options.loadRelations; } @@ -97,6 +104,7 @@ export class UsersService implements OnApplicationBootstrap { }, relations, withDeleted: true, + relationLoadStrategy: "query", }) .catch((error) => { throw new NotFoundException(`User with id ${id} was not found.`, { @@ -107,7 +115,7 @@ export class UsersService implements OnApplicationBootstrap { } /** Get user by username or throw an exception if not found */ - public async findByUsernameOrFail( + public async findOneByUsernameOrFail( username: string, options: FindOptions = { loadRelations: true, loadDeletedEntities: true }, ): Promise { @@ -126,7 +134,6 @@ export class UsersService implements OnApplicationBootstrap { username: ILike(username), deleted_at: options.loadDeletedEntities ? undefined : IsNull(), }, - relations, withDeleted: true, }) @@ -141,18 +148,17 @@ export class UsersService implements OnApplicationBootstrap { return this.filterDeletedProgresses(user); } - public async getAll( - includeHidden: boolean = false, - ): Promise { + public async find(includeHidden: boolean = false): Promise { const query: FindManyOptions = { order: { id: "ASC" }, withDeleted: includeHidden, + relationLoadStrategy: "query", where: includeHidden ? undefined : { activated: true, username: Not(ILike("gvbot_%")) }, }; - return await this.userRepository.find(query); + return this.userRepository.find(query); } /** Register a new user */ @@ -171,6 +177,7 @@ export class UsersService implements OnApplicationBootstrap { user.first_name = dto.first_name || undefined; user.last_name = dto.last_name || undefined; user.email = dto.email || undefined; + user.birth_date = dto.birth_date ? new Date(dto.birth_date) : undefined; user.activated = isActivated; user.role = isAdministrator ? Role.ADMIN : undefined; @@ -204,6 +211,12 @@ export class UsersService implements OnApplicationBootstrap { }, ); }); + + if (configuration.TESTING.AUTHENTICATION_DISABLED) { + delete user.password; + return user; + } + if (!compareSync(password, user.password)) { throw new UnauthorizedException("Login Failed: Incorrect Password"); } @@ -225,7 +238,7 @@ export class UsersService implements OnApplicationBootstrap { dto: UpdateUserDto, admin = false, ): Promise { - const user = await this.findByUserIdOrFail(id); + const user = await this.findOneByUserIdOrFail(id); const logUpdate = (property: string, from: string, to: string) => { this.logger.log({ message: "Updating user property", @@ -236,6 +249,23 @@ export class UsersService implements OnApplicationBootstrap { }); }; + if (admin && dto.role != null) { + logUpdate("role", user.role.toString(), dto.role.toString()); + user.role = dto.role; + } + + if (admin && dto.activated != null) { + logUpdate( + "activated", + user.activated.toString(), + dto.activated.toString(), + ); + user.activated = dto.activated; + this.logger.log({ + message: { message: "User has been activated.", user: user.username }, + }); + } + if (dto.username != null && dto.username !== user.username) { logUpdate("username", user.username, dto.username); await this.updateUsername(dto, user); @@ -256,50 +286,34 @@ export class UsersService implements OnApplicationBootstrap { user.last_name = dto.last_name; } + if (dto.birth_date != null) { + logUpdate("birth_date", user.birth_date?.toISOString(), dto.birth_date); + await this.updateBirthDate(dto, user); + } + if (dto.password != null) { logUpdate("password", user.password, "**REDACTED**"); user.password = hashSync(dto.password, 10); } - if (dto.profile_picture_id != null) { - const image = await this.imagesService.findByImageIdOrFail( - dto.profile_picture_id, + if (dto.avatar_id != null) { + const image = await this.mediaService.findOneByMediaIdOrFail( + dto.avatar_id, ); - logUpdate( - "profile_picture_id", - user.profile_picture?.id.toString(), - image.id.toString(), - ); - user.profile_picture = image; + logUpdate("avatar_id", user.avatar?.id.toString(), image.id.toString()); + user.avatar = image; } - if (dto.background_image_id != null) { - const image = await this.imagesService.findByImageIdOrFail( - dto.background_image_id, + if (dto.background_id != null) { + const image = await this.mediaService.findOneByMediaIdOrFail( + dto.background_id, ); logUpdate( - "background_image_id", - user.background_image?.id.toString(), + "background_id", + user.background?.id.toString(), image.id.toString(), ); - user.background_image = image; - } - - if (admin && dto.activated != null) { - logUpdate( - "activated", - user.activated.toString(), - dto.activated.toString(), - ); - user.activated = dto.activated; - this.logger.log({ - message: { message: "User has been activated.", user: user.username }, - }); - } - - if (admin && dto.role != null) { - logUpdate("role", user.role.toString(), dto.role.toString()); - user.role = dto.role; + user.background = image; } return this.userRepository.save(user); @@ -309,7 +323,7 @@ export class UsersService implements OnApplicationBootstrap { dto: UpdateUserDto, user: GamevaultUser, ): Promise { - if (dto.username?.toLowerCase() !== user.username?.toLowerCase()) { + if (toLower(dto.username) !== toLower(user.username)) { await this.throwIfAlreadyExists(dto.username, undefined); } user.username = dto.username; @@ -319,34 +333,65 @@ export class UsersService implements OnApplicationBootstrap { dto: UpdateUserDto, user: GamevaultUser, ): Promise { - if (dto.email?.toLowerCase() !== user.email?.toLowerCase()) { + if (toLower(dto.email) !== toLower(user.email)) { await this.throwIfAlreadyExists(undefined, dto.email); } user.email = dto.email; } + private async updateBirthDate( + dto: UpdateUserDto, + user: GamevaultUser, + ): Promise { + if ( + user.birth_date && + configuration.PARENTAL.AGE_RESTRICTION_ENABLED && + this.calculateAge(user.birth_date) < + configuration.PARENTAL.AGE_OF_MAJORITY && + user.role !== Role.ADMIN + ) { + throw new ForbiddenException( + "You are too young to update your birth date. Contact an Administrator to update your birth date.", + ); + } + user.birth_date = new Date(dto.birth_date); + } + /** Soft deletes a user with the specified ID. */ public async delete(id: number): Promise { - const user = await this.findByUserIdOrFail(id); + const user = await this.findOneByUserIdOrFail(id); return this.userRepository.softRemove(user); } /** Recovers a deleted user with the specified ID. */ public async recover(id: number): Promise { - const user = await this.findByUserIdOrFail(id); + const user = await this.findOneByUserIdOrFail(id); return this.userRepository.recover(user); } /** Check if the user with the given username has at least the given role */ public async checkIfUsernameIsAtLeastRole(username: string, role: Role) { try { - const user = await this.findByUsernameOrFail(username); + const user = await this.findOneByUsernameOrFail(username); return user.role >= role; } catch { return false; } } + public async findUserAgeByUsername( + username: string, + ): Promise { + if (!configuration.PARENTAL.AGE_RESTRICTION_ENABLED) { + return undefined; + } + const user = await this.findOneByUsernameOrFail(username, { + loadDeletedEntities: false, + loadRelations: false, + }); + return this.calculateAge(user.birth_date); + } + /** Check if the username matches the user ID or is an administrator */ public async checkIfUsernameMatchesIdOrIsAdmin( userId: number, @@ -358,11 +403,11 @@ export class UsersService implements OnApplicationBootstrap { if (!username) { throw new UnauthorizedException("No Authorization provided"); } - const user = await this.findByUserIdOrFail(userId); + const user = await this.findOneByUserIdOrFail(userId); if (user.role === Role.ADMIN) { return true; } - if (user.username?.toLowerCase() !== username?.toLowerCase()) { + if (toLower(user.username) !== toLower(username)) { throw new ForbiddenException( { requestedId: userId, @@ -377,7 +422,7 @@ export class UsersService implements OnApplicationBootstrap { /** Bookmarks a game with the specified ID to the given user. */ public async bookmarkGame(userId: number, gameId: number) { - const user = await this.findByUserIdOrFail(userId, { + const user = await this.findOneByUserIdOrFail(userId, { loadDeletedEntities: false, loadRelations: ["bookmarked_games"], }); @@ -385,9 +430,8 @@ export class UsersService implements OnApplicationBootstrap { return user; } - const game = await this.gamesService.findByGameIdOrFail(gameId, { + const game = await this.gamesService.findOneByGameIdOrFail(gameId, { loadDeletedEntities: false, - loadRelations: false, }); await this.userRepository @@ -411,7 +455,7 @@ export class UsersService implements OnApplicationBootstrap { /** Unbookmarks a game with the specified ID from the given user. */ public async unbookmarkGame(userId: number, gameId: number) { - const user = await this.findByUserIdOrFail(userId, { + const user = await this.findOneByUserIdOrFail(userId, { loadDeletedEntities: false, loadRelations: ["bookmarked_games"], }); @@ -419,9 +463,8 @@ export class UsersService implements OnApplicationBootstrap { return user; } - const game = await this.gamesService.findByGameIdOrFail(gameId, { + const game = await this.gamesService.findOneByGameIdOrFail(gameId, { loadDeletedEntities: false, - loadRelations: false, }); await this.userRepository @@ -446,6 +489,19 @@ export class UsersService implements OnApplicationBootstrap { return user; } + public calculateAge(birthDate: Date) { + if (!birthDate) { + return 0; + } + const today = new Date(); + let age = today.getFullYear() - birthDate.getFullYear(); + const m = today.getMonth() - birthDate.getMonth(); + if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) { + age--; + } + return age; + } + /** * Throws an exception if there is already a user with the given username or * email. @@ -469,11 +525,14 @@ export class UsersService implements OnApplicationBootstrap { where.push({ email: ILike(email) }); } - const existingUser = await this.userRepository.findOne({ where }); + const existingUser = await this.userRepository.findOne({ + where, + relationLoadStrategy: "query", + }); if (existingUser) { const duplicateField = - existingUser.username?.toLowerCase() === username?.toLowerCase() + toLower(existingUser.username) === toLower(username) ? "username" : "email"; throw new ForbiddenException( diff --git a/src/validators/is-date-string-before-now.validator.ts b/src/validators/is-date-string-before-now.validator.ts new file mode 100644 index 00000000..064b82b6 --- /dev/null +++ b/src/validators/is-date-string-before-now.validator.ts @@ -0,0 +1,34 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from "class-validator"; + +export function IsDateStringBeforeNow(validationOptions?: ValidationOptions) { + return (object: object, propertyName: string) => { + registerDecorator({ + name: "isDateStringBeforeNow", + target: object.constructor, + propertyName, + options: validationOptions, + validator: { + validate(value: string) { + if (typeof value !== "string") { + return false; + } + + const date = new Date(value); + if (isNaN(date.getTime())) { + return false; + } + + const now = new Date(); + return date < now; + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a valid ISO 8601 date string before the current time.`; + }, + }, + }); + }; +} diff --git a/src/validators/is-optional-if.validator.ts b/src/validators/is-optional-if.validator.ts new file mode 100644 index 00000000..3f521b9b --- /dev/null +++ b/src/validators/is-optional-if.validator.ts @@ -0,0 +1,8 @@ +import { IsOptional } from "class-validator"; + +export function IsOptionalIf(condition: boolean) { + if (!condition) { + return () => {}; + } + return IsOptional(); +} diff --git a/src/validators/media.validator.ts b/src/validators/media.validator.ts new file mode 100644 index 00000000..feb23f33 --- /dev/null +++ b/src/validators/media.validator.ts @@ -0,0 +1,45 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from "class-validator"; + +import { Media } from "../modules/media/media.entity"; + +@ValidatorConstraint({ async: false }) +class MediaMimeTypeConstraint implements ValidatorConstraintInterface { + validate(value: Media, args: ValidationArguments) { + const [types] = args.constraints; + if (!value || typeof value.type !== "string") { + return false; + } + + if (Array.isArray(types)) { + return types.every((type) => value.type.startsWith(type)); + } + + return value.type.startsWith(types); + } + + defaultMessage(args: ValidationArguments) { + const [types] = args.constraints; + return `Media type must start with ${Array.isArray(types) ? types.join(" or ") : types}`; + } +} + +export function MediaValidator( + types: string | string[], + validationOptions?: ValidationOptions, +) { + return (object: object, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [types], + validator: MediaMimeTypeConstraint, + }); + }; +} diff --git a/tsconfig.json b/tsconfig.json index ae146a80..046c3390 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "exclude": ["node_modules"], - "include": ["src/**/*"], + "include": ["src/**/*", ".local/plugins/**/*"], "compilerOptions": { "module": "CommonJS", "esModuleInterop": true,