diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index ef57cec..d3d909b 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -1,20 +1,45 @@ name: PR Tests -on: pull_request +on: + pull_request: + push: + branches: + - main permissions: contents: read concurrency: - group: pr-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + lint: + name: ESLint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + cache-dependency-path: apps/backend/package-lock.json + + - name: Install deps + run: npm ci + working-directory: apps/backend + + - name: Run ESLint + run: npx eslint . + working-directory: apps/backend + node-tests: name: Node.js tests runs-on: ubuntu-latest steps: - - name: Check out code + - name: Checkout code uses: actions/checkout@v4 - name: Set up Node @@ -36,7 +61,7 @@ jobs: name: Python tests (lemmas) runs-on: ubuntu-latest steps: - - name: Check out code + - name: Checkout code uses: actions/checkout@v4 - name: Set up Python diff --git a/apps/backend/.prettierignore b/apps/backend/.prettierignore new file mode 100644 index 0000000..d590972 --- /dev/null +++ b/apps/backend/.prettierignore @@ -0,0 +1,7 @@ +# Ignore artifacts: +build +coverage +logs +prisma +node_modules +*.md diff --git a/apps/backend/.prettierrc b/apps/backend/.prettierrc new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/apps/backend/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/apps/backend/eslint.config.mts b/apps/backend/eslint.config.mts new file mode 100644 index 0000000..303c17c --- /dev/null +++ b/apps/backend/eslint.config.mts @@ -0,0 +1,23 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import { defineConfig } from "eslint/config"; + +export default defineConfig([ + { + ignores: [ + "**/*.test.{js,mjs,cjs,ts,mts,cts}", + "**/*.config.{js,mjs,cjs,ts,mts,cts}", + "coverage/", + "logs/", + "prisma/", + ], + }, + { + files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], + plugins: { js }, + extends: ["js/recommended"], + languageOptions: { globals: globals.browser }, + }, + tseslint.configs.recommended, +]); diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 3caa6c4..1f02482 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -36,6 +36,7 @@ "@babel/core": "^7.26.10", "@babel/preset-env": "^7.26.9", "@babel/preset-typescript": "^7.27.0", + "@eslint/js": "^9.34.0", "@types/bcrypt": "^5.0.2", "@types/cookie-parser": "^1.4.8", "@types/cors": "^2.8.17", @@ -45,13 +46,18 @@ "@types/jsonwebtoken": "^9.0.9", "@types/node": "^22.14.0", "babel-jest": "^29.7.0", + "eslint": "^9.34.0", + "globals": "^16.3.0", "jest": "^29.7.0", + "jiti": "^2.5.1", "nodemon": "^3.1.9", + "prettier": "3.6.2", "prisma": "^6.5.0", "ts-jest": "^29.3.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "typescript-eslint": "^8.41.0" } }, "node_modules/@ampproject/remapping": { @@ -1049,6 +1055,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-classes/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/plugin-transform-computed-properties": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", @@ -1961,6 +1977,16 @@ } } }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/traverse/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2447,6 +2473,296 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", + "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", @@ -2974,6 +3290,44 @@ "win32" ] }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@prisma/client": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.5.0.tgz", @@ -3342,6 +3696,13 @@ "@types/node": "*" } }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", @@ -3433,6 +3794,13 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", @@ -3555,42 +3923,410 @@ "dev": true, "license": "MIT" }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz", + "integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==", + "dev": true, "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/type-utils": "8.41.0", + "@typescript-eslint/utils": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" }, "engines": { - "node": ">= 0.6" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.41.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, "engines": { - "node": ">=0.4.0" + "node": ">= 4" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "node_modules/@typescript-eslint/parser": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz", + "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz", + "integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.41.0", + "@typescript-eslint/types": "^8.41.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz", + "integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz", + "integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz", + "integrity": "sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/utils": "8.41.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz", + "integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz", + "integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.41.0", + "@typescript-eslint/tsconfig-utils": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz", + "integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz", + "integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.41.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", "dev": true, "license": "MIT", "dependencies": { @@ -3635,6 +4371,23 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -4625,6 +5378,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -4984,21 +5744,266 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=8" + } + }, + "node_modules/eslint": { + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", + "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.34.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, "engines": { - "node": ">=4" + "node": ">=4.0" } }, "node_modules/esutils": { @@ -5131,6 +6136,30 @@ "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -5138,6 +6167,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -5154,6 +6200,19 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -5232,6 +6291,27 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/fluent-ffmpeg": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", @@ -5495,13 +6575,16 @@ } }, "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/gopd": { @@ -5523,6 +6606,13 @@ "dev": true, "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -5668,6 +6758,16 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -5675,6 +6775,33 @@ "dev": true, "license": "ISC" }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -6648,6 +7775,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6682,6 +7819,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -6689,6 +7833,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6751,6 +7909,16 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6777,6 +7945,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6859,6 +8041,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -6989,6 +8178,16 @@ "dev": true, "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -7418,6 +8617,24 @@ } } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7473,6 +8690,19 @@ "node": ">=6" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -7586,6 +8816,32 @@ "node": ">=8" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -7683,6 +8939,16 @@ "dev": true, "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -7715,6 +8981,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7962,6 +9249,17 @@ "node": ">=10" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -7978,6 +9276,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -8520,6 +9842,19 @@ "node": ">= 14.0.0" } }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-jest": { "version": "29.3.2", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.2.tgz", @@ -8648,6 +9983,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -8698,6 +10046,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.41.0.tgz", + "integrity": "sha512-n66rzs5OBXW3SFSnZHr2T685q1i4ODm2nulFJhMZBotaTavsS8TrI3d7bDlRSs9yWo7HmyWrN9qDu14Qv7Y0Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.41.0", + "@typescript-eslint/parser": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/utils": "8.41.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -8795,6 +10167,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8961,6 +10343,16 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index 51ef7cd..824d76d 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,7 +1,7 @@ { "name": "backend", "version": "1.0.0", - "main": "src/app.js", + "main": "src/app.ts", "scripts": { "start": "ts-node -r tsconfig-paths/register src/app.ts", "dev": "nodemon --config nodemon.json", @@ -40,6 +40,7 @@ "@babel/core": "^7.26.10", "@babel/preset-env": "^7.26.9", "@babel/preset-typescript": "^7.27.0", + "@eslint/js": "^9.34.0", "@types/bcrypt": "^5.0.2", "@types/cookie-parser": "^1.4.8", "@types/cors": "^2.8.17", @@ -49,12 +50,17 @@ "@types/jsonwebtoken": "^9.0.9", "@types/node": "^22.14.0", "babel-jest": "^29.7.0", + "eslint": "^9.34.0", + "globals": "^16.3.0", "jest": "^29.7.0", + "jiti": "^2.5.1", "nodemon": "^3.1.9", + "prettier": "3.6.2", "prisma": "^6.5.0", "ts-jest": "^29.3.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "typescript-eslint": "^8.41.0" } } diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index dd9b301..28cd903 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -9,7 +9,10 @@ import { requestLogger } from "./middlewares/requestLogger"; import { logger } from "./utils/logger"; import { prisma } from "./services/prisma"; import { ApiResponse } from "./types/response.types"; -import { closeRedisConnection, connectRedis } from "./services/redis/redisClient"; +import { + closeRedisConnection, + connectRedis, +} from "./services/redis/redisClient"; import { authRouter, jobsModule, diff --git a/apps/backend/src/cache/redisStoryCache.ts b/apps/backend/src/cache/redisStoryCache.ts index dd6d2b0..67af21c 100644 --- a/apps/backend/src/cache/redisStoryCache.ts +++ b/apps/backend/src/cache/redisStoryCache.ts @@ -12,7 +12,9 @@ export class RedisStoryCache extends BaseRedisCache { super(redis); } - private parseRedisStory(storyString: string): Story & { unknownWords: UnknownWord[] } { + private parseRedisStory( + storyString: string, + ): Story & { unknownWords: UnknownWord[] } { try { const storyJson = JSON.parse(storyString); return { @@ -25,7 +27,9 @@ export class RedisStoryCache extends BaseRedisCache { }; } catch (error) { logger.error("Failed to parse cached story", error, storyString); - throw new RedisError("Invalid cached story format", error, { storyString }); + throw new RedisError("Invalid cached story format", error, { + storyString, + }); } } @@ -36,12 +40,15 @@ export class RedisStoryCache extends BaseRedisCache { } async getAllStoriesFromCache( - userId: number + userId: number, ): Promise<(Story & { unknownWords: UnknownWord[] })[]> { const cacheKey = this.getKey(userId); const cachedStories = await this.lRange(cacheKey); if (cachedStories.length > 0) { - logger.info("Cache hit for stories", { userId, count: cachedStories.length }); + logger.info("Cache hit for stories", { + userId, + count: cachedStories.length, + }); } else { logger.info("Cache miss for stories", { userId }); } @@ -50,7 +57,7 @@ export class RedisStoryCache extends BaseRedisCache { async saveStoriesToCache( userId: number, - stories: (Story & { unknownWords: UnknownWord[] })[] + stories: (Story & { unknownWords: UnknownWord[] })[], ): Promise { if (stories.length === 0) { logger.info("Stories array is empty. Not saving to cache"); @@ -59,7 +66,7 @@ export class RedisStoryCache extends BaseRedisCache { const cacheKey = this.getKey(userId); await this.setList( cacheKey, - stories.map((item) => JSON.stringify(item)) + stories.map((item) => JSON.stringify(item)), ); logger.info("Stories saved to cache", { userId, count: stories.length }); } diff --git a/apps/backend/src/cache/redisStoryLimits.ts b/apps/backend/src/cache/redisStoryLimits.ts index 5059c3c..8f3faae 100644 --- a/apps/backend/src/cache/redisStoryLimits.ts +++ b/apps/backend/src/cache/redisStoryLimits.ts @@ -5,7 +5,10 @@ export class RedisStoryLimits extends BaseRedisCache { protected ttl = 86400; protected prefix = "stories:limits"; - constructor(redis: AppRedisClient, private limit = 5) { + constructor( + redis: AppRedisClient, + private limit = 5, + ) { super(redis); } @@ -22,10 +25,14 @@ export class RedisStoryLimits extends BaseRedisCache { await this.redis.incr(key); if (!exists) { const now = new Date(); - const laNow = new Date(now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" })); + const laNow = new Date( + now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }), + ); const laMidnight = new Date(laNow); laMidnight.setHours(24, 0, 0, 0); - const secondsToMidnight = Math.floor((laMidnight.getTime() - laNow.getTime()) / 1000); + const secondsToMidnight = Math.floor( + (laMidnight.getTime() - laNow.getTime()) / 1000, + ); await this.redis.expire(key, secondsToMidnight); } } diff --git a/apps/backend/src/cache/redisWordsCache.ts b/apps/backend/src/cache/redisWordsCache.ts index 3e8356b..9774393 100644 --- a/apps/backend/src/cache/redisWordsCache.ts +++ b/apps/backend/src/cache/redisWordsCache.ts @@ -11,7 +11,10 @@ export class RedisWordsCache extends BaseRedisCache { super(redis); } - async getWords(sourceLanguage: string, targetLanguage: string): Promise { + async getWords( + sourceLanguage: string, + targetLanguage: string, + ): Promise { const cacheKey = this.getKey(sourceLanguage, targetLanguage); const cachedWords = await this.get(cacheKey); if (cachedWords) { @@ -25,7 +28,7 @@ export class RedisWordsCache extends BaseRedisCache { async saveWords( sourceLanguage: string, targetLanguage: string, - words: WordRanking[] + words: WordRanking[], ): Promise { const cacheKey = this.getKey(sourceLanguage, targetLanguage); await this.set(cacheKey, words); diff --git a/apps/backend/src/container.ts b/apps/backend/src/container.ts index 28d7d72..5767349 100644 --- a/apps/backend/src/container.ts +++ b/apps/backend/src/container.ts @@ -33,7 +33,10 @@ export const authModule = createAuthModule({ const authMiddleware = createAuthMiddleware(authModule.service); -export const authRouter = buildAuthRouter(authModule.controller, authMiddleware); +export const authRouter = buildAuthRouter( + authModule.controller, + authMiddleware, +); export const jobsModule = createJobsModule(authMiddleware); export const sessionModule = createSessionModule({ redis: redisClient }); @@ -44,7 +47,10 @@ export const unknownWordModule = createUnknownWordModule({ authMiddleware, }); -export const vocabularyModule = createVocabularyModule({ prisma, authMiddleware }); +export const vocabularyModule = createVocabularyModule({ + prisma, + authMiddleware, +}); export const storyModule = createStoryModule({ prisma, diff --git a/apps/backend/src/errors/BadRequestError.ts b/apps/backend/src/errors/BadRequestError.ts index d6b0bdc..efc82e9 100644 --- a/apps/backend/src/errors/BadRequestError.ts +++ b/apps/backend/src/errors/BadRequestError.ts @@ -1,17 +1,24 @@ import { ZodIssue } from "zod"; import { CustomError } from "./CustomError"; -import { serializeError } from "./common"; import { ErrorDetails } from "./ErrorDetails"; export class BadRequestError extends CustomError { errors?: ZodIssue[]; - constructor(message: string, errors?: ZodIssue[], public details?: ErrorDetails) { + constructor( + message: string, + errors?: ZodIssue[], + public details?: ErrorDetails, + ) { super(message, 400, null); this.errors = errors; } - formatResponse(): { message: string; statusCode: number; userDetails: ZodIssue[] } { + formatResponse(): { + message: string; + statusCode: number; + userDetails: ZodIssue[]; + } { return { message: this.message, statusCode: this.statusCode, diff --git a/apps/backend/src/errors/CustomError.ts b/apps/backend/src/errors/CustomError.ts index 7423f89..90b5b97 100644 --- a/apps/backend/src/errors/CustomError.ts +++ b/apps/backend/src/errors/CustomError.ts @@ -3,18 +3,22 @@ import { IHandleableError, serializeError } from "./common"; import { ErrorDetails } from "./ErrorDetails"; export class CustomError extends Error implements IHandleableError { - originalError: any; + originalError: Error | unknown; constructor( public message: string, public statusCode: number, originalError: unknown, - public details?: ErrorDetails + public details?: ErrorDetails, ) { super(message); this.originalError = serializeError(originalError); } - formatResponse(): { message: string; statusCode: number; userDetails?: ZodIssue[] } { + formatResponse(): { + message: string; + statusCode: number; + userDetails?: ZodIssue[]; + } { return { message: this.message, statusCode: this.statusCode, diff --git a/apps/backend/src/errors/NotFoundError.ts b/apps/backend/src/errors/NotFoundError.ts index ab34da9..5a73cec 100644 --- a/apps/backend/src/errors/NotFoundError.ts +++ b/apps/backend/src/errors/NotFoundError.ts @@ -1,7 +1,11 @@ import { CustomError } from "./CustomError"; import { ErrorDetails } from "./ErrorDetails"; export class NotFoundError extends CustomError { - constructor(resource = "Resource", originalError: unknown | null = null, details?: ErrorDetails) { + constructor( + resource = "Resource", + originalError: unknown | null = null, + details?: ErrorDetails, + ) { super(`${resource} not found`, 404, originalError, details); } } diff --git a/apps/backend/src/errors/auth/AuthError.ts b/apps/backend/src/errors/auth/AuthError.ts index fd1f63f..10a3abb 100644 --- a/apps/backend/src/errors/auth/AuthError.ts +++ b/apps/backend/src/errors/auth/AuthError.ts @@ -7,7 +7,11 @@ export class AuthError extends CustomError { super(message, 401, originalError, details); } - formatResponse(): { message: string; statusCode: number; userDetails?: ZodIssue[] } { + formatResponse(): { + message: string; + statusCode: number; + userDetails?: ZodIssue[]; + } { return { message: this.message, statusCode: this.statusCode, diff --git a/apps/backend/src/errors/common.ts b/apps/backend/src/errors/common.ts index 396000f..6de3d06 100644 --- a/apps/backend/src/errors/common.ts +++ b/apps/backend/src/errors/common.ts @@ -1,12 +1,25 @@ import { ZodIssue } from "zod"; +export type SerializedError = { + message: string; + type: string; + name: string; + stack: string | undefined; +}; + export interface IHandleableError { statusCode: number; - formatResponse(): { message: string; statusCode: number; userDetails?: ZodIssue[] }; - log(): Record; + formatResponse(): { + message: string; + statusCode: number; + userDetails?: ZodIssue[]; + }; + log(): Record; } -export function serializeError(error?: unknown) { +export function serializeError( + error?: Error | unknown, +): SerializedError | unknown { if (error instanceof Error) { return { message: error.message, diff --git a/apps/backend/src/health.ts b/apps/backend/src/health.ts index a3b1f38..505a7d5 100644 --- a/apps/backend/src/health.ts +++ b/apps/backend/src/health.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import { prisma } from "./services/prisma"; import { redisClient } from "./services/redis/redisClient"; +import { logger } from "./utils/logger"; export const liveness = (_req: Request, res: Response) => { res.status(200).json({ status: "ok" }); @@ -8,20 +9,31 @@ export const liveness = (_req: Request, res: Response) => { export const readiness = async (_req: Request, res: Response) => { const withTimeout = (p: Promise, ms = 3000) => - Promise.race([p, new Promise((_, r) => setTimeout(() => r(new Error("timeout")), ms))]); + Promise.race([ + p, + new Promise((_, r) => + setTimeout(() => r(new Error("timeout")), ms), + ), + ]); let dbOk = false, redisOk = false; try { - await withTimeout(prisma.$queryRaw`SELECT 1` as any); + await withTimeout(prisma.$queryRaw`SELECT 1`); dbOk = true; - } catch {} + } catch (error) { + logger.error(error); + } try { await withTimeout(redisClient.ping()); redisOk = true; - } catch {} + } catch (error) { + logger.error(error); + } const ok = dbOk && redisOk; - res.status(ok ? 200 : 503).json({ status: ok ? "ok" : "unhealthy", dbOk, redisOk }); + res + .status(ok ? 200 : 503) + .json({ status: ok ? "ok" : "unhealthy", dbOk, redisOk }); }; diff --git a/apps/backend/src/middlewares/asyncHandler.ts b/apps/backend/src/middlewares/asyncHandler.ts index 755a86e..43052cb 100644 --- a/apps/backend/src/middlewares/asyncHandler.ts +++ b/apps/backend/src/middlewares/asyncHandler.ts @@ -1,7 +1,8 @@ import { Request, Response, NextFunction, RequestHandler } from "express"; export const asyncHandler = ( - fn: (req: Req, res: Response, next: NextFunction) => Promise + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fn: (req: Req, res: Response, next: NextFunction) => Promise, ): RequestHandler => { return (req, res, next) => { fn(req as Req, res, next).catch(next); diff --git a/apps/backend/src/middlewares/authMiddleware.ts b/apps/backend/src/middlewares/authMiddleware.ts index 74ec716..542804d 100644 --- a/apps/backend/src/middlewares/authMiddleware.ts +++ b/apps/backend/src/middlewares/authMiddleware.ts @@ -3,7 +3,8 @@ import { AuthService } from "@/modules/auth/authService"; import { Request, Response, NextFunction } from "express"; export const createAuthMiddleware = - (authService: AuthService) => async (req: Request, res: Response, next: NextFunction) => { + (authService: AuthService) => + async (req: Request, res: Response, next: NextFunction) => { const { accessToken } = req.cookies; if (!accessToken) { @@ -14,11 +15,19 @@ export const createAuthMiddleware = try { const user = await authService.verifyAccessToken(accessToken); if (!user.userId) { - next(new AuthError("Unauthorized", null, { message: "Unable to verify access token" })); + next( + new AuthError("Unauthorized", null, { + message: "Unable to verify access token", + }), + ); } req.user = { userId: user.userId }; next(); } catch (error) { - next(new AuthError("Unauthorized", error, { message: "Unable to verify access token" })); + next( + new AuthError("Unauthorized", error, { + message: "Unable to verify access token", + }), + ); } }; diff --git a/apps/backend/src/middlewares/errorHandler.ts b/apps/backend/src/middlewares/errorHandler.ts index a0ccede..d017256 100644 --- a/apps/backend/src/middlewares/errorHandler.ts +++ b/apps/backend/src/middlewares/errorHandler.ts @@ -1,9 +1,9 @@ -import { Request, Response, NextFunction } from "express"; +import { Request, Response } from "express"; import { formatErrorResponse } from "./responseFormatter"; import { logger } from "@/utils/logger"; import { IHandleableError } from "@/errors/common"; -export const errorHandler = (err: unknown, req: Request, res: Response, next: NextFunction) => { +export const errorHandler = (err: unknown, req: Request, res: Response) => { const user = req.user; const logBase = { method: req.method, @@ -12,7 +12,12 @@ export const errorHandler = (err: unknown, req: Request, res: Response, next: Ne }; // Check if err matches IHandleableError - if (err instanceof Error && "formatResponse" in err && "log" in err && "statusCode" in err) { + if ( + err instanceof Error && + "formatResponse" in err && + "log" in err && + "statusCode" in err + ) { const handleableError = err as IHandleableError; const logObject = { ...logBase, @@ -30,12 +35,17 @@ export const errorHandler = (err: unknown, req: Request, res: Response, next: Ne return; } - const unknownError = err instanceof Error ? err : new Error("Unknown server error"); + const unknownError = + err instanceof Error ? err : new Error("Unknown server error"); logger.error({ ...logBase, message: unknownError.message, stack: unknownError.stack, }); - res.status(500).json(formatErrorResponse({ message: unknownError.message, statusCode: 500 })); + res + .status(500) + .json( + formatErrorResponse({ message: unknownError.message, statusCode: 500 }), + ); }; diff --git a/apps/backend/src/middlewares/responseFormatter.ts b/apps/backend/src/middlewares/responseFormatter.ts index a2fe367..5f3b715 100644 --- a/apps/backend/src/middlewares/responseFormatter.ts +++ b/apps/backend/src/middlewares/responseFormatter.ts @@ -1,7 +1,14 @@ -import { ErrorResponse, Pagination, SuccessResponse } from "@/types/response.types"; +import { + ErrorResponse, + Pagination, + SuccessResponse, +} from "@/types/response.types"; import { ZodIssue } from "zod"; -export function formatResponse(data: T, pagination?: Pagination): SuccessResponse { +export function formatResponse( + data: T, + pagination?: Pagination, +): SuccessResponse { return { success: true, data, diff --git a/apps/backend/src/modules/auth/authController.ts b/apps/backend/src/modules/auth/authController.ts index 1f07add..e8452e9 100644 --- a/apps/backend/src/modules/auth/authController.ts +++ b/apps/backend/src/modules/auth/authController.ts @@ -10,8 +10,16 @@ import { AuthError } from "@/errors/auth/AuthError"; import { AuthedRequest } from "@/types/types"; export class AuthController { - cookieOpts: { httpOnly: boolean; secure: boolean; sameSite: "lax"; maxAge?: number }; - constructor(private authService: AuthService, private userRepository: UserRepository) { + cookieOpts: { + httpOnly: boolean; + secure: boolean; + sameSite: "lax"; + maxAge?: number; + }; + constructor( + private authService: AuthService, + private userRepository: UserRepository, + ) { this.cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === "production", @@ -21,14 +29,23 @@ export class AuthController { register = async (req: Request, res: Response) => { const validatedData = validateData(userCredentialsSchema, req.body); - const existingUser = await this.userRepository.getUserByEmail(validatedData.email); + const existingUser = await this.userRepository.getUserByEmail( + validatedData.email, + ); if (existingUser) { throw new RegisterError("A user with this email already exists"); } - const hashedPassword = await this.authService.hashPassword(validatedData.password); - const user = await this.userRepository.createUser(validatedData.email, hashedPassword); - const { refreshToken, accessToken } = await this.authService.issueTokens(user.id); + const hashedPassword = await this.authService.hashPassword( + validatedData.password, + ); + const user = await this.userRepository.createUser( + validatedData.email, + hashedPassword, + ); + const { refreshToken, accessToken } = await this.authService.issueTokens( + user.id, + ); res .cookie("accessToken", accessToken, { ...this.cookieOpts, @@ -50,14 +67,16 @@ export class AuthController { const checkPassword = await this.authService.comparePassword( validatedData.password, - user.password + user.password, ); if (!checkPassword) { throw new LoginError("Invalid credentials"); } req.user = { userId: user.id }; - const { refreshToken, accessToken } = await this.authService.issueTokens(user.id); + const { refreshToken, accessToken } = await this.authService.issueTokens( + user.id, + ); res .cookie("accessToken", accessToken, { ...this.cookieOpts, @@ -76,7 +95,10 @@ export class AuthController { throw new AuthError("Refresh token not found", null); } await this.authService.revokeToken(refreshToken); - res.clearCookie("accessToken").clearCookie("refreshToken").json(formatResponse({})); + res + .clearCookie("accessToken") + .clearCookie("refreshToken") + .json(formatResponse({})); }; refresh = async (req: Request, res: Response) => { @@ -84,8 +106,11 @@ export class AuthController { if (!oldRefreshToken) { throw new AuthError("Refresh token not found", null); } - const { refreshToken, record } = await this.authService.rotateRefreshToken(oldRefreshToken); - const accessToken = await this.authService.generateAccessToken(record.userId); + const { refreshToken, record } = + await this.authService.rotateRefreshToken(oldRefreshToken); + const accessToken = await this.authService.generateAccessToken( + record.userId, + ); res .cookie("accessToken", accessToken, { ...this.cookieOpts, diff --git a/apps/backend/src/modules/auth/authController.unit.test.ts b/apps/backend/src/modules/auth/authController.unit.test.ts index 9875468..964aca2 100644 --- a/apps/backend/src/modules/auth/authController.unit.test.ts +++ b/apps/backend/src/modules/auth/authController.unit.test.ts @@ -9,10 +9,12 @@ function createResponseStub() { const cookies: Record = {}; const res: any = { _json: null as any, - cookie: jest.fn().mockImplementation((name: string, value: any, opts: any) => { - cookies[name] = { value, opts }; - return res; - }), + cookie: jest + .fn() + .mockImplementation((name: string, value: any, opts: any) => { + cookies[name] = { value, opts }; + return res; + }), clearCookie: jest.fn().mockImplementation((name: string) => { delete cookies[name]; return res; @@ -41,7 +43,9 @@ describe("AuthController (unit)", () => { it("register sets cookies and returns user id", async () => { const authService = { hashPassword: jest.fn().mockResolvedValue("hashed"), - issueTokens: jest.fn().mockResolvedValue({ accessToken: "a", refreshToken: "r" }), + issueTokens: jest + .fn() + .mockResolvedValue({ accessToken: "a", refreshToken: "r" }), } as unknown as AuthService; const userRepository = { getUserByEmail: jest.fn().mockResolvedValue(null), @@ -70,10 +74,14 @@ describe("AuthController (unit)", () => { it("login validates password and sets tokens", async () => { const authService = { comparePassword: jest.fn().mockResolvedValue(true), - issueTokens: jest.fn().mockResolvedValue({ accessToken: "a2", refreshToken: "r2" }), + issueTokens: jest + .fn() + .mockResolvedValue({ accessToken: "a2", refreshToken: "r2" }), } as unknown as AuthService; const userRepository = { - getUserByEmail: jest.fn().mockResolvedValue({ id: 7, password: "hashed" }), + getUserByEmail: jest + .fn() + .mockResolvedValue({ id: 7, password: "hashed" }), } as unknown as UserRepository; const controller = new AuthController(authService, userRepository); @@ -160,7 +168,9 @@ describe("AuthController (unit)", () => { const req: any = { body: { email: "user@example.com", password: "1234" } }; const res = createResponseStub(); - await expect(controller.register(req, res)).rejects.toBeInstanceOf(RegisterError); + await expect(controller.register(req, res)).rejects.toBeInstanceOf( + RegisterError, + ); }); it("login throws LoginError when user not found", async () => { @@ -219,12 +229,16 @@ describe("AuthController (unit)", () => { const req: any = { cookies: {} }; const res = createResponseStub(); - await expect(controller.refresh(req, res)).rejects.toBeInstanceOf(AuthError); + await expect(controller.refresh(req, res)).rejects.toBeInstanceOf( + AuthError, + ); }); it("refresh propagates service error when rotate fails", async () => { const authService = { - rotateRefreshToken: jest.fn().mockRejectedValue(new AuthError("Invalid refresh token", null)), + rotateRefreshToken: jest + .fn() + .mockRejectedValue(new AuthError("Invalid refresh token", null)), generateAccessToken: jest.fn(), } as unknown as AuthService; const userRepository = {} as unknown as UserRepository; @@ -233,6 +247,8 @@ describe("AuthController (unit)", () => { const req: any = { cookies: { refreshToken: "bad" } }; const res = createResponseStub(); - await expect(controller.refresh(req, res)).rejects.toBeInstanceOf(AuthError); + await expect(controller.refresh(req, res)).rejects.toBeInstanceOf( + AuthError, + ); }); }); diff --git a/apps/backend/src/modules/auth/authRepository.ts b/apps/backend/src/modules/auth/authRepository.ts index b604630..6fd4c02 100644 --- a/apps/backend/src/modules/auth/authRepository.ts +++ b/apps/backend/src/modules/auth/authRepository.ts @@ -7,7 +7,11 @@ export class AuthRepository { return `refreshToken:${key}`; } - async saveRefreshToken(refreshToken: string, expiresAt: Date, userId: number) { + async saveRefreshToken( + refreshToken: string, + expiresAt: Date, + userId: number, + ) { const key = this.getKey(refreshToken); const timestampInSeconds = Math.floor(expiresAt.getTime() / 1000); await this.redis diff --git a/apps/backend/src/modules/auth/authRoutes.ts b/apps/backend/src/modules/auth/authRoutes.ts index 5ea05ae..1868e01 100644 --- a/apps/backend/src/modules/auth/authRoutes.ts +++ b/apps/backend/src/modules/auth/authRoutes.ts @@ -5,7 +5,7 @@ import { AuthedRequest } from "@/types/types"; export function buildAuthRouter( controller: AuthController, - authMiddleware: (req: Request, res: Response, next: NextFunction) => void + authMiddleware: (req: Request, res: Response, next: NextFunction) => void, ) { const router = Router(); router.post("/register", asyncHandler(controller.register)); diff --git a/apps/backend/src/modules/auth/authService.test.ts b/apps/backend/src/modules/auth/authService.test.ts index 5f1e4ee..b99c5a3 100644 --- a/apps/backend/src/modules/auth/authService.test.ts +++ b/apps/backend/src/modules/auth/authService.test.ts @@ -71,14 +71,18 @@ describe("AuthService", () => { // approx 7 days in ms const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; expect(expiresAt).toBeInstanceOf(Date); - expect(Math.abs((expiresAt as Date).getTime() - (now + sevenDaysMs))).toBeLessThan(2000); + expect( + Math.abs((expiresAt as Date).getTime() - (now + sevenDaysMs)), + ).toBeLessThan(2000); }); it("verifyRefreshToken throws on invalid JWT", async () => { const repo = createAuthRepositoryMock(); const service = new AuthService(repo); - await expect(service.verifyRefreshToken("invalid.jwt.token")).rejects.toBeInstanceOf(AuthError); + await expect( + service.verifyRefreshToken("invalid.jwt.token"), + ).rejects.toBeInstanceOf(AuthError); }); it("verifyRefreshToken throws when repository has no record", async () => { @@ -87,7 +91,9 @@ describe("AuthService", () => { const service = new AuthService(repo); const valid = await service.generateRefreshToken(10); - await expect(service.verifyRefreshToken(valid)).rejects.toBeInstanceOf(AuthError); + await expect(service.verifyRefreshToken(valid)).rejects.toBeInstanceOf( + AuthError, + ); }); it("verifyRefreshToken returns record when JWT is valid and repo has record", async () => { diff --git a/apps/backend/src/modules/auth/authService.ts b/apps/backend/src/modules/auth/authService.ts index d206016..00a93fc 100644 --- a/apps/backend/src/modules/auth/authService.ts +++ b/apps/backend/src/modules/auth/authService.ts @@ -33,11 +33,13 @@ export class AuthService { } async generateRefreshToken(userId: number) { - const refreshToken = jwt.sign({ userId }, this.jwtSecret, { expiresIn: "7d" }); + const refreshToken = jwt.sign({ userId }, this.jwtSecret, { + expiresIn: "7d", + }); await this.authRepository.saveRefreshToken( refreshToken, new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), - userId + userId, ); return refreshToken; } @@ -46,7 +48,7 @@ export class AuthService { try { jwt.verify(token, this.jwtSecret); } catch (error) { - throw new AuthError("Invalid refresh token", { token }); + throw new AuthError("Invalid refresh token", error, { token }); } const record = await this.authRepository.getRefreshTokenRecord(token); diff --git a/apps/backend/src/modules/auth/composition.ts b/apps/backend/src/modules/auth/composition.ts index 6221c48..c7fb882 100644 --- a/apps/backend/src/modules/auth/composition.ts +++ b/apps/backend/src/modules/auth/composition.ts @@ -3,9 +3,11 @@ import { AuthController } from "./authController"; import { AuthService } from "./authService"; import { AppRedisClient } from "@/services/redis/redisClient"; import { UserRepository } from "../user/userRepository"; -import { buildAuthRouter } from "./authRoutes"; -export function createAuthModule(deps: { redis: AppRedisClient; userRepository: UserRepository }) { +export function createAuthModule(deps: { + redis: AppRedisClient; + userRepository: UserRepository; +}) { const repository = new AuthRepository(deps.redis); const service = new AuthService(repository); const controller = new AuthController(service, deps.userRepository); diff --git a/apps/backend/src/modules/jobs/bullWorker.test.ts b/apps/backend/src/modules/jobs/bullWorker.test.ts index b2c0a5d..791f209 100644 --- a/apps/backend/src/modules/jobs/bullWorker.test.ts +++ b/apps/backend/src/modules/jobs/bullWorker.test.ts @@ -6,10 +6,12 @@ jest.mock("bullmq", () => { const actual = jest.requireActual("bullmq"); return { ...actual, - Worker: jest.fn().mockImplementation((_queue: string, _proc: any, _opts: any) => ({ - on: jest.fn(), - close: jest.fn(), - })), + Worker: jest + .fn() + .mockImplementation((_queue: string, _proc: any, _opts: any) => ({ + on: jest.fn(), + close: jest.fn(), + })), }; }); @@ -48,7 +50,9 @@ describe("BullWorker", () => { const worker = new BullWorker("q", connection, handlers); const job = { id: "1", name: "unknown" } as unknown as Job; - await expect(worker.processor(job)).rejects.toMatchObject({ message: "Unknown task name" }); + await expect(worker.processor(job)).rejects.toMatchObject({ + message: "Unknown task name", + }); }); it("propagates handler errors", async () => { diff --git a/apps/backend/src/modules/jobs/bullWorker.ts b/apps/backend/src/modules/jobs/bullWorker.ts index 5eefb09..4ea6629 100644 --- a/apps/backend/src/modules/jobs/bullWorker.ts +++ b/apps/backend/src/modules/jobs/bullWorker.ts @@ -4,6 +4,7 @@ import { logger } from "@/utils/logger"; import { Job, Worker } from "bullmq"; import { ConnectionOptions } from "bullmq"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type JobHandler = (job: Job) => Promise; export class BullWorker { @@ -12,9 +13,11 @@ export class BullWorker { constructor( queueName: string, connection: ConnectionOptions, - private handlers: Map + private handlers: Map, ) { - this.worker = new Worker(queueName, this.processor.bind(this), { connection }); + this.worker = new Worker(queueName, this.processor.bind(this), { + connection, + }); this.worker.on("completed", (job) => { logger.info(`Job ${job.id} has completed! ${job.returnvalue}`); @@ -50,7 +53,9 @@ export class BullWorker { const result = await handler(job); return result; } else { - throw new CustomError("Unknown task name", 500, null, { name: job.name }); + throw new CustomError("Unknown task name", 500, null, { + name: job.name, + }); } } catch (error) { logger.error(`Error processing job ${job.id}:`, error); diff --git a/apps/backend/src/modules/jobs/composition.ts b/apps/backend/src/modules/jobs/composition.ts index 639af00..b710119 100644 --- a/apps/backend/src/modules/jobs/composition.ts +++ b/apps/backend/src/modules/jobs/composition.ts @@ -4,7 +4,7 @@ import { buildJobsRouter } from "./jobsRoutes"; import { mainQueue } from "../../services/jobQueue/queue"; export function createJobsModule( - authMiddleware: (req: Request, res: Response, next: NextFunction) => void + authMiddleware: (req: Request, res: Response, next: NextFunction) => void, ) { const controller = new JobsController(mainQueue); return { controller, router: buildJobsRouter(controller, authMiddleware) }; diff --git a/apps/backend/src/modules/jobs/jobsController.ts b/apps/backend/src/modules/jobs/jobsController.ts index d08b43c..9aa21bc 100644 --- a/apps/backend/src/modules/jobs/jobsController.ts +++ b/apps/backend/src/modules/jobs/jobsController.ts @@ -1,5 +1,8 @@ import { Request, Response } from "express"; -import { formatErrorResponse, formatResponse } from "@/middlewares/responseFormatter"; +import { + formatErrorResponse, + formatResponse, +} from "@/middlewares/responseFormatter"; import { Job, Queue } from "bullmq"; export class JobsController { @@ -11,18 +14,18 @@ export class JobsController { if (!job) { return res .status(404) - .json(formatErrorResponse({ message: "Job not found", statusCode: 404 })); + .json( + formatErrorResponse({ message: "Job not found", statusCode: 404 }), + ); } const state = await job.getState(); - res - .status(200) - .json( - formatResponse({ - status: state, - value: job.returnvalue, - failedReason: job.failedReason, - progress: job.progress, - }) - ); + res.status(200).json( + formatResponse({ + status: state, + value: job.returnvalue, + failedReason: job.failedReason, + progress: job.progress, + }), + ); }; } diff --git a/apps/backend/src/modules/jobs/jobsRoutes.ts b/apps/backend/src/modules/jobs/jobsRoutes.ts index d1d86a4..a9f0589 100644 --- a/apps/backend/src/modules/jobs/jobsRoutes.ts +++ b/apps/backend/src/modules/jobs/jobsRoutes.ts @@ -5,10 +5,14 @@ import { AuthedRequest } from "@/types/types"; export function buildJobsRouter( controller: JobsController, - authMiddleware: (req: Request, res: Response, next: NextFunction) => void + authMiddleware: (req: Request, res: Response, next: NextFunction) => void, ) { const router = Router(); - router.get("/status/:jobId", authMiddleware, asyncHandler(controller.jobStatus)); + router.get( + "/status/:jobId", + authMiddleware, + asyncHandler(controller.jobStatus), + ); return router; } diff --git a/apps/backend/src/modules/onboarding/onboardingController.test.ts b/apps/backend/src/modules/onboarding/onboardingController.test.ts index 6bc777d..dc39799 100644 --- a/apps/backend/src/modules/onboarding/onboardingController.test.ts +++ b/apps/backend/src/modules/onboarding/onboardingController.test.ts @@ -41,7 +41,9 @@ describe("OnboardingController", () => { const req: any = { user: { userId: 1 } }; const res = resStub(); - await expect(controller.completeOnboarding(req, res)).rejects.toBeInstanceOf(PrismaError); + await expect( + controller.completeOnboarding(req, res), + ).rejects.toBeInstanceOf(PrismaError); }); it("checkOnboarding returns completed when record found", async () => { @@ -62,7 +64,9 @@ describe("OnboardingController", () => { }); it("checkOnboarding returns not_started when not found", async () => { - const prisma: any = { onboarding: { findFirst: jest.fn().mockResolvedValue(null) } }; + const prisma: any = { + onboarding: { findFirst: jest.fn().mockResolvedValue(null) }, + }; const controller = new OnboardingController(prisma); const req: any = { user: { userId: 10 } }; const res = resStub(); @@ -76,11 +80,15 @@ describe("OnboardingController", () => { }); it("checkOnboarding wraps errors in PrismaError", async () => { - const prisma: any = { onboarding: { findFirst: jest.fn().mockRejectedValue(new Error("db")) } }; + const prisma: any = { + onboarding: { findFirst: jest.fn().mockRejectedValue(new Error("db")) }, + }; const controller = new OnboardingController(prisma); const req: any = { user: { userId: 10 } }; const res = resStub(); - await expect(controller.checkOnboarding(req, res)).rejects.toBeInstanceOf(PrismaError); + await expect(controller.checkOnboarding(req, res)).rejects.toBeInstanceOf( + PrismaError, + ); }); }); diff --git a/apps/backend/src/modules/onboarding/onboardingController.ts b/apps/backend/src/modules/onboarding/onboardingController.ts index 434298f..150f4f4 100644 --- a/apps/backend/src/modules/onboarding/onboardingController.ts +++ b/apps/backend/src/modules/onboarding/onboardingController.ts @@ -33,14 +33,18 @@ export class OnboardingController { checkOnboarding = async (req: AuthedRequest, res: Response) => { const { userId } = req.user; try { - const result = await this.prisma.onboarding.findFirst({ where: { userId } }); + const result = await this.prisma.onboarding.findFirst({ + where: { userId }, + }); if (result) { res.status(200).json(formatResponse({ status: "completed" })); } else { res.status(200).json(formatResponse({ status: "not_started" })); } } catch (error) { - throw new PrismaError("Failed on check onboarding status", error, { userId }); + throw new PrismaError("Failed on check onboarding status", error, { + userId, + }); } }; } diff --git a/apps/backend/src/modules/onboarding/onboardingRoutes.ts b/apps/backend/src/modules/onboarding/onboardingRoutes.ts index c7f852f..6266403 100644 --- a/apps/backend/src/modules/onboarding/onboardingRoutes.ts +++ b/apps/backend/src/modules/onboarding/onboardingRoutes.ts @@ -4,12 +4,20 @@ import { asyncHandler } from "@/middlewares/asyncHandler"; export function buildOnboardingRouter( controller: OnboardingController, - authMiddleware: (req: Request, res: Response, next: NextFunction) => void + authMiddleware: (req: Request, res: Response, next: NextFunction) => void, ) { const router = Router(); - router.post("/complete", authMiddleware, asyncHandler(controller.completeOnboarding)); - router.get("/check", authMiddleware, asyncHandler(controller.checkOnboarding)); + router.post( + "/complete", + authMiddleware, + asyncHandler(controller.completeOnboarding), + ); + router.get( + "/check", + authMiddleware, + asyncHandler(controller.checkOnboarding), + ); return router; } diff --git a/apps/backend/src/modules/session/sessionRepository.test.ts b/apps/backend/src/modules/session/sessionRepository.test.ts index 94fa9c9..e0d166c 100644 --- a/apps/backend/src/modules/session/sessionRepository.test.ts +++ b/apps/backend/src/modules/session/sessionRepository.test.ts @@ -9,7 +9,9 @@ function createRedisMock() { hSet: jest.fn().mockImplementation((key: string, data: any) => { store[key] = { ...(store[key] || {}), - ...Object.fromEntries(Object.entries(data).map(([k, v]) => [k, String(v)])), + ...Object.fromEntries( + Object.entries(data).map(([k, v]) => [k, String(v)]), + ), }; pipelineOps.push(["hSet", key, data]); return pipeline; @@ -64,7 +66,9 @@ describe("SessionRepository", () => { it("getSession throws when sessionUUID missing", async () => { const redis = createRedisMock(); const repo = new SessionRepository(redis); - await expect(repo.getSession(1, "" as any)).rejects.toBeInstanceOf(RedisError); + await expect(repo.getSession(1, "" as any)).rejects.toBeInstanceOf( + RedisError, + ); }); it("updateSessionState updates only state JSON and returns parsed session", async () => { @@ -89,8 +93,18 @@ describe("SessionRepository", () => { it("parseSession wraps invalid JSON in RedisError", async () => { const hGetAll = jest .fn() - .mockResolvedValue({ userId: "1", state: "{", sessionUUID: "u", status: "active" }); - const redis: any = { hGetAll, multi: jest.fn(), hSet: jest.fn(), expire: jest.fn() }; + .mockResolvedValue({ + userId: "1", + state: "{", + sessionUUID: "u", + status: "active", + }); + const redis: any = { + hGetAll, + multi: jest.fn(), + hSet: jest.fn(), + expire: jest.fn(), + }; const repo = new SessionRepository(redis); await expect(repo.getSession(1, "u")).rejects.toBeInstanceOf(RedisError); }); diff --git a/apps/backend/src/modules/session/sessionRepository.ts b/apps/backend/src/modules/session/sessionRepository.ts index 6d8c75b..4df8af3 100644 --- a/apps/backend/src/modules/session/sessionRepository.ts +++ b/apps/backend/src/modules/session/sessionRepository.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { RedisError } from "@/errors/RedisError"; import { AppRedisClient } from "@/services/redis/redisClient"; import { v4 as uuidv4 } from "uuid"; @@ -36,7 +37,10 @@ export class SessionRepository { return session; } catch (error) { - throw new RedisError("Unable to create a new session", error, { userId, state }); + throw new RedisError("Unable to create a new session", error, { + userId, + state, + }); } } @@ -64,7 +68,9 @@ export class SessionRepository { return this.parseSession(session); } catch (error) { - throw new RedisError("Unable to retrieve a session", error, { sessionUUID }); + throw new RedisError("Unable to retrieve a session", error, { + sessionUUID, + }); } } @@ -82,7 +88,10 @@ export class SessionRepository { return this.parseSession(session); } catch (error) { - throw new RedisError("Unable to update a session's state", error, { sessionUUID, state }); + throw new RedisError("Unable to update a session's state", error, { + sessionUUID, + state, + }); } } @@ -101,7 +110,9 @@ export class SessionRepository { return this.parseSession(session); } catch (error) { - throw new RedisError("Unable to complete a session", error, { sessionUUID }); + throw new RedisError("Unable to complete a session", error, { + sessionUUID, + }); } } } diff --git a/apps/backend/src/modules/session/sessionService.test.ts b/apps/backend/src/modules/session/sessionService.test.ts index 63e7807..80a53dc 100644 --- a/apps/backend/src/modules/session/sessionService.test.ts +++ b/apps/backend/src/modules/session/sessionService.test.ts @@ -2,7 +2,9 @@ import { SessionService } from "./sessionService"; describe("SessionService", () => { it("createSession proxies to repo", async () => { - const repo: any = { createSession: jest.fn().mockResolvedValue({ sessionUUID: "u" }) }; + const repo: any = { + createSession: jest.fn().mockResolvedValue({ sessionUUID: "u" }), + }; const svc = new SessionService(repo); const res = await svc.createSession(1, { a: 1 }); expect(repo.createSession).toHaveBeenCalledWith(1, { a: 1 }); @@ -10,7 +12,9 @@ describe("SessionService", () => { }); it("getSession proxies to repo", async () => { - const repo: any = { getSession: jest.fn().mockResolvedValue({ sessionUUID: "u" }) }; + const repo: any = { + getSession: jest.fn().mockResolvedValue({ sessionUUID: "u" }), + }; const svc = new SessionService(repo); const res = await svc.getSession(1, "u"); expect(repo.getSession).toHaveBeenCalledWith(1, "u"); @@ -18,7 +22,9 @@ describe("SessionService", () => { }); it("updateSessionState proxies to repo", async () => { - const repo: any = { updateSessionState: jest.fn().mockResolvedValue({ state: { a: 2 } }) }; + const repo: any = { + updateSessionState: jest.fn().mockResolvedValue({ state: { a: 2 } }), + }; const svc = new SessionService(repo); const res = await svc.updateSessionState(1, "u", { a: 2 }); expect(repo.updateSessionState).toHaveBeenCalledWith(1, "u", { a: 2 }); @@ -26,7 +32,9 @@ describe("SessionService", () => { }); it("completeSession proxies to repo", async () => { - const repo: any = { completeSession: jest.fn().mockResolvedValue({ status: "completed" }) }; + const repo: any = { + completeSession: jest.fn().mockResolvedValue({ status: "completed" }), + }; const svc = new SessionService(repo); const res = await svc.completeSession(1, "u"); expect(repo.completeSession).toHaveBeenCalledWith(1, "u"); diff --git a/apps/backend/src/modules/session/sessionService.ts b/apps/backend/src/modules/session/sessionService.ts index fdf5b0d..88ba485 100644 --- a/apps/backend/src/modules/session/sessionService.ts +++ b/apps/backend/src/modules/session/sessionService.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { SessionRepository } from "./sessionRepository"; export class SessionService { @@ -9,17 +10,27 @@ export class SessionService { } async getSession(userId: number, sessionUUID: string) { - const session = await this.sessionRepository.getSession(userId, sessionUUID); + const session = await this.sessionRepository.getSession( + userId, + sessionUUID, + ); return session; } async updateSessionState(userId: number, sessionUUID: string, state: any) { - const session = await this.sessionRepository.updateSessionState(userId, sessionUUID, state); + const session = await this.sessionRepository.updateSessionState( + userId, + sessionUUID, + state, + ); return session; } async completeSession(userId: number, sessionUUID: string) { - const session = await this.sessionRepository.completeSession(userId, sessionUUID); + const session = await this.sessionRepository.completeSession( + userId, + sessionUUID, + ); return session; } } diff --git a/apps/backend/src/modules/story/composition.ts b/apps/backend/src/modules/story/composition.ts index 746d580..6cd805f 100644 --- a/apps/backend/src/modules/story/composition.ts +++ b/apps/backend/src/modules/story/composition.ts @@ -40,11 +40,14 @@ export function createStoryModule(deps: { deps.vocabularyService, deps.storyGeneratorService, deps.translationService, - deps.unknownWordService + deps.unknownWordService, ); const lemmaAssembler = new LemmaAssembler(deps.lemmatizationService); const storyAudioStorageService = new StoryAudioStorageService(repository); - const audioAssembler = new AudioAssembler(storyAudioStorageService, deps.textToSpeechService); + const audioAssembler = new AudioAssembler( + storyAudioStorageService, + deps.textToSpeechService, + ); const service = new StoriesService( repository, storyAssembler, @@ -53,8 +56,12 @@ export function createStoryModule(deps: { cache, deps.queue, deps.unknownWordService, - redisStoryLimits + redisStoryLimits, ); const controller = new StoryController(service, deps.unknownWordService); - return { service, controller, router: buildStoryRouter(controller, deps.authMiddleware) }; + return { + service, + controller, + router: buildStoryRouter(controller, deps.authMiddleware), + }; } diff --git a/apps/backend/src/modules/story/services/audioAssembler/audioAssembler.test.ts b/apps/backend/src/modules/story/services/audioAssembler/audioAssembler.test.ts index 4bb53c1..18d37f5 100644 --- a/apps/backend/src/modules/story/services/audioAssembler/audioAssembler.test.ts +++ b/apps/backend/src/modules/story/services/audioAssembler/audioAssembler.test.ts @@ -23,31 +23,54 @@ describe("AudioAssembler", () => { const assembler = new AudioAssembler(storage, tts); - const translationChunks = [{ chunk: "Hallo Welt", translatedChunk: "Hello world" }]; + const translationChunks = [ + { chunk: "Hallo Welt", translatedChunk: "Hello world" }, + ]; const unknownWords: any[] = []; - const url = await assembler.assemble(translationChunks, unknownWords, "DE", "EN"); + const url = await assembler.assemble( + translationChunks, + unknownWords, + "DE", + "EN", + ); // Silences generated - expect(generateSilence).toHaveBeenCalledWith(2); expect(generateSilence).toHaveBeenCalledWith(1); expect(generateSilence).toHaveBeenCalledWith(0.3); // TTS calls: N chunks -> german N, translation 2N, plus 1 transition = 3N + 1 expect((tts as any).textToSpeech).toHaveBeenCalledTimes(4); - expect((tts as any).textToSpeech).toHaveBeenCalledWith("Hallo Welt", true, "DE", "EN"); + expect((tts as any).textToSpeech).toHaveBeenCalledWith( + "Hallo Welt", + true, + "DE", + "EN", + ); expect((tts as any).textToSpeech).toHaveBeenCalledWith( "Now listen to the story with translation.", false, "DE", - "EN" + "EN", + ); + expect((tts as any).textToSpeech).toHaveBeenCalledWith( + "Hallo Welt", + true, + "DE", + "EN", + ); + expect((tts as any).textToSpeech).toHaveBeenCalledWith( + "Hello world", + false, + "DE", + "EN", ); - expect((tts as any).textToSpeech).toHaveBeenCalledWith("Hallo Welt", true, "DE", "EN"); - expect((tts as any).textToSpeech).toHaveBeenCalledWith("Hello world", false, "DE", "EN"); // Combining and saving expect(combineAudioFromBase64).toHaveBeenCalled(); - expect((storage as any).saveToStorage).toHaveBeenCalledWith("COMBINED_AUDIO"); + expect((storage as any).saveToStorage).toHaveBeenCalledWith( + "COMBINED_AUDIO", + ); expect(url).toBe("audio-url.mp3"); }); }); diff --git a/apps/backend/src/modules/story/services/audioAssembler/audioAssembler.ts b/apps/backend/src/modules/story/services/audioAssembler/audioAssembler.ts index 0ea9308..fa50eb5 100644 --- a/apps/backend/src/modules/story/services/audioAssembler/audioAssembler.ts +++ b/apps/backend/src/modules/story/services/audioAssembler/audioAssembler.ts @@ -9,20 +9,20 @@ import { LanguageCode } from "@/utils/languages"; export class AudioAssembler { constructor( private storyAudioStorageService: StoryAudioStorageService, - private textToSpeechService: TextToSpeechService + private textToSpeechService: TextToSpeechService, ) {} async assemble( translationChunks: ChunkTranslation[], unknownWords: CreateUnknownWordDTO[], languageCode: LanguageCode, - originalLanguageCode: LanguageCode + originalLanguageCode: LanguageCode, ): Promise { const audio = await this.createAudioForStory( translationChunks, unknownWords, languageCode, - originalLanguageCode + originalLanguageCode, ); const audioUrl = await this.storyAudioStorageService.saveToStorage(audio); return audioUrl; @@ -32,9 +32,8 @@ export class AudioAssembler { translationChunks: ChunkTranslation[], newWords: CreateUnknownWordDTO[], languageCode: LanguageCode, - originalLanguageCode: LanguageCode + originalLanguageCode: LanguageCode, ): Promise { - const longSilenceBase64 = await generateSilence(2); const shortSilenceBase64 = await generateSilence(1); const veryShortSilenceBase64 = await generateSilence(0.3); @@ -44,17 +43,17 @@ export class AudioAssembler { chunk.chunk, true, languageCode, - originalLanguageCode + originalLanguageCode, ), veryShortSilenceBase64, - ]) + ]), ); const transitionAudioBase64 = await this.textToSpeechService.textToSpeech( "Now listen to the story with translation.", false, languageCode, - originalLanguageCode + originalLanguageCode, ); // const translationTransitionAudioBase64 = await this.textToSpeechService.textToSpeech( @@ -70,17 +69,17 @@ export class AudioAssembler { chunk.chunk, true, languageCode, - originalLanguageCode + originalLanguageCode, ), shortSilenceBase64, this.textToSpeechService.textToSpeech( chunk.translatedChunk, false, languageCode, - originalLanguageCode + originalLanguageCode, ), shortSilenceBase64, - ]) + ]), ); // const newWordsAudioBase64 = await Promise.all( diff --git a/apps/backend/src/modules/story/services/audioAssembler/audioUtils.ts b/apps/backend/src/modules/story/services/audioAssembler/audioUtils.ts index 30c4365..1c784c9 100644 --- a/apps/backend/src/modules/story/services/audioAssembler/audioUtils.ts +++ b/apps/backend/src/modules/story/services/audioAssembler/audioUtils.ts @@ -13,7 +13,9 @@ import { logger } from "@/utils/logger"; * @param base64AudioFiles - Array of base64 strings representing audio files. * @returns A promise that resolves to the base64 encoded combined audio. */ -export async function combineAudioFromBase64(base64AudioFiles: Base64[][]): Promise { +export async function combineAudioFromBase64( + base64AudioFiles: Base64[][], +): Promise { // Use the system temporary directory to store intermediate files. const tmpDir = os.tmpdir(); const tempFiles: string[] = []; @@ -38,10 +40,14 @@ export async function combineAudioFromBase64(base64AudioFiles: Base64[][]): Prom // Create the concat filter string for the inputs. const filterString = - tempFiles.map((_, index) => `[${index}:a]`).join("") + `concat=n=${tempFiles.length}:v=0:a=1[outa]`; + tempFiles.map((_, index) => `[${index}:a]`).join("") + + `concat=n=${tempFiles.length}:v=0:a=1[outa]`; // Configure ffmpeg to use the concat filter and set the format. - command.complexFilter(filterString).outputOptions("-map", "[outa]").format("mp3"); + command + .complexFilter(filterString) + .outputOptions("-map", "[outa]") + .format("mp3"); // Pipe the output to a stream instead of a file. const outputStream = command.pipe(); @@ -75,7 +81,9 @@ export async function combineAudioFromBase64(base64AudioFiles: Base64[][]): Prom } } -export function generateSilence(durationSeconds: number = 0.5): Promise { +export function generateSilence( + durationSeconds: number = 0.5, +): Promise { return new Promise((resolve, reject) => { const args = [ "-nostdin", // Prevent ffmpeg from reading from stdin diff --git a/apps/backend/src/modules/story/services/audioAssembler/textToSpeechService.ts b/apps/backend/src/modules/story/services/audioAssembler/textToSpeechService.ts index 2ebd380..ee92705 100644 --- a/apps/backend/src/modules/story/services/audioAssembler/textToSpeechService.ts +++ b/apps/backend/src/modules/story/services/audioAssembler/textToSpeechService.ts @@ -9,7 +9,7 @@ export class TextToSpeechService { text: string, isTargetLanguage: boolean, languageCode: LanguageCode, - originalLanguageCode: LanguageCode + originalLanguageCode: LanguageCode, ): Promise { const instructions = isTargetLanguage ? `"Speak as if you are doing a voiceover for a story using the 'Comprehensible Input' method of learning. You must speak at a slow pace, and speak expressively. The language is ${LANGUAGES_MAP[languageCode]}` diff --git a/apps/backend/src/modules/story/services/lemmaAssembler/lemmaAssembler.test.ts b/apps/backend/src/modules/story/services/lemmaAssembler/lemmaAssembler.test.ts index 18f9833..e557950 100644 --- a/apps/backend/src/modules/story/services/lemmaAssembler/lemmaAssembler.test.ts +++ b/apps/backend/src/modules/story/services/lemmaAssembler/lemmaAssembler.test.ts @@ -80,7 +80,14 @@ describe("LemmaAssember", () => { ]; const jobStub = { updateProgress: jest.fn() } as unknown as Job; - const result = await assembler.assemble(storyMock, knownWordsMock, 1, "DE", "EN", jobStub); + const result = await assembler.assemble( + storyMock, + knownWordsMock, + 1, + "DE", + "EN", + jobStub, + ); expect(result).toEqual(expectedResult); }); @@ -110,7 +117,7 @@ describe("LemmaAssember", () => { 1, "DE", "EN", - jobStub + jobStub, ); expect(result).toEqual([expect.objectContaining({ word: "Katze" })]); @@ -129,10 +136,16 @@ describe("LemmaAssember", () => { const totalSteps = Object.keys(GENERATION_PHASES).length; expect((jobStub as any).updateProgress).toHaveBeenCalledWith( - expect.objectContaining({ phase: GENERATION_PHASES.lemmatization, totalSteps }) + expect.objectContaining({ + phase: GENERATION_PHASES.lemmatization, + totalSteps, + }), ); expect((jobStub as any).updateProgress).toHaveBeenCalledWith( - expect.objectContaining({ phase: GENERATION_PHASES.creatingExamples, totalSteps }) + expect.objectContaining({ + phase: GENERATION_PHASES.creatingExamples, + totalSteps, + }), ); }); }); diff --git a/apps/backend/src/modules/story/services/lemmaAssembler/lemmaAssembler.ts b/apps/backend/src/modules/story/services/lemmaAssembler/lemmaAssembler.ts index 153d4b6..107e5c5 100644 --- a/apps/backend/src/modules/story/services/lemmaAssembler/lemmaAssembler.ts +++ b/apps/backend/src/modules/story/services/lemmaAssembler/lemmaAssembler.ts @@ -15,7 +15,7 @@ export class LemmaAssembler { userId: number, languageCode: LanguageCode, originalLanguageCode: LanguageCode, - job: Job + job: Job, ): Promise { job.updateProgress({ phase: GENERATION_PHASES["lemmatization"], @@ -27,39 +27,46 @@ export class LemmaAssembler { phase: GENERATION_PHASES["creatingExamples"], totalSteps: Object.keys(GENERATION_PHASES).length, }); - const translatedUnknownLemmas = await this.lemmatizationService.translateLemmas( - unknownLemmas, - languageCode, - originalLanguageCode - ); + const translatedUnknownLemmas = + await this.lemmatizationService.translateLemmas( + unknownLemmas, + languageCode, + originalLanguageCode, + ); const unknownWords = this.mapUnknownLemmasToCreateUnknownWordDTO( translatedUnknownLemmas, unknownLemmas, - userId + userId, ); return unknownWords; } - private filterUnknownLemmas(storyLemmas: Lemma[], knownWords: UserVocabulary[]): Lemma[] { + private filterUnknownLemmas( + storyLemmas: Lemma[], + knownWords: UserVocabulary[], + ): Lemma[] { return storyLemmas.filter( (lemma: Lemma) => !knownWords.some( - (targetWord) => targetWord.word.toLowerCase() === lemma.lemma.toLowerCase() - ) + (targetWord) => + targetWord.word.toLowerCase() === lemma.lemma.toLowerCase(), + ), ); } private mapUnknownLemmasToCreateUnknownWordDTO( translatedUnknownLemmas: LemmaWithTranslation[], originalLemmas: Lemma[], - userId: number + userId: number, ): CreateUnknownWordDTO[] { return translatedUnknownLemmas.map((lemma) => ({ userId: userId, word: lemma.lemma, translation: lemma.translation, - article: originalLemmas.find((word) => word.lemma === lemma.lemma)?.article ?? null, + article: + originalLemmas.find((word) => word.lemma === lemma.lemma)?.article ?? + null, exampleSentence: lemma.exampleSentence, exampleSentenceTranslation: lemma.exampleSentenceTranslation, })); diff --git a/apps/backend/src/modules/story/services/lemmaAssembler/lemmatizationService.test.ts b/apps/backend/src/modules/story/services/lemmaAssembler/lemmatizationService.test.ts index b1bf9fe..bb6234d 100644 --- a/apps/backend/src/modules/story/services/lemmaAssembler/lemmatizationService.test.ts +++ b/apps/backend/src/modules/story/services/lemmaAssembler/lemmatizationService.test.ts @@ -1,6 +1,9 @@ import OpenAI from "openai"; import { Lemma } from "../../story.types"; -import { LemmatizationService, OpenAILemmasResponse } from "./lemmatizationService"; +import { + LemmatizationService, + OpenAILemmasResponse, +} from "./lemmatizationService"; const lemmasMock: Lemma[] = [ { diff --git a/apps/backend/src/modules/story/services/lemmaAssembler/lemmatizationService.ts b/apps/backend/src/modules/story/services/lemmaAssembler/lemmatizationService.ts index 545fb3a..5b7707c 100644 --- a/apps/backend/src/modules/story/services/lemmaAssembler/lemmatizationService.ts +++ b/apps/backend/src/modules/story/services/lemmaAssembler/lemmatizationService.ts @@ -14,19 +14,26 @@ export class LemmatizationService { // TODO: add support for other languages async lemmatize(text: string): Promise { try { - const response = await axios.post(`${process.env.LEMMA_SERVICE_URL}/lemmatize`, { - text, - }); + const response = await axios.post( + `${process.env.LEMMA_SERVICE_URL}/lemmatize`, + { + text, + }, + ); return response.data.lemmas; } catch (error) { - throw new LemmatizationError("Server error: Unable to lemmatize text", error, { text }); + throw new LemmatizationError( + "Server error: Unable to lemmatize text", + error, + { text }, + ); } } async translateLemmas( lemmas: Lemma[], languageCode: LanguageCode, - originalLanguageCode: LanguageCode + originalLanguageCode: LanguageCode, ): Promise { let response: OpenAIResponse; try { @@ -71,7 +78,7 @@ Your task is to translate the given **lemmas (base word forms)** into natural ${ lemmas.map((lemma) => ({ lemma: lemma.lemma, sentence: lemma.sentence, - })) + })), )}`, }, ], @@ -128,10 +135,15 @@ Your task is to translate the given **lemmas (base word forms)** into natural ${ try { result = JSON.parse(content) as OpenAILemmasResponse; } catch (error) { - throw new OpenAIError("Invalid response format, try again", error, { content }); + throw new OpenAIError("Invalid response format, try again", error, { + content, + }); } if (!result.lemmas || !Array.isArray(result.lemmas)) { - throw new OpenAIError("Invalid response format, try again", null, { content, result }); + throw new OpenAIError("Invalid response format, try again", null, { + content, + result, + }); } return result; } diff --git a/apps/backend/src/modules/story/services/storyAssembler/storyAssembler.test.ts b/apps/backend/src/modules/story/services/storyAssembler/storyAssembler.test.ts index 6d21480..95a1e1e 100644 --- a/apps/backend/src/modules/story/services/storyAssembler/storyAssembler.test.ts +++ b/apps/backend/src/modules/story/services/storyAssembler/storyAssembler.test.ts @@ -74,7 +74,7 @@ describe("StoryAssembler", () => { vocabularyServiceMock, storyGeneratorServiceMock, translationServiceMock, - unknownWordServiceMock + unknownWordServiceMock, ); const expected = { @@ -91,11 +91,11 @@ describe("StoryAssembler", () => { expect(storyGeneratorServiceMock.generateStory).toHaveBeenCalledWith( ["Hund", "Katze", "jagen"], "Pets", - "DE" + "DE", ); expect(translationServiceMock.translateChunks).toHaveBeenCalledWith( "Der Hund jagt schnell die Katze.", - "EN" + "EN", ); }); @@ -120,13 +120,13 @@ describe("StoryAssembler", () => { vocabularyServiceMock, storyGeneratorServiceMock, translationServiceMock, - unknownWordServiceMock + unknownWordServiceMock, ); const jobStub = { updateProgress: jest.fn() } as unknown as Job; - await expect(assembler.assemble("Pets", 1, "DE", "EN", jobStub)).rejects.toBeInstanceOf( - CustomError - ); + await expect( + assembler.assemble("Pets", 1, "DE", "EN", jobStub), + ).rejects.toBeInstanceOf(CustomError); }); it("updates job progress across fetchingWords, generation, translation phases", async () => { @@ -150,7 +150,7 @@ describe("StoryAssembler", () => { vocabularyServiceMock, storyGeneratorServiceMock, translationServiceMock, - unknownWordServiceMock + unknownWordServiceMock, ); const jobStub = { updateProgress: jest.fn() } as unknown as Job; @@ -158,13 +158,22 @@ describe("StoryAssembler", () => { const totalSteps = Object.keys(GENERATION_PHASES).length; expect((jobStub as any).updateProgress).toHaveBeenCalledWith( - expect.objectContaining({ phase: GENERATION_PHASES.fetchingWords, totalSteps }) + expect.objectContaining({ + phase: GENERATION_PHASES.fetchingWords, + totalSteps, + }), ); expect((jobStub as any).updateProgress).toHaveBeenCalledWith( - expect.objectContaining({ phase: GENERATION_PHASES.generation, totalSteps }) + expect.objectContaining({ + phase: GENERATION_PHASES.generation, + totalSteps, + }), ); expect((jobStub as any).updateProgress).toHaveBeenCalledWith( - expect.objectContaining({ phase: GENERATION_PHASES.translation, totalSteps }) + expect.objectContaining({ + phase: GENERATION_PHASES.translation, + totalSteps, + }), ); }); }); diff --git a/apps/backend/src/modules/story/services/storyAssembler/storyAssembler.ts b/apps/backend/src/modules/story/services/storyAssembler/storyAssembler.ts index 7451b2d..123d6b3 100644 --- a/apps/backend/src/modules/story/services/storyAssembler/storyAssembler.ts +++ b/apps/backend/src/modules/story/services/storyAssembler/storyAssembler.ts @@ -13,7 +13,7 @@ export class StoryAssembler { private vocabularyService: VocabularyService, private storyGeneratorService: StoryGeneratorService, private translationService: TranslationService, - private unknownWordService: UnknownWordService + private unknownWordService: UnknownWordService, ) {} async assemble( @@ -21,7 +21,7 @@ export class StoryAssembler { userId: number, languageCode: LanguageCode, originalLanguageCode: LanguageCode, - job: Job + job: Job, ): Promise<{ story: string; knownWords: UserVocabulary[]; @@ -34,9 +34,14 @@ export class StoryAssembler { }); const vocabularyResult = await this.vocabularyService.getWords(userId); if (vocabularyResult.data.length === 0) { - throw new CustomError("User's vocabulary is empty. Vocab assessment is requred!", 500, null); + throw new CustomError( + "User's vocabulary is empty. Vocab assessment is requred!", + 500, + null, + ); } - const unknownwordsResult = await this.unknownWordService.getUnknownWords(userId); + const unknownwordsResult = + await this.unknownWordService.getUnknownWords(userId); const knownWords = vocabularyResult.data; const knownWordsList = knownWords.map((word) => word.word); const unknownWordsList = unknownwordsResult.map((word) => word.word); @@ -49,7 +54,7 @@ export class StoryAssembler { const story = await this.storyGeneratorService.generateStory( combinedWordsList, subject, - languageCode + languageCode, ); const cleanedStoryText = story.replace(/\n/g, " ").trim(); @@ -59,10 +64,17 @@ export class StoryAssembler { }); const translationChunks = await this.translationService.translateChunks( cleanedStoryText, - originalLanguageCode + originalLanguageCode, ); - const fullTranslation = translationChunks.map((chunk) => chunk.translatedChunk).join(" "); + const fullTranslation = translationChunks + .map((chunk) => chunk.translatedChunk) + .join(" "); - return { story: cleanedStoryText, knownWords, fullTranslation, translationChunks }; + return { + story: cleanedStoryText, + knownWords, + fullTranslation, + translationChunks, + }; } } diff --git a/apps/backend/src/modules/story/services/storyAssembler/storyGeneratorService.test.ts b/apps/backend/src/modules/story/services/storyAssembler/storyGeneratorService.test.ts index 8ddaebf..d41656c 100644 --- a/apps/backend/src/modules/story/services/storyAssembler/storyGeneratorService.test.ts +++ b/apps/backend/src/modules/story/services/storyAssembler/storyGeneratorService.test.ts @@ -16,7 +16,11 @@ describe("StoryGeneratorService", () => { const generator = new StoryGeneratorService(openaiMock); - const result = await generator.generateStory(["Hund", "Katze"], "Pets", "DE"); + const result = await generator.generateStory( + ["Hund", "Katze"], + "Pets", + "DE", + ); expect(result).toBe(storyMock); }); diff --git a/apps/backend/src/modules/story/services/storyAssembler/storyGeneratorService.ts b/apps/backend/src/modules/story/services/storyAssembler/storyGeneratorService.ts index cd8ff18..e358fe3 100644 --- a/apps/backend/src/modules/story/services/storyAssembler/storyGeneratorService.ts +++ b/apps/backend/src/modules/story/services/storyAssembler/storyGeneratorService.ts @@ -8,7 +8,7 @@ export class StoryGeneratorService { async generateStory( targetLanguageWords: string[], subject: string, - languageCode: LanguageCode + languageCode: LanguageCode, ): Promise { let response: OpenAIResponse; try { @@ -59,7 +59,10 @@ Requirements: const story = response.output_text; if (!story) { - throw new OpenAIError("Unable to generate a story", null, { targetLanguageWords, subject }); + throw new OpenAIError("Unable to generate a story", null, { + targetLanguageWords, + subject, + }); } return story; } diff --git a/apps/backend/src/modules/story/services/storyAssembler/translationService.ts b/apps/backend/src/modules/story/services/storyAssembler/translationService.ts index cfa5628..dcf70be 100644 --- a/apps/backend/src/modules/story/services/storyAssembler/translationService.ts +++ b/apps/backend/src/modules/story/services/storyAssembler/translationService.ts @@ -16,7 +16,7 @@ export class TranslationService { constructor(private openai: OpenAI) {} async translateChunks( story: string, - originalLanguageCode: LanguageCode + originalLanguageCode: LanguageCode, ): Promise { let response: OpenAIResponse; try { @@ -100,10 +100,15 @@ export class TranslationService { try { result = JSON.parse(content) as OpenAIChunkResponse; } catch (error) { - throw new OpenAIError("Invalid response format, try again", error, { content }); + throw new OpenAIError("Invalid response format, try again", error, { + content, + }); } if (!result.chunks || !Array.isArray(result.chunks)) { - throw new OpenAIError("Invalid response format, try again", null, { content, result }); + throw new OpenAIError("Invalid response format, try again", null, { + content, + result, + }); } return result; } diff --git a/apps/backend/src/modules/story/storyController.ts b/apps/backend/src/modules/story/storyController.ts index 706a92d..0a0e8b3 100644 --- a/apps/backend/src/modules/story/storyController.ts +++ b/apps/backend/src/modules/story/storyController.ts @@ -1,26 +1,27 @@ -import { Request, Response } from "express"; +import { Response } from "express"; import { StoriesService } from "./storyService"; import { UnknownWordService } from "../unknownWord/unknownWordService"; import { UnknownWord } from "@prisma/client"; import { validateData } from "@/validation/validateData"; import { storySubjectRequestSchema } from "./schemas/storySubjectSchema"; import { formatResponse } from "@/middlewares/responseFormatter"; -import { logger } from "@/utils/logger"; import { z } from "zod"; -import { AuthError } from "@/errors/auth/AuthError"; import { AuthedRequest } from "@/types/types"; export class StoryController { constructor( private storiesService: StoriesService, - private unknownWordService: UnknownWordService + private unknownWordService: UnknownWordService, ) {} generateStory = async (req: AuthedRequest, res: Response) => { const { subject } = validateData(storySubjectRequestSchema, req.body); const { languageCode, originalLanguageCode } = validateData( - z.object({ languageCode: z.enum(["DE", "EN"]), originalLanguageCode: z.enum(["DE", "EN"]) }), - req.body + z.object({ + languageCode: z.enum(["DE", "EN"]), + originalLanguageCode: z.enum(["DE", "EN"]), + }), + req.body, ); const user = req.user; @@ -28,7 +29,7 @@ export class StoryController { user.userId, languageCode, originalLanguageCode, - subject + subject, ); // logger.info( diff --git a/apps/backend/src/modules/story/storyRepository.test.ts b/apps/backend/src/modules/story/storyRepository.test.ts index 2ee2f76..591207b 100644 --- a/apps/backend/src/modules/story/storyRepository.test.ts +++ b/apps/backend/src/modules/story/storyRepository.test.ts @@ -54,7 +54,9 @@ describe("StoryRepository", () => { }); prisma.story.create.mockRejectedValue(new Error("db")); - await expect(repo.saveStoryToDB({} as any)).rejects.toBeInstanceOf(PrismaError); + await expect(repo.saveStoryToDB({} as any)).rejects.toBeInstanceOf( + PrismaError, + ); }); it("saveStoryAudioToStorage uploads mp3 and returns path; wraps storage errors", async () => { @@ -62,7 +64,9 @@ describe("StoryRepository", () => { const storage = { storage: { from: jest.fn().mockReturnValue({ - upload: jest.fn().mockResolvedValue({ data: { path: "path.mp3" }, error: null }), + upload: jest + .fn() + .mockResolvedValue({ data: { path: "path.mp3" }, error: null }), }), }, } as any; @@ -76,17 +80,24 @@ describe("StoryRepository", () => { const storageErr = { storage: { from: jest.fn().mockReturnValue({ - upload: jest.fn().mockResolvedValue({ data: null, error: new Error("storage") }), + upload: jest + .fn() + .mockResolvedValue({ data: null, error: new Error("storage") }), }), }, } as any; const repo2 = new StoryRepository(prisma, storageErr); - await expect(repo2.saveStoryAudioToStorage("QkFTRTY0")).rejects.toBeInstanceOf(StorageError); + await expect( + repo2.saveStoryAudioToStorage("QkFTRTY0"), + ).rejects.toBeInstanceOf(StorageError); }); it("connectUnknownWords updates with connect and includes unknownWords; wraps errors", async () => { const prisma = prismaWithStory(); - prisma.story.update.mockResolvedValue({ id: 1, unknownWords: [{ id: 2 }] } as any); + prisma.story.update.mockResolvedValue({ + id: 1, + unknownWords: [{ id: 2 }], + } as any); const repo = new StoryRepository(prisma, {} as any); const res = await repo.connectUnknownWords(1, [{ id: 2 }]); @@ -98,7 +109,9 @@ describe("StoryRepository", () => { }); prisma.story.update.mockRejectedValue(new Error("db")); - await expect(repo.connectUnknownWords(1, [{ id: 2 }])).rejects.toBeInstanceOf(PrismaError); + await expect( + repo.connectUnknownWords(1, [{ id: 2 }]), + ).rejects.toBeInstanceOf(PrismaError); }); it("methods use provided tx when present", async () => { diff --git a/apps/backend/src/modules/story/storyRepository.ts b/apps/backend/src/modules/story/storyRepository.ts index 3a07996..90df800 100644 --- a/apps/backend/src/modules/story/storyRepository.ts +++ b/apps/backend/src/modules/story/storyRepository.ts @@ -11,19 +11,27 @@ function getRandomFileName(extension: string) { function base64ToArrayBuffer(base64: Base64) { const binary = Buffer.from(base64, "base64"); - return binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength); + return binary.buffer.slice( + binary.byteOffset, + binary.byteOffset + binary.byteLength, + ); } export class StoryRepository { - constructor(private prisma: PrismaClient, private storageClient: SupabaseClient) {} + constructor( + private prisma: PrismaClient, + private storageClient: SupabaseClient, + ) {} - private getClient(tx?: Prisma.TransactionClient): Prisma.TransactionClient | PrismaClient { + private getClient( + tx?: Prisma.TransactionClient, + ): Prisma.TransactionClient | PrismaClient { return tx ? tx : this.prisma; } async getAllStories( userId: number, - tx?: Prisma.TransactionClient + tx?: Prisma.TransactionClient, ): Promise<(Story & { unknownWords: UnknownWord[] })[]> { const client = this.getClient(tx); try { @@ -45,7 +53,10 @@ export class StoryRepository { } } - async saveStoryToDB(story: CreateStoryDTO, tx?: Prisma.TransactionClient): Promise { + async saveStoryToDB( + story: CreateStoryDTO, + tx?: Prisma.TransactionClient, + ): Promise { const client = this.getClient(tx); try { const savedStory = await client.story.create({ @@ -69,10 +80,14 @@ export class StoryRepository { }); if (error) { - throw new StorageError("Unable to save story audio to storage", error, { fileName }); + throw new StorageError("Unable to save story audio to storage", error, { + fileName, + }); } if (!data) { - throw new StorageError("Unable to save story audio to storage", error, { fileName }); + throw new StorageError("Unable to save story audio to storage", error, { + fileName, + }); } return data.path; @@ -81,7 +96,7 @@ export class StoryRepository { async connectUnknownWords( storyId: number, wordIds: { id: number }[], - tx?: Prisma.TransactionClient + tx?: Prisma.TransactionClient, ): Promise { const client = this.getClient(tx); try { @@ -98,7 +113,10 @@ export class StoryRepository { }); return response; } catch (error) { - throw new PrismaError("Unable to connect unknown words", error, { storyId, wordIds }); + throw new PrismaError("Unable to connect unknown words", error, { + storyId, + wordIds, + }); } } } diff --git a/apps/backend/src/modules/story/storyRoutes.ts b/apps/backend/src/modules/story/storyRoutes.ts index 1687fd0..a1f32dc 100644 --- a/apps/backend/src/modules/story/storyRoutes.ts +++ b/apps/backend/src/modules/story/storyRoutes.ts @@ -5,11 +5,19 @@ import { AuthedRequest } from "@/types/types"; export function buildStoryRouter( controller: StoryController, - authMiddleware: (req: Request, res: Response, next: NextFunction) => void + authMiddleware: (req: Request, res: Response, next: NextFunction) => void, ) { const router = Router(); - router.post("/generate", authMiddleware, asyncHandler(controller.generateStory)); - router.get("/", authMiddleware, asyncHandler(controller.getAllStories)); + router.post( + "/generate", + authMiddleware, + asyncHandler(controller.generateStory), + ); + router.get( + "/", + authMiddleware, + asyncHandler(controller.getAllStories), + ); return router; } diff --git a/apps/backend/src/modules/story/storyService.test.ts b/apps/backend/src/modules/story/storyService.test.ts index 33bd825..fb75abc 100644 --- a/apps/backend/src/modules/story/storyService.test.ts +++ b/apps/backend/src/modules/story/storyService.test.ts @@ -70,8 +70,13 @@ describe("StoriesService", () => { decrementCount: jest.fn().mockResolvedValue(undefined), } as unknown as RedisStoryLimits; - const jobStub = { id: "job-123", updateProgress: jest.fn() } as unknown as Job; - const jobQueueMock = { add: jest.fn().mockResolvedValue(jobStub) } as unknown as Queue; + const jobStub = { + id: "job-123", + updateProgress: jest.fn(), + } as unknown as Job; + const jobQueueMock = { + add: jest.fn().mockResolvedValue(jobStub), + } as unknown as Queue; const unknownWordServiceMock = {} as unknown as UnknownWordService; const service = new StoriesService( @@ -82,10 +87,15 @@ describe("StoriesService", () => { redisStoryCacheMock, jobQueueMock, unknownWordServiceMock, - redisStoryLimitsMock + redisStoryLimitsMock, ); - const res = await service.generateFullStoryExperience(1, "DE", "EN", "Pets"); + const res = await service.generateFullStoryExperience( + 1, + "DE", + "EN", + "Pets", + ); expect(res).toEqual({ jobId: "job-123" }); expect((jobQueueMock as any).add).toHaveBeenCalledWith("generateStory", { @@ -95,24 +105,33 @@ describe("StoriesService", () => { subject: "Pets", }); expect(jobStub.updateProgress).toHaveBeenCalled(); - expect((redisStoryLimitsMock as any).isLimitReached).toHaveBeenCalledWith(1); - expect((redisStoryLimitsMock as any).incrementCount).toHaveBeenCalledWith(1); + expect((redisStoryLimitsMock as any).isLimitReached).toHaveBeenCalledWith( + 1, + ); + expect((redisStoryLimitsMock as any).incrementCount).toHaveBeenCalledWith( + 1, + ); }); it("processStoryGenerationJob runs full pipeline, saves data in transaction, invalidates cache", async () => { const storyRepositoryMock: any = { saveStoryToDB: jest .fn() - .mockImplementation(async (story: any, tx: any) => ({ id: 42, ...story })), + .mockImplementation(async (story: any, tx: any) => ({ + id: 42, + ...story, + })), connectUnknownWords: jest .fn() - .mockImplementation(async (storyId: number, wordIds: { id: number }[], tx: any) => ({ - id: storyId, - storyText: "Der Hund jagt die Katze.", - translationText: "The dog chases the cat.", - audioUrl: "audioUrl", - unknownWords: wordIds, - })), + .mockImplementation( + async (storyId: number, wordIds: { id: number }[], tx: any) => ({ + id: storyId, + storyText: "Der Hund jagt die Katze.", + translationText: "The dog chases the cat.", + audioUrl: "audioUrl", + unknownWords: wordIds, + }), + ), getAllStories: jest.fn(), } as unknown as StoryRepository; @@ -145,7 +164,12 @@ describe("StoriesService", () => { } as unknown as RedisStoryLimits; const job = { - data: { userId: 1, languageCode: "DE", originalLanguageCode: "EN", subject: "Pets" }, + data: { + userId: 1, + languageCode: "DE", + originalLanguageCode: "EN", + subject: "Pets", + }, updateProgress: jest.fn(), } as unknown as Job; @@ -160,7 +184,7 @@ describe("StoriesService", () => { redisStoryCacheMock, { add: jest.fn() } as unknown as Queue, unknownWordServiceMock, - redisStoryLimitsMock + redisStoryLimitsMock, ); const res = await service.processStoryGenerationJob(job, prisma); @@ -175,7 +199,7 @@ describe("StoriesService", () => { unknownWordsMock, 42, 1, - tx + tx, ); expect(storyRepositoryMock.connectUnknownWords).toHaveBeenCalled(); expect(redisStoryCacheMock.invalidateStoryCache).toHaveBeenCalledWith(1); diff --git a/apps/backend/src/modules/story/storyService.ts b/apps/backend/src/modules/story/storyService.ts index a9f520e..ec95d92 100644 --- a/apps/backend/src/modules/story/storyService.ts +++ b/apps/backend/src/modules/story/storyService.ts @@ -22,14 +22,14 @@ export class StoriesService { private redisStoryCache: RedisStoryCache, private jobQueue: Queue, private unknownWordService: UnknownWordService, - private redisStoryLimits: RedisStoryLimits + private redisStoryLimits: RedisStoryLimits, ) {} async generateFullStoryExperience( userId: number, languageCode: LanguageCode, originalLanguageCode: LanguageCode, - subject: string = "" + subject: string = "", ) { const isLimitReached = await this.redisStoryLimits.isLimitReached(userId); if (isLimitReached) { @@ -52,20 +52,28 @@ export class StoriesService { return { jobId: job.id }; } - async processStoryGenerationJob(job: Job, prisma: PrismaClient): Promise { + async processStoryGenerationJob( + job: Job, + prisma: PrismaClient, + ): Promise { const { userId, languageCode, originalLanguageCode, subject } = job.data; if (!userId || !languageCode || !originalLanguageCode || !subject) { - throw new CustomError("Unable to generate a story: Invalud parameters", 500, null, { - data: job.data, - }); + throw new CustomError( + "Unable to generate a story: Invalud parameters", + 500, + null, + { + data: job.data, + }, + ); } - const { story, unknownWords, knownWords } = await this.createStory( + const { story, unknownWords } = await this.createStory( subject, userId, languageCode, originalLanguageCode, - job + job, ); job.updateProgress({ @@ -75,17 +83,18 @@ export class StoriesService { try { const storyWithUnknownWords = await prisma.$transaction(async (tx) => { const savedStory = await this.saveStoryToDB(story, tx); - const savedUnknownWords = await this.unknownWordService.saveUnknownWords( - unknownWords, - savedStory.id, - userId, - tx - ); + const savedUnknownWords = + await this.unknownWordService.saveUnknownWords( + unknownWords, + savedStory.id, + userId, + tx, + ); const unknownWordIds = this.extractUnknownWordIds(savedUnknownWords); const storyWithUnknownWords = await this.connectUnknownWords( savedStory.id, unknownWordIds, - tx + tx, ); return storyWithUnknownWords; }); @@ -106,10 +115,16 @@ export class StoriesService { userId: number, languageCode: "DE", originalLanguageCode: "EN", - job: Job + job: Job, ) { const { story, fullTranslation, translationChunks, knownWords } = - await this.storyAssembler.assemble(subject, userId, languageCode, originalLanguageCode, job); + await this.storyAssembler.assemble( + subject, + userId, + languageCode, + originalLanguageCode, + job, + ); const unknownWords = await this.lemmaAssembler.assemble( story, @@ -117,7 +132,7 @@ export class StoriesService { userId, languageCode, originalLanguageCode, - job + job, ); job.updateProgress({ phase: GENERATION_PHASES["creatingAudio"], @@ -127,7 +142,7 @@ export class StoriesService { translationChunks, unknownWords, languageCode, - originalLanguageCode + originalLanguageCode, ); return { @@ -142,7 +157,10 @@ export class StoriesService { }; } - private async saveStoryToDB(story: CreateStoryDTO, tx: Prisma.TransactionClient): Promise { + private async saveStoryToDB( + story: CreateStoryDTO, + tx: Prisma.TransactionClient, + ): Promise { const res = await this.storyRepository.saveStoryToDB(story, tx); try { await this.redisStoryCache.invalidateStoryCache(story.userId); @@ -153,7 +171,8 @@ export class StoriesService { } async getAllStories(userId: number): Promise { - const cachedStories = await this.redisStoryCache.getAllStoriesFromCache(userId); + const cachedStories = + await this.redisStoryCache.getAllStoriesFromCache(userId); if (cachedStories.length > 0) { return cachedStories; } @@ -170,7 +189,7 @@ export class StoriesService { private async connectUnknownWords( storyId: number, wordIds: { id: number }[], - tx: Prisma.TransactionClient + tx: Prisma.TransactionClient, ): Promise { return await this.storyRepository.connectUnknownWords(storyId, wordIds, tx); } diff --git a/apps/backend/src/modules/unknownWord/composition.ts b/apps/backend/src/modules/unknownWord/composition.ts index d88d979..aee455f 100644 --- a/apps/backend/src/modules/unknownWord/composition.ts +++ b/apps/backend/src/modules/unknownWord/composition.ts @@ -19,5 +19,9 @@ export function createUnknownWordModule(deps: { const service = new UnknownWordService(repository, cache, deps.queue); const controller = new UnknownWordController(service); - return { service, controller, router: buildUnknownWordRouter(controller, deps.authMiddleware) }; + return { + service, + controller, + router: buildUnknownWordRouter(controller, deps.authMiddleware), + }; } diff --git a/apps/backend/src/modules/unknownWord/unknownWordController.ts b/apps/backend/src/modules/unknownWord/unknownWordController.ts index f8b3aef..6108083 100644 --- a/apps/backend/src/modules/unknownWord/unknownWordController.ts +++ b/apps/backend/src/modules/unknownWord/unknownWordController.ts @@ -1,9 +1,8 @@ -import { Request, Response } from "express"; +import { Response } from "express"; import { UnknownWordService } from "./unknownWordService"; import { validateData } from "@/validation/validateData"; import { wordIdRequestSchema } from "./schemas/wordIdSchema"; import { formatResponse } from "@/middlewares/responseFormatter"; -import { AuthError } from "@/errors/auth/AuthError"; import { AuthedRequest } from "@/types/types"; export class UnknownWordController { @@ -12,7 +11,10 @@ export class UnknownWordController { const { wordId } = validateData(wordIdRequestSchema, req.params); const user = req.user; - const job = await this.unknownWordService.markAsLearned(wordId, user.userId); + const job = await this.unknownWordService.markAsLearned( + wordId, + user.userId, + ); res.status(200).json(formatResponse(job)); }; @@ -20,7 +22,10 @@ export class UnknownWordController { const { wordId } = validateData(wordIdRequestSchema, req.params); const user = req.user; - const job = await this.unknownWordService.markAsLearning(wordId, user.userId); + const job = await this.unknownWordService.markAsLearning( + wordId, + user.userId, + ); res.status(200).json(formatResponse(job)); }; diff --git a/apps/backend/src/modules/unknownWord/unknownWordRepository.test.ts b/apps/backend/src/modules/unknownWord/unknownWordRepository.test.ts index 78515ab..b364621 100644 --- a/apps/backend/src/modules/unknownWord/unknownWordRepository.test.ts +++ b/apps/backend/src/modules/unknownWord/unknownWordRepository.test.ts @@ -15,7 +15,9 @@ function prismaWithUnknownWord(overrides: any = {}) { describe("UnknownWordRepository", () => { it("saveUnknownWords uses client and returns created rows", async () => { const prisma = prismaWithUnknownWord(); - prisma.unknownWord.createManyAndReturn.mockResolvedValue([{ id: 1 }] as any); + prisma.unknownWord.createManyAndReturn.mockResolvedValue([ + { id: 1 }, + ] as any); const repo = new UnknownWordRepository(prisma); const res = await repo.saveUnknownWords([{ word: "Hund" } as any]); @@ -29,7 +31,9 @@ describe("UnknownWordRepository", () => { const prisma = prismaWithUnknownWord(); prisma.unknownWord.createManyAndReturn.mockRejectedValue(new Error("db")); const repo = new UnknownWordRepository(prisma); - await expect(repo.saveUnknownWords([] as any)).rejects.toBeInstanceOf(PrismaError); + await expect(repo.saveUnknownWords([] as any)).rejects.toBeInstanceOf( + PrismaError, + ); }); it("markAsLearned updates by id+userId and returns row", async () => { @@ -72,7 +76,9 @@ describe("UnknownWordRepository", () => { const res = await repo.getUnknownWords(5); expect(res).toEqual([{ id: 1 }]); - expect(prisma.unknownWord.findMany).toHaveBeenCalledWith({ where: { userId: 5 } }); + expect(prisma.unknownWord.findMany).toHaveBeenCalledWith({ + where: { userId: 5 }, + }); }); it("getUnknownWords wraps errors", async () => { @@ -99,7 +105,9 @@ describe("UnknownWordRepository", () => { const prisma = prismaWithUnknownWord(); prisma.unknownWord.update.mockRejectedValue(new Error("db")); const repo = new UnknownWordRepository(prisma); - await expect(repo.updateTimesSeenAndConnectStory(1, 1, 1)).rejects.toBeInstanceOf(PrismaError); + await expect( + repo.updateTimesSeenAndConnectStory(1, 1, 1), + ).rejects.toBeInstanceOf(PrismaError); }); it("methods use provided transaction client when present", async () => { diff --git a/apps/backend/src/modules/unknownWord/unknownWordRepository.ts b/apps/backend/src/modules/unknownWord/unknownWordRepository.ts index 8b5ea9b..c5b7b06 100644 --- a/apps/backend/src/modules/unknownWord/unknownWordRepository.ts +++ b/apps/backend/src/modules/unknownWord/unknownWordRepository.ts @@ -5,13 +5,15 @@ import { Prisma, PrismaClient, UnknownWord } from "@prisma/client"; export class UnknownWordRepository { constructor(private prisma: PrismaClient) {} - private getClient(tx?: Prisma.TransactionClient): Prisma.TransactionClient | PrismaClient { + private getClient( + tx?: Prisma.TransactionClient, + ): Prisma.TransactionClient | PrismaClient { return tx ? tx : this.prisma; } async saveUnknownWords( unknownWords: CreateUnknownWordDTO[], - tx?: Prisma.TransactionClient + tx?: Prisma.TransactionClient, ): Promise { const client = this.getClient(tx); try { @@ -20,11 +22,17 @@ export class UnknownWordRepository { }); return response; } catch (error) { - throw new PrismaError("Unable to save unknown words", error, { unknownWords }); + throw new PrismaError("Unable to save unknown words", error, { + unknownWords, + }); } } - async markAsLearned(wordId: number, userId: number, tx?: Prisma.TransactionClient) { + async markAsLearned( + wordId: number, + userId: number, + tx?: Prisma.TransactionClient, + ) { const client = this.getClient(tx); try { const response = await client.unknownWord.update({ @@ -38,11 +46,17 @@ export class UnknownWordRepository { }); return response; } catch (error) { - throw new PrismaError("Unable to mark word as 'learned'", error, { wordId }); + throw new PrismaError("Unable to mark word as 'learned'", error, { + wordId, + }); } } - async markAsLearning(wordId: number, userId: number, tx?: Prisma.TransactionClient) { + async markAsLearning( + wordId: number, + userId: number, + tx?: Prisma.TransactionClient, + ) { const client = this.getClient(tx); try { const response = await client.unknownWord.update({ @@ -56,11 +70,16 @@ export class UnknownWordRepository { }); return response; } catch (error) { - throw new PrismaError("Unable to mark word as learning", error, { wordId }); + throw new PrismaError("Unable to mark word as learning", error, { + wordId, + }); } } - async getUnknownWords(userId: number, tx?: Prisma.TransactionClient): Promise { + async getUnknownWords( + userId: number, + tx?: Prisma.TransactionClient, + ): Promise { const client = this.getClient(tx); try { const response = await client.unknownWord.findMany({ where: { userId } }); @@ -74,7 +93,7 @@ export class UnknownWordRepository { wordId: number, timesSeen: number, storyId: number, - tx?: Prisma.TransactionClient + tx?: Prisma.TransactionClient, ): Promise { const client = this.getClient(tx); try { @@ -92,11 +111,15 @@ export class UnknownWordRepository { return response; } catch (error) { - throw new PrismaError("Unable to update times seen and connect story", error, { - wordId, - timesSeen, - storyId, - }); + throw new PrismaError( + "Unable to update times seen and connect story", + error, + { + wordId, + timesSeen, + storyId, + }, + ); } } } diff --git a/apps/backend/src/modules/unknownWord/unknownWordRoutes.ts b/apps/backend/src/modules/unknownWord/unknownWordRoutes.ts index 4ba57ed..f8daa07 100644 --- a/apps/backend/src/modules/unknownWord/unknownWordRoutes.ts +++ b/apps/backend/src/modules/unknownWord/unknownWordRoutes.ts @@ -5,21 +5,25 @@ import { AuthedRequest } from "@/types/types"; export function buildUnknownWordRouter( controller: UnknownWordController, - authMiddleware: (req: Request, res: Response, next: NextFunction) => void + authMiddleware: (req: Request, res: Response, next: NextFunction) => void, ) { const router = Router(); router.post( "/mark-as-learned/:wordId", authMiddleware, - asyncHandler(controller.markAsLearned) + asyncHandler(controller.markAsLearned), ); router.post( "/mark-as-learning/:wordId", authMiddleware, - asyncHandler(controller.markAsLearning) + asyncHandler(controller.markAsLearning), + ); + router.get( + "/words", + authMiddleware, + asyncHandler(controller.getAllWords), ); - router.get("/words", authMiddleware, asyncHandler(controller.getAllWords)); return router; } diff --git a/apps/backend/src/modules/unknownWord/unknownWordService.test.ts b/apps/backend/src/modules/unknownWord/unknownWordService.test.ts index 9af1716..e56e1ca 100644 --- a/apps/backend/src/modules/unknownWord/unknownWordService.test.ts +++ b/apps/backend/src/modules/unknownWord/unknownWordService.test.ts @@ -60,7 +60,12 @@ describe("UnknownWordService", () => { const result = await svc.saveUnknownWords(input as any, 999, 1, tx); // Updated first, then saved - expect(repo.updateTimesSeenAndConnectStory).toHaveBeenCalledWith(10, 3, 999, tx); + expect(repo.updateTimesSeenAndConnectStory).toHaveBeenCalledWith( + 10, + 3, + 999, + tx, + ); expect(repo.saveUnknownWords).toHaveBeenCalledWith([input[1]], tx); expect(result).toEqual([{ id: 10 }, { id: 11 }]); }); @@ -95,7 +100,9 @@ describe("UnknownWordService", () => { it("processUpdateWordStatus throws CustomError when fields missing", async () => { const svc = new UnknownWordService({} as any, {} as any, {} as any); const badJob = { data: {} } as unknown as Job; - await expect(svc.processUpdateWordStatus(badJob)).rejects.toBeInstanceOf(CustomError); + await expect(svc.processUpdateWordStatus(badJob)).rejects.toBeInstanceOf( + CustomError, + ); }); it("processUpdateWordStatus updates learned and invalidates cache", async () => { @@ -108,7 +115,9 @@ describe("UnknownWordService", () => { } as unknown as RedisStoryCache; const svc = new UnknownWordService(repo, cache, {} as any); - const job = { data: { wordId: 1, userId: 2, wordStatus: "learned" } } as unknown as Job; + const job = { + data: { wordId: 1, userId: 2, wordStatus: "learned" }, + } as unknown as Job; const res = await svc.processUpdateWordStatus(job); expect(repo.markAsLearned).toHaveBeenCalledWith(1, 2); @@ -126,7 +135,9 @@ describe("UnknownWordService", () => { } as unknown as RedisStoryCache; const svc = new UnknownWordService(repo, cache, {} as any); - const job = { data: { wordId: 3, userId: 4, wordStatus: "learning" } } as unknown as Job; + const job = { + data: { wordId: 3, userId: 4, wordStatus: "learning" }, + } as unknown as Job; const res = await svc.processUpdateWordStatus(job); expect(repo.markAsLearning).toHaveBeenCalledWith(3, 4); diff --git a/apps/backend/src/modules/unknownWord/unknownWordService.ts b/apps/backend/src/modules/unknownWord/unknownWordService.ts index 1009c90..17f5d07 100644 --- a/apps/backend/src/modules/unknownWord/unknownWordService.ts +++ b/apps/backend/src/modules/unknownWord/unknownWordService.ts @@ -9,21 +9,35 @@ export class UnknownWordService { constructor( private unknownWordRepository: UnknownWordRepository, private redisStoryCache: RedisStoryCache, - private jobQueue: Queue + private jobQueue: Queue, ) {} async saveUnknownWords( unknownWords: CreateUnknownWordDTO[], storyId: number, userId: number, - tx: Prisma.TransactionClient + tx: Prisma.TransactionClient, ): Promise { - const existingWords = await this.unknownWordRepository.getUnknownWords(userId, tx); + const existingWords = await this.unknownWordRepository.getUnknownWords( + userId, + tx, + ); const existingWordsMap = this.createWordsMap(existingWords); - const { wordsToSave, wordsToUpdate } = this.partitionWords(unknownWords, existingWordsMap); + const { wordsToSave, wordsToUpdate } = this.partitionWords( + unknownWords, + existingWordsMap, + ); - const updatedWords = await this.updateExistingWords(wordsToUpdate, storyId, userId, tx); - const savedWords = await this.unknownWordRepository.saveUnknownWords(wordsToSave, tx); + const updatedWords = await this.updateExistingWords( + wordsToUpdate, + storyId, + userId, + tx, + ); + const savedWords = await this.unknownWordRepository.saveUnknownWords( + wordsToSave, + tx, + ); return [...updatedWords, ...savedWords]; } @@ -34,7 +48,7 @@ export class UnknownWordService { private partitionWords( unknownWords: CreateUnknownWordDTO[], - existingWordsMap: Map + existingWordsMap: Map, ): { wordsToSave: CreateUnknownWordDTO[]; wordsToUpdate: UnknownWord[]; @@ -63,15 +77,15 @@ export class UnknownWordService { wordsToUpdate: UnknownWord[], storyId: number, userId: number, - tx: Prisma.TransactionClient + tx: Prisma.TransactionClient, ): Promise { const tasks = wordsToUpdate.map((word) => this.unknownWordRepository.updateTimesSeenAndConnectStory( word.id, word.timesSeen, storyId, - tx - ) + tx, + ), ); return await Promise.all(tasks); } @@ -100,7 +114,9 @@ export class UnknownWordService { const userId = jobData.userId; const wordStatus = jobData.wordStatus; if (!wordId || !userId || !wordStatus) { - throw new CustomError("Unable to update word status", 500, null, { jobData }); + throw new CustomError("Unable to update word status", 500, null, { + jobData, + }); } if (wordStatus === "learned") { diff --git a/apps/backend/src/modules/user/userRepository.ts b/apps/backend/src/modules/user/userRepository.ts index 04d3a3c..ab9c1f4 100644 --- a/apps/backend/src/modules/user/userRepository.ts +++ b/apps/backend/src/modules/user/userRepository.ts @@ -7,6 +7,8 @@ export class UserRepository { } async createUser(email: string, hashedPassword: string) { - return this.prisma.user.create({ data: { email, password: hashedPassword } }); + return this.prisma.user.create({ + data: { email, password: hashedPassword }, + }); } } diff --git a/apps/backend/src/modules/vocabAssessment/composition.ts b/apps/backend/src/modules/vocabAssessment/composition.ts index 8839a61..b4a7d37 100644 --- a/apps/backend/src/modules/vocabAssessment/composition.ts +++ b/apps/backend/src/modules/vocabAssessment/composition.ts @@ -22,9 +22,12 @@ export function createVocabAssessmentModule(deps: { repository, deps.sessionService, deps.vocabularyService, - cache + cache, ); const controller = new VocabAssessmentController(service); - return { controller, router: buildVocabAssessmentRouter(controller, deps.authMiddleware) }; + return { + controller, + router: buildVocabAssessmentRouter(controller, deps.authMiddleware), + }; } diff --git a/apps/backend/src/modules/vocabAssessment/vocabAssessmentController.ts b/apps/backend/src/modules/vocabAssessment/vocabAssessmentController.ts index b939f7f..8fe4e51 100644 --- a/apps/backend/src/modules/vocabAssessment/vocabAssessmentController.ts +++ b/apps/backend/src/modules/vocabAssessment/vocabAssessmentController.ts @@ -1,9 +1,8 @@ -import { Request, Response } from "express"; +import { Response } from "express"; import { formatResponse } from "@/middlewares/responseFormatter"; import { VocabAssessmentService } from "./vocabAssessmentService"; import { z } from "zod"; import { validateData } from "@/validation/validateData"; -import { AuthError } from "@/errors/auth/AuthError"; import { AuthedRequest } from "@/types/types"; const answerSchema = z.object({ @@ -17,7 +16,11 @@ export class VocabAssessmentController { start = async (req: AuthedRequest, res: Response) => { const user = req.user; - const result = await this.vocabAssessmentService.startAssessment(user.userId, "en", "de"); + const result = await this.vocabAssessmentService.startAssessment( + user.userId, + "en", + "de", + ); res.status(200).json(formatResponse(result)); }; @@ -28,7 +31,7 @@ export class VocabAssessmentController { const result = await this.vocabAssessmentService.continueAssessment( user.userId, sessionUUID, - wordsData + wordsData, ); res.status(200).json(formatResponse(result)); }; diff --git a/apps/backend/src/modules/vocabAssessment/vocabAssessmentRepository.ts b/apps/backend/src/modules/vocabAssessment/vocabAssessmentRepository.ts index a81b9f0..12be351 100644 --- a/apps/backend/src/modules/vocabAssessment/vocabAssessmentRepository.ts +++ b/apps/backend/src/modules/vocabAssessment/vocabAssessmentRepository.ts @@ -4,7 +4,10 @@ import { PrismaClient, WordRanking } from "@prisma/client"; export class VocabAssessmentRepository { constructor(private prisma: PrismaClient) {} - async getWords(sourceLanguage: string, targetLanguage: string): Promise { + async getWords( + sourceLanguage: string, + targetLanguage: string, + ): Promise { try { const response = await this.prisma.wordRanking.findMany({ where: { diff --git a/apps/backend/src/modules/vocabAssessment/vocabAssessmentRoutes.ts b/apps/backend/src/modules/vocabAssessment/vocabAssessmentRoutes.ts index 155e3b1..35ee5db 100644 --- a/apps/backend/src/modules/vocabAssessment/vocabAssessmentRoutes.ts +++ b/apps/backend/src/modules/vocabAssessment/vocabAssessmentRoutes.ts @@ -4,7 +4,7 @@ import { VocabAssessmentController } from "./vocabAssessmentController"; export function buildVocabAssessmentRouter( controller: VocabAssessmentController, - authMiddleware: (req: Request, res: Response, next: NextFunction) => void + authMiddleware: (req: Request, res: Response, next: NextFunction) => void, ) { const router = Router(); diff --git a/apps/backend/src/modules/vocabAssessment/vocabAssessmentService.test.ts b/apps/backend/src/modules/vocabAssessment/vocabAssessmentService.test.ts index 701288b..543f121 100644 --- a/apps/backend/src/modules/vocabAssessment/vocabAssessmentService.test.ts +++ b/apps/backend/src/modules/vocabAssessment/vocabAssessmentService.test.ts @@ -23,9 +23,13 @@ describe("VocabAssessmentService", () => { it("startAssessment uses cache hit and returns words chunk", async () => { const words = generateWords(300); - const repository = { getWords: jest.fn() } as unknown as VocabAssessmentRepository; + const repository = { + getWords: jest.fn(), + } as unknown as VocabAssessmentRepository; const sessionService = { - createSession: jest.fn().mockResolvedValue({ sessionUUID: "abc", status: "active" }), + createSession: jest + .fn() + .mockResolvedValue({ sessionUUID: "abc", status: "active" }), } as unknown as SessionService; const vocabularyService = {} as unknown as VocabularyService; const cache = { @@ -37,9 +41,13 @@ describe("VocabAssessmentService", () => { repository, sessionService, vocabularyService, - cache + cache, + ); + const res = await service.startAssessment( + 1, + sourceLanguage, + targetLanguage, ); - const res = await service.startAssessment(1, sourceLanguage, targetLanguage); expect(cache.getWords).toHaveBeenCalledWith(sourceLanguage, targetLanguage); expect(repository.getWords).not.toHaveBeenCalled(); @@ -57,7 +65,9 @@ describe("VocabAssessmentService", () => { getWords: jest.fn().mockResolvedValue(words), } as unknown as VocabAssessmentRepository; const sessionService = { - createSession: jest.fn().mockResolvedValue({ sessionUUID: "abc", status: "active" }), + createSession: jest + .fn() + .mockResolvedValue({ sessionUUID: "abc", status: "active" }), } as unknown as SessionService; const vocabularyService = {} as unknown as VocabularyService; const cache = { @@ -69,11 +79,18 @@ describe("VocabAssessmentService", () => { repository, sessionService, vocabularyService, - cache + cache, + ); + const res = await service.startAssessment( + 1, + sourceLanguage, + targetLanguage, ); - const res = await service.startAssessment(1, sourceLanguage, targetLanguage); - expect(repository.getWords).toHaveBeenCalledWith(sourceLanguage, targetLanguage); + expect(repository.getWords).toHaveBeenCalledWith( + sourceLanguage, + targetLanguage, + ); expect(cache.saveWords).toHaveBeenCalled(); expect(res.wordsToReview.length).toBe(15); }); @@ -90,11 +107,17 @@ describe("VocabAssessmentService", () => { repository, sessionService, vocabularyService, - cache + cache, ); await expect( - service.continueAssessment(1, "sess", undefined, sourceLanguage, targetLanguage) + service.continueAssessment( + 1, + "sess", + undefined, + sourceLanguage, + targetLanguage, + ), ).rejects.toBeInstanceOf(VocabAssessmentError); }); @@ -110,7 +133,12 @@ describe("VocabAssessmentService", () => { isLastStep: false, step: 3, }; - const session: Session = { userId: 1, state, sessionUUID: "s", status: "active" }; + const session: Session = { + userId: 1, + state, + sessionUUID: "s", + status: "active", + }; const sessionService = { getSession: jest.fn().mockResolvedValue(session), } as unknown as SessionService; @@ -118,9 +146,20 @@ describe("VocabAssessmentService", () => { const cache = { getWords: jest.fn().mockResolvedValue(words), } as unknown as RedisWordsCache; - const service = new VocabAssessmentService({} as any, sessionService, vocabularyService, cache); + const service = new VocabAssessmentService( + {} as any, + sessionService, + vocabularyService, + cache, + ); - const res = await service.continueAssessment(1, "s", undefined, sourceLanguage, targetLanguage); + const res = await service.continueAssessment( + 1, + "s", + undefined, + sourceLanguage, + targetLanguage, + ); expect(res).toEqual({ sessionId: "s", status: "active", @@ -142,24 +181,31 @@ describe("VocabAssessmentService", () => { isLastStep: false, step: 1, }; - const session: Session = { userId: 1, state, sessionUUID: "s1", status: "active" }; + const session: Session = { + userId: 1, + state, + sessionUUID: "s1", + status: "active", + }; const sessionService = { getSession: jest.fn().mockResolvedValue(session), updateSessionState: jest.fn().mockResolvedValue({ ...session, state }), } as unknown as SessionService; const repository = {} as unknown as VocabAssessmentRepository; - const cache = { getWords: jest.fn().mockResolvedValue(words) } as unknown as RedisWordsCache; + const cache = { + getWords: jest.fn().mockResolvedValue(words), + } as unknown as RedisWordsCache; const vocabularyService = {} as unknown as VocabularyService; const service = new VocabAssessmentService( repository, sessionService, vocabularyService, - cache + cache, ); const answer: Record = Object.fromEntries( - state.wordsToReview.map((w: any) => [String(w.id), true]) + state.wordsToReview.map((w: any) => [String(w.id), true]), ); const res: any = await service.continueAssessment( @@ -167,7 +213,7 @@ describe("VocabAssessmentService", () => { "s1", answer, sourceLanguage, - targetLanguage + targetLanguage, ); expect(sessionService.updateSessionState).toHaveBeenCalled(); @@ -197,11 +243,17 @@ describe("VocabAssessmentService", () => { }; const sessionService = { getSession: jest.fn().mockResolvedValue(session), - completeSession: jest.fn().mockResolvedValue({ ...session, status: "completed" }), - updateSessionState: jest.fn().mockResolvedValue({ ...session, status: "completed" }), + completeSession: jest + .fn() + .mockResolvedValue({ ...session, status: "completed" }), + updateSessionState: jest + .fn() + .mockResolvedValue({ ...session, status: "completed" }), } as unknown as SessionService; const repository = {} as unknown as VocabAssessmentRepository; - const cache = { getWords: jest.fn().mockResolvedValue(words) } as unknown as RedisWordsCache; + const cache = { + getWords: jest.fn().mockResolvedValue(words), + } as unknown as RedisWordsCache; const vocabularyService = { saveManyWords: jest.fn().mockResolvedValue([]), } as unknown as VocabularyService; @@ -210,15 +262,24 @@ describe("VocabAssessmentService", () => { repository, sessionService, vocabularyService, - cache + cache, ); // Provide an answer that results in knowledge ratio below threshold so max = mid const answer: Record = Object.fromEntries( - initialState.wordsToReview.map((w: any, idx: number) => [String(w.id), idx % 2 === 0]) + initialState.wordsToReview.map((w: any, idx: number) => [ + String(w.id), + idx % 2 === 0, + ]), ); - const res = await service.continueAssessment(2, "sx", answer, sourceLanguage, targetLanguage); + const res = await service.continueAssessment( + 2, + "sx", + answer, + sourceLanguage, + targetLanguage, + ); expect(sessionService.completeSession).toHaveBeenCalledWith(2, "sx"); expect(vocabularyService.saveManyWords).toHaveBeenCalled(); @@ -239,24 +300,37 @@ describe("VocabAssessmentService", () => { isLastStep: false, step: 1, }; - const session: Session = { userId: 1, state, sessionUUID: "s2", status: "active" }; + const session: Session = { + userId: 1, + state, + sessionUUID: "s2", + status: "active", + }; const sessionService = { getSession: jest.fn().mockResolvedValue(session), } as unknown as SessionService; const repository = {} as unknown as VocabAssessmentRepository; - const cache = { getWords: jest.fn().mockResolvedValue(words) } as unknown as RedisWordsCache; + const cache = { + getWords: jest.fn().mockResolvedValue(words), + } as unknown as RedisWordsCache; const vocabularyService = {} as unknown as VocabularyService; const service = new VocabAssessmentService( repository, sessionService, vocabularyService, - cache + cache, ); const badAnswer: Record = { "999": true }; await expect( - service.continueAssessment(1, "s2", badAnswer, sourceLanguage, targetLanguage) + service.continueAssessment( + 1, + "s2", + badAnswer, + sourceLanguage, + targetLanguage, + ), ).rejects.toBeInstanceOf(VocabAssessmentError); }); }); diff --git a/apps/backend/src/modules/vocabAssessment/vocabAssessmentService.ts b/apps/backend/src/modules/vocabAssessment/vocabAssessmentService.ts index 56e5818..33f9b2a 100644 --- a/apps/backend/src/modules/vocabAssessment/vocabAssessmentService.ts +++ b/apps/backend/src/modules/vocabAssessment/vocabAssessmentService.ts @@ -28,16 +28,27 @@ export class VocabAssessmentService { private vocabAssessmentRepository: VocabAssessmentRepository, private sessionService: SessionService, private vocabularyService: VocabularyService, - private redisWordsCache: RedisWordsCache + private redisWordsCache: RedisWordsCache, ) {} - async startAssessment(userId: number, sourceLanguage: string, targetLanguage: string) { + async startAssessment( + userId: number, + sourceLanguage: string, + targetLanguage: string, + ) { let words: WordRanking[] | null; words = await this.redisWordsCache.getWords(sourceLanguage, targetLanguage); if (!words) { - words = await this.vocabAssessmentRepository.getWords(sourceLanguage, targetLanguage); + words = await this.vocabAssessmentRepository.getWords( + sourceLanguage, + targetLanguage, + ); try { - await this.redisWordsCache.saveWords(sourceLanguage, targetLanguage, words); + await this.redisWordsCache.saveWords( + sourceLanguage, + targetLanguage, + words, + ); } catch (error) { logger.error("[cache] Failed to save words in Redis", error); } @@ -46,7 +57,10 @@ export class VocabAssessmentService { const max = words.length - 1; const min = 0; const mid = (max + min) / 2; - const wordsToReview = words.slice(mid - WORDS_PER_BATCH / 2, mid + WORDS_PER_BATCH / 2); + const wordsToReview = words.slice( + mid - WORDS_PER_BATCH / 2, + mid + WORDS_PER_BATCH / 2, + ); const state: SessionState = { min: 1, max: words.length, @@ -71,15 +85,21 @@ export class VocabAssessmentService { sessionUUID: string, answer: Record | undefined, sourceLanguage = "en", - targetLanguage = "de" + targetLanguage = "de", ) { const session = await this.sessionService.getSession(userId, sessionUUID); if (!session?.state) - throw new VocabAssessmentError("Session not found or invalid state", null, { session }); + throw new VocabAssessmentError( + "Session not found or invalid state", + null, + { session }, + ); const state = session.state as unknown as SessionState; if (!this.isValidSessionState(state)) { - throw new VocabAssessmentError("Invalid session state format", null, { state }); + throw new VocabAssessmentError("Invalid session state format", null, { + state, + }); } if (!answer) { @@ -96,9 +116,16 @@ export class VocabAssessmentService { let words: WordRanking[] | null; words = await this.redisWordsCache.getWords(sourceLanguage, targetLanguage); if (!words) { - words = await this.vocabAssessmentRepository.getWords(sourceLanguage, targetLanguage); + words = await this.vocabAssessmentRepository.getWords( + sourceLanguage, + targetLanguage, + ); try { - await this.redisWordsCache.saveWords(sourceLanguage, targetLanguage, words); + await this.redisWordsCache.saveWords( + sourceLanguage, + targetLanguage, + words, + ); } catch (error) { logger.error("[cache] Failed to save words in Redis", error); } @@ -124,13 +151,9 @@ export class VocabAssessmentService { state.wordsToReview = words.slice( state.mid - WORDS_PER_BATCH / 2, - state.mid + WORDS_PER_BATCH / 2 - ); - const updatedSession = await this.sessionService.updateSessionState( - userId, - sessionUUID, - state + state.mid + WORDS_PER_BATCH / 2, ); + await this.sessionService.updateSessionState(userId, sessionUUID, state); return { sessionId: sessionUUID, status: "active", @@ -146,7 +169,7 @@ export class VocabAssessmentService { state: SessionState, words: WordRanking[], sessionUUID: string, - session: Session + session: Session, ) { const knownVocabularyCount = state.mid; const knownVocabulary = words.slice(0, knownVocabularyCount); @@ -161,37 +184,53 @@ export class VocabAssessmentService { state.range = 0; state.vocabularySize = vocabularyDTO.length; await this.sessionService.updateSessionState(userId, sessionUUID, state); - return { sessionId: sessionUUID, status: "completed", vocabularySize: vocabularyDTO.length }; + return { + sessionId: sessionUUID, + status: "completed", + vocabularySize: vocabularyDTO.length, + }; } - private checkAnswer(answer: Record, wordsToReview: WordRanking[]) { + private checkAnswer( + answer: Record, + wordsToReview: WordRanking[], + ) { if ( !this.arraysEqual( Object.keys(answer), - wordsToReview.map((item) => String(item.id)) + wordsToReview.map((item) => String(item.id)), ) ) { - throw new VocabAssessmentError("Answer doesn't contain all required words", null, { - answer, - wordsToReview, - }); + throw new VocabAssessmentError( + "Answer doesn't contain all required words", + null, + { + answer, + wordsToReview, + }, + ); } let identifiedWordsCount = 0; - for (const [word, isKnown] of Object.entries(answer)) { + for (const [, isKnown] of Object.entries(answer)) { if (isKnown) identifiedWordsCount++; } return identifiedWordsCount / Object.keys(answer).length; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any private arraysEqual(arr1: any[], arr2: any[]) { if (arr1.length !== arr2.length) return false; - return arr1.every((item) => arr2.includes(item)) && arr2.every((item) => arr1.includes(item)); + return ( + arr1.every((item) => arr2.includes(item)) && + arr2.every((item) => arr1.includes(item)) + ); } private isValidSessionState(state: unknown): state is SessionState { if (!state || typeof state !== "object") return false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const s = state as any; return ( typeof s.min === "number" && diff --git a/apps/backend/src/modules/vocabulary/composition.ts b/apps/backend/src/modules/vocabulary/composition.ts index 7543a84..85453e8 100644 --- a/apps/backend/src/modules/vocabulary/composition.ts +++ b/apps/backend/src/modules/vocabulary/composition.ts @@ -12,5 +12,9 @@ export function createVocabularyModule(deps: { const repository = new VocabularyRepository(deps.prisma); const service = new VocabularyService(repository); const controller = new VocabularyController(service); - return { service, controller, router: buildVocabularyRouter(controller, deps.authMiddleware) }; + return { + service, + controller, + router: buildVocabularyRouter(controller, deps.authMiddleware), + }; } diff --git a/apps/backend/src/modules/vocabulary/vocabularyController.ts b/apps/backend/src/modules/vocabulary/vocabularyController.ts index 0e00286..8567335 100644 --- a/apps/backend/src/modules/vocabulary/vocabularyController.ts +++ b/apps/backend/src/modules/vocabulary/vocabularyController.ts @@ -1,9 +1,8 @@ -import { Request, Response } from "express"; +import { Response } from "express"; import { VocabularyService } from "./vocabularyService"; import { formatResponse } from "@/middlewares/responseFormatter"; import { validateData } from "@/validation/validateData"; import { z } from "zod"; -import { AuthError } from "@/errors/auth/AuthError"; import { AuthedRequest } from "@/types/types"; export class VocabularyController { @@ -30,9 +29,13 @@ export class VocabularyController { .lt(200, { message: "Maximum pageSize is 200" }) .default(20), }), - req.query + req.query, + ); + const result = await this.vocabularyService.getWords( + userId, + page, + pageSize, ); - const result = await this.vocabularyService.getWords(userId, page, pageSize); res.status(200).json(formatResponse(result.data, result.pagination)); }; @@ -41,7 +44,8 @@ export class VocabularyController { const { userId } = user; - const result = await this.vocabularyService.getWordsWithoutPagination(userId); + const result = + await this.vocabularyService.getWordsWithoutPagination(userId); res.status(200).json(formatResponse(result)); }; @@ -55,7 +59,7 @@ export class VocabularyController { translation: z.string().min(1).max(50), article: z.string().min(1).max(15).nullable(), }), - req.body + req.body, ); const newWord = await this.vocabularyService.saveNewWord({ word, @@ -77,12 +81,15 @@ export class VocabularyController { word: z.string().min(1).max(50), translation: z.string().min(1).max(50), article: z.string().min(1).max(15).nullable(), - }) + }), ), }), - req.body + req.body, + ); + const savedWords = await this.vocabularyService.saveManyWords( + words, + userId, ); - const savedWords = await this.vocabularyService.saveManyWords(words, userId); res.status(201).json(formatResponse(savedWords)); }; @@ -115,9 +122,13 @@ export class VocabularyController { translation: z.string().min(1).max(50).optional(), article: z.string().min(1).max(15).nullable().optional(), }), - req.body + req.body, + ); + const updatedWord = await this.vocabularyService.updateWord( + wordId, + userId, + wordData, ); - const updatedWord = await this.vocabularyService.updateWord(wordId, userId, wordData); res.status(200).json(formatResponse(updatedWord)); }; } diff --git a/apps/backend/src/modules/vocabulary/vocabularyRepository.test.ts b/apps/backend/src/modules/vocabulary/vocabularyRepository.test.ts index ad50537..c7aad2e 100644 --- a/apps/backend/src/modules/vocabulary/vocabularyRepository.test.ts +++ b/apps/backend/src/modules/vocabulary/vocabularyRepository.test.ts @@ -17,7 +17,9 @@ describe("VocabularyRepository.getWordsCount", () => { const result = await repo.getWordsCount(123); expect(result).toBe(5); - expect(prisma.userVocabulary.count).toHaveBeenCalledWith({ where: { userId: 123 } }); + expect(prisma.userVocabulary.count).toHaveBeenCalledWith({ + where: { userId: 123 }, + }); }); }); @@ -30,7 +32,12 @@ describe("VocabularyRepository other methods", () => { }; const repo = new VocabularyRepository(prisma); - const input = { word: "Hund", translation: "Dog", article: "der", userId: 1 }; + const input = { + word: "Hund", + translation: "Dog", + article: "der", + userId: 1, + }; const res = await repo.saveWord(input as any); expect(res).toEqual({ id: 1, word: "Hund" }); expect(prisma.userVocabulary.create).toHaveBeenCalledWith({ data: input }); @@ -43,7 +50,12 @@ describe("VocabularyRepository other methods", () => { }, }; const repo = new VocabularyRepository(prisma); - const input = { word: "Hund", translation: "Dog", article: "der", userId: 1 } as any; + const input = { + word: "Hund", + translation: "Dog", + article: "der", + userId: 1, + } as any; await expect(repo.saveWord(input)).rejects.toBeInstanceOf(PrismaError); }); @@ -66,7 +78,9 @@ describe("VocabularyRepository other methods", () => { ] as any; const res = await repo.saveManyWords(input); expect(res).toBe(created); - expect(prisma.userVocabulary.createManyAndReturn).toHaveBeenCalledWith({ data: input }); + expect(prisma.userVocabulary.createManyAndReturn).toHaveBeenCalledWith({ + data: input, + }); }); it("saveManyWords throws PrismaError on failure", async () => { @@ -76,7 +90,9 @@ describe("VocabularyRepository other methods", () => { }, }; const repo = new VocabularyRepository(prisma); - await expect(repo.saveManyWords([] as any)).rejects.toBeInstanceOf(PrismaError); + await expect(repo.saveManyWords([] as any)).rejects.toBeInstanceOf( + PrismaError, + ); }); it("getWordByID calls findUnique with id and returns result", async () => { @@ -89,7 +105,9 @@ describe("VocabularyRepository other methods", () => { const res = await repo.getWordByID(7); expect(res).toEqual({ id: 7, word: "Hund" }); - expect(prisma.userVocabulary.findUnique).toHaveBeenCalledWith({ where: { id: 7 } }); + expect(prisma.userVocabulary.findUnique).toHaveBeenCalledWith({ + where: { id: 7 }, + }); }); it("getWordByID throws PrismaError on failure", async () => { @@ -119,7 +137,9 @@ describe("VocabularyRepository other methods", () => { skip: 10, take: 20, }); - expect(prisma.userVocabulary.count).toHaveBeenCalledWith({ where: { userId: 5 } }); + expect(prisma.userVocabulary.count).toHaveBeenCalledWith({ + where: { userId: 5 }, + }); }); it("getAllWords throws PrismaError on failure", async () => { @@ -130,7 +150,9 @@ describe("VocabularyRepository other methods", () => { }, }; const repo = new VocabularyRepository(prisma); - await expect(repo.getAllWords(1, 0, 10)).rejects.toBeInstanceOf(PrismaError); + await expect(repo.getAllWords(1, 0, 10)).rejects.toBeInstanceOf( + PrismaError, + ); }); it("getAllWordsWithoutPagination filters by userId", async () => { @@ -143,7 +165,9 @@ describe("VocabularyRepository other methods", () => { const res = await repo.getAllWordsWithoutPagination(77); expect(res).toEqual([{ id: 1 }]); - expect(prisma.userVocabulary.findMany).toHaveBeenCalledWith({ where: { userId: 77 } }); + expect(prisma.userVocabulary.findMany).toHaveBeenCalledWith({ + where: { userId: 77 }, + }); }); it("deleteWord deletes by composite where id+userId", async () => { @@ -156,7 +180,9 @@ describe("VocabularyRepository other methods", () => { const res = await repo.deleteWord(9, 3); expect(res).toEqual({ id: 9 }); - expect(prisma.userVocabulary.delete).toHaveBeenCalledWith({ where: { id: 9, userId: 3 } }); + expect(prisma.userVocabulary.delete).toHaveBeenCalledWith({ + where: { id: 9, userId: 3 }, + }); }); it("deleteWord throws PrismaError on failure", async () => { diff --git a/apps/backend/src/modules/vocabulary/vocabularyRepository.ts b/apps/backend/src/modules/vocabulary/vocabularyRepository.ts index d29aaea..825068f 100644 --- a/apps/backend/src/modules/vocabulary/vocabularyRepository.ts +++ b/apps/backend/src/modules/vocabulary/vocabularyRepository.ts @@ -7,7 +7,9 @@ export class VocabularyRepository { async getWordsCount(userId: number): Promise { try { - const count = await this.prisma.userVocabulary.count({ where: { userId } }); + const count = await this.prisma.userVocabulary.count({ + where: { userId }, + }); return count; } catch (error) { throw new PrismaError("Unable to get words count", error, { userId }); @@ -26,7 +28,9 @@ export class VocabularyRepository { } } - async saveManyWords(words: UserVocabularyWithUserIdDTO[]): Promise { + async saveManyWords( + words: UserVocabularyWithUserIdDTO[], + ): Promise { try { const newWords = await this.prisma.userVocabulary.createManyAndReturn({ data: words, @@ -34,7 +38,9 @@ export class VocabularyRepository { return newWords; } catch (error) { - throw new PrismaError("Unable to save many words to DB", error, { words }); + throw new PrismaError("Unable to save many words to DB", error, { + words, + }); } } @@ -47,14 +53,16 @@ export class VocabularyRepository { }); return word; } catch (error) { - throw new PrismaError("Unable to retrieve word by ID from DB", error, { wordId }); + throw new PrismaError("Unable to retrieve word by ID from DB", error, { + wordId, + }); } } async getAllWords( userId: number, skip: number, - take: number + take: number, ): Promise<[UserVocabulary[], number]> { try { const whereClause = { userId }; @@ -68,17 +76,25 @@ export class VocabularyRepository { ]); return [words, totalItems]; } catch (error) { - throw new PrismaError("Unable to get all words from DB", error, { userId, skip, take }); + throw new PrismaError("Unable to get all words from DB", error, { + userId, + skip, + take, + }); } } - async getAllWordsWithoutPagination(userId: number): Promise { + async getAllWordsWithoutPagination( + userId: number, + ): Promise { try { return this.prisma.userVocabulary.findMany({ where: { userId }, }); } catch (error) { - throw new PrismaError("Unable to get all words from DB", error, { userId }); + throw new PrismaError("Unable to get all words from DB", error, { + userId, + }); } } @@ -92,11 +108,17 @@ export class VocabularyRepository { }); return deleted; } catch (error) { - throw new PrismaError("Unable to delete word from DB", error, { wordId, userId }); + throw new PrismaError("Unable to delete word from DB", error, { + wordId, + userId, + }); } } - async updateWord(wordId: number, wordData: Partial): Promise { + async updateWord( + wordId: number, + wordData: Partial, + ): Promise { try { const updated = await this.prisma.userVocabulary.update({ where: { @@ -106,7 +128,10 @@ export class VocabularyRepository { }); return updated; } catch (error) { - throw new PrismaError("Unable to update the word from DB", error, { wordId, wordData }); + throw new PrismaError("Unable to update the word from DB", error, { + wordId, + wordData, + }); } } } diff --git a/apps/backend/src/modules/vocabulary/vocabularyRoutes.ts b/apps/backend/src/modules/vocabulary/vocabularyRoutes.ts index 4d94123..a15a086 100644 --- a/apps/backend/src/modules/vocabulary/vocabularyRoutes.ts +++ b/apps/backend/src/modules/vocabulary/vocabularyRoutes.ts @@ -5,21 +5,45 @@ import { AuthedRequest } from "@/types/types"; export function buildVocabularyRouter( controller: VocabularyController, - authMiddleware: (req: Request, res: Response, next: NextFunction) => void + authMiddleware: (req: Request, res: Response, next: NextFunction) => void, ) { const router = express.Router(); - router.get("/words-count", authMiddleware, asyncHandler(controller.getWordsCount)); - router.get("/words", authMiddleware, asyncHandler(controller.getAllWords)); + router.get( + "/words-count", + authMiddleware, + asyncHandler(controller.getWordsCount), + ); + router.get( + "/words", + authMiddleware, + asyncHandler(controller.getAllWords), + ); router.get( "/allwords", authMiddleware, - asyncHandler(controller.getWordsWithoutPagination) + asyncHandler(controller.getWordsWithoutPagination), + ); + router.post( + "/words", + authMiddleware, + asyncHandler(controller.saveNewWord), + ); + router.post( + "/words/list", + authMiddleware, + asyncHandler(controller.saveManyWords), + ); + router.delete( + "/words/:id", + authMiddleware, + asyncHandler(controller.deleteWord), + ); + router.patch( + "/words/:id", + authMiddleware, + asyncHandler(controller.updateWord), ); - router.post("/words", authMiddleware, asyncHandler(controller.saveNewWord)); - router.post("/words/list", authMiddleware, asyncHandler(controller.saveManyWords)); - router.delete("/words/:id", authMiddleware, asyncHandler(controller.deleteWord)); - router.patch("/words/:id", authMiddleware, asyncHandler(controller.updateWord)); return router; } diff --git a/apps/backend/src/modules/vocabulary/vocabularyService.test.ts b/apps/backend/src/modules/vocabulary/vocabularyService.test.ts index c083f84..efd653a 100644 --- a/apps/backend/src/modules/vocabulary/vocabularyService.test.ts +++ b/apps/backend/src/modules/vocabulary/vocabularyService.test.ts @@ -45,10 +45,12 @@ const wordsMock: UserVocabulary[] = [ describe("VocabularyService", () => { const vocabularyRepositoryMock = { getAllWordsWithoutPagination: jest.fn().mockResolvedValue(wordsMock), - getAllWords: jest.fn().mockImplementation((userId: number, skip: number, take: number) => { - if (skip > wordsMock.length) return Promise.resolve([[], 5]); - return Promise.resolve([wordsMock.slice(skip, skip + take), 5]); - }), + getAllWords: jest + .fn() + .mockImplementation((userId: number, skip: number, take: number) => { + if (skip > wordsMock.length) return Promise.resolve([[], 5]); + return Promise.resolve([wordsMock.slice(skip, skip + take), 5]); + }), } as unknown as VocabularyRepository; it("getWords() returns all words without pagination", async () => { @@ -134,24 +136,32 @@ describe("VocabularyService extended", () => { }); it("saveManyWords throws BadRequestError when input is not array (negative)", async () => { - const repo: any = { saveManyWords: jest.fn() } as unknown as VocabularyRepository; + const repo: any = { + saveManyWords: jest.fn(), + } as unknown as VocabularyRepository; const svc = new VocabularyService(repo); - await expect(svc.saveManyWords({} as any, 1)).rejects.toBeInstanceOf(BadRequestError); + await expect(svc.saveManyWords({} as any, 1)).rejects.toBeInstanceOf( + BadRequestError, + ); }); it("saveManyWords throws BadRequestError when any item missing fields (negative)", async () => { - const repo: any = { saveManyWords: jest.fn() } as unknown as VocabularyRepository; + const repo: any = { + saveManyWords: jest.fn(), + } as unknown as VocabularyRepository; const svc = new VocabularyService(repo); - await expect(svc.saveManyWords([{ word: "Hund" } as any], 1)).rejects.toBeInstanceOf( - BadRequestError - ); + await expect( + svc.saveManyWords([{ word: "Hund" } as any], 1), + ).rejects.toBeInstanceOf(BadRequestError); }); it("getWordByID returns word if belongs to user (positive)", async () => { const repo: any = { - getWordByID: jest.fn().mockResolvedValue({ id: 5, userId: 22 } as UserVocabulary), + getWordByID: jest + .fn() + .mockResolvedValue({ id: 5, userId: 22 } as UserVocabulary), } as unknown as VocabularyRepository; const svc = new VocabularyService(repo); @@ -161,7 +171,9 @@ describe("VocabularyService extended", () => { it("getWordByID throws NotFoundError if not belongs (negative)", async () => { const repo: any = { - getWordByID: jest.fn().mockResolvedValue({ id: 5, userId: 99 } as UserVocabulary), + getWordByID: jest + .fn() + .mockResolvedValue({ id: 5, userId: 99 } as UserVocabulary), } as unknown as VocabularyRepository; const svc = new VocabularyService(repo); @@ -170,7 +182,9 @@ describe("VocabularyService extended", () => { it("updateWord updates when word belongs to user (positive)", async () => { const repo: any = { - getWordByID: jest.fn().mockResolvedValue({ id: 7, userId: 1 } as UserVocabulary), + getWordByID: jest + .fn() + .mockResolvedValue({ id: 7, userId: 1 } as UserVocabulary), updateWord: jest.fn().mockResolvedValue({ id: 7, translation: "New" }), } as unknown as VocabularyRepository; const svc = new VocabularyService(repo); @@ -182,14 +196,16 @@ describe("VocabularyService extended", () => { it("updateWord throws NotFoundError when word not belongs (negative)", async () => { const repo: any = { - getWordByID: jest.fn().mockResolvedValue({ id: 7, userId: 2 } as UserVocabulary), + getWordByID: jest + .fn() + .mockResolvedValue({ id: 7, userId: 2 } as UserVocabulary), updateWord: jest.fn(), } as unknown as VocabularyRepository; const svc = new VocabularyService(repo); - await expect(svc.updateWord(7, 1, { translation: "New" } as any)).rejects.toBeInstanceOf( - NotFoundError - ); + await expect( + svc.updateWord(7, 1, { translation: "New" } as any), + ).rejects.toBeInstanceOf(NotFoundError); expect(repo.updateWord).not.toHaveBeenCalled(); }); diff --git a/apps/backend/src/modules/vocabulary/vocabularyService.ts b/apps/backend/src/modules/vocabulary/vocabularyService.ts index d6c7d10..e32e493 100644 --- a/apps/backend/src/modules/vocabulary/vocabularyService.ts +++ b/apps/backend/src/modules/vocabulary/vocabularyService.ts @@ -1,5 +1,8 @@ import { BadRequestError } from "@/errors/BadRequestError"; -import { UserVocabularyDTO, UserVocabularyWithUserIdDTO } from "./vocabulary.types"; +import { + UserVocabularyDTO, + UserVocabularyWithUserIdDTO, +} from "./vocabulary.types"; import { VocabularyRepository } from "./vocabularyRepository"; import { UserVocabulary } from "@prisma/client"; import { NotFoundError } from "@/errors/NotFoundError"; @@ -22,14 +25,19 @@ export class VocabularyService { async getWords( userId: number, page?: number, - pageSize?: number + pageSize?: number, ): Promise<{ data: UserVocabulary[]; pagination?: Pagination }> { if (!page || !pageSize) { - const words = await this.vocabularyRepository.getAllWordsWithoutPagination(userId); + const words = + await this.vocabularyRepository.getAllWordsWithoutPagination(userId); return { data: words }; } const skip = (page - 1) * pageSize; - const [words, totalItems] = await this.vocabularyRepository.getAllWords(userId, skip, pageSize); + const [words, totalItems] = await this.vocabularyRepository.getAllWords( + userId, + skip, + pageSize, + ); const totalPages = Math.ceil(totalItems / pageSize); return { data: words, @@ -43,22 +51,34 @@ export class VocabularyService { } async getWordsWithoutPagination(userId: number): Promise { - const words = await this.vocabularyRepository.getAllWordsWithoutPagination(userId); + const words = + await this.vocabularyRepository.getAllWordsWithoutPagination(userId); return words; } - async saveNewWord(word: UserVocabularyWithUserIdDTO): Promise { + async saveNewWord( + word: UserVocabularyWithUserIdDTO, + ): Promise { return this.vocabularyRepository.saveWord(word); } - async saveManyWords(words: UserVocabularyDTO[], userId: number): Promise { + async saveManyWords( + words: UserVocabularyDTO[], + userId: number, + ): Promise { if (!Array.isArray(words)) { - throw new BadRequestError("The request body must be an array of objects."); + throw new BadRequestError( + "The request body must be an array of objects.", + ); } - const allWordsValid = words.every((wordObj) => wordObj.word && wordObj.translation); + const allWordsValid = words.every( + (wordObj) => wordObj.word && wordObj.translation, + ); if (!allWordsValid) { - throw new BadRequestError("Each item must have a 'word' and a 'translation' property."); + throw new BadRequestError( + "Each item must have a 'word' and a 'translation' property.", + ); } const wordsWithUserId = this.attachUserIdToWords(words, userId); @@ -69,7 +89,11 @@ export class VocabularyService { return this.vocabularyRepository.deleteWord(wordId, userId); } - async updateWord(wordId: number, userId: number, wordData: Partial) { + async updateWord( + wordId: number, + userId: number, + wordData: Partial, + ) { // check if the user has the word await this.getWordByID(wordId, userId); @@ -78,12 +102,15 @@ export class VocabularyService { private attachUserIdToWords( words: UserVocabularyDTO[], - userId: number + userId: number, ): UserVocabularyWithUserIdDTO[] { return words.map((word) => ({ ...word, userId })); } - private doesWordBelongToUser(word: UserVocabulary | null, userId: number): boolean { + private doesWordBelongToUser( + word: UserVocabulary | null, + userId: number, + ): boolean { return word !== null && word.userId === userId; } } diff --git a/apps/backend/src/services/redis/baseRedisCache.ts b/apps/backend/src/services/redis/baseRedisCache.ts index b3fd8da..5d38487 100644 --- a/apps/backend/src/services/redis/baseRedisCache.ts +++ b/apps/backend/src/services/redis/baseRedisCache.ts @@ -31,13 +31,17 @@ export abstract class BaseRedisCache { } } + // eslint-disable-next-line @typescript-eslint/no-explicit-any async set(key: string, value: object | any[]): Promise { try { await this.redis.set(key, JSON.stringify(value), { expiration: { type: "EX", value: this.ttl }, }); } catch (error) { - throw new RedisError("Failed to set value for a key", error, { key, value }); + throw new RedisError("Failed to set value for a key", error, { + key, + value, + }); } } @@ -53,9 +57,17 @@ export abstract class BaseRedisCache { async setList(key: string, values: string[]): Promise { try { - await this.redis.multi().del(key).rPush(key, values).expire(key, this.ttl).exec(); + await this.redis + .multi() + .del(key) + .rPush(key, values) + .expire(key, this.ttl) + .exec(); } catch (error) { - throw new RedisError("Failed to set list in Redis", error, { key, values }); + throw new RedisError("Failed to set list in Redis", error, { + key, + values, + }); } } } diff --git a/apps/backend/src/services/redis/redisClient.ts b/apps/backend/src/services/redis/redisClient.ts index 8928e35..eadc327 100644 --- a/apps/backend/src/services/redis/redisClient.ts +++ b/apps/backend/src/services/redis/redisClient.ts @@ -1,10 +1,11 @@ import { RedisError } from "@/errors/RedisError"; import { createClient } from "redis"; import { logger } from "../../utils/logger"; -import { log } from "console"; if (!process.env.REDIS_HOST || !process.env.REDIS_PORT) { - throw new Error("REDIS_HOST and REDIS_PORT environment variables are required"); + throw new Error( + "REDIS_HOST and REDIS_PORT environment variables are required", + ); } const redisHost = process.env.REDIS_HOST; diff --git a/apps/backend/src/services/redis/redisConnection.ts b/apps/backend/src/services/redis/redisConnection.ts index ad5ba64..da660aa 100644 --- a/apps/backend/src/services/redis/redisConnection.ts +++ b/apps/backend/src/services/redis/redisConnection.ts @@ -1,7 +1,9 @@ import IORedis from "ioredis"; if (!process.env.REDIS_HOST || !process.env.REDIS_PORT) { - throw new Error("REDIS_HOST and REDIS_PORT environment variables are required"); + throw new Error( + "REDIS_HOST and REDIS_PORT environment variables are required", + ); } const redisHost = process.env.REDIS_HOST; diff --git a/apps/backend/src/types/express.d.ts b/apps/backend/src/types/express.d.ts index 8302823..5c42ee8 100644 --- a/apps/backend/src/types/express.d.ts +++ b/apps/backend/src/types/express.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ declare global { namespace Express { interface Request { diff --git a/apps/backend/src/utils/logger.ts b/apps/backend/src/utils/logger.ts index b69b11c..cc3085e 100644 --- a/apps/backend/src/utils/logger.ts +++ b/apps/backend/src/utils/logger.ts @@ -3,7 +3,7 @@ import { createLogger, format, transports } from "winston"; const fileFormat = format.combine( format.timestamp(), format.errors({ stack: true }), - format.json() + format.json(), ); const consoleFormat = format.combine( @@ -12,9 +12,11 @@ const consoleFormat = format.combine( format.timestamp(), format.printf(({ timestamp, level, message, ...meta }) => { const base = `[${timestamp}] ${level}: ${message}`; - const extra = Object.keys(meta).length ? `\n${JSON.stringify(meta, null, 2)}` : ""; + const extra = Object.keys(meta).length + ? `\n${JSON.stringify(meta, null, 2)}` + : ""; return base + extra; - }) + }), ); export const logger = createLogger({ @@ -24,7 +26,11 @@ export const logger = createLogger({ new transports.Console({ // format: consoleFormat, }), - new transports.File({ filename: "logs/error.log", level: "error", format: fileFormat }), + new transports.File({ + filename: "logs/error.log", + level: "error", + format: fileFormat, + }), new transports.File({ filename: "logs/combined.log", format: fileFormat }), ], }); diff --git a/apps/backend/src/validation/validateData.ts b/apps/backend/src/validation/validateData.ts index 5cc5962..816900d 100644 --- a/apps/backend/src/validation/validateData.ts +++ b/apps/backend/src/validation/validateData.ts @@ -1,6 +1,7 @@ -import { ZodIssue, ZodSchema } from "zod"; +import { ZodSchema } from "zod"; import { BadRequestError } from "@/errors/BadRequestError"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export const validateData = (schema: ZodSchema, data: any): T => { const result = schema.safeParse(data); if (!result.success) { diff --git a/apps/backend/src/worker.ts b/apps/backend/src/worker.ts index 02de534..29f1dfa 100644 --- a/apps/backend/src/worker.ts +++ b/apps/backend/src/worker.ts @@ -2,10 +2,16 @@ import { config } from "dotenv"; config(); import { storyModule, unknownWordModule } from "./container"; import { BullWorker, JobHandler } from "./modules/jobs/bullWorker"; -import { closeIORedisConnection, redisConnection } from "./services/redis/redisConnection"; +import { + closeIORedisConnection, + redisConnection, +} from "./services/redis/redisConnection"; import { prisma } from "./services/prisma"; import { Job } from "bullmq"; -import { closeRedisConnection, connectRedis } from "./services/redis/redisClient"; +import { + closeRedisConnection, + connectRedis, +} from "./services/redis/redisClient"; import { GENERATION_PHASES } from "./modules/story/generationPhases"; let bullWorker: BullWorker; @@ -17,7 +23,9 @@ const startWorker = async () => { handlers.set( "updateWordStatus", - unknownWordModule.service.processUpdateWordStatus.bind(unknownWordModule.service) + unknownWordModule.service.processUpdateWordStatus.bind( + unknownWordModule.service, + ), ); handlers.set("generateStory", (job: Job) => { job.updateProgress({ diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 6a4f29f..0000000 --- a/package-lock.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "compinput", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "devDependencies": { - "@types/node": "^22.13.13" - } - }, - "node_modules/@types/node": { - "version": "22.13.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.13.tgz", - "integrity": "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.20.0" - } - }, - "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 8ca4bb5..0000000 --- a/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "devDependencies": { - "@types/node": "^22.13.13" - } -}